markdown · 11733 bytes Raw Blame History

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 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.
  • 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 blob or raw route 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.IsVisibleTo before 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 with policy.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_contributions preference. The checked state mirrors the persisted setting, and the graph is recomputed after the POST redirect.
  • Cache-Control flips from max-age=300 (anonymous) to no-cache, private so 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.
  • 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
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).