feat(web): require GitHub sign-in to generate cards

The web UI now runs every job on an OAuth token from "Sign in with
GitHub". The ticked options decide the requested scopes (read:user, plus
repo for private repos, plus read:org for org repos); a grant wider than
requested is refused. The token lives only on the job and is revoked when
the job ends, on every path including shutdown.

The server no longer holds a GitHub token of its own, and the pasted-token
field and /generate are gone. -serve requires -oauth-client-id,
-oauth-client-secret and -public-url (GHGLANCE_OAUTH_* and
GHGLANCE_PUBLIC_URL), and compose.yml requires them too. The CLI and the
Action keep -token unchanged.
This commit is contained in:
tiennm99 committed 2026-10-07 17:50:33 +07:00
1 parent 3b157e4215
commit 2def27f49a
17 files changed
+1599 -406

No files matched your search

+14 -3
View File
@@ -108,10 +108,21 @@ the `ghglance-data` volume, and health-checks `/healthz` with busybox
| Variable | Needed for |
| --- | --- |
| `GHGLANCE_TOKEN` | Required by `compose.yml`, which passes it to the container as `GITHUB_TOKEN` for token-less submissions. Must be public-only: a classic PAT with just `read:user`. A token with `repo` scope or any private-repo access is refused for token-less jobs (GitHub would count private contributions in totals and calendars). |
| `GHGLANCE_OAUTH_CLIENT_ID` | Required. Client ID of the GitHub OAuth App behind "Sign in with GitHub" (`-oauth-client-id`). |
| `GHGLANCE_OAUTH_CLIENT_SECRET` | Required. That OAuth App's client secret (`-oauth-client-secret`). Never logged or printed. |
| `GHGLANCE_PUBLIC_URL` | Required. The site's external origin, e.g. `https://ghglance.sg.miti99.com` (`-public-url`). The OAuth App's callback URL must be exactly `<public-url>/auth/callback`. |
Coolify: create a Docker Compose resource from this repo, compose file
`/compose.yml`, set `GHGLANCE_TOKEN`, assign the domain, deploy. Server flags
The web UI is sign-in only: the server holds no GitHub token, and every
generation runs on the visitor's OAuth token, revoked when the job ends.
`compose.yml` refuses to start while any of the three variables is empty,
and `-serve` exits with an error naming the missing settings.
Coolify: register an OAuth App (GitHub **Settings > Developer settings >
OAuth Apps > New OAuth App**; homepage URL = the domain, authorization
callback URL = `<domain>/auth/callback`) and generate a client secret.
Then create a Docker Compose resource from this repo, compose file
`/compose.yml`, set the three variables, assign the domain, and deploy;
the startup log prints the callback URL it uses. Server flags
(`-cooldown`, `-retention`, `-workers`, `-timeout`) are changed by editing `command:` in
`compose.yml`. Steps for a plain Docker host and the request-handling rules
are in the README's "Run the web UI" section.
+41 -18
View File
@@ -152,13 +152,18 @@ Light themes (`default`, `github`, `nord_bright`, etc.) use `StrokeOpacity: 1` w
CLI path.
```
POST /generate ─► validate ─► rate limit / cooldown ─► Queue (dedup per user)
│ -workers goroutines
▼
github.Collect ─► Store.Publish (every theme)
│
GET /u/{user} ◄── meta.json + card list ◄─────────────────┘
POST /auth/start ─► validate ─► rate limit ─► pending sign-in (state, PKCE verifier; memory, 10 min)
─► 302 github.com/login/oauth/authorize (scope from ticks, state, S256 challenge)
GET /auth/callback ─► state == cookie, single use ─► POST /login/oauth/access_token
─► narrow options to granted scopes (wider than ticked: revoke, refuse)
─► Queue (dedup per user) ─► -workers goroutines
│ token owner check, cooldown
▼
github.Collect ─► Store.Publish (every theme)
│
GET /u/{user} ◄── meta.json + card list ◄──────────┘
GET /u/{user}/{theme}/{card}.svg ◄── os.Root read
job ends ─► DELETE api.github.com/applications/{client_id}/token
```
- **Storage.** `<data>/<user>` (lowercased login) is a symlink into
@@ -176,24 +181,42 @@ GET /u/{user}/{theme}/{card}.svg ◄── os.Root read
`-workers` concurrent, `-timeout` each, capacity 64. `SIGTERM` stops the
HTTP server, cancels running jobs and drops queued ones; nothing is
published mid-render.
- **Tokens.** GitHub folds every private contribution a token can see into
totals and calendars, so repo filters alone cannot keep cards public. Each
job first identifies its token (`viewer` query: login, a one-repo
`privacy: PRIVATE` probe, and a classic token's `X-OAuth-Scopes`).
Token-less jobs use the server's `GITHUB_TOKEN` (identified once and
cached) with private and org-repo scope forced off; they are refused when
that token can read private repos, and for the token owner's own login. A
submitter's token keeps its scope and skips the cooldown only for its own
login; for anyone else it is refused if private-capable, otherwise forced
to public scope under the cooldown. It lives only on the job and is
cleared when it ends.
- **Tokens.** The server has no GitHub token of its own: every job runs on
the token of the visitor's sign-in. GitHub folds every private
contribution a token can see into totals and calendars, so repo filters
alone cannot keep cards public. Each job first identifies its token
(`viewer` query: login, a one-repo `privacy: PRIVATE` probe, and a
classic token's `X-OAuth-Scopes`). Signed in as the target login, the job
keeps the ticked scope and skips the cooldown; as anyone else it is
refused if private-capable, otherwise forced to public scope under the
cooldown. The token lives only on the job and is cleared when it ends.
- **Sign in with GitHub** (`internal/web/oauth.go`). OAuth App web flow,
required: `-serve` exits at startup unless `-oauth-client-id`,
`-oauth-client-secret` and `-public-url` are all set. Scopes come from
the ticked options (`read:user`; `repo` for private; `read:org` on top
for org repos). `/auth/start` parks the validated submission under a
256-bit random `state` (10-minute TTL, 1,000 entries max) and binds it to
the browser with an `HttpOnly`, `SameSite=Lax` cookie on `/`, named
`__Host-ghglance_oauth` and `Secure` when the public URL is https so a
sibling subdomain cannot plant it (plain `ghglance_oauth` over http, a
weaker binding). The callback compares state and cookie in constant time,
consumes the entry, exchanges the code with the PKCE verifier (15 s
timeout), drops options whose scope GitHub did not grant, revokes and
refuses a token carrying scopes the ticks did not ask for (GitHub folds
earlier grants into new tokens), and hands the token to the queue. The
worker revokes it before it reports the job finished; `Stop` revokes the
tokens of queued jobs it drops, and a callback whose job is not queued
revokes at once. Revocation is best effort and logs only the status.
GitHub endpoints come from `OAuthConfig.WebURL`/`APIURL`, so tests run
against `httptest`. The page CSP adds GitHub's origin to `form-action`,
since browsers check a form submission's redirect against it.
- **Partial fetches.** The web path runs `github.Collect` with `Strict`, so
a failed all-time or commit-history stage fails the job, and a job whose
deadline passed is failed even if the fetch returned. The CLI keeps
rendering partial data with warnings.
- **HTTP hardening.** Strict CSP on pages, `default-src 'none'` + `sandbox`
on SVGs, `nosniff`, 16 KiB form limit, `http.CrossOriginProtection` on the
POST, per-client token bucket (burst 5, +1 per 2 min) keyed by IPv4
POSTs, per-client token bucket (burst 5, +1 per 2 min) keyed by IPv4
address or IPv6 /64, capped at 10,000 tracked clients.
## Extension points