User profile
S09 shipped the public /{username} page and the /avatars/{username} route. Later sprints added edit-profile UI, pinned repositories, a profile README card, and a local contribution calendar so the overview follows GitHub's profile layout.
Routes
| Route | Source | Notes |
|---|---|---|
GET /{username} |
profile.serveProfile | Public profile. citext lookup; canonical-case 301; reserved short-circuit. |
GET /{username}?tab=repositories |
profile.serveRepositoriesTab | Visibility-filtered user-owned repositories. |
GET /{username}?tab=stars |
profile.serveStarsTab | Visibility-filtered starred repositories with search, type, language, and sort controls. |
POST /{username}/contribution-settings |
profile.contributionSettingsUpdate | Auth required. Profile owner toggles private contribution counts. |
POST /{username}/pins |
profile.pinsUpdate | Auth required. Profile owner saves up to six public affiliated 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.
- Organizations the viewer can already discover through membership/profile navigation.
- Public pinned repositories and visible repository activity.
- 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.
Profile README
The overview looks for a visible repository owned by the user whose name matches the username, then renders a root-level README* file in a GitHub-style bordered card:
- Markdown is rendered only through
internal/markdown.RenderDocumentHTML. - Plain non-Markdown README files are HTML-escaped and shown in a
<pre>. - Relative links and images are rewritten to the matching repository
bloborrawroute on the repository default branch. - The self-view edit pencil links to the actual repository default branch, not a hard-coded
trunk. - Visibility is checked with
policy.IsVisibleTo; private profile READMEs only render to actors who can already see that repository.
Contribution calendar
The overview contribution calendar is computed from local Git history:
- The window is the last 365 days, rendered as a 53-week GitHub-style grid.
- Public repositories are scanned when visible to the viewer, capped at 500 repos and 5,000 commits per repo for request-time safety.
- When the user has verified email addresses, commits are counted only if the author email matches one of them.
- On affiliated repositories (user-owned repos and repos owned by organizations the user belongs to), shithub also accepts username, display-name, and GitHub noreply-address matches as a best-effort imported-history signal.
- Arbitrary public repositories outside the user's affiliation remain verified-email-only to avoid spoofed username/display-name commits.
- Private repositories are excluded by default. When the profile owner enables "Private contributions", private user-owned and member-org repositories contribute to the aggregate graph counts, but repository names and commit metadata are not exposed.
The "Contribution activity" timeline below the graph reuses the same visibility gates:
- Commit rows are grouped by month and repository, with private repository names collapsed to "Private repositories".
- Public user-owned repositories created during the selected contribution window are shown as "Created repositories" entries.
- Issues and pull requests authored by the profile user are loaded through
the issues sqlc package and then post-filtered with
policy.IsVisibleTobefore rendering. PR rows distinguish open, closed, and merged counts. - The first month is shown initially; the "Show more activity" button expands older months in place without another request.
Stars tab
/{username}?tab=stars uses the same profile shell as the GitHub stars page:
- The left profile sidebar matches the overview profile metadata and organization badges.
- The placeholder Lists panel is rendered so the page layout matches GitHub while list management remains future work.
- Starred repositories are loaded in one bounded scan (
starsTabScanLimit) and then visibility-filtered withpolicy.IsVisibleTo. - Both user-owned and organization-owned repository stars render from the stored owner slug, so org-owned stars do not require an extra owner lookup.
- Filters are applied server-side for search text, repository type, language,
and sort order (
recently-starred,recently-active,stars).
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 public repositories affiliated with the
user: user-owned repositories, repositories owned by organizations the
user belongs to, and repositories where the user is an explicit
collaborator. The modal has a live client-side filter and persists up
to six selected repos through
profile_pin_sets/profile_pins(migration 0040). - The "Contribution settings" menu toggles the owner's
users.include_private_contributionspreference. The checked state mirrors the persisted setting, and the graph is recomputed after the POST redirect. - 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 affiliated 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 |
| 2 | |
| 3 | S09 shipped the public `/{username}` page and the `/avatars/{username}` route. Later sprints added edit-profile UI, pinned repositories, a profile README card, and a local contribution calendar so the overview follows GitHub's profile layout. |
| 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 /{username}?tab=repositories` | profile.serveRepositoriesTab | Visibility-filtered user-owned repositories. | |
| 11 | | `GET /{username}?tab=stars` | profile.serveStarsTab | Visibility-filtered starred repositories with search, type, language, and sort controls. | |
| 12 | | `POST /{username}/contribution-settings` | profile.contributionSettingsUpdate | Auth required. Profile owner toggles private contribution counts. | |
| 13 | | `POST /{username}/pins` | profile.pinsUpdate | Auth required. Profile owner saves up to six public affiliated repositories. | |
| 14 | | `GET /avatars/{username}` | profile.serveAvatar | Streams uploaded avatar OR falls back to deterministic SVG identicon. | |
| 15 | |
| 16 | `/{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. |
| 17 | |
| 18 | ## Behavior summary |
| 19 | |
| 20 | | Request | Response | |
| 21 | |---|---| |
| 22 | | `/alice` (exists) | 200 + profile page | |
| 23 | | `/Alice` (canonical is `alice`) | 301 → `/alice` | |
| 24 | | `/oldname` (renamed → `alice`) | 301 → `/alice` (via `username_redirects`) | |
| 25 | | `/badactor` (suspended) | 410 + "account unavailable" page | |
| 26 | | `/no-such-user` | 404 styled error | |
| 27 | | `/login` (reserved) | route handler runs (NOT the profile catch-all) | |
| 28 | | `/admin` (reserved, no handler) | profile handler short-circuits with 404 | |
| 29 | | `/avatars/alice` (no upload) | 200 + identicon SVG | |
| 30 | | `/avatars/alice` (uploaded) | 200 + object-store stream | |
| 31 | | `/avatars/no-such-user` | 200 + identicon (no existence leak) | |
| 32 | |
| 33 | ## Suspended users — 410 not 404 |
| 34 | |
| 35 | Suspended (or soft-deleted-during-grace) users render the dedicated "account unavailable" template with status 410 (Gone). Choices behind that: |
| 36 | |
| 37 | - 404 would lie about existence — letting an attacker probe whether a name was ever taken. |
| 38 | - 200 would imply a normal profile, confusing crawlers and humans. |
| 39 | - 410 is semantically "this resource existed, it's gone now" — accurate for suspension, neutral about reason. |
| 40 | |
| 41 | The template never reveals *why* the user is suspended. |
| 42 | |
| 43 | ## Casing redirects |
| 44 | |
| 45 | 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: |
| 46 | |
| 47 | - `/Alice` → DB returns user with `username = "alice"` (citext) → 301 → `/alice` |
| 48 | - `/zzzzz` → DB miss → tryRedirectOrNotFound → 404 (no redirect) |
| 49 | |
| 50 | ## Username redirects |
| 51 | |
| 52 | 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: |
| 53 | |
| 54 | - Hit → 301 to the new canonical name. |
| 55 | - Miss → 404. |
| 56 | |
| 57 | The redirect record itself doubles as a 30-day reservation — the signup path will check this table when validating new usernames (handled in S10). |
| 58 | |
| 59 | ## Identicons |
| 60 | |
| 61 | `internal/avatars.Identicon(username, pixelSize)` returns a deterministic SVG built from `sha256(lowercase(username))`: |
| 62 | |
| 63 | - 5×5 grid, mirrored horizontally so every identicon is symmetric. |
| 64 | - Color picked from a fixed 12-entry palette derived from Primer accent ramps. |
| 65 | - Same input → same output. No upload needed. |
| 66 | - No user input ever reaches the SVG body — only sha256-derived bytes. |
| 67 | |
| 68 | `X-Content-Type-Options: nosniff` is set on the response as defense in depth. |
| 69 | |
| 70 | ## Avatar upload (placeholder) |
| 71 | |
| 72 | S09 does not implement upload. The avatar route reads `users.avatar_object_key`: |
| 73 | |
| 74 | - NULL → identicon |
| 75 | - Set + ObjectStore wired → stream from object store with `Cache-Control: public, max-age=31536000, immutable` |
| 76 | - Set + ObjectStore nil → identicon (graceful fallback) |
| 77 | |
| 78 | S10 will write the upload path, including resize-to-variants (`avatars/<owner>/<size>.<ext>`) and EXIF stripping. |
| 79 | |
| 80 | ## Privacy |
| 81 | |
| 82 | The profile renders only fields the user explicitly set (or that the system computed publicly): |
| 83 | |
| 84 | - Username, display name, bio, location, company, website, pronouns, joined date, avatar. |
| 85 | - Organizations the viewer can already discover through membership/profile navigation. |
| 86 | - Public pinned repositories and visible repository activity. |
| 87 | - **Email addresses NEVER appear on the profile page.** Post-MVP opt-in only. |
| 88 | - Verified-email status is exposed via the API (`/api/v1/user`), not the public page. |
| 89 | |
| 90 | ## Profile README |
| 91 | |
| 92 | The overview looks for a visible repository owned by the user whose name matches the username, then renders a root-level `README*` file in a GitHub-style bordered card: |
| 93 | |
| 94 | - Markdown is rendered only through `internal/markdown.RenderDocumentHTML`. |
| 95 | - Plain non-Markdown README files are HTML-escaped and shown in a `<pre>`. |
| 96 | - Relative links and images are rewritten to the matching repository `blob` or `raw` route on the repository default branch. |
| 97 | - The self-view edit pencil links to the actual repository default branch, not a hard-coded `trunk`. |
| 98 | - Visibility is checked with `policy.IsVisibleTo`; private profile READMEs only render to actors who can already see that repository. |
| 99 | |
| 100 | ## Contribution calendar |
| 101 | |
| 102 | The overview contribution calendar is computed from local Git history: |
| 103 | |
| 104 | - The window is the last 365 days, rendered as a 53-week GitHub-style grid. |
| 105 | - Public repositories are scanned when visible to the viewer, capped at 500 repos and 5,000 commits per repo for request-time safety. |
| 106 | - When the user has verified email addresses, commits are counted only if the author email matches one of them. |
| 107 | - On affiliated repositories (user-owned repos and repos owned by organizations the user belongs to), shithub also accepts username, display-name, and GitHub noreply-address matches as a best-effort imported-history signal. |
| 108 | - Arbitrary public repositories outside the user's affiliation remain verified-email-only to avoid spoofed username/display-name commits. |
| 109 | - Private repositories are excluded by default. When the profile owner enables "Private contributions", private user-owned and member-org repositories contribute to the aggregate graph counts, but repository names and commit metadata are not exposed. |
| 110 | |
| 111 | The "Contribution activity" timeline below the graph reuses the same |
| 112 | visibility gates: |
| 113 | |
| 114 | - Commit rows are grouped by month and repository, with private repository |
| 115 | names collapsed to "Private repositories". |
| 116 | - Public user-owned repositories created during the selected contribution |
| 117 | window are shown as "Created repositories" entries. |
| 118 | - Issues and pull requests authored by the profile user are loaded through |
| 119 | the issues sqlc package and then post-filtered with `policy.IsVisibleTo` |
| 120 | before rendering. PR rows distinguish open, closed, and merged counts. |
| 121 | - The first month is shown initially; the "Show more activity" button expands |
| 122 | older months in place without another request. |
| 123 | |
| 124 | ## Stars tab |
| 125 | |
| 126 | `/{username}?tab=stars` uses the same profile shell as the GitHub stars page: |
| 127 | |
| 128 | - The left profile sidebar matches the overview profile metadata and |
| 129 | organization badges. |
| 130 | - The placeholder Lists panel is rendered so the page layout matches GitHub |
| 131 | while list management remains future work. |
| 132 | - Starred repositories are loaded in one bounded scan (`starsTabScanLimit`) and |
| 133 | then visibility-filtered with `policy.IsVisibleTo`. |
| 134 | - Both user-owned and organization-owned repository stars render from the |
| 135 | stored owner slug, so org-owned stars do not require an extra owner lookup. |
| 136 | - Filters are applied server-side for search text, repository type, language, |
| 137 | and sort order (`recently-starred`, `recently-active`, `stars`). |
| 138 | |
| 139 | ## Self-view enrichment |
| 140 | |
| 141 | When the viewer's session matches the profile's user (`viewer.ID == user.ID`): |
| 142 | |
| 143 | - A small "you" badge renders next to the display name. |
| 144 | - An "Edit profile" button links to `/settings/profile` (S10). |
| 145 | - A "Customize pins" modal lists public repositories affiliated with the |
| 146 | user: user-owned repositories, repositories owned by organizations the |
| 147 | user belongs to, and repositories where the user is an explicit |
| 148 | collaborator. The modal has a live client-side filter and persists up |
| 149 | to six selected repos through `profile_pin_sets` / `profile_pins` |
| 150 | (migration 0040). |
| 151 | - The "Contribution settings" menu toggles the owner's |
| 152 | `users.include_private_contributions` preference. The checked state |
| 153 | mirrors the persisted setting, and the graph is recomputed after the |
| 154 | POST redirect. |
| 155 | - Cache-Control flips from `max-age=300` (anonymous) to `no-cache, private` so admin- or settings-driven changes appear immediately. |
| 156 | |
| 157 | Pinned repositories are intentionally public-only. Private repos are |
| 158 | not offered in the picker and saved pin IDs are revalidated against the |
| 159 | current public affiliated repo list before write. A `profile_pin_sets` |
| 160 | row records that the owner customized the set, so "zero pins" is |
| 161 | distinct from "never customized." |
| 162 | |
| 163 | ## OG metadata |
| 164 | |
| 165 | The layout reads `OGTitle`, `OGDescription`, `OGImage` from the page data and emits the corresponding meta tags. The profile handler populates all three: |
| 166 | |
| 167 | - Title = `"<display name> (@<username>)"` |
| 168 | - Description = bio if set, else `"@<username> on shithub"` |
| 169 | - Image = avatar URL (`/avatars/<username>`) |
| 170 | |
| 171 | Empty values omit the tag entirely. |
| 172 | |
| 173 | ## Caching policy |
| 174 | |
| 175 | | View | Cache-Control | |
| 176 | |---|---| |
| 177 | | Anonymous profile | `max-age=300` | |
| 178 | | Self-view | `no-cache, private` | |
| 179 | | Identicon | `public, max-age=86400` | |
| 180 | | Uploaded avatar | `public, max-age=31536000, immutable` | |
| 181 | |
| 182 | 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. |
| 183 | |
| 184 | ## Sprint-test discipline |
| 185 | |
| 186 | `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. |
| 187 | |
| 188 | Other tests cover: |
| 189 | |
| 190 | - Existing-user 200 with rendered fields. |
| 191 | - Unknown-user 404. |
| 192 | - Casing redirect 301. |
| 193 | - Username-change redirect 301. |
| 194 | - Suspended user 410 with the unavailable template. |
| 195 | - User and organization pin customization, including public-only |
| 196 | candidate filtering and owner-only writes. |
| 197 | - Reserved-name path with a registered handler routes there (NOT the wildcard). |
| 198 | - Reserved-name path without a registered handler short-circuits with 404 (NOT a DB lookup). |
| 199 | - Avatar identicon falls back when no key is stored. |
| 200 | |
| 201 | ## Pitfalls / what to remember |
| 202 | |
| 203 | - **Wildcard ordering** is everything. `/{username}` MUST be registered after every static top-level route. Defense in depth via the reserved-name list catches drift. |
| 204 | - **Casing redirect must not loop.** Match on canonical case ONLY after a successful lookup confirmed the user exists. |
| 205 | - **Avatar route must not 404 on missing user.** It silently falls back to the identicon — leaking less existence info. |
| 206 | - **Suspended page is 410, not 404.** Picked deliberately for the privacy/honesty trade-off. |
| 207 | - **Profile data is public.** Don't add private fields without a feature gate. |
| 208 | |
| 209 | ## Related docs |
| 210 | |
| 211 | - `docs/internal/auth.md` — email/password auth (S05). |
| 212 | - `docs/internal/storage.md` — avatar object-store path conventions (S04). |
| 213 | - `docs/internal/tokens.md` — `/api/v1/user` PAT-authenticated profile JSON (S08). |