mirror of
https://github.com/tiennm99/ghglance.git
synced 2026-10-11 03:13:20 +00:00
feat(web): show cards inline for quick viewing and accept the visitor's own token
The web UI is for a quick look at a profile, not for hosting embeddable images. Cards are inlined into /u/<user> as data: images, the per-card route is gone, and the page no longer offers copy links or Markdown snippets. The page itself stays shareable. Visitors can again paste their own token, with a button that opens GitHub's new-token page with the needed scopes ticked. A pasted token takes precedence over sign-in, follows the same ownership, privacy and cooldown rules, is never stored or logged, and is not revoked. The server still uses no token of its own.
This commit is contained in:
1 parent
006ff6e34c
commit
537c87c458
18 files changed
+492
-216
No files matched your search
@@ -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/<username>` 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/<username>` 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/<user>` | The user's cards (`?theme=<id>` picks the theme), or job progress while one runs |
|
||||
| `/u/<user>/<theme>/<card>.svg` | One card, embeddable in a README |
|
||||
| `/u/<user>` | The user's cards inlined as `data:` images (`?theme=<id>` picks the theme), or job progress while one runs |
|
||||
| `/u/<user>/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
|
||||
|
||||
+3
-2
@@ -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}
|
||||
|
||||
@@ -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 `<public-url>/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/<user>` 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.
|
||||
|
||||
|
||||
+21
-10
@@ -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.** `<data>/<user>` (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 `<img>` 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.
|
||||
|
||||
|
||||
+35
-14
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+10
-1
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
+50
-46
@@ -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 <img> 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, "\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
|
||||
|
||||
@@ -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<void>}
|
||||
*/
|
||||
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);
|
||||
});
|
||||
@@ -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; }
|
||||
@@ -43,8 +43,8 @@
|
||||
<label><input type="checkbox" name="include_forks" value="1"{{if .Form.IncludeForks}} checked{{end}}> Include forks</label>
|
||||
<label><input type="checkbox" name="include_private" value="1"{{if .Form.IncludePrivate}} checked{{end}}> Include private repos</label>
|
||||
<label><input type="checkbox" name="include_org_repos" value="1"{{if .Form.IncludeOrgRepos}} checked{{end}}> Include org repos you administer</label>
|
||||
<p class="hint">Private and org repos are only counted when you sign in as the username's own account.
|
||||
<strong>The resulting cards are public on this site</strong>, including totals drawn from private repos.</p>
|
||||
<p class="hint">Private and org repos are only counted when you sign in, or paste a token, as the username's own account.
|
||||
<strong>The resulting cards are visible on this site to anyone with the page link</strong>, including totals drawn from private repos.</p>
|
||||
</fieldset>
|
||||
</div>
|
||||
</details>
|
||||
@@ -59,5 +59,27 @@
|
||||
<p class="hint">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.</p>
|
||||
</div>
|
||||
|
||||
<details class="own-token">
|
||||
<summary>Or use your own token</summary>
|
||||
<div class="field">
|
||||
<label for="token">Your GitHub token</label>
|
||||
<input id="token" name="token" type="password" autocomplete="off" spellcheck="false"
|
||||
maxlength="255" aria-describedby="token-hint">
|
||||
<p class="token-create">
|
||||
<a class="button" href="https://github.com/settings/tokens/new?scopes=repo,read:user&description=ghglance"
|
||||
target="_blank" rel="noopener noreferrer">Create a token on GitHub</a>
|
||||
<span class="hint">Opens a classic token with the <code>repo</code> and <code>read:user</code> scopes already ticked. Pick a short expiration, generate it, and paste it here.</span>
|
||||
</p>
|
||||
<p class="hint" id="token-hint">
|
||||
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.
|
||||
<strong>The resulting cards are visible on this site to anyone with the page link.</strong>
|
||||
</p>
|
||||
</div>
|
||||
<button type="submit">{{if .Meta}}Regenerate{{else}}Generate{{end}} with this token</button>
|
||||
</details>
|
||||
</form>
|
||||
{{end}}
|
||||
@@ -3,8 +3,8 @@
|
||||
<h1>Profile cards for any GitHub user</h1>
|
||||
<p class="lead">
|
||||
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 <code>/u/<username></code>.
|
||||
productive hours, contribution heatmaps and more) in every theme, for a
|
||||
quick look at <code>/u/<username></code>.
|
||||
</p>
|
||||
{{if .Error}}<p class="alert alert-error" role="alert">{{.Error}}</p>{{end}}
|
||||
{{if .Message}}<p class="alert alert-info" role="status">{{.Message}}</p>{{end}}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
</main>
|
||||
<footer class="site">
|
||||
<div class="wrap">
|
||||
Rendered by <a href="https://github.com/tiennm99/ghglance">ghglance</a>. Every card on this site is public.
|
||||
Rendered by <a href="https://github.com/tiennm99/ghglance">ghglance</a>. Every card on this site is visible to anyone with its page link.
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
|
||||
@@ -39,32 +39,19 @@
|
||||
{{range .Cards}}
|
||||
<li class="card">
|
||||
<figure>
|
||||
<div class="card-image"><img src="{{.Src}}" alt="{{.Name}} card for {{$.Login}}" loading="lazy" decoding="async"></div>
|
||||
<figcaption>
|
||||
<span class="card-name">{{.Name}}</span>
|
||||
<div class="copy-row">
|
||||
<input type="text" readonly value="{{.URL}}" aria-label="Embed URL for the {{.Name}} card">
|
||||
<button type="button" data-copy="{{.URL}}">Copy</button>
|
||||
</div>
|
||||
</figcaption>
|
||||
<div class="card-image"><img src="{{.Src}}" alt="{{.Name}} card for {{$.Login}}" decoding="async"></div>
|
||||
<figcaption><span class="card-name">{{.Name}}</span></figcaption>
|
||||
</figure>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Embed every card</h2>
|
||||
<label for="markdown">Markdown for a README ({{.Theme}})</label>
|
||||
<textarea id="markdown" readonly rows="8" spellcheck="false">{{.Markdown}}</textarea>
|
||||
<button type="button" data-copy-target="markdown">Copy Markdown</button>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{if not .Job.Active}}
|
||||
<section class="panel">
|
||||
<h2>{{if .Meta}}Regenerate{{else}}Try again{{end}}</h2>
|
||||
{{if .ExpiresIn}}<p class="hint">These cards are deleted in about {{.ExpiresIn}}; regenerate to keep embedded links working.</p>{{end}}
|
||||
{{if .CooldownLeft}}<p class="hint">Signed in as another account, these cards can be regenerated in {{.CooldownLeft}}. Signed in as {{.Login}}, you can regenerate now.</p>{{end}}
|
||||
{{if .ExpiresIn}}<p class="hint">These cards are deleted in about {{.ExpiresIn}}; regenerate to see them again after that.</p>{{end}}
|
||||
{{if .CooldownLeft}}<p class="hint">With a sign-in or token for another account, these cards can be regenerated in {{.CooldownLeft}}. As {{.Login}}, you can regenerate now.</p>{{end}}
|
||||
{{template "form" .}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+80
-26
@@ -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, `<details class="own-token">`)
|
||||
if signInAt < 0 || tokenAt < signInAt || strings.Contains(body, `<details class="own-token" open`) {
|
||||
t.Error("index lacks a collapsed token section below the sign-in button")
|
||||
}
|
||||
if rec.Header().Get("Content-Security-Policy") == "" || rec.Header().Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Error("index missing security headers")
|
||||
for _, want := range []string{
|
||||
`<summary>Or use your own token</summary>`,
|
||||
`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", "![", "<img src="", "<img",
|
||||
"Markdown", "data-copy", "Copy", `type="button"`, "<textarea", "readonly",
|
||||
"embed", "Embed", "README", "GitHub Action",
|
||||
} {
|
||||
if strings.Contains(body, leak) {
|
||||
t.Errorf("user page still offers a card link or embed: contains %q", leak)
|
||||
}
|
||||
}
|
||||
if csp := rec.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "img-src 'self' data:;") {
|
||||
t.Errorf("user page CSP blocks data: images: %s", csp)
|
||||
}
|
||||
other, err := os.ReadFile(filepath.Join(s.store.dir, "octocat", "dracula", "stats.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec := get("/u/octocat?theme=../../etc"); !strings.Contains(html.UnescapeString(rec.Body.String()), base64.StdEncoding.EncodeToString(other)) {
|
||||
t.Error("invalid theme not replaced by default")
|
||||
}
|
||||
|
||||
rec = get("/u/octocat/dracula/stats.svg")
|
||||
if rec.Code != 200 || rec.Header().Get("Content-Type") != "image/svg+xml" {
|
||||
t.Fatalf("card = %d %q", rec.Code, rec.Header().Get("Content-Type"))
|
||||
}
|
||||
if !strings.Contains(rec.Header().Get("Content-Security-Policy"), "default-src 'none'") ||
|
||||
rec.Header().Get("X-Content-Type-Options") != "nosniff" ||
|
||||
!strings.Contains(rec.Header().Get("Cache-Control"), "max-age") {
|
||||
t.Errorf("card headers = %v", rec.Header())
|
||||
}
|
||||
if !strings.HasPrefix(rec.Body.String(), "<svg") {
|
||||
t.Error("card body is not an SVG")
|
||||
}
|
||||
|
||||
// There is no card route: nothing serves a card by URL.
|
||||
for _, p := range []string{
|
||||
"/u/octocat/dracula/stats.svg",
|
||||
"/u/octocat/github_dark/stats.svg?v=1",
|
||||
"/u/octocat/nope/stats.svg",
|
||||
"/u/octocat/dracula/nope.svg",
|
||||
"/u/octocat/dracula/meta.json",
|
||||
"/u/octocat/..%2fmeta.json/stats.svg",
|
||||
"/u/..%2f..%2fetc/dracula/stats.svg",
|
||||
"/u/octocat/dracula/..%2f..%2fmeta.json",
|
||||
} {
|
||||
if rec := get(p); rec.Code != 404 {
|
||||
t.Errorf("GET %s = %d, want 404", p, rec.Code)
|
||||
rec := get(p)
|
||||
if rec.Code != 404 || strings.Contains(rec.Header().Get("Content-Type"), "svg") || strings.Contains(rec.Body.String(), "<svg") {
|
||||
t.Errorf("GET %s = %d %q, want a 404 page", p, rec.Code, rec.Header().Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
func main() {
|
||||
var (
|
||||
user = flag.String("user", "", "GitHub username (required)")
|
||||
token = flag.String("token", os.Getenv("GITHUB_TOKEN"), "GitHub token (or env GITHUB_TOKEN); not used by -serve, which runs every generation on the visitor's sign-in")
|
||||
token = flag.String("token", os.Getenv("GITHUB_TOKEN"), "GitHub token (or env GITHUB_TOKEN); not used by -serve, which runs every generation on the visitor's sign-in or pasted token")
|
||||
out = flag.String("out", "output", "output directory")
|
||||
themesFlag = flag.String("themes", "dracula", "comma-separated theme ids, or 'all'")
|
||||
tzName = flag.String("tz", "Local", "timezone for productive-time card (IANA name, e.g. Asia/Saigon)")
|
||||
@@ -35,7 +35,7 @@ func main() {
|
||||
listThemes = flag.Bool("list-themes", false, "print available theme ids and exit")
|
||||
serve = flag.String("serve", "", "run the web UI on this address (e.g. :8080) instead of generating once")
|
||||
dataDir = flag.String("data-dir", "data", "web UI: directory holding generated cards")
|
||||
cooldown = flag.Duration("cooldown", 6*time.Hour, "web UI: minimum age of a user's cards before someone signed in as another account can regenerate them")
|
||||
cooldown = flag.Duration("cooldown", 6*time.Hour, "web UI: minimum age of a user's cards before a sign-in or token for another account can regenerate them")
|
||||
retention = flag.Duration("retention", 24*time.Hour, "web UI: delete a user's generated cards this long after they were generated (0 = keep forever)")
|
||||
workers = flag.Int("workers", 2, "web UI: concurrent generation jobs")
|
||||
// Empty defaults keep the secret out of -help; the env fallback is
|
||||
|
||||
Reference in new issue
Block a user