@@ -0,0 +1,162 @@ |
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | + |
| 3 | +package auth |
| 4 | + |
| 5 | +import ( |
| 6 | + "errors" |
| 7 | + "net/http" |
| 8 | + "strings" |
| 9 | + "time" |
| 10 | + |
| 11 | + "github.com/jackc/pgx/v5" |
| 12 | + "github.com/jackc/pgx/v5/pgconn" |
| 13 | + "github.com/jackc/pgx/v5/pgtype" |
| 14 | + |
| 15 | + authpkg "github.com/tenseleyFlow/shithub/internal/auth" |
| 16 | + "github.com/tenseleyFlow/shithub/internal/auth/audit" |
| 17 | + usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc" |
| 18 | + "github.com/tenseleyFlow/shithub/internal/web/middleware" |
| 19 | +) |
| 20 | + |
| 21 | +// usernameChangeWindow is the rolling rate-limit window for renames. |
| 22 | +// Counted against username_redirects.changed_at — the redirect row IS |
| 23 | +// the audit trail, so no separate counter table is needed. |
| 24 | +const usernameChangeWindow = 60 * 24 * time.Hour |
| 25 | + |
| 26 | +// usernameChangeMax caps how many renames a user can make in |
| 27 | +// usernameChangeWindow. Three is GitHub's posted ceiling. |
| 28 | +const usernameChangeMax = 3 |
| 29 | + |
| 30 | +// settingsAccountForm renders GET /settings/account. |
| 31 | +func (h *Handlers) settingsAccountForm(w http.ResponseWriter, r *http.Request) { |
| 32 | + h.renderAccountForm(w, r, "", "") |
| 33 | +} |
| 34 | + |
| 35 | +// settingsAccountUsername handles POST /settings/account/username. |
| 36 | +func (h *Handlers) settingsAccountUsername(w http.ResponseWriter, r *http.Request) { |
| 37 | + if err := r.ParseForm(); err != nil { |
| 38 | + h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse") |
| 39 | + return |
| 40 | + } |
| 41 | + user := middleware.CurrentUserFromContext(r.Context()) |
| 42 | + desired := strings.ToLower(strings.TrimSpace(r.PostFormValue("new_username"))) |
| 43 | + |
| 44 | + // Quick local checks: shape, reservation, no-op. |
| 45 | + if desired == user.Username { |
| 46 | + h.renderAccountForm(w, r, "That's already your username.", "") |
| 47 | + return |
| 48 | + } |
| 49 | + if !usernameRE.MatchString(desired) { |
| 50 | + h.renderAccountForm(w, r, "Username must be 1–39 characters: lowercase letters, digits, and hyphens (no leading/trailing hyphen).", "") |
| 51 | + return |
| 52 | + } |
| 53 | + if authpkg.IsReserved(desired) { |
| 54 | + h.renderAccountForm(w, r, "That username is reserved.", "") |
| 55 | + return |
| 56 | + } |
| 57 | + |
| 58 | + // Rate limit. |
| 59 | + count, err := h.q.CountRecentUsernameChanges(r.Context(), h.d.Pool, usersdb.CountRecentUsernameChangesParams{ |
| 60 | + UserID: user.ID, |
| 61 | + ChangedAt: pgtype.Timestamptz{Time: time.Now().Add(-usernameChangeWindow), Valid: true}, |
| 62 | + }) |
| 63 | + if err != nil { |
| 64 | + h.d.Logger.ErrorContext(r.Context(), "account: count renames", "error", err) |
| 65 | + h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "") |
| 66 | + return |
| 67 | + } |
| 68 | + if count >= usernameChangeMax { |
| 69 | + h.renderAccountForm(w, r, "You've changed your username too many times recently. Try again later.", "") |
| 70 | + return |
| 71 | + } |
| 72 | + |
| 73 | + // Tx: redirect-row + rename. The unique constraint on |
| 74 | + // username_redirects.old_username AND users.username (citext) blocks |
| 75 | + // taking a name held by either an active user or a recent redirect. |
| 76 | + tx, err := h.d.Pool.Begin(r.Context()) |
| 77 | + if err != nil { |
| 78 | + h.d.Logger.ErrorContext(r.Context(), "account: begin tx", "error", err) |
| 79 | + h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "") |
| 80 | + return |
| 81 | + } |
| 82 | + defer func() { _ = tx.Rollback(r.Context()) }() |
| 83 | + |
| 84 | + if err := h.q.InsertUsernameRedirect(r.Context(), tx, usersdb.InsertUsernameRedirectParams{ |
| 85 | + OldUsername: user.Username, |
| 86 | + UserID: user.ID, |
| 87 | + }); err != nil { |
| 88 | + h.d.Logger.ErrorContext(r.Context(), "account: insert redirect", "error", err) |
| 89 | + h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "") |
| 90 | + return |
| 91 | + } |
| 92 | + |
| 93 | + if err := h.q.RenameUser(r.Context(), tx, usersdb.RenameUserParams{ |
| 94 | + ID: user.ID, |
| 95 | + Username: desired, |
| 96 | + }); err != nil { |
| 97 | + if isUsernameTaken(err) { |
| 98 | + h.renderAccountForm(w, r, "That username is taken.", "") |
| 99 | + return |
| 100 | + } |
| 101 | + h.d.Logger.ErrorContext(r.Context(), "account: rename", "error", err) |
| 102 | + h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "") |
| 103 | + return |
| 104 | + } |
| 105 | + |
| 106 | + if err := tx.Commit(r.Context()); err != nil { |
| 107 | + h.d.Logger.ErrorContext(r.Context(), "account: commit", "error", err) |
| 108 | + h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "") |
| 109 | + return |
| 110 | + } |
| 111 | + |
| 112 | + if err := h.d.Audit.Record(r.Context(), h.d.Pool, user.ID, |
| 113 | + audit.ActionUsernameChanged, audit.TargetUser, user.ID, map[string]any{ |
| 114 | + "from": user.Username, |
| 115 | + "to": desired, |
| 116 | + }); err != nil { |
| 117 | + h.d.Logger.WarnContext(r.Context(), "account: audit rename", "error", err) |
| 118 | + } |
| 119 | + |
| 120 | + h.renderAccountForm(w, r, "", "Username updated to "+desired+".") |
| 121 | +} |
| 122 | + |
| 123 | +// renderAccountForm is the shared render path. The current username is |
| 124 | +// pulled out of context so it reflects post-rename state on success. |
| 125 | +func (h *Handlers) renderAccountForm(w http.ResponseWriter, r *http.Request, errMsg, successMsg string) { |
| 126 | + user := middleware.CurrentUserFromContext(r.Context()) |
| 127 | + // Re-read the canonical username from the DB so the form always |
| 128 | + // reflects post-commit state when called after a successful rename. |
| 129 | + canonical := user.Username |
| 130 | + if row, err := h.q.GetUserByID(r.Context(), h.d.Pool, user.ID); err == nil { |
| 131 | + canonical = row.Username |
| 132 | + } |
| 133 | + |
| 134 | + count, _ := h.q.CountRecentUsernameChanges(r.Context(), h.d.Pool, usersdb.CountRecentUsernameChangesParams{ |
| 135 | + UserID: user.ID, |
| 136 | + ChangedAt: pgtype.Timestamptz{Time: time.Now().Add(-usernameChangeWindow), Valid: true}, |
| 137 | + }) |
| 138 | + h.renderPage(w, r, "settings/account", map[string]any{ |
| 139 | + "Title": "Account", |
| 140 | + "CSRFToken": middleware.CSRFTokenForRequest(r), |
| 141 | + "SettingsActive": "account", |
| 142 | + "CurrentUsername": canonical, |
| 143 | + "RecentRenames": count, |
| 144 | + "MaxRenames": usernameChangeMax, |
| 145 | + "WindowDays": int(usernameChangeWindow / (24 * time.Hour)), |
| 146 | + "RenameRateLimited": count >= usernameChangeMax, |
| 147 | + "Error": errMsg, |
| 148 | + "Success": successMsg, |
| 149 | + }) |
| 150 | +} |
| 151 | + |
| 152 | +// isUsernameTaken matches the unique-violation surface for username collisions. |
| 153 | +// Both users.username (citext, unique) and username_redirects.old_username |
| 154 | +// (unique) raise SQLSTATE 23505 here. |
| 155 | +func isUsernameTaken(err error) bool { |
| 156 | + var pgErr *pgconn.PgError |
| 157 | + if errors.As(err, &pgErr) { |
| 158 | + return pgErr.Code == "23505" |
| 159 | + } |
| 160 | + // pgx v5 also wraps tx errors; double-check the not-rows path. |
| 161 | + return errors.Is(err, pgx.ErrNoRows) |
| 162 | +} |