diff --git a/README.md b/README.md index 6ee5f1c3..c944e343 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ ghglance -user tiennm99 -themes dracula -include-org-repos -out output | `-list-themes` | | Print available theme ids and exit | | `-serve` | | Run the [web UI](#run-the-web-ui) on this address (e.g. `:8080`) instead of generating once | | `-data-dir` | `data` | Web UI only: directory holding generated cards | -| `-cooldown` | `6h` | Web UI only: minimum age of a user's cards before someone signed in as another account regenerates them | +| `-cooldown` | `6h` | Web UI only: minimum age of a user's cards before a sign-in or token for another account regenerates them | | `-retention` | `24h` | Web UI only: delete a user's cards this long after they were generated, `0` = keep forever | | `-workers` | `2` | Web UI only: concurrent generation jobs | | `-oauth-client-id` | `$GHGLANCE_OAUTH_CLIENT_ID` | Web UI only, required: GitHub OAuth App client ID for [Sign in with GitHub](#sign-in-with-github) | @@ -189,15 +189,20 @@ the Action (`action.yml`, `entrypoint.sh`) does not expose them. ## Run the web UI -`-serve` turns the binary into a small web app: a form takes a GitHub -username plus options, the visitor signs in with GitHub, a background job -renders all sixteen cards in every theme on that sign-in's token, and -`/u/` shows them again with a theme picker and copyable embed -URLs. Cards are stored on disk and survive restarts. +`-serve` turns the binary into a small web app for quickly viewing +profile cards: a form takes a GitHub username plus options, the visitor +signs in with GitHub or pastes their own token, a background job renders +all sixteen cards in every theme on that token, and `/u/` shows +them with a theme picker. Cards are stored on disk and survive restarts. -[Sign in with GitHub](#sign-in-with-github) is required: the server has no -GitHub token of its own, and `-serve` refuses to start until the OAuth -client ID, client secret and public URL are all set. +The web UI is for viewing, not hosting: cards are inlined into the page as +`data:` URIs, so there is no card URL to link to or put in Markdown. To +show cards in a README, use the [GitHub Action](#use-as-a-github-action-recommended) +or the [CLI](#use-as-a-cli). + +The server has no GitHub token of its own. [Sign in with +GitHub](#sign-in-with-github) must be configured: `-serve` refuses to start +until the OAuth client ID, client secret and public URL are all set. ```sh export GHGLANCE_OAUTH_CLIENT_SECRET=xxxx # keep the secret out of the command line @@ -209,30 +214,30 @@ ghglance -serve :8080 -data-dir data -retention 24h \ | Path | Serves | | --- | --- | | `/` | The submission form | -| `/u/` | The user's cards (`?theme=` picks the theme), or job progress while one runs | -| `/u///.svg` | One card, embeddable in a README | +| `/u/` | The user's cards inlined as `data:` images (`?theme=` picks the theme), or job progress while one runs | | `/u//status` | Job status as JSON, polled by the progress page | -| `/auth/start` | Validates the form and redirects to GitHub's consent page | +| `/auth/start` | Validates the form, then queues the job on a pasted token or redirects to GitHub's consent page | | `/auth/callback` | Finishes the sign-in and queues the job | | `/healthz` | Liveness probe | How submissions are handled: -- **Every job runs on a sign-in token.** The token is used for that one - job, then revoked: never logged, never written to disk. Signed in as the - username being generated, the ticked private and org repos count and the - cooldown is skipped. Signed in as someone else, the job renders public - data only, does not skip the cooldown, and is refused outright if the - token can read private repositories. The cards are public on the site - like any other. +- **Every job runs on the visitor's token**: a sign-in token, or one they + paste (see [Use your own token](#use-your-own-token)). The token is used + for that one job only: never logged, never written to disk. A token for + the username being generated keeps the ticked private and org repos and + skips the cooldown. A token for someone else renders public data only, + does not skip the cooldown, and is refused outright if it can read + private repositories. The cards are visible on the site to anyone with + the page link. - **Failures.** A job that fails or times out at any fetch stage publishes nothing, so an earlier complete set stays in place. -- **Cooldown.** Cards younger than `-cooldown` are not regenerated by a - sign-in as another account; the owner's own sign-in skips the wait. +- **Cooldown.** Cards younger than `-cooldown` are not regenerated with a + sign-in or token for another account; the owner's own token skips the + wait. - **Retention.** Cards are deleted `-retention` (default `24h`) after they were generated, checked at startup and hourly. The user's page then - offers a fresh generation, and embedded card URLs return 404 until - someone regenerates them. + offers a fresh generation. - **Limits.** One queued or running job per user, `-workers` jobs at once, `-timeout` per job, and five submissions per client followed by one every two minutes. A client is an IPv4 address or an IPv6 /64. Behind a @@ -285,6 +290,21 @@ Then generate a client secret and give the server all three values `GHGLANCE_PUBLIC_URL` environment variables). With any of them missing, `-serve` exits at startup with an error naming the missing settings. +### Use your own token + +Below the sign-in button, a collapsed **Or use your own token** section +takes a token the visitor creates themselves. Its **Create a token on +GitHub** button opens GitHub's new classic token page with `repo` and +`read:user` pre-ticked. + +- A non-empty token takes precedence over signing in, whichever button is + pressed, so Enter in the token field never starts a sign-in. +- It goes through the same validation, rate limit, queue, ownership, + privacy and cooldown rules as a sign-in token. +- It is used for that one generation only and never stored, logged or + echoed back into the form. It is not revoked: it belongs to the + visitor, who deletes it on GitHub when done. + Each user takes about 9 MB on disk for an active profile (16 cards × every theme). ### Deploy with Docker Compose or Coolify diff --git a/compose.yml b/compose.yml index 437b8f66..5d88e820 100644 --- a/compose.yml +++ b/compose.yml @@ -11,8 +11,9 @@ services: - "8080" environment: - SERVICE_FQDN_GHGLANCE_8080 - # "Sign in with GitHub" (a GitHub OAuth App) is required: every - # generation runs on the visitor's own sign-in token. + # "Sign in with GitHub" (a GitHub OAuth App) is required. The server + # has no token of its own: every generation runs on the visitor's + # sign-in token or a token they paste into the form. - GHGLANCE_OAUTH_CLIENT_ID=${GHGLANCE_OAUTH_CLIENT_ID:?set the GitHub OAuth App client ID} - GHGLANCE_OAUTH_CLIENT_SECRET=${GHGLANCE_OAUTH_CLIENT_SECRET:?set the GitHub OAuth App client secret} - GHGLANCE_PUBLIC_URL=${GHGLANCE_PUBLIC_URL:?set the external origin, e.g. https://ghglance.example.com} diff --git a/docs/deployment-guide.md b/docs/deployment-guide.md index 82df52a6..d8854fb6 100644 --- a/docs/deployment-guide.md +++ b/docs/deployment-guide.md @@ -112,8 +112,12 @@ the `ghglance-data` volume, and health-checks `/healthz` with busybox | `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 `/auth/callback`. | -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. +The server holds no GitHub token: every generation runs on the visitor's +token, either from Sign in with GitHub (revoked when the job ends) or one +they paste into the form (used once, never stored, not revoked). The +OAuth App is still required. No token variable exists. The site is for +quick viewing only: cards are inlined into `/u/` and have no URL of +their own. `compose.yml` refuses to start while any of the three variables is empty, and `-serve` exits with an error naming the missing settings. diff --git a/docs/system-architecture.md b/docs/system-architecture.md index 7b2948b5..9d8e98a5 100644 --- a/docs/system-architecture.md +++ b/docs/system-architecture.md @@ -149,10 +149,13 @@ Light themes (`default`, `github`, `nord_bright`, etc.) use `StrokeOpacity: 1` w `ghglance -serve :8080` runs `internal/web` (stdlib `net/http`, `html/template`, `embed`; vanilla JS, no build step) instead of the one-shot -CLI path. +CLI path. It is for quickly viewing cards, not hosting them: the user page +inlines each card as a `data:` URI and no route serves a card by URL, so +cards cannot be linked to or embedded in Markdown. ``` -POST /auth/start ─► validate ─► rate limit ─► pending sign-in (state, PKCE verifier; memory, 10 min) +POST /auth/start ─► validate ─► rate limit ─┬─ pasted token ─► Queue (below), never revoked + └─ 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) @@ -161,9 +164,8 @@ GET /auth/callback ─► state == cookie, single use ─► POST /login/oauth/a ▼ 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 +GET /u/{user} ◄── meta.json + os.Root card reads, inlined as data: URIs +job ends (sign-in token only) ─► DELETE api.github.com/applications/{client_id}/token ``` - **Storage.** `/` (lowercased login) is a symlink into @@ -182,16 +184,23 @@ job ends ─► DELETE api.github.com/applications/{client_id}/token HTTP server, cancels running jobs and drops queued ones; nothing is published mid-render. - **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 + the visitor's token, from their sign-in or pasted into the form's + collapsed "Or use your own token" section. A non-empty pasted token + (charset-checked like a sign-in token, which rules out header injection) + takes precedence in `/auth/start` whichever button sent the form, and is + queued straight away instead of starting a 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. + cooldown. The rules are the same for both kinds of token. The token + lives only on the job and is cleared when it ends; it is never logged, + written to disk or echoed into the form. Only sign-in tokens are + revoked; a pasted token belongs to the visitor. - **Sign in with GitHub** (`internal/web/oauth.go`). OAuth App web flow, - required: `-serve` exits at startup unless `-oauth-client-id`, + configuration required even though visitors may paste a token instead: `-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 @@ -214,8 +223,10 @@ job ends ─► DELETE api.github.com/applications/{client_id}/token 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 +- **HTTP hardening.** Strict CSP on pages, with `img-src 'self' data:` for + the inlined cards (an SVG loaded through `` runs no scripts and + fetches nothing, and stays isolated from the page and the other cards), + `nosniff`, 16 KiB form limit, `http.CrossOriginProtection` on the POSTs, per-client token bucket (burst 5, +1 per 2 min) keyed by IPv4 address or IPv6 /64, capped at 10,000 tracked clients. diff --git a/internal/web/jobs.go b/internal/web/jobs.go index 978a6359..7cb59e15 100644 --- a/internal/web/jobs.go +++ b/internal/web/jobs.go @@ -32,8 +32,10 @@ const queueCapacity = 64 var ( errQueueFull = errors.New("the generation queue is full, try again in a few minutes") errStopped = errors.New("server is shutting down") - errNoToken = errors.New("this generation has no sign-in token; sign in with GitHub again") + errNoToken = errors.New("this generation has no token; sign in with GitHub or paste your own token") errFresh = errors.New("these cards are recent; you signed in as another account, so it does not skip the wait") + // errFreshPasted is errFresh for a pasted token. + errFreshPasted = errors.New("these cards are recent; your token belongs to another account, so it does not skip the wait") ) // Fetcher runs the GitHub fetch. Tests swap in a fake; the server uses @@ -53,14 +55,16 @@ func (githubFetcher) TokenInfo(ctx context.Context, token string) (github.TokenI return github.NewClient(token).TokenInfo(ctx) } -// job is one queued generation. token is the submitter's sign-in token, -// held only until the job ends, never logged or persisted, and revoked on -// GitHub then. +// job is one queued generation. token is the submitter's token, held only +// until the job ends and never logged or persisted. A sign-in token is +// revoked on GitHub then; a pasted one (pasted is true) belongs to the +// visitor and is only dropped. type job struct { - key string - login string - opts Options - token string + key string + login string + opts Options + token string + pasted bool state string stage string @@ -89,7 +93,8 @@ type Queue struct { timeout time.Duration cooldown time.Duration now func() time.Time - // revoke deletes a sign-in token on GitHub once its job is over. + // revoke deletes a sign-in token on GitHub once its job is over. It is + // never called with a pasted token. revoke func(token string) mu sync.Mutex @@ -148,6 +153,7 @@ func (q *Queue) Submit(sub submission) (created bool, err error) { login: sub.Login, opts: sub.Options, token: sub.Token, + pasted: sub.Pasted, state: stateQueued, queued: q.now(), } @@ -182,14 +188,17 @@ func (q *Queue) Status(login string) JobStatus { } // Stop cancels running jobs, drops queued ones and waits for the workers. -// Tokens of dropped jobs are revoked before it returns; running jobs revoke -// theirs as their workers wind down. +// Sign-in tokens of dropped jobs are revoked before it returns; running +// jobs revoke theirs as their workers wind down. Pasted tokens are only +// dropped. func (q *Queue) Stop() { q.mu.Lock() q.closed = true var dropped []string for _, j := range q.pending { - dropped = append(dropped, j.token) + if !j.pasted { + dropped = append(dropped, j.token) + } j.token = "" j.state, j.err = stateFailed, errStopped.Error() } @@ -242,12 +251,15 @@ func (q *Queue) worker() { err := q.run(j) // Revoke before reporting the job over, so a finished job never - // leaves a live sign-in token behind. + // leaves a live sign-in token behind. A pasted token is the + // visitor's to keep or revoke; it is only dropped. q.mu.Lock() token := j.token j.token = "" q.mu.Unlock() - q.revokeToken(token) + if !j.pasted { + q.revokeToken(token) + } q.mu.Lock() j.finished = q.now() @@ -289,15 +301,24 @@ func (q *Queue) run(j *job) error { opts := j.opts info, err := q.fetcher.TokenInfo(ctx, token) if err != nil { + if j.pasted { + return errors.New("GitHub did not accept your token") + } return errors.New("GitHub did not accept your sign-in token") } own := strings.EqualFold(info.Login, j.login) if !own { if info.CanReadPrivate { + if j.pasted { + return fmt.Errorf("your token belongs to %s and can read private repositories, so it can only generate cards for %s", info.Login, info.Login) + } return fmt.Errorf("you signed in as %s with access to private repositories, so you can only generate cards for %s", info.Login, info.Login) } opts.IncludePrivate, opts.IncludeOrgRepos = false, false if q.store.cooldownLeft(j.login, q.cooldown, q.now()) > 0 { + if j.pasted { + return errFreshPasted + } return errFresh } } diff --git a/internal/web/oauth.go b/internal/web/oauth.go index 637d2634..7a11a641 100644 --- a/internal/web/oauth.go +++ b/internal/web/oauth.go @@ -345,12 +345,21 @@ func pkceChallenge(verifier string) string { } // handleAuthStart validates the generation form, parks it under a random -// state and sends the browser to GitHub's consent page. +// state and sends the browser to GitHub's consent page. A pasted token +// takes precedence over signing in, whichever button sent the form (Enter +// in the token field presses the first one, the sign-in button): the job +// is queued on that token straight away, under the same ownership, +// privacy and cooldown rules, and the token is never revoked. func (s *Server) handleAuthStart(w http.ResponseWriter, r *http.Request) { sub, form, ok := s.readSubmission(w, r) if !ok { return } + if sub.Pasted { + w.Header().Set("Cache-Control", "no-store") + s.enqueue(w, r, sub, form, "") + return + } if s.queue.Status(sub.Login).Active() { http.Redirect(w, r, "/u/"+url.PathEscape(userKey(sub.Login))+"?notice=pending", http.StatusSeeOther) return diff --git a/internal/web/oauth_test.go b/internal/web/oauth_test.go index 81c340ff..52cf805d 100644 --- a/internal/web/oauth_test.go +++ b/internal/web/oauth_test.go @@ -230,7 +230,7 @@ func TestOAuthStartRedirectsToGitHub(t *testing.T) { q, c := signIn(t, h, g, url.Values{ "user": {"octocat"}, "include_private": {"1"}, "include_org_repos": {"1"}, - "token": {testToken}, // not a form field: ignored, still a sign-in + "token": {" "}, // a blank token field still signs in }) want := map[string]string{ "client_id": testClientID, diff --git a/internal/web/pasted_token_test.go b/internal/web/pasted_token_test.go new file mode 100644 index 00000000..b342d221 --- /dev/null +++ b/internal/web/pasted_token_test.go @@ -0,0 +1,182 @@ +package web + +import ( + "bytes" + "io/fs" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/tiennm99/ghglance/internal/github" +) + +// assertTokenNowhere fails when token reached a file under the data dir or +// the log. +func assertTokenNowhere(t *testing.T, s *Server, logs *lockedBuffer, token string) { + t.Helper() + filepath.WalkDir(s.store.dir, func(path string, d fs.DirEntry, err error) error { + if err != nil || !d.Type().IsRegular() { + return err + } + raw, _ := os.ReadFile(path) + if bytes.Contains(raw, []byte(token)) { + t.Errorf("token written to %s", path) + } + return nil + }) + if strings.Contains(logs.String(), token) { + t.Errorf("log contains the token:\n%s", logs.String()) + } +} + +func TestPastedTokenTakesPrecedence(t *testing.T) { + logs := captureLog(t) + g := newFakeGitHub(t) + f := &fakeFetcher{tokens: map[string]github.TokenInfo{testToken: {Login: "octocat", CanReadPrivate: true}}} + s := newOAuthTestServer(t, f, g) + h := s.Handler() + + publishTest(t, s.store, "octocat") // in cooldown: the owner's token skips it + // The form posts the same way whichever button is pressed, Enter in + // the token field included: a non-empty token wins over signing in. + rec := startSignIn(h, url.Values{ + "user": {"OctoCat"}, "include_private": {"1"}, "token": {" " + testToken + " "}, + }, "203.0.113.60") + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/u/octocat" { + t.Fatalf("pasted token post = %d %q: %s", rec.Code, rec.Header().Get("Location"), rec.Body.String()) + } + if c := rec.Result().Cookies(); len(c) != 0 { + t.Errorf("pasted token started a sign-in: cookies %v", c) + } + if st := waitIdle(t, s.queue, "octocat"); st.State != stateDone { + t.Fatalf("job = %+v", st) + } + call := f.lastCall() + if call.token != testToken || !call.cfg.Options.IncludePrivate || !call.cfg.Strict { + t.Errorf("fetch = %+v", call) + } + if m, err := s.store.Meta("octocat"); err != nil || m.Scope != "private" { + t.Errorf("meta = %+v, %v", m, err) + } + if got := g.revokedTokens(); len(got) != 0 { + t.Errorf("pasted token revoked: %q", got) + } + if g.exchangeCount() != 0 { + t.Error("pasted token talked to the OAuth token endpoint") + } + s.logins.mu.Lock() + pending := len(s.logins.entries) + s.logins.mu.Unlock() + if pending != 0 { + t.Errorf("%d sign-ins parked for a pasted token", pending) + } + s.queue.mu.Lock() + leftover := s.queue.jobs["octocat"].token + s.queue.mu.Unlock() + if leftover != "" { + t.Error("pasted token kept in memory after the job ended") + } + assertTokenNowhere(t, s, logs, testToken) +} + +func TestPastedTokenForeignAccount(t *testing.T) { + logs := captureLog(t) + const publicToken = "ghp_publicONLYvalue0123456789abcdef" + g := newFakeGitHub(t) + f := &fakeFetcher{tokens: map[string]github.TokenInfo{ + testToken: {Login: "alice", CanReadPrivate: true}, + publicToken: {Login: "alice"}, + }} + s := newOAuthTestServer(t, f, g) + h := s.Handler() + post := func(token, ip string) { + t.Helper() + rec := startSignIn(h, url.Values{ + "user": {"xavier"}, "include_private": {"1"}, "include_org_repos": {"1"}, "token": {token}, + }, ip) + if rec.Code != http.StatusSeeOther { + t.Fatalf("post = %d: %s", rec.Code, rec.Body.String()) + } + } + + // A token that can read private repos is only good for its owner. + post(testToken, "203.0.113.70") + if st := waitIdle(t, s.queue, "xavier"); st.State != stateFailed || !strings.Contains(st.Error, "your token belongs to alice") { + t.Fatalf("private-capable foreign token = %+v", st) + } + if f.callCount() != 0 { + t.Fatal("fetched another user with a private-capable token") + } + + // A public-only token for another account renders public data... + post(publicToken, "203.0.113.71") + if st := waitIdle(t, s.queue, "xavier"); st.State != stateDone { + t.Fatalf("public foreign token = %+v", st) + } + if o := f.lastCall().cfg.Options; o.IncludePrivate || o.IncludeOrgRepos { + t.Errorf("foreign token kept private scope: %+v", o) + } + if m, err := s.store.Meta("xavier"); err != nil || m.Scope != "public" || m.Options.IncludePrivate { + t.Errorf("meta = %+v, %v", m, err) + } + + // ...under the cooldown. + post(publicToken, "203.0.113.72") + if st := waitIdle(t, s.queue, "xavier"); st.State != stateFailed || st.Error != errFreshPasted.Error() { + t.Fatalf("foreign token during cooldown = %+v", st) + } + if n := f.callCount(); n != 1 { + t.Errorf("fetched %d times, want 1", n) + } + if got := g.revokedTokens(); len(got) != 0 { + t.Errorf("pasted tokens revoked: %q", got) + } + assertTokenNowhere(t, s, logs, testToken) + assertTokenNowhere(t, s, logs, publicToken) +} + +func TestPastedTokenNeverEchoed(t *testing.T) { + s := newTestServer(t, &fakeFetcher{}) + h := s.Handler() + + for name, v := range map[string]url.Values{ + "invalid user": {"user": {"--bad"}, "token": {testToken}}, + "invalid tz": {"user": {"octocat"}, "tz": {"Mars/Olympus"}, "token": {testToken}}, + "invalid token": {"user": {"octocat"}, "token": {testToken + "\r\nX-Evil: 1"}}, + } { + rec := startSignIn(h, v, "203.0.113.80") + if rec.Code != http.StatusBadRequest { + t.Errorf("%s = %d, want 400", name, rec.Code) + } + if body := rec.Body.String(); strings.Contains(body, testToken) { + t.Errorf("%s: token echoed back into the page", name) + } + } + + // The same rate limit covers pasted tokens. + var last int + for range submitBurst + 1 { + last = startSignIn(h, url.Values{"user": {"--bad"}, "token": {testToken}}, "203.0.113.81").Code + } + if last != http.StatusTooManyRequests { + t.Errorf("pasted-token post past burst = %d, want 429", last) + } +} + +func TestPastedTokenNotRevokedOnShutdown(t *testing.T) { + g := newFakeGitHub(t) + f := &fakeFetcher{gate: make(chan struct{})} + s := newOAuthTestServer(t, f, g) + for _, login := range []string{"a1", "a2", "a3"} { + if _, err := s.queue.Submit(submission{Login: login, Token: testToken, Pasted: true}); err != nil { + t.Fatal(err) + } + } + s.queue.Stop() // two running, one still queued + if got := g.revokedTokens(); len(got) != 0 { + t.Errorf("revoked %d pasted tokens on shutdown, want 0", len(got)) + } +} diff --git a/internal/web/server.go b/internal/web/server.go index 30cdffe1..acb8f6ff 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -1,14 +1,18 @@ -// Package web serves the ghglance web UI: a form that signs the visitor in -// with GitHub and queues card generation for any GitHub user on that -// sign-in's token, and pages that re-show the stored cards. +// Package web serves the ghglance web UI: a form that queues card +// generation for any GitHub user on the visitor's own GitHub access (a +// "Sign in with GitHub" token or a token they paste), and pages that show +// the stored cards for quick viewing. Cards are inlined into the page as +// data: URIs; there is no URL to link to or embed a card. package web import ( "context" "embed" + "encoding/base64" "encoding/json" "fmt" "html/template" + "io" "io/fs" "log" "net" @@ -38,11 +42,13 @@ const ( // pageCSP takes GitHub's origin as an extra form-action source: the // sign-in form is redirected to GitHub's consent page, and browsers // check redirects of a form submission against form-action too. - pageCSP = "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; " + + // img-src allows data: for the cards, which the user page inlines; an + // SVG loaded through runs no scripts and fetches nothing. + pageCSP = "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; " + "connect-src 'self'; form-action 'self' %s; base-uri 'none'; frame-ancestors 'none'" - // Cards are static drawings: no scripts, no external fetches. Inline - // style attributes are the only thing they need. - svgCSP = "default-src 'none'; style-src 'unsafe-inline'; sandbox" + // maxCardBytes bounds one stored card read into the user page; real + // cards are a few tens of KiB at most. + maxCardBytes = 1 << 20 ) // Config configures the web server. @@ -58,8 +64,9 @@ type Config struct { JobTimeout time.Duration // Fetcher overrides the GitHub fetch; nil uses the real API. Fetcher Fetcher - // OAuth configures "Sign in with GitHub", which every generation runs - // through. It is required. + // OAuth configures "Sign in with GitHub". It is required; visitors may + // paste their own token instead of signing in, but the server never + // uses a token of its own. OAuth OAuthConfig } @@ -165,7 +172,6 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /auth/callback", s.handleAuthCallback) mux.HandleFunc("GET /u/{user}", s.handleUser) mux.HandleFunc("GET /u/{user}/status", s.handleStatus) - mux.HandleFunc("GET /u/{user}/{theme}/{card}", s.handleCard) mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Cache-Control", "no-store") @@ -215,13 +221,13 @@ type pageData struct { Themes []string Theme string Cards []cardView - Markdown string } +// cardView is one card on the user page. Src is a data: URI of the stored +// SVG, so the page carries the card itself and no card URL. type cardView struct { Name string - Src string - URL string + Src template.URL } func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { @@ -256,7 +262,7 @@ func (s *Server) readSubmission(w http.ResponseWriter, r *http.Request) (submiss // enqueue queues sub and redirects to the user's page, adding notice when // one is given. It reports whether a new job took the submission's token; // when not, the caller still owns it. The cooldown is checked by the job, -// once it knows whose token it holds: a sign-in as the target account +// once it knows whose token it holds: a token for the target account // skips it. func (s *Server) enqueue(w http.ResponseWriter, r *http.Request, sub submission, form formValues, notice string) bool { target := "/u/" + url.PathEscape(userKey(sub.Login)) @@ -336,16 +342,19 @@ func (s *Server) handleUser(w http.ResponseWriter, r *http.Request) { if s.cfg.Retention > 0 { d.ExpiresIn = humanDuration(max(meta.GeneratedAt.Add(s.cfg.Retention).Sub(time.Now()), time.Minute)) } - base := baseURL(r) - version := strconv.FormatInt(meta.GeneratedAt.Unix(), 10) - var md strings.Builder for _, f := range card.Filenames() { - rel := "/u/" + d.Key + "/" + d.Theme + "/" + f + src, err := s.cardDataURI(login, d.Theme, f) + if err != nil { + // A set expiring or being replaced between the meta read + // and this one is not worth logging; it just drops a card. + if !isNotExist(err) { + log.Printf("read card %s/%s/%s: %v", userKey(login), d.Theme, f, err) + } + continue + } name := strings.ReplaceAll(strings.TrimSuffix(f, ".svg"), "-", " ") - d.Cards = append(d.Cards, cardView{Name: name, Src: rel + "?v=" + version, URL: base + rel}) - fmt.Fprintf(&md, "![%s](%s)\n", name, base+rel) + d.Cards = append(d.Cards, cardView{Name: name, Src: src}) } - d.Markdown = md.String() } w.Header().Set("Cache-Control", "no-store") s.render(w, http.StatusOK, "user", d) @@ -362,26 +371,31 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(s.queue.Status(login)) } -func (s *Server) handleCard(w http.ResponseWriter, r *http.Request) { - f, err := s.store.OpenCard(r.PathValue("user"), r.PathValue("theme"), r.PathValue("card")) +// cardDataURI reads one stored card and returns it as a base64 data: URI. +// The bytes are our own renderer's output and base64 cannot break out of +// the attribute, so the URI is marked safe for html/template, which would +// otherwise replace any data: URL. +func (s *Server) cardDataURI(login, themeID, file string) (template.URL, error) { + f, err := s.store.OpenCard(login, themeID, file) if err != nil { - if !isNotExist(err) { - log.Printf("open card %s: %v", r.URL.Path, err) - } - http.NotFound(w, r) - return + return "", err } defer f.Close() st, err := f.Stat() - if err != nil || !st.Mode().IsRegular() { - http.NotFound(w, r) - return + if err != nil { + return "", err } - h := w.Header() - h.Set("Content-Type", "image/svg+xml") - h.Set("Content-Security-Policy", svgCSP) - h.Set("Cache-Control", "public, max-age=3600") - http.ServeContent(w, r, "", st.ModTime(), f) + if !st.Mode().IsRegular() { + return "", fs.ErrNotExist + } + raw, err := io.ReadAll(io.LimitReader(f, maxCardBytes+1)) + if err != nil { + return "", err + } + if len(raw) > maxCardBytes { + return "", fmt.Errorf("card larger than %d bytes", maxCardBytes) + } + return template.URL("data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString(raw)), nil } func (s *Server) render(w http.ResponseWriter, status int, page string, d pageData) { @@ -431,16 +445,6 @@ func formFromOptions(login string, o Options) formValues { } } -// baseURL is the absolute origin for copyable embed links. The scheme -// follows the proxy's X-Forwarded-Proto when one sits in front. -func baseURL(r *http.Request) string { - scheme := "http" - if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { - scheme = "https" - } - return scheme + "://" + r.Host -} - func humanDuration(d time.Duration) string { d = d.Round(time.Minute) h, m := int(d.Hours()), int(d.Minutes())%60 diff --git a/internal/web/static/app.js b/internal/web/static/app.js index ea11dc24..1d5b8ece 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -1,6 +1,6 @@ // ghglance web UI enhancements. Every page works without JavaScript; this // adds browser-timezone detection, a live list of the GitHub permissions a -// sign-in asks for, copy buttons and job-status polling. +// sign-in asks for, and job-status polling. 'use strict'; /** @@ -58,52 +58,6 @@ function wireOAuthScopes(form) { sync(); } -/** - * Copies text to the clipboard, falling back to a selection copy. - * @param {string} text - * @returns {Promise} - */ -function copyText(text) { - if (navigator.clipboard && window.isSecureContext) { - return navigator.clipboard.writeText(text); - } - const area = document.createElement('textarea'); - area.value = text; - area.setAttribute('readonly', ''); - area.style.position = 'fixed'; - area.style.opacity = '0'; - document.body.appendChild(area); - area.select(); - try { - document.execCommand('copy'); - } finally { - area.remove(); - } - return Promise.resolve(); -} - -/** - * Wires a copy button; data-copy holds the text, data-copy-target names an - * element whose value is copied. - * @param {HTMLButtonElement} button - */ -function wireCopy(button) { - const label = button.textContent; - button.addEventListener('click', () => { - let text = button.dataset.copy || ''; - if (button.dataset.copyTarget) { - const el = /** @type {HTMLTextAreaElement|null} */ (document.getElementById(button.dataset.copyTarget)); - text = el ? el.value : ''; - } - copyText(text).then( - () => { button.textContent = 'Copied'; }, - () => { button.textContent = 'Copy failed'; }, - ).finally(() => { - setTimeout(() => { button.textContent = label; }, 1500); - }); - }); -} - /** * Formats seconds as "1m 05s" for the progress line. * @param {number} secs @@ -165,7 +119,6 @@ document.documentElement.classList.add('js'); document.addEventListener('DOMContentLoaded', () => { document.querySelectorAll('input[data-autotz]').forEach((el) => detectTimezone(/** @type {HTMLInputElement} */ (el))); document.querySelectorAll('form.gen').forEach((el) => wireOAuthScopes(/** @type {HTMLFormElement} */ (el))); - document.querySelectorAll('button[data-copy], button[data-copy-target]').forEach((el) => wireCopy(/** @type {HTMLButtonElement} */ (el))); const progress = document.getElementById('progress'); if (progress) pollStatus(progress); }); diff --git a/internal/web/static/style.css b/internal/web/static/style.css index 13660f36..8f4c3bc3 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -119,7 +119,7 @@ form.gen, .panel { .field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 16px; } label, legend { font-weight: 600; font-size: 0.92rem; } -input[type="text"], input[type="password"], input[type="number"], select, textarea { +input[type="text"], input[type="password"], input[type="number"], select { width: 100%; font: inherit; color: var(--text); @@ -128,8 +128,7 @@ input[type="text"], input[type="password"], input[type="number"], select, textar border-radius: 8px; padding: 9px 11px; } -input:focus-visible, select:focus-visible, textarea:focus-visible { border-color: var(--focus); } -textarea { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.85rem; resize: vertical; } +input:focus-visible, select:focus-visible { border-color: var(--focus); } details.options { margin-bottom: 18px; } details.options summary { cursor: pointer; font-weight: 600; padding: 4px 0; width: max-content; } @@ -157,6 +156,12 @@ button.primary, .button { background: var(--accent); color: var(--accent-text); button.primary:hover { filter: brightness(1.08); } .signin .hint { max-width: 640px; } +details.own-token { margin-top: 20px; border-top: 1px solid var(--border); padding-top: 14px; } +details.own-token summary { cursor: pointer; font-weight: 600; padding: 4px 0; width: max-content; } +details.own-token .field { margin-top: 12px; } +details.own-token .hint { max-width: 640px; } +.token-create { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin: 4px 0 0; } + .user-head .meta { color: var(--muted); margin: 0 0 20px; overflow-wrap: anywhere; } .toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 20px; } @@ -181,11 +186,5 @@ button.primary:hover { filter: brightness(1.08); } padding: 14px; background: var(--card-bg); min-height: 120px; } .card-image img { display: block; max-width: 100%; height: auto; } -.card figcaption { padding: 12px 14px 14px; display: flex; flex-direction: column; gap: 8px; } +.card figcaption { padding: 12px 14px 14px; } .card-name { font-weight: 600; text-transform: capitalize; } -.copy-row { display: flex; gap: 8px; } -.copy-row input { font-size: 0.8rem; padding: 7px 9px; min-width: 0; } -.copy-row button { flex: none; padding: 7px 12px; } - -.panel label { display: block; margin-bottom: 6px; } -.panel textarea { margin-bottom: 12px; } diff --git a/internal/web/templates/form.html b/internal/web/templates/form.html index 86625be4..372e4843 100644 --- a/internal/web/templates/form.html +++ b/internal/web/templates/form.html @@ -43,8 +43,8 @@ -

Private and org repos are only counted when you sign in as the username's own account. - The resulting cards are public on this site, including totals drawn from private repos.

+

Private and org repos are only counted when you sign in, or paste a token, as the username's own account. + The resulting cards are visible on this site to anyone with the page link, including totals drawn from private repos.

@@ -59,5 +59,27 @@

Tick private repos only when the username is your own GitHub account: a sign-in with private access as another account is refused. Signed in as another account, you get public data only.

+ +
+ Or use your own token +
+ + +

+ Create a token on GitHub + Opens a classic token with the repo and read:user scopes already ticked. Pick a short expiration, generate it, and paste it here. +

+

+ Used for this one generation only: never stored, never logged, and not revoked, so it stays yours to delete on GitHub. + A token here is used instead of signing in, whichever button you press. + With a token for the username's own account, private repos count when ticked and the regenerate wait is skipped; + a token for another account renders public data only and is refused if it can read private repos. + The resulting cards are visible on this site to anyone with the page link. +

+
+ +
{{end}} diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html index 9d37cf43..1c5ce6a7 100644 --- a/internal/web/templates/index.html +++ b/internal/web/templates/index.html @@ -3,8 +3,8 @@

Profile cards for any GitHub user

Enter a username and ghglance renders sixteen SVG cards (languages, streaks, - productive hours, contribution heatmaps and more) in every theme. They stay - here, ready to embed, at /u/<username>. + productive hours, contribution heatmaps and more) in every theme, for a + quick look at /u/<username>.

