markdown · 6820 bytes Raw Blame History

User profile (read-only)

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.

Routes

Route Source Notes
GET /{username} profile.serveProfile Public profile. citext lookup; canonical-case 301; reserved short-circuit.
GET /avatars/{username} profile.serveAvatar Streams uploaded avatar OR falls back to deterministic SVG identicon.

/{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.

Behavior summary

Request Response
/alice (exists) 200 + profile page
/Alice (canonical is alice) 301 → /alice
/oldname (renamed → alice) 301 → /alice (via username_redirects)
/badactor (suspended) 410 + "account unavailable" page
/no-such-user 404 styled error
/login (reserved) route handler runs (NOT the profile catch-all)
/admin (reserved, no handler) profile handler short-circuits with 404
/avatars/alice (no upload) 200 + identicon SVG
/avatars/alice (uploaded) 200 + object-store stream
/avatars/no-such-user 200 + identicon (no existence leak)

Suspended users — 410 not 404

Suspended (or soft-deleted-during-grace) users render the dedicated "account unavailable" template with status 410 (Gone). Choices behind that:

  • 404 would lie about existence — letting an attacker probe whether a name was ever taken.
  • 200 would imply a normal profile, confusing crawlers and humans.
  • 410 is semantically "this resource existed, it's gone now" — accurate for suspension, neutral about reason.

The template never reveals why the user is suspended.

Casing redirects

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:

  • /Alice → DB returns user with username = "alice" (citext) → 301 → /alice
  • /zzzzz → DB miss → tryRedirectOrNotFound → 404 (no redirect)

Username redirects

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:

  • Hit → 301 to the new canonical name.
  • Miss → 404.

The redirect record itself doubles as a 30-day reservation — the signup path will check this table when validating new usernames (handled in S10).

Identicons

internal/avatars.Identicon(username, pixelSize) returns a deterministic SVG built from sha256(lowercase(username)):

  • 5×5 grid, mirrored horizontally so every identicon is symmetric.
  • Color picked from a fixed 12-entry palette derived from Primer accent ramps.
  • Same input → same output. No upload needed.
  • No user input ever reaches the SVG body — only sha256-derived bytes.

X-Content-Type-Options: nosniff is set on the response as defense in depth.

Avatar upload (placeholder)

S09 does not implement upload. The avatar route reads users.avatar_object_key:

  • NULL → identicon
  • Set + ObjectStore wired → stream from object store with Cache-Control: public, max-age=31536000, immutable
  • Set + ObjectStore nil → identicon (graceful fallback)

S10 will write the upload path, including resize-to-variants (avatars/<owner>/<size>.<ext>) and EXIF stripping.

Privacy

The profile renders only fields the user explicitly set (or that the system computed publicly):

  • Username, display name, bio, location, company, website, pronouns, joined date, avatar.
  • Email addresses NEVER appear on the profile page. Post-MVP opt-in only.
  • Verified-email status is exposed via the API (/api/v1/user), not the public page.

Self-view enrichment

When the viewer's session matches the profile's user (viewer.ID == user.ID):

  • A small "you" badge renders next to the display name.
  • An "Edit profile" button links to /settings/profile (S10).
  • Cache-Control flips from max-age=300 (anonymous) to no-cache, private so admin- or settings-driven changes appear immediately.

OG metadata

The layout reads OGTitle, OGDescription, OGImage from the page data and emits the corresponding meta tags. The profile handler populates all three:

  • Title = "<display name> (@<username>)"
  • Description = bio if set, else "@<username> on shithub"
  • Image = avatar URL (/avatars/<username>)

Empty values omit the tag entirely.

Caching policy

View Cache-Control
Anonymous profile max-age=300
Self-view no-cache, private
Identicon public, max-age=86400
Uploaded avatar public, max-age=31536000, immutable

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.

Sprint-test discipline

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.

Other tests cover:

  • Existing-user 200 with rendered fields.
  • Unknown-user 404.
  • Casing redirect 301.
  • Username-change redirect 301.
  • Suspended user 410 with the unavailable template.
  • Reserved-name path with a registered handler routes there (NOT the wildcard).
  • Reserved-name path without a registered handler short-circuits with 404 (NOT a DB lookup).
  • Avatar identicon falls back when no key is stored.

Pitfalls / what to remember

  • Wildcard ordering is everything. /{username} MUST be registered after every static top-level route. Defense in depth via the reserved-name list catches drift.
  • Casing redirect must not loop. Match on canonical case ONLY after a successful lookup confirmed the user exists.
  • Avatar route must not 404 on missing user. It silently falls back to the identicon — leaking less existence info.
  • Suspended page is 410, not 404. Picked deliberately for the privacy/honesty trade-off.
  • Profile data is public. Don't add private fields without a feature gate.
  • docs/internal/auth.md — email/password auth (S05).
  • docs/internal/storage.md — avatar object-store path conventions (S04).
  • docs/internal/tokens.md/api/v1/user PAT-authenticated profile JSON (S08).
View source
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).