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 |
|
| `-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 |
|
| `-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 |
|
| `-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 |
|
| `-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 |
|
| `-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) |
|
| `-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
|
## Run the web UI
|
||||||
|
|
||||||
`-serve` turns the binary into a small web app: a form takes a GitHub
|
`-serve` turns the binary into a small web app for quickly viewing
|
||||||
username plus options, the visitor signs in with GitHub, a background job
|
profile cards: a form takes a GitHub username plus options, the visitor
|
||||||
renders all sixteen cards in every theme on that sign-in's token, and
|
signs in with GitHub or pastes their own token, a background job renders
|
||||||
`/u/<username>` shows them again with a theme picker and copyable embed
|
all sixteen cards in every theme on that token, and `/u/<username>` shows
|
||||||
URLs. Cards are stored on disk and survive restarts.
|
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
|
The web UI is for viewing, not hosting: cards are inlined into the page as
|
||||||
GitHub token of its own, and `-serve` refuses to start until the OAuth
|
`data:` URIs, so there is no card URL to link to or put in Markdown. To
|
||||||
client ID, client secret and public URL are all set.
|
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
|
```sh
|
||||||
export GHGLANCE_OAUTH_CLIENT_SECRET=xxxx # keep the secret out of the command line
|
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 |
|
| Path | Serves |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `/` | The submission form |
|
| `/` | The submission form |
|
||||||
| `/u/<user>` | The user's cards (`?theme=<id>` picks the theme), or job progress while one runs |
|
| `/u/<user>` | The user's cards inlined as `data:` images (`?theme=<id>` picks the theme), or job progress while one runs |
|
||||||
| `/u/<user>/<theme>/<card>.svg` | One card, embeddable in a README |
|
|
||||||
| `/u/<user>/status` | Job status as JSON, polled by the progress page |
|
| `/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 |
|
| `/auth/callback` | Finishes the sign-in and queues the job |
|
||||||
| `/healthz` | Liveness probe |
|
| `/healthz` | Liveness probe |
|
||||||
|
|
||||||
How submissions are handled:
|
How submissions are handled:
|
||||||
|
|
||||||
- **Every job runs on a sign-in token.** The token is used for that one
|
- **Every job runs on the visitor's token**: a sign-in token, or one they
|
||||||
job, then revoked: never logged, never written to disk. Signed in as the
|
paste (see [Use your own token](#use-your-own-token)). The token is used
|
||||||
username being generated, the ticked private and org repos count and the
|
for that one job only: never logged, never written to disk. A token for
|
||||||
cooldown is skipped. Signed in as someone else, the job renders public
|
the username being generated keeps the ticked private and org repos and
|
||||||
data only, does not skip the cooldown, and is refused outright if the
|
skips the cooldown. A token for someone else renders public data only,
|
||||||
token can read private repositories. The cards are public on the site
|
does not skip the cooldown, and is refused outright if it can read
|
||||||
like any other.
|
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
|
- **Failures.** A job that fails or times out at any fetch stage publishes
|
||||||
nothing, so an earlier complete set stays in place.
|
nothing, so an earlier complete set stays in place.
|
||||||
- **Cooldown.** Cards younger than `-cooldown` are not regenerated by a
|
- **Cooldown.** Cards younger than `-cooldown` are not regenerated with a
|
||||||
sign-in as another account; the owner's own sign-in skips the wait.
|
sign-in or token for another account; the owner's own token skips the
|
||||||
|
wait.
|
||||||
- **Retention.** Cards are deleted `-retention` (default `24h`) after they
|
- **Retention.** Cards are deleted `-retention` (default `24h`) after they
|
||||||
were generated, checked at startup and hourly. The user's page then
|
were generated, checked at startup and hourly. The user's page then
|
||||||
offers a fresh generation, and embedded card URLs return 404 until
|
offers a fresh generation.
|
||||||
someone regenerates them.
|
|
||||||
- **Limits.** One queued or running job per user, `-workers` jobs at once,
|
- **Limits.** One queued or running job per user, `-workers` jobs at once,
|
||||||
`-timeout` per job, and five submissions per client followed by one
|
`-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
|
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,
|
`GHGLANCE_PUBLIC_URL` environment variables). With any of them missing,
|
||||||
`-serve` exits at startup with an error naming the missing settings.
|
`-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).
|
Each user takes about 9 MB on disk for an active profile (16 cards × every theme).
|
||||||
|
|
||||||
### Deploy with Docker Compose or Coolify
|
### Deploy with Docker Compose or Coolify
|
||||||
|
|||||||
+3
-2
@@ -11,8 +11,9 @@ services:
|
|||||||
- "8080"
|
- "8080"
|
||||||
environment:
|
environment:
|
||||||
- SERVICE_FQDN_GHGLANCE_8080
|
- SERVICE_FQDN_GHGLANCE_8080
|
||||||
# "Sign in with GitHub" (a GitHub OAuth App) is required: every
|
# "Sign in with GitHub" (a GitHub OAuth App) is required. The server
|
||||||
# generation runs on the visitor's own sign-in token.
|
# 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_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_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}
|
- 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_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`. |
|
| `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
|
The server holds no GitHub token: every generation runs on the visitor's
|
||||||
generation runs on the visitor's OAuth token, revoked when the job ends.
|
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,
|
`compose.yml` refuses to start while any of the three variables is empty,
|
||||||
and `-serve` exits with an error naming the missing settings.
|
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`,
|
`ghglance -serve :8080` runs `internal/web` (stdlib `net/http`,
|
||||||
`html/template`, `embed`; vanilla JS, no build step) instead of the one-shot
|
`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)
|
─► 302 github.com/login/oauth/authorize (scope from ticks, state, S256 challenge)
|
||||||
GET /auth/callback ─► state == cookie, single use ─► POST /login/oauth/access_token
|
GET /auth/callback ─► state == cookie, single use ─► POST /login/oauth/access_token
|
||||||
─► narrow options to granted scopes (wider than ticked: revoke, refuse)
|
─► 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)
|
github.Collect ─► Store.Publish (every theme)
|
||||||
│
|
│
|
||||||
GET /u/{user} ◄── meta.json + card list ◄──────────┘
|
GET /u/{user} ◄── meta.json + os.Root card reads, inlined as data: URIs
|
||||||
GET /u/{user}/{theme}/{card}.svg ◄── os.Root read
|
job ends (sign-in token only) ─► DELETE api.github.com/applications/{client_id}/token
|
||||||
job ends ─► DELETE api.github.com/applications/{client_id}/token
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Storage.** `<data>/<user>` (lowercased login) is a symlink into
|
- **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
|
HTTP server, cancels running jobs and drops queued ones; nothing is
|
||||||
published mid-render.
|
published mid-render.
|
||||||
- **Tokens.** The server has no GitHub token of its own: every job runs on
|
- **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
|
contribution a token can see into totals and calendars, so repo filters
|
||||||
alone cannot keep cards public. Each job first identifies its token
|
alone cannot keep cards public. Each job first identifies its token
|
||||||
(`viewer` query: login, a one-repo `privacy: PRIVATE` probe, and a
|
(`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
|
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
|
keeps the ticked scope and skips the cooldown; as anyone else it is
|
||||||
refused if private-capable, otherwise forced to public scope under the
|
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,
|
- **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
|
`-oauth-client-secret` and `-public-url` are all set. Scopes come from
|
||||||
the ticked options (`read:user`; `repo` for private; `read:org` on top
|
the ticked options (`read:user`; `repo` for private; `read:org` on top
|
||||||
for org repos). `/auth/start` parks the validated submission under a
|
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
|
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
|
deadline passed is failed even if the fetch returned. The CLI keeps
|
||||||
rendering partial data with warnings.
|
rendering partial data with warnings.
|
||||||
- **HTTP hardening.** Strict CSP on pages, `default-src 'none'` + `sandbox`
|
- **HTTP hardening.** Strict CSP on pages, with `img-src 'self' data:` for
|
||||||
on SVGs, `nosniff`, 16 KiB form limit, `http.CrossOriginProtection` on the
|
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
|
POSTs, per-client token bucket (burst 5, +1 per 2 min) keyed by IPv4
|
||||||
address or IPv6 /64, capped at 10,000 tracked clients.
|
address or IPv6 /64, capped at 10,000 tracked clients.
|
||||||
|
|
||||||
|
|||||||
+35
-14
@@ -32,8 +32,10 @@ const queueCapacity = 64
|
|||||||
var (
|
var (
|
||||||
errQueueFull = errors.New("the generation queue is full, try again in a few minutes")
|
errQueueFull = errors.New("the generation queue is full, try again in a few minutes")
|
||||||
errStopped = errors.New("server is shutting down")
|
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")
|
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
|
// 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)
|
return github.NewClient(token).TokenInfo(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// job is one queued generation. token is the submitter's sign-in token,
|
// job is one queued generation. token is the submitter's token, held only
|
||||||
// held only until the job ends, never logged or persisted, and revoked on
|
// until the job ends and never logged or persisted. A sign-in token is
|
||||||
// GitHub then.
|
// revoked on GitHub then; a pasted one (pasted is true) belongs to the
|
||||||
|
// visitor and is only dropped.
|
||||||
type job struct {
|
type job struct {
|
||||||
key string
|
key string
|
||||||
login string
|
login string
|
||||||
opts Options
|
opts Options
|
||||||
token string
|
token string
|
||||||
|
pasted bool
|
||||||
|
|
||||||
state string
|
state string
|
||||||
stage string
|
stage string
|
||||||
@@ -89,7 +93,8 @@ type Queue struct {
|
|||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
cooldown time.Duration
|
cooldown time.Duration
|
||||||
now func() time.Time
|
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)
|
revoke func(token string)
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@@ -148,6 +153,7 @@ func (q *Queue) Submit(sub submission) (created bool, err error) {
|
|||||||
login: sub.Login,
|
login: sub.Login,
|
||||||
opts: sub.Options,
|
opts: sub.Options,
|
||||||
token: sub.Token,
|
token: sub.Token,
|
||||||
|
pasted: sub.Pasted,
|
||||||
state: stateQueued,
|
state: stateQueued,
|
||||||
queued: q.now(),
|
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.
|
// Stop cancels running jobs, drops queued ones and waits for the workers.
|
||||||
// Tokens of dropped jobs are revoked before it returns; running jobs revoke
|
// Sign-in tokens of dropped jobs are revoked before it returns; running
|
||||||
// theirs as their workers wind down.
|
// jobs revoke theirs as their workers wind down. Pasted tokens are only
|
||||||
|
// dropped.
|
||||||
func (q *Queue) Stop() {
|
func (q *Queue) Stop() {
|
||||||
q.mu.Lock()
|
q.mu.Lock()
|
||||||
q.closed = true
|
q.closed = true
|
||||||
var dropped []string
|
var dropped []string
|
||||||
for _, j := range q.pending {
|
for _, j := range q.pending {
|
||||||
dropped = append(dropped, j.token)
|
if !j.pasted {
|
||||||
|
dropped = append(dropped, j.token)
|
||||||
|
}
|
||||||
j.token = ""
|
j.token = ""
|
||||||
j.state, j.err = stateFailed, errStopped.Error()
|
j.state, j.err = stateFailed, errStopped.Error()
|
||||||
}
|
}
|
||||||
@@ -242,12 +251,15 @@ func (q *Queue) worker() {
|
|||||||
err := q.run(j)
|
err := q.run(j)
|
||||||
|
|
||||||
// Revoke before reporting the job over, so a finished job never
|
// 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()
|
q.mu.Lock()
|
||||||
token := j.token
|
token := j.token
|
||||||
j.token = ""
|
j.token = ""
|
||||||
q.mu.Unlock()
|
q.mu.Unlock()
|
||||||
q.revokeToken(token)
|
if !j.pasted {
|
||||||
|
q.revokeToken(token)
|
||||||
|
}
|
||||||
|
|
||||||
q.mu.Lock()
|
q.mu.Lock()
|
||||||
j.finished = q.now()
|
j.finished = q.now()
|
||||||
@@ -289,15 +301,24 @@ func (q *Queue) run(j *job) error {
|
|||||||
opts := j.opts
|
opts := j.opts
|
||||||
info, err := q.fetcher.TokenInfo(ctx, token)
|
info, err := q.fetcher.TokenInfo(ctx, token)
|
||||||
if err != nil {
|
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")
|
return errors.New("GitHub did not accept your sign-in token")
|
||||||
}
|
}
|
||||||
own := strings.EqualFold(info.Login, j.login)
|
own := strings.EqualFold(info.Login, j.login)
|
||||||
if !own {
|
if !own {
|
||||||
if info.CanReadPrivate {
|
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)
|
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
|
opts.IncludePrivate, opts.IncludeOrgRepos = false, false
|
||||||
if q.store.cooldownLeft(j.login, q.cooldown, q.now()) > 0 {
|
if q.store.cooldownLeft(j.login, q.cooldown, q.now()) > 0 {
|
||||||
|
if j.pasted {
|
||||||
|
return errFreshPasted
|
||||||
|
}
|
||||||
return errFresh
|
return errFresh
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-1
@@ -345,12 +345,21 @@ func pkceChallenge(verifier string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleAuthStart validates the generation form, parks it under a random
|
// 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) {
|
func (s *Server) handleAuthStart(w http.ResponseWriter, r *http.Request) {
|
||||||
sub, form, ok := s.readSubmission(w, r)
|
sub, form, ok := s.readSubmission(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
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() {
|
if s.queue.Status(sub.Login).Active() {
|
||||||
http.Redirect(w, r, "/u/"+url.PathEscape(userKey(sub.Login))+"?notice=pending", http.StatusSeeOther)
|
http.Redirect(w, r, "/u/"+url.PathEscape(userKey(sub.Login))+"?notice=pending", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ func TestOAuthStartRedirectsToGitHub(t *testing.T) {
|
|||||||
|
|
||||||
q, c := signIn(t, h, g, url.Values{
|
q, c := signIn(t, h, g, url.Values{
|
||||||
"user": {"octocat"}, "include_private": {"1"}, "include_org_repos": {"1"},
|
"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{
|
want := map[string]string{
|
||||||
"client_id": testClientID,
|
"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
|
// Package web serves the ghglance web UI: a form that queues card
|
||||||
// with GitHub and queues card generation for any GitHub user on that
|
// generation for any GitHub user on the visitor's own GitHub access (a
|
||||||
// sign-in's token, and pages that re-show the stored cards.
|
// "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
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"embed"
|
"embed"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
@@ -38,11 +42,13 @@ const (
|
|||||||
// pageCSP takes GitHub's origin as an extra form-action source: the
|
// pageCSP takes GitHub's origin as an extra form-action source: the
|
||||||
// sign-in form is redirected to GitHub's consent page, and browsers
|
// sign-in form is redirected to GitHub's consent page, and browsers
|
||||||
// check redirects of a form submission against form-action too.
|
// 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'"
|
"connect-src 'self'; form-action 'self' %s; base-uri 'none'; frame-ancestors 'none'"
|
||||||
// Cards are static drawings: no scripts, no external fetches. Inline
|
// maxCardBytes bounds one stored card read into the user page; real
|
||||||
// style attributes are the only thing they need.
|
// cards are a few tens of KiB at most.
|
||||||
svgCSP = "default-src 'none'; style-src 'unsafe-inline'; sandbox"
|
maxCardBytes = 1 << 20
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config configures the web server.
|
// Config configures the web server.
|
||||||
@@ -58,8 +64,9 @@ type Config struct {
|
|||||||
JobTimeout time.Duration
|
JobTimeout time.Duration
|
||||||
// Fetcher overrides the GitHub fetch; nil uses the real API.
|
// Fetcher overrides the GitHub fetch; nil uses the real API.
|
||||||
Fetcher Fetcher
|
Fetcher Fetcher
|
||||||
// OAuth configures "Sign in with GitHub", which every generation runs
|
// OAuth configures "Sign in with GitHub". It is required; visitors may
|
||||||
// through. It is required.
|
// paste their own token instead of signing in, but the server never
|
||||||
|
// uses a token of its own.
|
||||||
OAuth OAuthConfig
|
OAuth OAuthConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +172,6 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("GET /auth/callback", s.handleAuthCallback)
|
mux.HandleFunc("GET /auth/callback", s.handleAuthCallback)
|
||||||
mux.HandleFunc("GET /u/{user}", s.handleUser)
|
mux.HandleFunc("GET /u/{user}", s.handleUser)
|
||||||
mux.HandleFunc("GET /u/{user}/status", s.handleStatus)
|
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) {
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
@@ -215,13 +221,13 @@ type pageData struct {
|
|||||||
Themes []string
|
Themes []string
|
||||||
Theme string
|
Theme string
|
||||||
Cards []cardView
|
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 {
|
type cardView struct {
|
||||||
Name string
|
Name string
|
||||||
Src string
|
Src template.URL
|
||||||
URL string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
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
|
// 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;
|
// 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,
|
// 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.
|
// skips it.
|
||||||
func (s *Server) enqueue(w http.ResponseWriter, r *http.Request, sub submission, form formValues, notice string) bool {
|
func (s *Server) enqueue(w http.ResponseWriter, r *http.Request, sub submission, form formValues, notice string) bool {
|
||||||
target := "/u/" + url.PathEscape(userKey(sub.Login))
|
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 {
|
if s.cfg.Retention > 0 {
|
||||||
d.ExpiresIn = humanDuration(max(meta.GeneratedAt.Add(s.cfg.Retention).Sub(time.Now()), time.Minute))
|
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() {
|
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"), "-", " ")
|
name := strings.ReplaceAll(strings.TrimSuffix(f, ".svg"), "-", " ")
|
||||||
d.Cards = append(d.Cards, cardView{Name: name, Src: rel + "?v=" + version, URL: base + rel})
|
d.Cards = append(d.Cards, cardView{Name: name, Src: src})
|
||||||
fmt.Fprintf(&md, "\n", name, base+rel)
|
|
||||||
}
|
}
|
||||||
d.Markdown = md.String()
|
|
||||||
}
|
}
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
s.render(w, http.StatusOK, "user", d)
|
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))
|
json.NewEncoder(w).Encode(s.queue.Status(login))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleCard(w http.ResponseWriter, r *http.Request) {
|
// cardDataURI reads one stored card and returns it as a base64 data: URI.
|
||||||
f, err := s.store.OpenCard(r.PathValue("user"), r.PathValue("theme"), r.PathValue("card"))
|
// 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 err != nil {
|
||||||
if !isNotExist(err) {
|
return "", err
|
||||||
log.Printf("open card %s: %v", r.URL.Path, err)
|
|
||||||
}
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
st, err := f.Stat()
|
st, err := f.Stat()
|
||||||
if err != nil || !st.Mode().IsRegular() {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
return "", err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
h := w.Header()
|
if !st.Mode().IsRegular() {
|
||||||
h.Set("Content-Type", "image/svg+xml")
|
return "", fs.ErrNotExist
|
||||||
h.Set("Content-Security-Policy", svgCSP)
|
}
|
||||||
h.Set("Cache-Control", "public, max-age=3600")
|
raw, err := io.ReadAll(io.LimitReader(f, maxCardBytes+1))
|
||||||
http.ServeContent(w, r, "", st.ModTime(), f)
|
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) {
|
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 {
|
func humanDuration(d time.Duration) string {
|
||||||
d = d.Round(time.Minute)
|
d = d.Round(time.Minute)
|
||||||
h, m := int(d.Hours()), int(d.Minutes())%60
|
h, m := int(d.Hours()), int(d.Minutes())%60
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// ghglance web UI enhancements. Every page works without JavaScript; this
|
// ghglance web UI enhancements. Every page works without JavaScript; this
|
||||||
// adds browser-timezone detection, a live list of the GitHub permissions a
|
// 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';
|
'use strict';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,52 +58,6 @@ function wireOAuthScopes(form) {
|
|||||||
sync();
|
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.
|
* Formats seconds as "1m 05s" for the progress line.
|
||||||
* @param {number} secs
|
* @param {number} secs
|
||||||
@@ -165,7 +119,6 @@ document.documentElement.classList.add('js');
|
|||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
document.querySelectorAll('input[data-autotz]').forEach((el) => detectTimezone(/** @type {HTMLInputElement} */ (el)));
|
document.querySelectorAll('input[data-autotz]').forEach((el) => detectTimezone(/** @type {HTMLInputElement} */ (el)));
|
||||||
document.querySelectorAll('form.gen').forEach((el) => wireOAuthScopes(/** @type {HTMLFormElement} */ (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');
|
const progress = document.getElementById('progress');
|
||||||
if (progress) pollStatus(progress);
|
if (progress) pollStatus(progress);
|
||||||
});
|
});
|
||||||
@@ -119,7 +119,7 @@ form.gen, .panel {
|
|||||||
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 16px; }
|
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 16px; }
|
||||||
label, legend { font-weight: 600; font-size: 0.92rem; }
|
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%;
|
width: 100%;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -128,8 +128,7 @@ input[type="text"], input[type="password"], input[type="number"], select, textar
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 9px 11px;
|
padding: 9px 11px;
|
||||||
}
|
}
|
||||||
input:focus-visible, select:focus-visible, textarea:focus-visible { border-color: var(--focus); }
|
input:focus-visible, select:focus-visible { border-color: var(--focus); }
|
||||||
textarea { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.85rem; resize: vertical; }
|
|
||||||
|
|
||||||
details.options { margin-bottom: 18px; }
|
details.options { margin-bottom: 18px; }
|
||||||
details.options summary { cursor: pointer; font-weight: 600; padding: 4px 0; width: max-content; }
|
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); }
|
button.primary:hover { filter: brightness(1.08); }
|
||||||
.signin .hint { max-width: 640px; }
|
.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; }
|
.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; }
|
.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;
|
padding: 14px; background: var(--card-bg); min-height: 120px;
|
||||||
}
|
}
|
||||||
.card-image img { display: block; max-width: 100%; height: auto; }
|
.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; }
|
.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_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_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>
|
<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.
|
<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 public on this site</strong>, including totals drawn from private repos.</p>
|
<strong>The resulting cards are visible on this site to anyone with the page link</strong>, including totals drawn from private repos.</p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</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.
|
<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>
|
Signed in as another account, you get public data only.</p>
|
||||||
</div>
|
</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>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -3,8 +3,8 @@
|
|||||||
<h1>Profile cards for any GitHub user</h1>
|
<h1>Profile cards for any GitHub user</h1>
|
||||||
<p class="lead">
|
<p class="lead">
|
||||||
Enter a username and ghglance renders sixteen SVG cards (languages, streaks,
|
Enter a username and ghglance renders sixteen SVG cards (languages, streaks,
|
||||||
productive hours, contribution heatmaps and more) in every theme. They stay
|
productive hours, contribution heatmaps and more) in every theme, for a
|
||||||
here, ready to embed, at <code>/u/<username></code>.
|
quick look at <code>/u/<username></code>.
|
||||||
</p>
|
</p>
|
||||||
{{if .Error}}<p class="alert alert-error" role="alert">{{.Error}}</p>{{end}}
|
{{if .Error}}<p class="alert alert-error" role="alert">{{.Error}}</p>{{end}}
|
||||||
{{if .Message}}<p class="alert alert-info" role="status">{{.Message}}</p>{{end}}
|
{{if .Message}}<p class="alert alert-info" role="status">{{.Message}}</p>{{end}}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
</main>
|
</main>
|
||||||
<footer class="site">
|
<footer class="site">
|
||||||
<div class="wrap">
|
<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>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -39,32 +39,19 @@
|
|||||||
{{range .Cards}}
|
{{range .Cards}}
|
||||||
<li class="card">
|
<li class="card">
|
||||||
<figure>
|
<figure>
|
||||||
<div class="card-image"><img src="{{.Src}}" alt="{{.Name}} card for {{$.Login}}" loading="lazy" decoding="async"></div>
|
<div class="card-image"><img src="{{.Src}}" alt="{{.Name}} card for {{$.Login}}" decoding="async"></div>
|
||||||
<figcaption>
|
<figcaption><span class="card-name">{{.Name}}</span></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>
|
|
||||||
</figure>
|
</figure>
|
||||||
</li>
|
</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</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}}
|
{{end}}
|
||||||
|
|
||||||
{{if not .Job.Active}}
|
{{if not .Job.Active}}
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>{{if .Meta}}Regenerate{{else}}Try again{{end}}</h2>
|
<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 .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">Signed in as another account, these cards can be regenerated in {{.CooldownLeft}}. Signed in as {{.Login}}, you can regenerate now.</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" .}}
|
{{template "form" .}}
|
||||||
</section>
|
</section>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ import (
|
|||||||
// path segment, which is what lets it name a directory under the data dir.
|
// 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]+)*$`)
|
var usernameRE = regexp.MustCompile(`^[A-Za-z0-9]+(-[A-Za-z0-9]+)*$`)
|
||||||
|
|
||||||
// Sign-in tokens are only ever forwarded in an Authorization header;
|
// Tokens, whether GitHub's token endpoint returned them or a visitor pasted
|
||||||
// restricting the charset of what GitHub's token endpoint returns rules out
|
// one, are only ever forwarded in an Authorization header; restricting the
|
||||||
// header injection.
|
// charset rules out header injection.
|
||||||
var tokenRE = regexp.MustCompile(`^[A-Za-z0-9_]{20,255}$`)
|
var tokenRE = regexp.MustCompile(`^[A-Za-z0-9_]{20,255}$`)
|
||||||
|
|
||||||
// IANA zone names: letters, digits and _ + - / only. time.LoadLocation
|
// 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
|
// Options are the generation settings recorded in meta.json. They never
|
||||||
// include the sign-in token.
|
// include a token.
|
||||||
type Options struct {
|
type Options struct {
|
||||||
TZ string `json:"tz"`
|
TZ string `json:"tz"`
|
||||||
StartOfWeek string `json:"start_of_week"`
|
StartOfWeek string `json:"start_of_week"`
|
||||||
@@ -70,15 +70,19 @@ type Options struct {
|
|||||||
CommitsPerRepo int `json:"commits_per_repo"`
|
CommitsPerRepo int `json:"commits_per_repo"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// submission is a validated form post. Token is the sign-in token GitHub
|
// submission is a validated form post. Token is either the token the
|
||||||
// issues at the callback; it is revoked when the job ends.
|
// 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 {
|
type submission struct {
|
||||||
Login string
|
Login string
|
||||||
Token string
|
Token string
|
||||||
|
Pasted bool
|
||||||
Options Options
|
Options Options
|
||||||
}
|
}
|
||||||
|
|
||||||
// formValues is the raw form, kept so a rejected post re-renders as typed.
|
// 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 {
|
type formValues struct {
|
||||||
User string
|
User string
|
||||||
TZ string
|
TZ string
|
||||||
@@ -102,8 +106,9 @@ func defaultForm() formValues {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parseSubmission validates a generation form. The ticked scope is kept:
|
// 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
|
// it picks the scopes a sign-in asks for, and the job still enforces who
|
||||||
// the resulting token belongs to.
|
// 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) {
|
func parseSubmission(get func(string) string) (submission, formValues, error) {
|
||||||
f := formValues{
|
f := formValues{
|
||||||
User: strings.TrimSpace(get("user")),
|
User: strings.TrimSpace(get("user")),
|
||||||
@@ -114,10 +119,14 @@ func parseSubmission(get func(string) string) (submission, formValues, error) {
|
|||||||
IncludePrivate: get("include_private") != "",
|
IncludePrivate: get("include_private") != "",
|
||||||
CommitsPerRepo: strings.TrimSpace(get("commits_per_repo")),
|
CommitsPerRepo: strings.TrimSpace(get("commits_per_repo")),
|
||||||
}
|
}
|
||||||
|
token := strings.TrimSpace(get("token"))
|
||||||
|
|
||||||
if !validUsername(f.User) {
|
if !validUsername(f.User) {
|
||||||
return submission{}, f, errors.New("enter a valid GitHub username: letters, digits and single hyphens, up to 39 characters")
|
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
|
tz := f.TZ
|
||||||
if tz == "" {
|
if tz == "" {
|
||||||
@@ -144,7 +153,7 @@ func parseSubmission(get func(string) string) (submission, formValues, error) {
|
|||||||
perRepo = n
|
perRepo = n
|
||||||
}
|
}
|
||||||
|
|
||||||
return submission{Login: f.User, Options: Options{
|
return submission{Login: f.User, Token: token, Pasted: token != "", Options: Options{
|
||||||
TZ: tz,
|
TZ: tz,
|
||||||
StartOfWeek: strings.ToLower(wd.String()),
|
StartOfWeek: strings.ToLower(wd.String()),
|
||||||
IncludeForks: f.IncludeForks,
|
IncludeForks: f.IncludeForks,
|
||||||
|
|||||||
+80
-26
@@ -2,7 +2,10 @@ package web
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -196,7 +199,6 @@ func TestParseSubmissionKeepsTicks(t *testing.T) {
|
|||||||
sub, _, err := parseSubmission(form(
|
sub, _, err := parseSubmission(form(
|
||||||
"user", "octocat", "tz", "Asia/Saigon", "start_of_week", "Mon",
|
"user", "octocat", "tz", "Asia/Saigon", "start_of_week", "Mon",
|
||||||
"include_private", "1", "include_org_repos", "1", "include_forks", "1",
|
"include_private", "1", "include_org_repos", "1", "include_forks", "1",
|
||||||
"token", testToken, // no such field any more: ignored
|
|
||||||
))
|
))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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" {
|
if !o.IncludePrivate || !o.IncludeOrgRepos || !o.IncludeForks || o.StartOfWeek != "monday" || o.TZ != "Asia/Saigon" {
|
||||||
t.Errorf("options = %+v", o)
|
t.Errorf("options = %+v", o)
|
||||||
}
|
}
|
||||||
if o.CommitsPerRepo != defaultCommitsPerRepo || sub.Token != "" {
|
if o.CommitsPerRepo != defaultCommitsPerRepo || sub.Token != "" || sub.Pasted {
|
||||||
t.Errorf("submission = %+v", sub)
|
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 {
|
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)
|
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"),
|
"bad week": form("user", "a", "start_of_week", "moonday"),
|
||||||
"negative commits": form("user", "a", "commits_per_repo", "-1"),
|
"negative commits": form("user", "a", "commits_per_repo", "-1"),
|
||||||
"huge commits": form("user", "a", "commits_per_repo", "999999"),
|
"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 {
|
for name, get := range cases {
|
||||||
if _, _, err := parseSubmission(get); err == nil {
|
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"`) {
|
if rec.Code != 200 || !strings.Contains(rec.Body.String(), `action="/auth/start"`) {
|
||||||
t.Fatalf("index = %d", rec.Code)
|
t.Fatalf("index = %d", rec.Code)
|
||||||
}
|
}
|
||||||
if body := rec.Body.String(); strings.Contains(body, `name="token"`) || strings.Count(body, `type="submit"`) != 1 {
|
body := rec.Body.String()
|
||||||
t.Error("index offers something besides signing in")
|
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" {
|
for _, want := range []string{
|
||||||
t.Error("index missing security headers")
|
`<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" {
|
if rec := get("/healthz"); rec.Code != 200 || strings.TrimSpace(rec.Body.String()) != "ok" {
|
||||||
t.Errorf("healthz = %d %q", rec.Code, rec.Body.String())
|
t.Errorf("healthz = %d %q", rec.Code, rec.Body.String())
|
||||||
@@ -469,37 +502,58 @@ func TestHandlers(t *testing.T) {
|
|||||||
|
|
||||||
publishTest(t, s.store, "octocat")
|
publishTest(t, s.store, "octocat")
|
||||||
rec = get("/u/octocat?theme=github_dark")
|
rec = get("/u/octocat?theme=github_dark")
|
||||||
body := rec.Body.String()
|
// html/template writes "+" in attributes as "+"; compare decoded.
|
||||||
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") {
|
body = html.UnescapeString(rec.Body.String())
|
||||||
|
if rec.Code != 200 {
|
||||||
t.Fatalf("user page = %d", rec.Code)
|
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")
|
t.Error("invalid theme not replaced by default")
|
||||||
}
|
}
|
||||||
|
|
||||||
rec = get("/u/octocat/dracula/stats.svg")
|
// There is no card route: nothing serves a card by URL.
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, p := range []string{
|
for _, p := range []string{
|
||||||
|
"/u/octocat/dracula/stats.svg",
|
||||||
|
"/u/octocat/github_dark/stats.svg?v=1",
|
||||||
"/u/octocat/nope/stats.svg",
|
"/u/octocat/nope/stats.svg",
|
||||||
"/u/octocat/dracula/nope.svg",
|
|
||||||
"/u/octocat/dracula/meta.json",
|
"/u/octocat/dracula/meta.json",
|
||||||
"/u/octocat/..%2fmeta.json/stats.svg",
|
"/u/octocat/..%2fmeta.json/stats.svg",
|
||||||
"/u/..%2f..%2fetc/dracula/stats.svg",
|
"/u/..%2f..%2fetc/dracula/stats.svg",
|
||||||
"/u/octocat/dracula/..%2f..%2fmeta.json",
|
|
||||||
} {
|
} {
|
||||||
if rec := get(p); rec.Code != 404 {
|
rec := get(p)
|
||||||
t.Errorf("GET %s = %d, want 404", p, rec.Code)
|
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() {
|
func main() {
|
||||||
var (
|
var (
|
||||||
user = flag.String("user", "", "GitHub username (required)")
|
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")
|
out = flag.String("out", "output", "output directory")
|
||||||
themesFlag = flag.String("themes", "dracula", "comma-separated theme ids, or 'all'")
|
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)")
|
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")
|
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")
|
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")
|
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)")
|
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")
|
workers = flag.Int("workers", 2, "web UI: concurrent generation jobs")
|
||||||
// Empty defaults keep the secret out of -help; the env fallback is
|
// Empty defaults keep the secret out of -help; the env fallback is
|
||||||
|
|||||||
Reference in new issue
Block a user