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. |
POST /{username}/pins |
profile.pinsUpdate | Auth required. Profile owner saves up to six public owned repositories. |
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 withusername = "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). - A "Customize pins" modal lists the user's public repositories with a
live client-side filter and persists up to six selected repos through
profile_pin_sets/profile_pins(migration 0040). - Cache-Control flips from
max-age=300(anonymous) tono-cache, privateso admin- or settings-driven changes appear immediately.
Pinned repositories are intentionally public-only. Private repos are
not offered in the picker and saved pin IDs are revalidated against the
current public owner repo list before write. A profile_pin_sets row
records that the owner customized the set, so "zero pins" is distinct
from "never customized."
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.
- User and organization pin customization, including public-only candidate filtering and owner-only writes.
- 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.
Related docs
docs/internal/auth.md— email/password auth (S05).docs/internal/storage.md— avatar object-store path conventions (S04).docs/internal/tokens.md—/api/v1/userPAT-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 | | `POST /{username}/pins` | profile.pinsUpdate | Auth required. Profile owner saves up to six public owned repositories. | |
| 11 | | `GET /avatars/{username}` | profile.serveAvatar | Streams uploaded avatar OR falls back to deterministic SVG identicon. | |
| 12 | |
| 13 | `/{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. |
| 14 | |
| 15 | ## Behavior summary |
| 16 | |
| 17 | | Request | Response | |
| 18 | |---|---| |
| 19 | | `/alice` (exists) | 200 + profile page | |
| 20 | | `/Alice` (canonical is `alice`) | 301 → `/alice` | |
| 21 | | `/oldname` (renamed → `alice`) | 301 → `/alice` (via `username_redirects`) | |
| 22 | | `/badactor` (suspended) | 410 + "account unavailable" page | |
| 23 | | `/no-such-user` | 404 styled error | |
| 24 | | `/login` (reserved) | route handler runs (NOT the profile catch-all) | |
| 25 | | `/admin` (reserved, no handler) | profile handler short-circuits with 404 | |
| 26 | | `/avatars/alice` (no upload) | 200 + identicon SVG | |
| 27 | | `/avatars/alice` (uploaded) | 200 + object-store stream | |
| 28 | | `/avatars/no-such-user` | 200 + identicon (no existence leak) | |
| 29 | |
| 30 | ## Suspended users — 410 not 404 |
| 31 | |
| 32 | Suspended (or soft-deleted-during-grace) users render the dedicated "account unavailable" template with status 410 (Gone). Choices behind that: |
| 33 | |
| 34 | - 404 would lie about existence — letting an attacker probe whether a name was ever taken. |
| 35 | - 200 would imply a normal profile, confusing crawlers and humans. |
| 36 | - 410 is semantically "this resource existed, it's gone now" — accurate for suspension, neutral about reason. |
| 37 | |
| 38 | The template never reveals *why* the user is suspended. |
| 39 | |
| 40 | ## Casing redirects |
| 41 | |
| 42 | 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: |
| 43 | |
| 44 | - `/Alice` → DB returns user with `username = "alice"` (citext) → 301 → `/alice` |
| 45 | - `/zzzzz` → DB miss → tryRedirectOrNotFound → 404 (no redirect) |
| 46 | |
| 47 | ## Username redirects |
| 48 | |
| 49 | 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: |
| 50 | |
| 51 | - Hit → 301 to the new canonical name. |
| 52 | - Miss → 404. |
| 53 | |
| 54 | The redirect record itself doubles as a 30-day reservation — the signup path will check this table when validating new usernames (handled in S10). |
| 55 | |
| 56 | ## Identicons |
| 57 | |
| 58 | `internal/avatars.Identicon(username, pixelSize)` returns a deterministic SVG built from `sha256(lowercase(username))`: |
| 59 | |
| 60 | - 5×5 grid, mirrored horizontally so every identicon is symmetric. |
| 61 | - Color picked from a fixed 12-entry palette derived from Primer accent ramps. |
| 62 | - Same input → same output. No upload needed. |
| 63 | - No user input ever reaches the SVG body — only sha256-derived bytes. |
| 64 | |
| 65 | `X-Content-Type-Options: nosniff` is set on the response as defense in depth. |
| 66 | |
| 67 | ## Avatar upload (placeholder) |
| 68 | |
| 69 | S09 does not implement upload. The avatar route reads `users.avatar_object_key`: |
| 70 | |
| 71 | - NULL → identicon |
| 72 | - Set + ObjectStore wired → stream from object store with `Cache-Control: public, max-age=31536000, immutable` |
| 73 | - Set + ObjectStore nil → identicon (graceful fallback) |
| 74 | |
| 75 | S10 will write the upload path, including resize-to-variants (`avatars/<owner>/<size>.<ext>`) and EXIF stripping. |
| 76 | |
| 77 | ## Privacy |
| 78 | |
| 79 | The profile renders only fields the user explicitly set (or that the system computed publicly): |
| 80 | |
| 81 | - Username, display name, bio, location, company, website, pronouns, joined date, avatar. |
| 82 | - **Email addresses NEVER appear on the profile page.** Post-MVP opt-in only. |
| 83 | - Verified-email status is exposed via the API (`/api/v1/user`), not the public page. |
| 84 | |
| 85 | ## Self-view enrichment |
| 86 | |
| 87 | When the viewer's session matches the profile's user (`viewer.ID == user.ID`): |
| 88 | |
| 89 | - A small "you" badge renders next to the display name. |
| 90 | - An "Edit profile" button links to `/settings/profile` (S10). |
| 91 | - A "Customize pins" modal lists the user's public repositories with a |
| 92 | live client-side filter and persists up to six selected repos through |
| 93 | `profile_pin_sets` / `profile_pins` (migration 0040). |
| 94 | - Cache-Control flips from `max-age=300` (anonymous) to `no-cache, private` so admin- or settings-driven changes appear immediately. |
| 95 | |
| 96 | Pinned repositories are intentionally public-only. Private repos are |
| 97 | not offered in the picker and saved pin IDs are revalidated against the |
| 98 | current public owner repo list before write. A `profile_pin_sets` row |
| 99 | records that the owner customized the set, so "zero pins" is distinct |
| 100 | from "never customized." |
| 101 | |
| 102 | ## OG metadata |
| 103 | |
| 104 | The layout reads `OGTitle`, `OGDescription`, `OGImage` from the page data and emits the corresponding meta tags. The profile handler populates all three: |
| 105 | |
| 106 | - Title = `"<display name> (@<username>)"` |
| 107 | - Description = bio if set, else `"@<username> on shithub"` |
| 108 | - Image = avatar URL (`/avatars/<username>`) |
| 109 | |
| 110 | Empty values omit the tag entirely. |
| 111 | |
| 112 | ## Caching policy |
| 113 | |
| 114 | | View | Cache-Control | |
| 115 | |---|---| |
| 116 | | Anonymous profile | `max-age=300` | |
| 117 | | Self-view | `no-cache, private` | |
| 118 | | Identicon | `public, max-age=86400` | |
| 119 | | Uploaded avatar | `public, max-age=31536000, immutable` | |
| 120 | |
| 121 | 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. |
| 122 | |
| 123 | ## Sprint-test discipline |
| 124 | |
| 125 | `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. |
| 126 | |
| 127 | Other tests cover: |
| 128 | |
| 129 | - Existing-user 200 with rendered fields. |
| 130 | - Unknown-user 404. |
| 131 | - Casing redirect 301. |
| 132 | - Username-change redirect 301. |
| 133 | - Suspended user 410 with the unavailable template. |
| 134 | - User and organization pin customization, including public-only |
| 135 | candidate filtering and owner-only writes. |
| 136 | - Reserved-name path with a registered handler routes there (NOT the wildcard). |
| 137 | - Reserved-name path without a registered handler short-circuits with 404 (NOT a DB lookup). |
| 138 | - Avatar identicon falls back when no key is stored. |
| 139 | |
| 140 | ## Pitfalls / what to remember |
| 141 | |
| 142 | - **Wildcard ordering** is everything. `/{username}` MUST be registered after every static top-level route. Defense in depth via the reserved-name list catches drift. |
| 143 | - **Casing redirect must not loop.** Match on canonical case ONLY after a successful lookup confirmed the user exists. |
| 144 | - **Avatar route must not 404 on missing user.** It silently falls back to the identicon — leaking less existence info. |
| 145 | - **Suspended page is 410, not 404.** Picked deliberately for the privacy/honesty trade-off. |
| 146 | - **Profile data is public.** Don't add private fields without a feature gate. |
| 147 | |
| 148 | ## Related docs |
| 149 | |
| 150 | - `docs/internal/auth.md` — email/password auth (S05). |
| 151 | - `docs/internal/storage.md` — avatar object-store path conventions (S04). |
| 152 | - `docs/internal/tokens.md` — `/api/v1/user` PAT-authenticated profile JSON (S08). |