{{if .Error}}{{end}} {{if .Message}}

{{.Message}}

{{end}} diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index 6e012cf7..bea9749f 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -22,7 +22,7 @@
- Rendered by ghglance. Every card on this site is public. + Rendered by ghglance. Every card on this site is visible to anyone with its page link.
diff --git a/internal/web/templates/user.html b/internal/web/templates/user.html index bb7faa30..34632647 100644 --- a/internal/web/templates/user.html +++ b/internal/web/templates/user.html @@ -39,32 +39,19 @@ {{range .Cards}}
  • -
    {{.Name}} card for {{$.Login}}
    -
    - {{.Name}} -
    - - -
    -
    +
    {{.Name}} card for {{$.Login}}
    +
    {{.Name}}
  • {{end}} - -
    -

    Embed every card

    - - - -
    {{end}} {{if not .Job.Active}}

    {{if .Meta}}Regenerate{{else}}Try again{{end}}

    - {{if .ExpiresIn}}

    These cards are deleted in about {{.ExpiresIn}}; regenerate to keep embedded links working.

    {{end}} - {{if .CooldownLeft}}

    Signed in as another account, these cards can be regenerated in {{.CooldownLeft}}. Signed in as {{.Login}}, you can regenerate now.

    {{end}} + {{if .ExpiresIn}}

    These cards are deleted in about {{.ExpiresIn}}; regenerate to see them again after that.

    {{end}} + {{if .CooldownLeft}}

    With a sign-in or token for another account, these cards can be regenerated in {{.CooldownLeft}}. As {{.Login}}, you can regenerate now.

    {{end}} {{template "form" .}}
    {{end}} diff --git a/internal/web/validate.go b/internal/web/validate.go index f45aa9fe..96729175 100644 --- a/internal/web/validate.go +++ b/internal/web/validate.go @@ -18,9 +18,9 @@ import ( // path segment, which is what lets it name a directory under the data dir. var usernameRE = regexp.MustCompile(`^[A-Za-z0-9]+(-[A-Za-z0-9]+)*$`) -// Sign-in tokens are only ever forwarded in an Authorization header; -// restricting the charset of what GitHub's token endpoint returns rules out -// header injection. +// Tokens, whether GitHub's token endpoint returned them or a visitor pasted +// one, are only ever forwarded in an Authorization header; restricting the +// charset rules out header injection. var tokenRE = regexp.MustCompile(`^[A-Za-z0-9_]{20,255}$`) // IANA zone names: letters, digits and _ + - / only. time.LoadLocation @@ -60,7 +60,7 @@ func validCard(name string) bool { } // Options are the generation settings recorded in meta.json. They never -// include the sign-in token. +// include a token. type Options struct { TZ string `json:"tz"` StartOfWeek string `json:"start_of_week"` @@ -70,15 +70,19 @@ type Options struct { CommitsPerRepo int `json:"commits_per_repo"` } -// submission is a validated form post. Token is the sign-in token GitHub -// issues at the callback; it is revoked when the job ends. +// submission is a validated form post. Token is either the token the +// visitor pasted into the form (Pasted is true) or the sign-in token GitHub +// issues at the callback. A sign-in token is revoked when its job ends; a +// pasted one belongs to the visitor and is only dropped. type submission struct { Login string Token string + Pasted bool Options Options } // formValues is the raw form, kept so a rejected post re-renders as typed. +// It never carries a pasted token back to the page. type formValues struct { User string TZ string @@ -102,8 +106,9 @@ func defaultForm() formValues { } // parseSubmission validates a generation form. The ticked scope is kept: -// it picks the scopes the sign-in asks for, and the job still enforces who -// the resulting token belongs to. +// it picks the scopes a sign-in asks for, and the job still enforces who +// the token, signed in or pasted, belongs to. A non-empty pasted token is +// returned with Pasted set. func parseSubmission(get func(string) string) (submission, formValues, error) { f := formValues{ User: strings.TrimSpace(get("user")), @@ -114,10 +119,14 @@ func parseSubmission(get func(string) string) (submission, formValues, error) { IncludePrivate: get("include_private") != "", CommitsPerRepo: strings.TrimSpace(get("commits_per_repo")), } + token := strings.TrimSpace(get("token")) if !validUsername(f.User) { return submission{}, f, errors.New("enter a valid GitHub username: letters, digits and single hyphens, up to 39 characters") } + if token != "" && !tokenRE.MatchString(token) { + return submission{}, f, errors.New("that does not look like a GitHub token") + } tz := f.TZ if tz == "" { @@ -144,7 +153,7 @@ func parseSubmission(get func(string) string) (submission, formValues, error) { perRepo = n } - return submission{Login: f.User, Options: Options{ + return submission{Login: f.User, Token: token, Pasted: token != "", Options: Options{ TZ: tz, StartOfWeek: strings.ToLower(wd.String()), IncludeForks: f.IncludeForks, diff --git a/internal/web/web_test.go b/internal/web/web_test.go index ec0b655d..0494dd08 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -2,7 +2,10 @@ package web import ( "context" + "encoding/base64" "errors" + "fmt" + "html" "io/fs" "net/http" "net/http/httptest" @@ -196,7 +199,6 @@ func TestParseSubmissionKeepsTicks(t *testing.T) { sub, _, err := parseSubmission(form( "user", "octocat", "tz", "Asia/Saigon", "start_of_week", "Mon", "include_private", "1", "include_org_repos", "1", "include_forks", "1", - "token", testToken, // no such field any more: ignored )) if err != nil { t.Fatal(err) @@ -205,9 +207,16 @@ func TestParseSubmissionKeepsTicks(t *testing.T) { if !o.IncludePrivate || !o.IncludeOrgRepos || !o.IncludeForks || o.StartOfWeek != "monday" || o.TZ != "Asia/Saigon" { t.Errorf("options = %+v", o) } - if o.CommitsPerRepo != defaultCommitsPerRepo || sub.Token != "" { + if o.CommitsPerRepo != defaultCommitsPerRepo || sub.Token != "" || sub.Pasted { t.Errorf("submission = %+v", sub) } + sub, f, err := parseSubmission(form("user", "octocat", "token", " "+testToken+" ")) + if err != nil || sub.Token != testToken || !sub.Pasted { + t.Errorf("pasted token = %+v, %v", sub, err) + } + if strings.Contains(fmt.Sprintf("%+v", f), testToken) { + t.Error("form values carry the pasted token") + } if sub, _, err := parseSubmission(form("user", "octocat", "commits_per_repo", "0")); err != nil || sub.Options.CommitsPerRepo != 0 { t.Errorf("every commit = %+v, %v", sub.Options, err) } @@ -223,6 +232,9 @@ func TestParseSubmissionRejects(t *testing.T) { "bad week": form("user", "a", "start_of_week", "moonday"), "negative commits": form("user", "a", "commits_per_repo", "-1"), "huge commits": form("user", "a", "commits_per_repo", "999999"), + "short token": form("user", "a", "token", "ghp_short"), + "header injection": form("user", "a", "token", testToken+"\r\nX-Evil: 1"), + "token with space": form("user", "a", "token", "ghp_abc def0123456789abcdef"), } for name, get := range cases { if _, _, err := parseSubmission(get); err == nil { @@ -447,11 +459,32 @@ func TestHandlers(t *testing.T) { if rec.Code != 200 || !strings.Contains(rec.Body.String(), `action="/auth/start"`) { t.Fatalf("index = %d", rec.Code) } - if body := rec.Body.String(); strings.Contains(body, `name="token"`) || strings.Count(body, `type="submit"`) != 1 { - t.Error("index offers something besides signing in") + body := rec.Body.String() + signInAt := strings.Index(body, "Sign in with GitHub") + tokenAt := strings.Index(body, `
    `) + if signInAt < 0 || tokenAt < signInAt || strings.Contains(body, `
    Or use your own token`, + `name="token" type="password"`, + `href="https://github.com/settings/tokens/new?scopes=repo,read:user&description=ghglance"`, + `target="_blank" rel="noopener noreferrer"`, + "never stored, never logged", + "visible on this site to anyone with the page link", + } { + if !strings.Contains(body, want) { + t.Errorf("index lacks %q", want) + } + } + // Enter in any field presses the first submit button: the sign-in one. + if strings.Count(body, `type="submit"`) != 2 || strings.Index(body, `type="submit"`) > signInAt { + t.Error("index submit buttons are not sign-in first, then the token button") + } + csp := rec.Header().Get("Content-Security-Policy") + if !strings.Contains(csp, "img-src 'self' data:;") || !strings.Contains(csp, "default-src 'none'") || + !strings.Contains(csp, "script-src 'self';") || rec.Header().Get("X-Content-Type-Options") != "nosniff" { + t.Errorf("index security headers: CSP %q", csp) } if rec := get("/healthz"); rec.Code != 200 || strings.TrimSpace(rec.Body.String()) != "ok" { t.Errorf("healthz = %d %q", rec.Code, rec.Body.String()) @@ -469,37 +502,58 @@ func TestHandlers(t *testing.T) { publishTest(t, s.store, "octocat") rec = get("/u/octocat?theme=github_dark") - body := rec.Body.String() - if rec.Code != 200 || !strings.Contains(body, "/u/octocat/github_dark/stats.svg") || !strings.Contains(body, "http://example.com/u/octocat/github_dark/stats.svg") { + // html/template writes "+" in attributes as "+"; compare decoded. + body = html.UnescapeString(rec.Body.String()) + if rec.Code != 200 { t.Fatalf("user page = %d", rec.Code) } - if rec := get("/u/octocat?theme=../../etc"); !strings.Contains(rec.Body.String(), "/u/octocat/dracula/stats.svg") { + want, err := os.ReadFile(filepath.Join(s.store.dir, "octocat", "github_dark", "stats.svg")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(body, `src="data:image/svg+xml;base64,`+base64.StdEncoding.EncodeToString(want)+`"`) { + t.Error("user page does not inline the stats card as a data: URI") + } + if n := strings.Count(body, `src="data:image/svg+xml;base64,`); n != 16 { + t.Errorf("user page inlines %d cards, want 16", n) + } + if !strings.Contains(body, `alt="stats card for octocat"`) { + t.Error("cards lack alt text") + } + // The page is shareable but suggests copying nothing: no copy buttons, + // no Markdown or HTML snippet, no card URL, no README or embed advice. + for _, leak := range []string{ + "/u/octocat/github_dark/", "/u/octocat/dracula/", "stats.svg", "![", "