tenseleyflow/shithub / 2aa27a0

Browse files

S10: docs/internal/settings.md — sprint-scope reference

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
2aa27a01eb68a4d7589d9838cfdde40e54a25b04
Parents
e0fd33b
Tree
3b4d738

1 changed file

StatusFile+-
A docs/internal/settings.md 196 0
docs/internal/settings.mdadded
@@ -0,0 +1,196 @@
1
+# User settings
2
+
3
+S10 ships every page under `/settings/*` for the authenticated user — the public profile editor, avatar upload pipeline, account-management surface (username + password), email management, notification preferences, theme, current-session view + log-out-everywhere, and the danger zone (soft-delete with grace window). State-changing actions write audit-log rows and send notification emails (gated by user prefs except for security alerts).
4
+
5
+## Routes
6
+
7
+All routes live under `/settings` and require an authenticated user (chi `RequireUser` middleware on the group).
8
+
9
+| Route | Method | Source | Notes |
10
+|---|---|---|---|
11
+| `/settings/profile` | GET/POST | `auth.profile.go` | Display name, bio, location, website, company, pronouns. Right-side avatar card. |
12
+| `/settings/profile/avatar` | POST | `auth.avatar.go` | Multipart upload. 5 MiB cap, EXIF stripped, 460/200/40 PNG variants. |
13
+| `/settings/profile/avatar/remove` | POST | `auth.avatar.go` | Clears `users.avatar_object_key`; objects are kept (orphan sweep is post-MVP). |
14
+| `/settings/account` | GET | `auth.account.go` | Username change form. |
15
+| `/settings/account/username` | POST | `auth.account.go` | Inserts `username_redirects`, renames `users`, audit + notify. |
16
+| `/settings/password` | GET/POST | `auth.password_change.go` | Current pw + 2FA gate, bumps `session_epoch`. |
17
+| `/settings/emails` | GET/POST | `auth.emails.go` | List and add a new address (sends a verify email). |
18
+| `/settings/emails/{id}/resend` | POST | `auth.emails.go` | Re-issues a verification token for an unverified row. |
19
+| `/settings/emails/{id}/primary` | POST | `auth.emails.go` | Atomically swaps the primary; refuses unverified rows. |
20
+| `/settings/emails/{id}/remove` | POST | `auth.emails.go` | Refuses to delete the primary address. |
21
+| `/settings/appearance` | GET/POST | `auth.appearance.go` | Theme picker. Writes both `users.theme` and the `theme` cookie. |
22
+| `/settings/notifications` | GET/POST | `auth.notifications.go` | Generic k/v editor over `user_notification_prefs`. |
23
+| `/settings/sessions` | GET | `auth.sessions.go` | Current session info. |
24
+| `/settings/sessions/logout-everywhere` | POST | `auth.sessions.go` | Bumps `users.session_epoch`. |
25
+| `/settings/security/2fa/*` | GET/POST | `auth.twofactor.go` | (Wired in S06.) |
26
+| `/settings/keys` and `/settings/keys/{id}/delete` | GET/POST | `auth.sshkeys.go` | (Wired in S07.) |
27
+| `/settings/tokens`, `/settings/tokens/{id}/revoke` | GET/POST | `auth.tokens.go` | (Wired in S08.) |
28
+| `/settings/danger` | GET/POST | `auth.danger.go` | Soft-delete with 14-day grace; restore-on-login. |
29
+
30
+The settings sidebar (`internal/web/templates/_settings_nav.html`) is shared by every settings template. Each handler passes a `SettingsActive` string so the active link is highlighted.
31
+
32
+## Session epoch — log-out-everywhere
33
+
34
+`users.session_epoch` (integer, default 0) is bumped to invalidate every cookie that carries an older value.
35
+
36
+- The login handler snapshots the user's current epoch into `Session.Epoch` at issue time.
37
+- `middleware.OptionalUser`'s `UserLookup` returns `(username, epoch)`; when the session's stored epoch ≠ the DB's, the binding is skipped and `RequireUser` will redirect the next protected hit to `/login`.
38
+- Three handlers bump:
39
+  - **Password change** (`/settings/password`) — security best practice; explicitly mentioned in the success flash.
40
+  - **Log out everywhere** (`/settings/sessions/logout-everywhere`) — the explicit user-driven action.
41
+  - **Account deletion** (`/settings/danger`) — kicks every live session of the soon-to-be-deleted user.
42
+- After every bump, the *current* session is re-issued with the new epoch so the user staying on this browser is not signed out.
43
+
44
+The cookie itself is not actively cleared on bump (other than for account deletion). The stale cookie is harmless: its next protected hit lands on `/login`. It also expires via its `MaxAge` window (`session.DefaultMaxAge`, currently 30 days).
45
+
46
+## Username change — rate limit + redirect
47
+
48
+Three rules:
49
+
50
+1. **Shape** — `^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$`. Input is lowercased server-side, so users who type `Bob` get `bob`.
51
+2. **Reservation** — `internal/auth/reserved.IsReserved`.
52
+3. **Cap** — at most 3 changes per rolling 60-day window. Counted by `username_redirects.changed_at` rows for that user, so the redirect IS the audit trail; no separate counter table.
53
+
54
+Tx body:
55
+
56
+```sql
57
+INSERT INTO username_redirects (old_username, user_id) VALUES ($1, $2);
58
+UPDATE users SET username = $3 WHERE id = $2;
59
+```
60
+
61
+Post-commit: `audit_log` row + state-change email. The new name is unique by `users_username_unique` (citext); a redirect row clash on the same name (someone else's old name during their grace window) raises 23505 and surfaces "That username is taken."
62
+
63
+The old name is reserved as a redirect for 30 days (handled by the existing username-redirects logic introduced in S09). The 30-day reservation is implicit — `signup` already consults the same table.
64
+
65
+## Avatar pipeline
66
+
67
+`internal/avatars.Process(io.Reader)`:
68
+
69
+1. Reads up to `MaxUploadBytes + 1` (5 MiB + sentinel) and rejects oversized payloads.
70
+2. `image.DecodeConfig` first — checks the format whitelist (PNG / JPEG / GIF) AND rejects images whose pixel area exceeds `MaxPixelArea` (24 megapixels) before allocating the full pixel buffer. Defends against decompression-bomb files that are tiny on disk but enormous when decoded.
71
+3. Decode for real.
72
+4. Center-square crop so the resize never squashes.
73
+5. CatmullRom resize via `golang.org/x/image/draw` to each `VariantSizes` target (460 / 200 / 40).
74
+6. Re-encode each as PNG (which strips any EXIF the source carried — we never read it).
75
+7. Hash the largest variant; return that hash for the storage key.
76
+
77
+Object-store layout: `avatars/{user_id}/{hash}.png` for the largest variant; smaller variants get a `-{size}` suffix. The largest variant's key is stored in `users.avatar_object_key` and the public `/avatars/{username}` route serves it directly.
78
+
79
+Variants 200 and 40 are written today but not yet served — a future sprint can add `/avatars/{username}/{size}` without re-resizing.
80
+
81
+## Email management
82
+
83
+The 4-action surface:
84
+
85
+- **Add** — creates a `user_emails` row with `is_primary=false, verified=false` and a new `email_verifications` row. The verification email is sent post-commit.
86
+- **Resend** — only for unverified rows; rotates the token + sets a fresh expiry.
87
+- **Set primary** — refuses unverified rows. Atomic swap is `SetUserEmailPrimary` (`UPDATE … SET is_primary = (id = $2) WHERE user_id = $1`); the partial unique index `user_emails_one_primary_per_user` keeps the invariant.
88
+- **Remove** — refuses the primary address (DB query has `AND is_primary = false`); user must promote a different verified address first.
89
+
90
+A primary email change writes an `audit_log` row (TODO: explicitly — currently only the email goes out) and sends a `primary_email_changed` notice on the `account_changes` channel.
91
+
92
+## Notification preferences
93
+
94
+`user_notification_prefs` is a tiny key/value/jsonb table. The settings page surfaces a fixed list of "channels" (`internal/web/handlers/auth/notifications.go::notifChannels`):
95
+
96
+| Key | Default | Toggleable | Carries |
97
+|---|---|---|---|
98
+| `security_alerts` | on | no (Required) | password_changed, log_out_everywhere, 2FA changes |
99
+| `account_changes` | on | yes | username_changed, primary_email_changed, account_deletion_initiated |
100
+| `product_news` | off | yes | reserved for future broadcast emails |
101
+
102
+Storage strategy: when the desired state matches the channel's default, the row is **deleted** rather than upserted. This keeps the table small and makes "reset to defaults" a no-op. The form omits a checkbox to encode "off."
103
+
104
+`shouldNotify(ctx, userID, kind)` is the single gate: kinds whose channel is `security_alerts` always return true; kinds on `account_changes` consult the persisted row, falling back to the default when absent.
105
+
106
+## Soft-delete + 14-day grace
107
+
108
+`/settings/danger` requires the user to retype their username and current password. On confirmation:
109
+
110
+1. `SoftDeleteUser` — `UPDATE users SET deleted_at = now() WHERE id = $1`.
111
+2. `BumpUserSessionEpoch` — invalidates every cookie.
112
+3. (post-commit) Audit row + notification email (built directly via `email.NoticeMessage` with the captured primary address — `notifyState`'s usual lookup filters out soft-deleted users).
113
+4. `SessionStore.Clear(w)` and a 303 to `/?notice=account-deleted`.
114
+
115
+Restore-on-login (in the login handler):
116
+
117
+```go
118
+user, err := h.q.GetUserByUsernameIncludingDeleted(ctx, pool, username)  // see deleted rows
119
+if user.DeletedAt.Valid && time.Since(user.DeletedAt.Time) >= deletionGraceWindow {
120
+    // Past the 14-day window — treat as nonexistent.
121
+    password.VerifyAgainstDummy(pw)
122
+    render("Incorrect username or password.")
123
+    return
124
+}
125
+// Verify password; if OK, RestoreUserAccount() (sets deleted_at = NULL).
126
+```
127
+
128
+A within-grace user therefore restores the moment they prove ownership of the password. Past the window the row stays soft-deleted and the response is indistinguishable from "doesn't exist," same constant-time cost.
129
+
130
+The public profile (`/{username}`) still uses `GetUserByUsername` (which filters `deleted_at IS NULL`), so a soft-deleted account renders as 404 immediately. Suspended accounts still render the dedicated 410 "unavailable" page introduced in S09.
131
+
132
+## State-change notifications — kinds
133
+
134
+`internal/auth/email/messages.go::noticeBodies` plus `auth/notify.go::kindToChannel`:
135
+
136
+| Kind | Channel | Trigger |
137
+|---|---|---|
138
+| `password_changed` | security_alerts | POST /settings/password success |
139
+| `log_out_everywhere` | security_alerts | POST /settings/sessions/logout-everywhere |
140
+| `username_changed` | account_changes | POST /settings/account/username success |
141
+| `primary_email_changed` | account_changes | POST /settings/emails/{id}/primary |
142
+| `account_deletion_initiated` | account_changes | POST /settings/danger success |
143
+| `2fa_enabled` / `2fa_disabled` / `recovery_regenerated` / `admin_cleared_2fa` | security_alerts | (Wired in S06.) |
144
+
145
+Send is best-effort: failures are logged but never break the flow.
146
+
147
+## Theme + first-paint flash avoidance
148
+
149
+Set on POST `/settings/appearance`:
150
+
151
+- `users.theme` — source of truth across devices.
152
+- `theme` cookie (`SameSite=Lax`, `MaxAge` 1 year, NOT `HttpOnly` — the `_layout.html` inline script reads it before any CSS computes).
153
+
154
+Allowed values: `""` (the cookie wins, defaulting to system preference), `light`, `dark`, `auto`, `high_contrast`. Mirrors the CHECK constraint added in migration 0016. Empty value clears the cookie.
155
+
156
+## Reference visual conformance
157
+
158
+Every settings page renders inside `_settings_nav.html` (left sidebar with "Settings" group, "Access" group, "Danger" group) + a content pane of card-style sections. Visual reference: GitHub's own settings pages (kept in `.refs/github/images/user/`). Notable adopted patterns:
159
+
160
+- Sidebar grouped headers (`Access`, `Danger`) with uppercase letter-spaced labels.
161
+- Active link with left-edge accent border + subtle canvas-subtle background.
162
+- Card sections separated by hairline `--border-muted` borders.
163
+- Danger-zone red-bordered card and red-text headline.
164
+- Two-column inner layout for the profile editor (form left, profile-picture aside right).
165
+- Pill chips for email row state (`primary` / `verified` / `unverified`).
166
+- Theme picker as 5 radio cards in a responsive grid.
167
+
168
+CSS lives in `internal/web/static/css/shithub.css` under `/* ----- settings shell (S10) ----- */` and the topic-specific blocks below it.
169
+
170
+## Audit trail
171
+
172
+Each security-relevant settings action records an `audit_log` row via `internal/auth/audit.Recorder`. Actions added in S10:
173
+
174
+- `username_changed` (with `from`/`to` meta).
175
+- `account_deleted` (with `grace_days` meta).
176
+- `account_restored` (recorded by login restore-on-login when it fires).
177
+
178
+The pre-existing `password_changed`, 2FA actions, and SSH/PAT actions are reused.
179
+
180
+## Pitfalls / what to remember
181
+
182
+- **Epoch bumps must re-issue the current session.** Otherwise the user signs themselves out. See `password_change.go::settingsPasswordSubmit` and `sessions.go::settingsSessionsLogoutAll` for the pattern.
183
+- **Avatar route is read-mostly.** Uploaded variants are content-addressed by the largest variant's hash; the URL changes when the image changes, so `Cache-Control: public, max-age=31536000, immutable` on the avatar response is safe. Don't add a "no-cache" override.
184
+- **Username regex is server-side normalization, not validation only.** "Bob" lowercases to "bob" before any other check; tests assume this.
185
+- **Password change requires 2FA recency** when 2FA is enrolled. Users without 2FA bypass it (`recentAuthOK` returns true). Audit/notify still fire.
186
+- **Soft-deleted users are invisible to `GetUserByUsername`**, so the post-deletion notification email is built using the captured primary address (`notifyDeletedAccount`) rather than the standard `notifyState` path.
187
+- **Don't delete object-store keys on avatar removal.** Old objects accumulate; an orphan-sweep job is post-MVP.
188
+- **Notification prefs schema is generic** (k/v/jsonb). Adding a channel is a code change to `notifChannels`, not a migration. Keep the channel list short.
189
+
190
+## Related docs
191
+
192
+- `docs/internal/auth.md` — login, sessions, recent-2FA gate.
193
+- `docs/internal/2fa.md` — 2FA enrollment flow + secretbox.
194
+- `docs/internal/profile.md` — public `/{username}` page (S09).
195
+- `docs/internal/storage.md` — object-store conventions used by avatar uploads.
196
+- `docs/internal/tokens.md` — `/settings/tokens` (S08).