| 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.notifyState(r.Context(), user.ID, "username_changed") |
| 121 | |
| 122 | h.renderAccountForm(w, r, "", "Username updated to "+desired+".") |
| 123 | } |
| 124 | |
| 125 | // renderAccountForm is the shared render path. The current username is |
| 126 | // pulled out of context so it reflects post-rename state on success. |
| 127 | func (h *Handlers) renderAccountForm(w http.ResponseWriter, r *http.Request, errMsg, successMsg string) { |
| 128 | user := middleware.CurrentUserFromContext(r.Context()) |
| 129 | // Re-read the canonical username from the DB so the form always |
| 130 | // reflects post-commit state when called after a successful rename. |
| 131 | canonical := user.Username |
| 132 | if row, err := h.q.GetUserByID(r.Context(), h.d.Pool, user.ID); err == nil { |
| 133 | canonical = row.Username |
| 134 | } |
| 135 | |
| 136 | count, _ := h.q.CountRecentUsernameChanges(r.Context(), h.d.Pool, usersdb.CountRecentUsernameChangesParams{ |
| 137 | UserID: user.ID, |
| 138 | ChangedAt: pgtype.Timestamptz{Time: time.Now().Add(-usernameChangeWindow), Valid: true}, |
| 139 | }) |
| 140 | h.renderPage(w, r, "settings/account", map[string]any{ |
| 141 | "Title": "Account", |
| 142 | "CSRFToken": middleware.CSRFTokenForRequest(r), |
| 143 | "SettingsActive": "account", |
| 144 | "CurrentUsername": canonical, |
| 145 | "RecentRenames": count, |
| 146 | "MaxRenames": usernameChangeMax, |
| 147 | "WindowDays": int(usernameChangeWindow / (24 * time.Hour)), |
| 148 | "RenameRateLimited": count >= usernameChangeMax, |
| 149 | "Error": errMsg, |
| 150 | "Success": successMsg, |
| 151 | }) |
| 152 | } |
| 153 | |
| 154 | // isUsernameTaken matches the unique-violation surface for username collisions. |
| 155 | // Both users.username (citext, unique) and username_redirects.old_username |
| 156 | // (unique) raise SQLSTATE 23505 here. |
| 157 | func isUsernameTaken(err error) bool { |
| 158 | var pgErr *pgconn.PgError |
| 159 | if errors.As(err, &pgErr) { |
| 160 | return pgErr.Code == "23505" |
| 161 | } |
| 162 | // pgx v5 also wraps tx errors; double-check the not-rows path. |
| 163 | return errors.Is(err, pgx.ErrNoRows) |
| 164 | } |
| 165 |