tenseleyflow/shithub / f8fc2ba

Browse files

Add docs/internal/profile.md

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
f8fc2ba7f73d51e88ef30f2f3cbdb4dfae307486
Parents
47cd663
Tree
1cff505

1 changed file

StatusFile+-
A docs/internal/profile.md 140 0
docs/internal/profile.mdadded
@@ -0,0 +1,140 @@
1
+# User profile (read-only)
2
+
3
+S09 ships the public `/{username}` page and the `/avatars/{username}` route. Edit-profile UI lands in S10; pinned repos in S26; profile README and contribution graph post-MVP.
4
+
5
+## Routes
6
+
7
+| Route | Source | Notes |
8
+|---|---|---|
9
+| `GET /{username}` | profile.serveProfile | Public profile. citext lookup; canonical-case 301; reserved short-circuit. |
10
+| `GET /avatars/{username}` | profile.serveAvatar | Streams uploaded avatar OR falls back to deterministic SVG identicon. |
11
+
12
+`/{username}` is the **catch-all** — chi matches static routes in registration order, so the wildcard is registered last via the `ProfileMounter` hook. The reserved-name list (`internal/auth/reserved.go`) is the second line of defense if a future top-level route is added but not registered before the wildcard.
13
+
14
+## Behavior summary
15
+
16
+| Request | Response |
17
+|---|---|
18
+| `/alice` (exists) | 200 + profile page |
19
+| `/Alice` (canonical is `alice`) | 301 → `/alice` |
20
+| `/oldname` (renamed → `alice`) | 301 → `/alice` (via `username_redirects`) |
21
+| `/badactor` (suspended) | 410 + "account unavailable" page |
22
+| `/no-such-user` | 404 styled error |
23
+| `/login` (reserved) | route handler runs (NOT the profile catch-all) |
24
+| `/admin` (reserved, no handler) | profile handler short-circuits with 404 |
25
+| `/avatars/alice` (no upload) | 200 + identicon SVG |
26
+| `/avatars/alice` (uploaded) | 200 + object-store stream |
27
+| `/avatars/no-such-user` | 200 + identicon (no existence leak) |
28
+
29
+## Suspended users — 410 not 404
30
+
31
+Suspended (or soft-deleted-during-grace) users render the dedicated "account unavailable" template with status 410 (Gone). Choices behind that:
32
+
33
+- 404 would lie about existence — letting an attacker probe whether a name was ever taken.
34
+- 200 would imply a normal profile, confusing crawlers and humans.
35
+- 410 is semantically "this resource existed, it's gone now" — accurate for suspension, neutral about reason.
36
+
37
+The template never reveals *why* the user is suspended.
38
+
39
+## Casing redirects
40
+
41
+The profile handler stores the canonical (DB) casing and 301-redirects when the request path differs. The redirect happens **after** the DB lookup confirmed the user exists, so a typo never loops:
42
+
43
+- `/Alice` → DB returns user with `username = "alice"` (citext) → 301 → `/alice`
44
+- `/zzzzz` → DB miss → tryRedirectOrNotFound → 404 (no redirect)
45
+
46
+## Username redirects
47
+
48
+When S10 ships username-change, the old name is recorded in `username_redirects (old_username, user_id, changed_at)`. On profile lookup miss, we consult that table:
49
+
50
+- Hit → 301 to the new canonical name.
51
+- Miss → 404.
52
+
53
+The redirect record itself doubles as a 30-day reservation — the signup path will check this table when validating new usernames (handled in S10).
54
+
55
+## Identicons
56
+
57
+`internal/avatars.Identicon(username, pixelSize)` returns a deterministic SVG built from `sha256(lowercase(username))`:
58
+
59
+- 5×5 grid, mirrored horizontally so every identicon is symmetric.
60
+- Color picked from a fixed 12-entry palette derived from Primer accent ramps.
61
+- Same input → same output. No upload needed.
62
+- No user input ever reaches the SVG body — only sha256-derived bytes.
63
+
64
+`X-Content-Type-Options: nosniff` is set on the response as defense in depth.
65
+
66
+## Avatar upload (placeholder)
67
+
68
+S09 does not implement upload. The avatar route reads `users.avatar_object_key`:
69
+
70
+- NULL → identicon
71
+- Set + ObjectStore wired → stream from object store with `Cache-Control: public, max-age=31536000, immutable`
72
+- Set + ObjectStore nil → identicon (graceful fallback)
73
+
74
+S10 will write the upload path, including resize-to-variants (`avatars/<owner>/<size>.<ext>`) and EXIF stripping.
75
+
76
+## Privacy
77
+
78
+The profile renders only fields the user explicitly set (or that the system computed publicly):
79
+
80
+- Username, display name, bio, location, company, website, pronouns, joined date, avatar.
81
+- **Email addresses NEVER appear on the profile page.** Post-MVP opt-in only.
82
+- Verified-email status is exposed via the API (`/api/v1/user`), not the public page.
83
+
84
+## Self-view enrichment
85
+
86
+When the viewer's session matches the profile's user (`viewer.ID == user.ID`):
87
+
88
+- A small "you" badge renders next to the display name.
89
+- An "Edit profile" button links to `/settings/profile` (S10).
90
+- Cache-Control flips from `max-age=300` (anonymous) to `no-cache, private` so admin- or settings-driven changes appear immediately.
91
+
92
+## OG metadata
93
+
94
+The layout reads `OGTitle`, `OGDescription`, `OGImage` from the page data and emits the corresponding meta tags. The profile handler populates all three:
95
+
96
+- Title = `"<display name> (@<username>)"`
97
+- Description = bio if set, else `"@<username> on shithub"`
98
+- Image = avatar URL (`/avatars/<username>`)
99
+
100
+Empty values omit the tag entirely.
101
+
102
+## Caching policy
103
+
104
+| View | Cache-Control |
105
+|---|---|
106
+| Anonymous profile | `max-age=300` |
107
+| Self-view | `no-cache, private` |
108
+| Identicon | `public, max-age=86400` |
109
+| Uploaded avatar | `public, max-age=31536000, immutable` |
110
+
111
+The "immutable" on uploaded avatars is safe because S10 will store under a content-addressed key (`avatars/<owner>/<sha256>.<ext>`); when the image changes, the URL changes, so the old URL never needs to invalidate.
112
+
113
+## Sprint-test discipline
114
+
115
+`internal/web/handlers/profile/profile_test.go::TestReservedNameList_HasReasonableContents` enforces a discipline: every top-level path segment shithub registers as of this sprint MUST be on the reserved list. When a future sprint adds a new top-level route, this test fails until `internal/auth/reserved.go` is updated.
116
+
117
+Other tests cover:
118
+
119
+- Existing-user 200 with rendered fields.
120
+- Unknown-user 404.
121
+- Casing redirect 301.
122
+- Username-change redirect 301.
123
+- Suspended user 410 with the unavailable template.
124
+- Reserved-name path with a registered handler routes there (NOT the wildcard).
125
+- Reserved-name path without a registered handler short-circuits with 404 (NOT a DB lookup).
126
+- Avatar identicon falls back when no key is stored.
127
+
128
+## Pitfalls / what to remember
129
+
130
+- **Wildcard ordering** is everything. `/{username}` MUST be registered after every static top-level route. Defense in depth via the reserved-name list catches drift.
131
+- **Casing redirect must not loop.** Match on canonical case ONLY after a successful lookup confirmed the user exists.
132
+- **Avatar route must not 404 on missing user.** It silently falls back to the identicon — leaking less existence info.
133
+- **Suspended page is 410, not 404.** Picked deliberately for the privacy/honesty trade-off.
134
+- **Profile data is public.** Don't add private fields without a feature gate.
135
+
136
+## Related docs
137
+
138
+- `docs/internal/auth.md` — email/password auth (S05).
139
+- `docs/internal/storage.md` — avatar object-store path conventions (S04).
140
+- `docs/internal/tokens.md` — `/api/v1/user` PAT-authenticated profile JSON (S08).