Go · 5936 bytes Raw Blame History
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