mirror of
https://github.com/tiennm99/ghglance.git
synced 2026-10-11 03:13:20 +00:00
feat(web): add web UI and Coolify compose deployment
`ghglance -serve :8080` runs a web UI from the same binary: submit a username, a background job renders every card in every theme, and /u/<user> shows them again with a theme picker and embed URLs. - Cards are stored on disk with atomic symlink publishing and deleted after -retention (default 24h); -cooldown limits token-less regeneration. - Token-less jobs use a public-only server token with private and org scope forced off; a submitter's own token is used for that job only and never stored or logged. The form links to GitHub's new-token page with the needed scopes pre-ticked. - The CLI and web share one fetch helper; CLI output is unchanged. - compose.yml deploys to Coolify with a data volume and /healthz check.
This commit is contained in:
1 parent
af420f0db7
commit
ad775adffa
29 files changed
+3255
-152
No files matched your search
@@ -0,0 +1,12 @@
|
||||
# The image needs only go.mod, main.go, internal/ and entrypoint.sh. Keep
|
||||
# secrets and local output out of the Docker context and its cached layers.
|
||||
.env
|
||||
*.env
|
||||
data/
|
||||
output/
|
||||
.git
|
||||
.github
|
||||
.claude
|
||||
plans/
|
||||
demo/
|
||||
docs/
|
||||
@@ -0,0 +1 @@
|
||||
GHGLANCE_GITHUB_TOKEN=
|
||||
@@ -31,6 +31,9 @@ go.work.sum
|
||||
# in CI) as the reference render instead of committing a sample here.
|
||||
output/
|
||||
|
||||
# Cards stored by a local `ghglance -serve` run (default -data-dir).
|
||||
data/
|
||||
|
||||
# Editor/IDE
|
||||
# .idea/
|
||||
# .vscode/
|
||||
@@ -8,7 +8,8 @@
|
||||
|
||||
`ghglance` is a single-binary CLI (and a GitHub Action wrapping it) that fetches
|
||||
data for a GitHub user and writes a themed set of SVGs you can embed in your
|
||||
profile README.
|
||||
profile README. The same binary can also [run as a web UI](#run-the-web-ui)
|
||||
where anyone submits a username and gets the cards back.
|
||||
|
||||
Marketplace listing: **[ghglance](https://github.com/marketplace/actions/ghglance)** · Source: [`tiennm99/ghglance`](https://github.com/tiennm99/ghglance)
|
||||
|
||||
@@ -172,7 +173,104 @@ ghglance -user tiennm99 -themes dracula -include-org-repos -out output
|
||||
| `-include-forks` | `true` | Include forked repos in the stats |
|
||||
| `-include-private` | `true` | Include private repos (requires `repo` PAT scope; silently no-op otherwise) |
|
||||
| `-include-org-repos`| `false` | Count org-owned repos you administer toward stars, repo count, languages, top-starred |
|
||||
| `-timeout` | `30m` | Overall fetch deadline (per generation job under `-serve`), `0` = no limit |
|
||||
| `-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 a token-less submission 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 |
|
||||
|
||||
The five web UI flags are server-only: 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, a background job renders all sixteen cards in every
|
||||
theme, and `/u/<username>` shows them again with a theme picker and
|
||||
copyable embed URLs. Cards are stored on disk and survive restarts.
|
||||
|
||||
```sh
|
||||
export GITHUB_TOKEN=ghp_xxx
|
||||
ghglance -serve :8080 -data-dir data -retention 24h
|
||||
# open http://localhost:8080
|
||||
```
|
||||
|
||||
| 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>/status` | Job status as JSON, polled by the progress page |
|
||||
| `/healthz` | Liveness probe |
|
||||
|
||||
How submissions are handled:
|
||||
|
||||
- **Server token.** Submissions without a token use the server's
|
||||
`GITHUB_TOKEN`, with private repos and org repos forced off. That alone
|
||||
does not hide private work: GitHub counts every private contribution a
|
||||
token can see in the totals and the calendar. So the server token must be
|
||||
public-only (a classic PAT with just `read:user`, or a fine-grained token
|
||||
with public repositories only); a token with `repo` scope, or one that can
|
||||
list any private repository, is refused for token-less jobs and logged at
|
||||
startup. The token owner's own username is refused without a token too.
|
||||
- **Submitter's token.** An optional token in the form is used for that one
|
||||
job, then dropped: never logged, never written to disk. When it belongs to
|
||||
the username being generated, private repos count by default and the
|
||||
cooldown is skipped. A token that belongs to someone else renders public
|
||||
data only, does not skip the cooldown, and is refused outright if it can
|
||||
read private repositories. The cards it renders are public on the site
|
||||
like any other.
|
||||
A "Create a token on GitHub" button beside the field opens GitHub's
|
||||
new-token page with a classic token's `repo` and `read:user` scopes
|
||||
pre-ticked.
|
||||
- **Failures.** A job that fails or times out at any fetch stage publishes
|
||||
nothing, so an earlier complete set stays in place.
|
||||
- **Cooldown.** Without a token, cards younger than `-cooldown` are shown
|
||||
instead of regenerated.
|
||||
- **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.
|
||||
- **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
|
||||
reverse proxy on a private or loopback address, the client address comes
|
||||
from the last `X-Forwarded-For` hop.
|
||||
|
||||
Each user takes about 9 MB on disk for an active profile (16 cards × every theme).
|
||||
|
||||
### Deploy with Docker Compose or Coolify
|
||||
|
||||
[`compose.yml`](./compose.yml) builds the repo's `Dockerfile`, runs
|
||||
`ghglance -serve :8080 -data-dir /data`, keeps cards in the `ghglance-data`
|
||||
volume, and health-checks `/healthz`. It publishes no host port: Coolify's
|
||||
proxy routes the domain it generates for `SERVICE_FQDN_GHGLANCE_8080` to
|
||||
port 8080 in the container.
|
||||
|
||||
In Coolify:
|
||||
|
||||
1. Create a resource from this Git repository with the **Docker Compose**
|
||||
build pack and compose file `/compose.yml`.
|
||||
2. Set `GHGLANCE_GITHUB_TOKEN` (see [`.env.example`](./.env.example)) to a
|
||||
public-only token: a classic PAT with only `read:user`, never `repo`.
|
||||
`compose.yml` requires it and passes it to the container as
|
||||
`GITHUB_TOKEN`. The distinct name keeps a `GITHUB_TOKEN` exported in your
|
||||
shell from silently replacing it during `docker compose up`.
|
||||
3. Keep the generated domain or set your own on the `ghglance` service, then
|
||||
deploy.
|
||||
|
||||
On a plain Docker host, copy `.env.example` to `.env`, fill in the token,
|
||||
add a `ports: ["8080:8080"]` entry to the service, and run (`.dockerignore`
|
||||
keeps `.env` and `data/` out of the image context):
|
||||
|
||||
```sh
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
The Action image is unchanged: `compose.yml` overrides the entrypoint, so
|
||||
the Action still runs `entrypoint.sh`.
|
||||
|
||||
## How attribution works
|
||||
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
# ghglance web UI, built from this repo's Dockerfile.
|
||||
services:
|
||||
ghglance:
|
||||
build: .
|
||||
entrypoint: ["ghglance"]
|
||||
command: ["-serve", ":8080", "-data-dir", "/data", "-retention", "24h"]
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- SERVICE_FQDN_GHGLANCE_8080
|
||||
- GITHUB_TOKEN=${GHGLANCE_GITHUB_TOKEN:?set a public-only GitHub token}
|
||||
volumes:
|
||||
- ghglance-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
ghglance-data:
|
||||
@@ -1,6 +1,7 @@
|
||||
# Deployment Guide
|
||||
|
||||
Three consumption paths: **GitHub Action**, **prebuilt binaries**, **go install**.
|
||||
The same binary also runs as a self-hosted **web UI** (section 4).
|
||||
|
||||
## 1. GitHub Action (recommended for README auto-updates)
|
||||
|
||||
@@ -97,6 +98,27 @@ go install github.com/tiennm99/ghglance@latest
|
||||
|
||||
Requires Go 1.26+. Puts the binary in `$(go env GOPATH)/bin`.
|
||||
|
||||
## 4. Web UI (Docker Compose / Coolify)
|
||||
|
||||
`compose.yml` at the repo root builds the `Dockerfile`, overrides its
|
||||
entrypoint to run `ghglance -serve :8080 -data-dir /data`, stores cards in
|
||||
the `ghglance-data` volume, and health-checks `/healthz` with busybox
|
||||
`wget`. It publishes no host port; Coolify routes the domain generated for
|
||||
`SERVICE_FQDN_GHGLANCE_8080` to container port 8080.
|
||||
|
||||
| Variable | Needed for |
|
||||
| --- | --- |
|
||||
| `GHGLANCE_GITHUB_TOKEN` | Required by `compose.yml`, which passes it to the container as `GITHUB_TOKEN` for token-less submissions. Must be public-only: a classic PAT with just `read:user`. A token with `repo` scope or any private-repo access is refused for token-less jobs (GitHub would count private contributions in totals and calendars). |
|
||||
|
||||
Coolify: create a Docker Compose resource from this repo, compose file
|
||||
`/compose.yml`, set `GHGLANCE_GITHUB_TOKEN`, assign the domain, deploy. Server flags
|
||||
(`-cooldown`, `-retention`, `-workers`, `-timeout`) are changed by editing `command:` in
|
||||
`compose.yml`. Steps for a plain Docker host and the request-handling rules
|
||||
are in the README's "Run the web UI" section.
|
||||
|
||||
Rollback: redeploy the previous commit. Card sets on the volume are
|
||||
format-stable, so no data migration is involved.
|
||||
|
||||
## Docker image
|
||||
|
||||
Published to `ghcr.io/tiennm99/ghglance:<tag>` on each `v*` release via `.github/workflows/release.yml` (buildx, multi-tag: exact version, major.minor, major, latest).
|
||||
|
||||
@@ -14,14 +14,18 @@ One process, three phases: **flag parsing → data fetch → SVG render**.
|
||||
api.github.com internal/theme
|
||||
```
|
||||
|
||||
No database, no cache, no background workers. Stateless CLI; Action runtime just sets environment variables + runs the binary.
|
||||
No database, no cache, no background workers in CLI mode. Stateless CLI; Action runtime just sets environment variables + runs the binary. `-serve` switches the same binary into the long-running web UI described under [Web UI mode](#web-ui-mode).
|
||||
|
||||
A root `context.Context` is built in `main.go` with an overall deadline (`-timeout`, default 30m) and cancelled on `SIGINT`/`SIGTERM`. Every fetcher and HTTP request inherits it so a slow run aborts cleanly instead of draining the 6h Action budget.
|
||||
|
||||
## Data-fetch sequence
|
||||
|
||||
`github.Collect` owns this sequence; the CLI and every web UI job call it, so
|
||||
the two paths cannot drift. Only the profile fetch is fatal; later stages
|
||||
report through `CollectConfig.Warnf` and leave partial data.
|
||||
|
||||
```
|
||||
main.go
|
||||
github.Collect(ctx, client, login, cfg)
|
||||
│
|
||||
▼
|
||||
FetchProfile(ctx, login, opts)
|
||||
@@ -49,7 +53,7 @@ FetchContributionsAllTime(ctx, profile, opts)
|
||||
│ TotalCommitsAllTime
|
||||
│
|
||||
▼
|
||||
FetchProductive(ctx, profile, profile.SeedRepos, loc, commitsPerRepo) // 0 = no cap
|
||||
FetchProductive(ctx, profile, SeedRepos[:cfg.TopRepos], loc, commitsPerRepo) // 0 = no cap
|
||||
│ commitHistoryQuery × (#seeds × pages)
|
||||
│ per commit: t = committedDate in loc
|
||||
│ ProductiveAllTime[t.Hour]++
|
||||
@@ -60,7 +64,7 @@ FetchProductive(ctx, profile, profile.SeedRepos, loc, commitsPerRepo) // 0 = no
|
||||
│ CommitsByLanguage, CommitsByLanguageAllTime
|
||||
│
|
||||
▼
|
||||
card.RenderAll(profile, theme, outDir) × len(themes)
|
||||
card.RenderAll(profile, theme, outDir) × len(themes) // caller's step
|
||||
```
|
||||
|
||||
## GraphQL queries
|
||||
@@ -141,8 +145,59 @@ Light themes (`default`, `github`, `nord_bright`, etc.) use `StrokeOpacity: 1` w
|
||||
| Overall timeout (`-timeout`) or Ctrl-C | `ctx` cancels in-flight requests; partial data may render |
|
||||
| User with 0 commits | Card renders "No data available" |
|
||||
|
||||
## Web UI mode
|
||||
|
||||
`ghglance -serve :8080` runs `internal/web` (stdlib `net/http`,
|
||||
`html/template`, `embed`; vanilla JS, no build step) instead of the one-shot
|
||||
CLI path.
|
||||
|
||||
```
|
||||
POST /generate ─► validate ─► rate limit / cooldown ─► Queue (dedup per user)
|
||||
│ -workers goroutines
|
||||
▼
|
||||
github.Collect ─► Store.Publish (every theme)
|
||||
│
|
||||
GET /u/{user} ◄── meta.json + card list ◄─────────────────┘
|
||||
GET /u/{user}/{theme}/{card}.svg ◄── os.Root read
|
||||
```
|
||||
|
||||
- **Storage.** `<data>/<user>` (lowercased login) is a symlink into
|
||||
`<data>/.gen/<user>-<nanos>/`, which holds `<theme>/<card>.svg` plus
|
||||
`meta.json` (generated time, options, `public`/`private` scope; never the
|
||||
token). Publish renders into a fresh generation directory, then renames a
|
||||
new symlink over the old one, so a reader sees the old set or the new set,
|
||||
never a partial one. Startup sweeps generations no link points at.
|
||||
Sets older than `-retention` (default `24h`) are deleted at startup and
|
||||
hourly; a store mutex keeps that removal from racing a republish.
|
||||
- **Path safety.** Logins are checked against GitHub's rule (alphanumerics
|
||||
and single hyphens, 1–39 chars) and themes and card names against the
|
||||
registered lists before any path is built; reads go through `os.Root`.
|
||||
- **Jobs.** In-process queue, at most one queued or running job per user,
|
||||
`-workers` concurrent, `-timeout` each, capacity 64. `SIGTERM` stops the
|
||||
HTTP server, cancels running jobs and drops queued ones; nothing is
|
||||
published mid-render.
|
||||
- **Tokens.** GitHub folds every private contribution a token can see into
|
||||
totals and calendars, so repo filters alone cannot keep cards public. Each
|
||||
job first identifies its token (`viewer` query: login, a one-repo
|
||||
`privacy: PRIVATE` probe, and a classic token's `X-OAuth-Scopes`).
|
||||
Token-less jobs use the server's `GITHUB_TOKEN` (identified once and
|
||||
cached) with private and org-repo scope forced off; they are refused when
|
||||
that token can read private repos, and for the token owner's own login. A
|
||||
submitter's token keeps its scope and skips the cooldown only for its own
|
||||
login; for anyone else it is refused if private-capable, otherwise forced
|
||||
to public scope under the cooldown. It lives only on the job and is
|
||||
cleared when it ends.
|
||||
- **Partial fetches.** The web path runs `github.Collect` with `Strict`, so
|
||||
a failed all-time or commit-history stage fails the job, and a job whose
|
||||
deadline passed is failed even if the fetch returned. The CLI keeps
|
||||
rendering partial data with warnings.
|
||||
- **HTTP hardening.** Strict CSP on pages, `default-src 'none'` + `sandbox`
|
||||
on SVGs, `nosniff`, 16 KiB form limit, `http.CrossOriginProtection` on the
|
||||
POST, per-client token bucket (burst 5, +1 per 2 min) keyed by IPv4
|
||||
address or IPv6 /64, capped at 10,000 tracked clients.
|
||||
|
||||
## Extension points
|
||||
|
||||
- **New card**: implement `Card` interface, add to `allCards` in `card.go`.
|
||||
- **New theme**: add entry to `themes` map in `theme.go`.
|
||||
- **New fetcher mode** (e.g., REST per-commit): add a new method on `*Client`, call from `main.go`, wire to new `Profile` fields.
|
||||
- **New fetcher mode** (e.g., REST per-commit): add a new method on `*Client`, call from `github.Collect`, wire to new `Profile` fields.
|
||||
@@ -57,3 +57,12 @@ func RenderAll(p *github.Profile, t theme.Theme, outDir string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Filenames lists every card's on-disk basename in render order.
|
||||
func Filenames() []string {
|
||||
out := make([]string, len(allCards))
|
||||
for i, c := range allCards {
|
||||
out[i] = c.Filename()
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -99,4 +99,3 @@ func ownedNonForkRepos(repos []github.RepoInfo) []github.RepoInfo {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
+18
-13
@@ -58,15 +58,21 @@ const maxRateLimitSleep = 5 * time.Minute
|
||||
// caller's overall budget expires. On a primary-rate-limit 403, honors
|
||||
// Retry-After / X-RateLimit-Reset once before retrying.
|
||||
func (c *Client) query(ctx context.Context, q string, vars map[string]any, out any) error {
|
||||
_, err := c.queryHeader(ctx, q, vars, out)
|
||||
return err
|
||||
}
|
||||
|
||||
// queryHeader is query that also returns the successful response's headers.
|
||||
func (c *Client) queryHeader(ctx context.Context, q string, vars map[string]any, out any) (http.Header, error) {
|
||||
body, err := json.Marshal(gqlRequest{Query: q, Variables: vars})
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal request: %w", err)
|
||||
return nil, fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("new request: %w", err)
|
||||
return nil, fmt.Errorf("new request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "ghglance")
|
||||
@@ -76,51 +82,51 @@ func (c *Client) query(ctx context.Context, q string, vars map[string]any, out a
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("http: %w", err)
|
||||
return nil, fmt.Errorf("http: %w", err)
|
||||
}
|
||||
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read body: %w", err)
|
||||
return nil, fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
|
||||
if rateLimited(resp) && attempt == 0 {
|
||||
wait := rateLimitWait(resp)
|
||||
if wait > maxRateLimitSleep {
|
||||
return fmt.Errorf("http %d: rate limit resets in %s (>%s max wait)", resp.StatusCode, wait, maxRateLimitSleep)
|
||||
return nil, fmt.Errorf("http %d: rate limit resets in %s (>%s max wait)", resp.StatusCode, wait, maxRateLimitSleep)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "warn: rate-limited, sleeping %s before retry\n", wait.Round(time.Second))
|
||||
select {
|
||||
case <-time.After(wait):
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("http %d: %s", resp.StatusCode, truncate(raw, 500))
|
||||
return nil, fmt.Errorf("http %d: %s", resp.StatusCode, truncate(raw, 500))
|
||||
}
|
||||
|
||||
var r gqlResponse
|
||||
if err := json.Unmarshal(raw, &r); err != nil {
|
||||
return fmt.Errorf("decode body: %w", err)
|
||||
return nil, fmt.Errorf("decode body: %w", err)
|
||||
}
|
||||
if len(r.Errors) > 0 {
|
||||
msgs := make([]string, 0, len(r.Errors))
|
||||
for _, e := range r.Errors {
|
||||
msgs = append(msgs, e.Message)
|
||||
}
|
||||
return fmt.Errorf("graphql: %s", strings.Join(msgs, "; "))
|
||||
return nil, fmt.Errorf("graphql: %s", strings.Join(msgs, "; "))
|
||||
}
|
||||
if out != nil {
|
||||
if err := json.Unmarshal(r.Data, out); err != nil {
|
||||
return fmt.Errorf("decode data: %w", err)
|
||||
return nil, fmt.Errorf("decode data: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return resp.Header, nil
|
||||
}
|
||||
return fmt.Errorf("http: exceeded retry attempts")
|
||||
return nil, fmt.Errorf("http: exceeded retry attempts")
|
||||
}
|
||||
|
||||
// rateLimited returns true when the response indicates a GitHub primary or
|
||||
@@ -176,4 +182,3 @@ func truncate(b []byte, n int) string {
|
||||
}
|
||||
return string(b[:cut]) + "…"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CollectConfig tunes Collect beyond the repo filters in FetchOptions.
|
||||
type CollectConfig struct {
|
||||
Options FetchOptions
|
||||
// Location buckets commit timestamps for the productive-time cards and
|
||||
// names the UTC offset in their titles. Nil means UTC.
|
||||
Location *time.Location
|
||||
// WeekStart is the first heatmap row and weekday bar.
|
||||
WeekStart time.Weekday
|
||||
// TopRepos caps the seed repos probed for commit history (0 = unlimited).
|
||||
TopRepos int
|
||||
// CommitsPerRepo caps commits sampled per seed repo (0 = every commit).
|
||||
CommitsPerRepo int
|
||||
// Warnf reports non-fatal fetch failures; partial data still renders.
|
||||
// Nil discards them.
|
||||
Warnf func(format string, args ...any)
|
||||
// Strict turns those stage failures into an error, for callers that
|
||||
// would rather keep an older complete result than publish a partial one.
|
||||
Strict bool
|
||||
}
|
||||
|
||||
// Collect runs the full fetch sequence every card needs: FetchProfile, then
|
||||
// FetchContributionsAllTime (which yields the seed repos), then
|
||||
// FetchProductive over those seeds. Only the profile fetch is fatal unless
|
||||
// cfg.Strict is set; otherwise the later stages warn and leave their
|
||||
// aggregates partially filled.
|
||||
func Collect(ctx context.Context, c *Client, login string, cfg CollectConfig) (*Profile, error) {
|
||||
warnf := cfg.Warnf
|
||||
if warnf == nil {
|
||||
warnf = func(string, ...any) {}
|
||||
}
|
||||
loc := cfg.Location
|
||||
if loc == nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
|
||||
profile, err := c.FetchProfile(ctx, login, cfg.Options)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch profile: %w", err)
|
||||
}
|
||||
profile.UTCOffsetLabel = UTCOffsetLabel(loc)
|
||||
profile.WeekStart = cfg.WeekStart
|
||||
|
||||
// Year-loop fetch populates SeedRepos from commitContributionsByRepository
|
||||
// plus the all-time contribution calendar; must precede FetchProductive so
|
||||
// commit-history probes land on repos where the user actually committed.
|
||||
if len(profile.ContributionYears) > 0 {
|
||||
if err := c.FetchContributionsAllTime(ctx, profile, cfg.Options); err != nil {
|
||||
if cfg.Strict {
|
||||
return nil, fmt.Errorf("fetch all-time contributions: %w", err)
|
||||
}
|
||||
warnf("all-time contributions fetch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if profile.ID != "" && len(profile.SeedRepos) > 0 {
|
||||
repos := profile.SeedRepos
|
||||
if cfg.TopRepos > 0 && len(repos) > cfg.TopRepos {
|
||||
repos = repos[:cfg.TopRepos]
|
||||
}
|
||||
if err := c.FetchProductive(ctx, profile, repos, loc, cfg.CommitsPerRepo); err != nil {
|
||||
if cfg.Strict {
|
||||
return nil, fmt.Errorf("fetch commit history: %w", err)
|
||||
}
|
||||
warnf("productive-time + commits-per-language fetch: %v", err)
|
||||
}
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
// TokenInfo describes the account behind a token and how far it reaches.
|
||||
type TokenInfo struct {
|
||||
Login string
|
||||
// CanReadPrivate is true when the token can read private repositories.
|
||||
// GitHub then counts contributions to those repos in every total and
|
||||
// calendar it returns, not only in the repo lists.
|
||||
CanReadPrivate bool
|
||||
}
|
||||
|
||||
// TokenInfo identifies the client's token. A classic token is private-capable
|
||||
// when its X-OAuth-Scopes include "repo"; any token is when it can list a
|
||||
// private repository the viewer owns, collaborates on or reaches through an
|
||||
// org (the only signal a fine-grained token gives).
|
||||
func (c *Client) TokenInfo(ctx context.Context) (TokenInfo, error) {
|
||||
var resp struct {
|
||||
Viewer struct {
|
||||
Login string `json:"login"`
|
||||
Repositories struct {
|
||||
TotalCount int `json:"totalCount"`
|
||||
} `json:"repositories"`
|
||||
} `json:"viewer"`
|
||||
}
|
||||
h, err := c.queryHeader(ctx, viewerQuery, nil, &resp)
|
||||
if err != nil {
|
||||
return TokenInfo{}, err
|
||||
}
|
||||
return TokenInfo{
|
||||
Login: resp.Viewer.Login,
|
||||
CanReadPrivate: resp.Viewer.Repositories.TotalCount > 0 || scopesIncludeRepo(h.Get("X-OAuth-Scopes")),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// scopesIncludeRepo reports whether a classic token's scope list grants
|
||||
// full repo access, which covers every private repo its owner can see.
|
||||
func scopesIncludeRepo(scopes string) bool {
|
||||
for _, s := range strings.Split(scopes, ",") {
|
||||
if strings.TrimSpace(s) == "repo" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// UTCOffsetLabel formats the location's current offset from UTC compactly:
|
||||
//
|
||||
// integer hours → "UTC+7" (no ".00" padding — 3 chars shorter than
|
||||
// the old "UTC+7.00" format, keeps the
|
||||
// productive-time title at 15 px)
|
||||
// half-hour zone → "UTC+5:30" (India)
|
||||
// quarter-hour → "UTC+5:45" (Nepal)
|
||||
// negative zone → "UTC-3" / "UTC-3:30"
|
||||
func UTCOffsetLabel(loc *time.Location) string {
|
||||
_, offsetSec := time.Now().In(loc).Zone()
|
||||
sign := "+"
|
||||
if offsetSec < 0 {
|
||||
sign = "-"
|
||||
offsetSec = -offsetSec
|
||||
}
|
||||
hours := offsetSec / 3600
|
||||
minutes := (offsetSec % 3600) / 60
|
||||
if minutes == 0 {
|
||||
return fmt.Sprintf("UTC%s%d", sign, hours)
|
||||
}
|
||||
return fmt.Sprintf("UTC%s%d:%02d", sign, hours, minutes)
|
||||
}
|
||||
|
||||
// ParseWeekday maps a case-insensitive English weekday name (full or 3-letter)
|
||||
// to time.Weekday. Empty input → Sunday so a blank action input still works.
|
||||
func ParseWeekday(s string) (time.Weekday, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "", "sun", "sunday":
|
||||
return time.Sunday, nil
|
||||
case "mon", "monday":
|
||||
return time.Monday, nil
|
||||
case "tue", "tuesday":
|
||||
return time.Tuesday, nil
|
||||
case "wed", "wednesday":
|
||||
return time.Wednesday, nil
|
||||
case "thu", "thursday":
|
||||
return time.Thursday, nil
|
||||
case "fri", "friday":
|
||||
return time.Friday, nil
|
||||
case "sat", "saturday":
|
||||
return time.Saturday, nil
|
||||
default:
|
||||
return time.Sunday, fmt.Errorf("unknown start-of-week %q", s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestParseWeekday covers the common case-insensitive inputs and confirms
|
||||
// an unknown value errors (the CLI then falls back to Sunday with a warn).
|
||||
func TestParseWeekday(t *testing.T) {
|
||||
ok := []struct {
|
||||
in string
|
||||
want time.Weekday
|
||||
}{
|
||||
{"", time.Sunday},
|
||||
{"sunday", time.Sunday},
|
||||
{"SUNDAY", time.Sunday},
|
||||
{"Sun", time.Sunday},
|
||||
{" monday ", time.Monday},
|
||||
{"Mon", time.Monday},
|
||||
{"saturday", time.Saturday},
|
||||
}
|
||||
for _, c := range ok {
|
||||
got, err := ParseWeekday(c.in)
|
||||
if err != nil {
|
||||
t.Errorf("ParseWeekday(%q) err=%v", c.in, err)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("ParseWeekday(%q)=%v want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
if _, err := ParseWeekday("moonday"); err == nil {
|
||||
t.Error("ParseWeekday(moonday): want error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUTCOffsetLabel checks the compact format: integer hours drop the
|
||||
// minutes suffix, non-zero offsets render as `UTC±H:MM`.
|
||||
func TestUTCOffsetLabel(t *testing.T) {
|
||||
cases := []struct {
|
||||
zone string
|
||||
want string // must appear in the label; exact value varies by DST
|
||||
}{
|
||||
{"UTC", "UTC+0"},
|
||||
{"Asia/Saigon", "UTC+7"},
|
||||
{"Asia/Kolkata", "UTC+5:30"}, // half-hour zone
|
||||
{"Asia/Kathmandu", "UTC+5:45"}, // quarter-hour zone
|
||||
}
|
||||
for _, tc := range cases {
|
||||
loc, err := time.LoadLocation(tc.zone)
|
||||
if err != nil {
|
||||
t.Skipf("%s unavailable: %v", tc.zone, err)
|
||||
}
|
||||
got := UTCOffsetLabel(loc)
|
||||
if !strings.Contains(got, tc.want) {
|
||||
t.Errorf("UTCOffsetLabel(%q) = %q, want prefix %q", tc.zone, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestScopesIncludeRepo checks that only the full "repo" scope marks a
|
||||
// classic token as private-capable.
|
||||
func TestScopesIncludeRepo(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"": false,
|
||||
"read:user": false,
|
||||
"read:user, public_repo": false,
|
||||
"repo:status, read:user": false,
|
||||
"read:user, repo": true,
|
||||
"repo": true,
|
||||
"gist,repo,workflow": true,
|
||||
"read:org, read:user, repo": true,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := scopesIncludeRepo(in); got != want {
|
||||
t.Errorf("scopesIncludeRepo(%q) = %v, want %v", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,3 +128,15 @@ query($login: String!, $from: DateTime!, $to: DateTime!) {
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
// viewerQuery names the account behind the token and probes whether the
|
||||
// token can read any private repository, so the web server can refuse to
|
||||
// publish data a token sees beyond the public view.
|
||||
const viewerQuery = `query {
|
||||
viewer {
|
||||
login
|
||||
repositories(privacy: PRIVATE, ownerAffiliations: [OWNER, COLLABORATOR, ORGANIZATION_MEMBER], first: 1) {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}`
|
||||
@@ -0,0 +1,367 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tiennm99/ghglance/internal/github"
|
||||
)
|
||||
|
||||
// Job states reported by the status endpoint.
|
||||
const (
|
||||
stateQueued = "queued"
|
||||
stateRunning = "running"
|
||||
stateDone = "done"
|
||||
stateFailed = "failed"
|
||||
stateNone = "none"
|
||||
)
|
||||
|
||||
// finishedJobTTL is how long a finished job's status stays queryable, long
|
||||
// enough for a polling page to see "done" or the failure reason.
|
||||
const finishedJobTTL = time.Hour
|
||||
|
||||
// queueCapacity bounds jobs waiting for a worker; submissions beyond it are
|
||||
// refused rather than piling up in memory.
|
||||
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")
|
||||
errOwner = errors.New("this account owns the server's token, so its cards need your own token")
|
||||
errServerPrivate = errors.New("this server's GitHub token can read private repositories, so it cannot make public cards; add your own token under Options")
|
||||
errFresh = 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
|
||||
// github.Collect.
|
||||
type Fetcher interface {
|
||||
Fetch(ctx context.Context, token, login string, cfg github.CollectConfig) (*github.Profile, error)
|
||||
TokenInfo(ctx context.Context, token string) (github.TokenInfo, error)
|
||||
}
|
||||
|
||||
type githubFetcher struct{}
|
||||
|
||||
func (githubFetcher) Fetch(ctx context.Context, token, login string, cfg github.CollectConfig) (*github.Profile, error) {
|
||||
return github.Collect(ctx, github.NewClient(token), login, cfg)
|
||||
}
|
||||
|
||||
func (githubFetcher) TokenInfo(ctx context.Context, token string) (github.TokenInfo, error) {
|
||||
return github.NewClient(token).TokenInfo(ctx)
|
||||
}
|
||||
|
||||
// job is one queued generation. token is the submitter's own token, held
|
||||
// only until the job ends and never logged or persisted.
|
||||
type job struct {
|
||||
key string
|
||||
login string
|
||||
opts Options
|
||||
token string
|
||||
|
||||
state string
|
||||
stage string
|
||||
err string
|
||||
queued time.Time
|
||||
started time.Time
|
||||
finished time.Time
|
||||
}
|
||||
|
||||
// JobStatus is the polling view of a job.
|
||||
type JobStatus struct {
|
||||
State string `json:"state"`
|
||||
Stage string `json:"stage,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Position int `json:"position,omitempty"`
|
||||
Elapsed int `json:"elapsed_seconds,omitempty"`
|
||||
}
|
||||
|
||||
func (s JobStatus) Active() bool { return s.State == stateQueued || s.State == stateRunning }
|
||||
|
||||
// Queue runs generation jobs on a fixed worker pool, with at most one queued
|
||||
// or running job per user.
|
||||
type Queue struct {
|
||||
store *Store
|
||||
fetcher Fetcher
|
||||
serverToken string
|
||||
timeout time.Duration
|
||||
cooldown time.Duration
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
jobs map[string]*job
|
||||
pending []*job
|
||||
wake chan struct{}
|
||||
closed bool
|
||||
|
||||
serverMu sync.Mutex
|
||||
serverInfo *github.TokenInfo
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func newQueue(store *Store, fetcher Fetcher, serverToken string, timeout, cooldown time.Duration, workers int) *Queue {
|
||||
if workers < 1 {
|
||||
workers = 1
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
q := &Queue{
|
||||
store: store,
|
||||
fetcher: fetcher,
|
||||
serverToken: serverToken,
|
||||
timeout: timeout,
|
||||
cooldown: cooldown,
|
||||
now: time.Now,
|
||||
jobs: map[string]*job{},
|
||||
wake: make(chan struct{}, queueCapacity),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
for range workers {
|
||||
q.wg.Add(1)
|
||||
go q.worker()
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// Submit queues a job for sub.Login. When that user already has a queued or
|
||||
// running job, it is left alone and created is false.
|
||||
func (q *Queue) Submit(sub submission) (created bool, err error) {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
if q.closed {
|
||||
return false, errStopped
|
||||
}
|
||||
q.pruneLocked()
|
||||
key := userKey(sub.Login)
|
||||
if j, ok := q.jobs[key]; ok && (j.state == stateQueued || j.state == stateRunning) {
|
||||
return false, nil
|
||||
}
|
||||
if len(q.pending) >= queueCapacity {
|
||||
return false, errQueueFull
|
||||
}
|
||||
j := &job{
|
||||
key: key,
|
||||
login: sub.Login,
|
||||
opts: sub.Options,
|
||||
token: sub.Token,
|
||||
state: stateQueued,
|
||||
queued: q.now(),
|
||||
}
|
||||
q.jobs[key] = j
|
||||
q.pending = append(q.pending, j)
|
||||
q.wake <- struct{}{}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Status reports the job for login, or stateNone when there is none.
|
||||
func (q *Queue) Status(login string) JobStatus {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
j, ok := q.jobs[userKey(login)]
|
||||
if !ok {
|
||||
return JobStatus{State: stateNone}
|
||||
}
|
||||
st := JobStatus{State: j.state, Stage: j.stage, Error: j.err}
|
||||
switch j.state {
|
||||
case stateQueued:
|
||||
for i, p := range q.pending {
|
||||
if p == j {
|
||||
st.Position = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
st.Elapsed = int(q.now().Sub(j.queued).Seconds())
|
||||
case stateRunning:
|
||||
st.Elapsed = int(q.now().Sub(j.started).Seconds())
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// Stop cancels running jobs, drops queued ones and waits for the workers.
|
||||
func (q *Queue) Stop() {
|
||||
q.mu.Lock()
|
||||
q.closed = true
|
||||
for _, j := range q.pending {
|
||||
j.token = ""
|
||||
j.state, j.err = stateFailed, errStopped.Error()
|
||||
}
|
||||
q.pending = nil
|
||||
q.mu.Unlock()
|
||||
q.cancel()
|
||||
q.wg.Wait()
|
||||
}
|
||||
|
||||
// pruneLocked forgets finished jobs older than finishedJobTTL.
|
||||
func (q *Queue) pruneLocked() {
|
||||
cutoff := q.now().Add(-finishedJobTTL)
|
||||
for k, j := range q.jobs {
|
||||
if (j.state == stateDone || j.state == stateFailed) && j.finished.Before(cutoff) {
|
||||
delete(q.jobs, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (q *Queue) worker() {
|
||||
defer q.wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-q.ctx.Done():
|
||||
return
|
||||
case <-q.wake:
|
||||
}
|
||||
q.mu.Lock()
|
||||
if len(q.pending) == 0 {
|
||||
q.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
j := q.pending[0]
|
||||
q.pending = q.pending[1:]
|
||||
j.state, j.stage, j.started = stateRunning, "fetching from GitHub", q.now()
|
||||
q.mu.Unlock()
|
||||
|
||||
err := q.run(j)
|
||||
|
||||
q.mu.Lock()
|
||||
j.token = ""
|
||||
j.finished = q.now()
|
||||
if err != nil {
|
||||
j.state, j.stage, j.err = stateFailed, "", err.Error()
|
||||
} else {
|
||||
j.state, j.stage = stateDone, ""
|
||||
}
|
||||
q.mu.Unlock()
|
||||
if err != nil {
|
||||
log.Printf("job %s: failed after %s: %v", j.key, j.finished.Sub(j.started).Round(time.Second), err)
|
||||
} else {
|
||||
log.Printf("job %s: done in %s", j.key, j.finished.Sub(j.started).Round(time.Second))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// run fetches and publishes one job's cards under the per-job timeout.
|
||||
//
|
||||
// Published cards are public, so a job only renders what the anonymous
|
||||
// view of GitHub would show, unless the token belongs to the target user.
|
||||
// GitHub folds every private contribution a token can see into the totals
|
||||
// and the calendar, so filtering repo lists alone is not enough: a token
|
||||
// that can read private repos is refused for anyone but its owner.
|
||||
func (q *Queue) run(j *job) error {
|
||||
ctx := q.ctx
|
||||
if q.timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, q.timeout)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
q.mu.Lock()
|
||||
token := j.token
|
||||
q.mu.Unlock()
|
||||
opts := j.opts
|
||||
ownToken := token != ""
|
||||
if ownToken {
|
||||
info, err := q.fetcher.TokenInfo(ctx, token)
|
||||
if err != nil {
|
||||
return errors.New("GitHub did not accept your token")
|
||||
}
|
||||
if !strings.EqualFold(info.Login, j.login) {
|
||||
if info.CanReadPrivate {
|
||||
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)
|
||||
}
|
||||
ownToken = false
|
||||
opts.IncludePrivate, opts.IncludeOrgRepos = false, false
|
||||
if q.store.cooldownLeft(j.login, q.cooldown, q.now()) > 0 {
|
||||
return errFresh
|
||||
}
|
||||
}
|
||||
} else {
|
||||
info, err := q.server(ctx)
|
||||
if err != nil {
|
||||
return errors.New("could not verify the server token, try again later")
|
||||
}
|
||||
if info.CanReadPrivate {
|
||||
return errServerPrivate
|
||||
}
|
||||
if info.Login != "" && strings.EqualFold(info.Login, j.login) {
|
||||
return errOwner
|
||||
}
|
||||
token = q.serverToken
|
||||
}
|
||||
|
||||
cfg := opts.collectConfig()
|
||||
cfg.Strict = true
|
||||
cfg.Warnf = func(format string, args ...any) {
|
||||
log.Printf("job %s: warn: "+format, append([]any{j.key}, args...)...)
|
||||
}
|
||||
profile, err := q.fetcher.Fetch(ctx, token, j.login, cfg)
|
||||
token = ""
|
||||
if q.ctx.Err() != nil {
|
||||
return errStopped
|
||||
}
|
||||
if err == nil {
|
||||
// A fetch that ran out of time may still hand back a profile; a
|
||||
// partial set must never replace a complete one.
|
||||
err = ctx.Err()
|
||||
}
|
||||
if err != nil {
|
||||
return publicError(err)
|
||||
}
|
||||
|
||||
q.mu.Lock()
|
||||
j.stage = "rendering cards"
|
||||
q.mu.Unlock()
|
||||
|
||||
scope := "public"
|
||||
if ownToken && opts.IncludePrivate {
|
||||
scope = "private"
|
||||
}
|
||||
return q.store.Publish(profile, Meta{
|
||||
Login: j.login,
|
||||
GeneratedAt: q.now().UTC(),
|
||||
Scope: scope,
|
||||
Options: opts,
|
||||
})
|
||||
}
|
||||
|
||||
// server identifies the server token, cached after the first successful
|
||||
// lookup. An empty server token has no owner and no reach to protect.
|
||||
func (q *Queue) server(ctx context.Context) (github.TokenInfo, error) {
|
||||
if q.serverToken == "" {
|
||||
return github.TokenInfo{}, nil
|
||||
}
|
||||
q.serverMu.Lock()
|
||||
defer q.serverMu.Unlock()
|
||||
if q.serverInfo != nil {
|
||||
return *q.serverInfo, nil
|
||||
}
|
||||
info, err := q.fetcher.TokenInfo(ctx, q.serverToken)
|
||||
if err != nil {
|
||||
log.Printf("server token lookup failed: %v", err)
|
||||
return github.TokenInfo{}, err
|
||||
}
|
||||
if info.CanReadPrivate {
|
||||
log.Printf("warn: GITHUB_TOKEN can read private repositories; token-less submissions are refused until it is replaced with a public-only token")
|
||||
}
|
||||
q.serverInfo = &info
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// publicError trims a fetch error to something fit for the status page.
|
||||
func publicError(err error) error {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return errors.New("timed out while fetching from GitHub")
|
||||
}
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, "user not found") || strings.Contains(msg, "Could not resolve to a User") {
|
||||
return errors.New("GitHub user not found")
|
||||
}
|
||||
if r := []rune(msg); len(r) > 300 {
|
||||
msg = string(r[:300]) + "…"
|
||||
}
|
||||
return errors.New(msg)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// rateLimiter is a per-key token bucket: burst tokens up front, then one
|
||||
// more every interval.
|
||||
type rateLimiter struct {
|
||||
burst float64
|
||||
interval time.Duration
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
buckets map[string]*bucket
|
||||
lastSweep time.Time
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
tokens float64
|
||||
last time.Time
|
||||
}
|
||||
|
||||
// maxBuckets is a hard cap on tracked clients. Past it, idle buckets are
|
||||
// swept at most once per sweepEvery, and a new client is refused while the
|
||||
// table is still full: memory stays bounded and no request pays for a scan
|
||||
// of the whole table more than once a minute.
|
||||
const (
|
||||
maxBuckets = 10000
|
||||
sweepEvery = time.Minute
|
||||
)
|
||||
|
||||
func newRateLimiter(burst int, interval time.Duration) *rateLimiter {
|
||||
return &rateLimiter{
|
||||
burst: float64(burst),
|
||||
interval: interval,
|
||||
now: time.Now,
|
||||
buckets: map[string]*bucket{},
|
||||
}
|
||||
}
|
||||
|
||||
// Allow spends one token for key, reporting false when none is left.
|
||||
func (l *rateLimiter) Allow(key string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := l.now()
|
||||
b, ok := l.buckets[key]
|
||||
if !ok {
|
||||
if len(l.buckets) >= maxBuckets && now.Sub(l.lastSweep) >= sweepEvery {
|
||||
l.sweepLocked(now)
|
||||
}
|
||||
if len(l.buckets) >= maxBuckets {
|
||||
return false
|
||||
}
|
||||
b = &bucket{tokens: l.burst, last: now}
|
||||
l.buckets[key] = b
|
||||
}
|
||||
b.tokens += now.Sub(b.last).Seconds() / l.interval.Seconds()
|
||||
if b.tokens > l.burst {
|
||||
b.tokens = l.burst
|
||||
}
|
||||
b.last = now
|
||||
if b.tokens < 1 {
|
||||
return false
|
||||
}
|
||||
b.tokens--
|
||||
return true
|
||||
}
|
||||
|
||||
// sweepLocked drops buckets that have refilled completely; they hold no
|
||||
// state a fresh bucket would not.
|
||||
func (l *rateLimiter) sweepLocked(now time.Time) {
|
||||
l.lastSweep = now
|
||||
full := time.Duration(l.burst * float64(l.interval))
|
||||
for k, b := range l.buckets {
|
||||
if now.Sub(b.last) >= full {
|
||||
delete(l.buckets, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// clientKey is the rate-limit key for the submitting client: its IPv4
|
||||
// address, or its IPv6 /64, since one subscriber is routinely handed a
|
||||
// whole /64 and can rotate through it at will. A peer on a loopback or
|
||||
// private address is taken to be the reverse proxy (Coolify's Traefik), so
|
||||
// the last X-Forwarded-For hop (the one the proxy itself appended) wins.
|
||||
// A public peer is the client and its header is ignored.
|
||||
func clientKey(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
peer, err := netip.ParseAddr(host)
|
||||
if err != nil {
|
||||
return host
|
||||
}
|
||||
addr := peer
|
||||
if peer.IsLoopback() || peer.IsPrivate() {
|
||||
if vals := r.Header.Values("X-Forwarded-For"); len(vals) > 0 {
|
||||
hops := strings.Split(vals[len(vals)-1], ",")
|
||||
if ip, err := netip.ParseAddr(strings.TrimSpace(hops[len(hops)-1])); err == nil {
|
||||
addr = ip
|
||||
}
|
||||
}
|
||||
}
|
||||
addr = addr.Unmap().WithZone("")
|
||||
if addr.Is6() {
|
||||
return netip.PrefixFrom(addr, 64).Masked().String()
|
||||
}
|
||||
return addr.String()
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
// Package web serves the ghglance web UI: a form that queues card
|
||||
// generation for any GitHub user, and pages that re-show the stored cards.
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tiennm99/ghglance/internal/card"
|
||||
"github.com/tiennm99/ghglance/internal/theme"
|
||||
)
|
||||
|
||||
//go:embed templates static
|
||||
var assets embed.FS
|
||||
|
||||
// maxFormBytes bounds a form post; the real form is well under 1 KiB.
|
||||
const maxFormBytes = 16 << 10
|
||||
|
||||
// Submission rate per client IP: a burst of 5, then one every 2 minutes.
|
||||
const (
|
||||
submitBurst = 5
|
||||
submitInterval = 2 * time.Minute
|
||||
)
|
||||
|
||||
const (
|
||||
pageCSP = "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; " +
|
||||
"connect-src 'self'; form-action 'self'; 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"
|
||||
)
|
||||
|
||||
// Config configures the web server.
|
||||
type Config struct {
|
||||
Addr string
|
||||
DataDir string
|
||||
Cooldown time.Duration
|
||||
// Retention is how long generated cards are kept before they are
|
||||
// deleted (0 = forever).
|
||||
Retention time.Duration
|
||||
Workers int
|
||||
// JobTimeout bounds one generation job (0 = no limit).
|
||||
JobTimeout time.Duration
|
||||
// Token is the server's own GitHub token, used for submissions that
|
||||
// bring none. It never renders private or org-administered data.
|
||||
Token string
|
||||
// Fetcher overrides the GitHub fetch; nil uses the real API.
|
||||
Fetcher Fetcher
|
||||
}
|
||||
|
||||
// Server holds the store, job queue and handlers.
|
||||
type Server struct {
|
||||
cfg Config
|
||||
store *Store
|
||||
queue *Queue
|
||||
limiter *rateLimiter
|
||||
pages map[string]*template.Template
|
||||
static http.Handler
|
||||
}
|
||||
|
||||
// New opens the data directory and starts the job workers.
|
||||
func New(cfg Config) (*Server, error) {
|
||||
if cfg.Fetcher == nil {
|
||||
cfg.Fetcher = githubFetcher{}
|
||||
}
|
||||
store, err := OpenStore(cfg.DataDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pages, err := parsePages()
|
||||
if err != nil {
|
||||
store.Close()
|
||||
return nil, err
|
||||
}
|
||||
staticFS, err := fs.Sub(assets, "static")
|
||||
if err != nil {
|
||||
store.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
store: store,
|
||||
queue: newQueue(store, cfg.Fetcher, cfg.Token, cfg.JobTimeout, cfg.Cooldown, cfg.Workers),
|
||||
limiter: newRateLimiter(submitBurst, submitInterval),
|
||||
pages: pages,
|
||||
static: http.FileServerFS(staticFS),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close stops the workers and releases the data directory.
|
||||
func (s *Server) Close() {
|
||||
s.queue.Stop()
|
||||
s.store.Close()
|
||||
}
|
||||
|
||||
// Run serves until ctx is cancelled, then drains in-flight requests and
|
||||
// stops the job workers. Queued and running jobs are abandoned; nothing
|
||||
// half-rendered is ever published.
|
||||
func Run(ctx context.Context, cfg Config) error {
|
||||
s, err := New(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer s.Close()
|
||||
if cfg.Token == "" {
|
||||
log.Printf("warn: GITHUB_TOKEN is empty; only submissions with their own token will succeed")
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", cfg.Addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
srv := &http.Server{
|
||||
Handler: s.Handler(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 2 * time.Minute,
|
||||
}
|
||||
errc := make(chan error, 1)
|
||||
go func() { errc <- srv.Serve(ln) }()
|
||||
log.Printf("serving on %s, data in %s", ln.Addr(), s.store.dir)
|
||||
// Check the server token up front so a private-capable token is
|
||||
// reported at startup, not on the first submission.
|
||||
go s.queue.server(ctx)
|
||||
go s.expireLoop(ctx)
|
||||
|
||||
select {
|
||||
case err := <-errc:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
}
|
||||
log.Printf("shutting down")
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
return srv.Shutdown(shutdownCtx)
|
||||
}
|
||||
|
||||
// Handler returns the routed, hardened HTTP handler.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /{$}", s.handleIndex)
|
||||
mux.HandleFunc("POST /generate", s.handleGenerate)
|
||||
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")
|
||||
fmt.Fprintln(w, "ok")
|
||||
})
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", s.withCache(s.static, "public, max-age=3600")))
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderMessage(w, http.StatusNotFound, "Page not found", "There is nothing at this address.")
|
||||
})
|
||||
|
||||
cop := http.NewCrossOriginProtection()
|
||||
return commonHeaders(cop.Handler(mux))
|
||||
}
|
||||
|
||||
func commonHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
h.Set("Content-Security-Policy", pageCSP)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) withCache(next http.Handler, value string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", value)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// pageData feeds every template.
|
||||
type pageData struct {
|
||||
Title string
|
||||
Heading string
|
||||
Message string
|
||||
Error string
|
||||
Form formValues
|
||||
|
||||
Login string
|
||||
Key string
|
||||
Meta *Meta
|
||||
Job JobStatus
|
||||
Notice string
|
||||
CooldownLeft string
|
||||
ExpiresIn string
|
||||
Themes []string
|
||||
Theme string
|
||||
Cards []cardView
|
||||
Markdown string
|
||||
}
|
||||
|
||||
type cardView struct {
|
||||
Name string
|
||||
Src string
|
||||
URL string
|
||||
}
|
||||
|
||||
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
s.render(w, http.StatusOK, "index", pageData{Title: "ghglance", Form: defaultForm()})
|
||||
}
|
||||
|
||||
func (s *Server) handleGenerate(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.render(w, http.StatusBadRequest, "index", pageData{Title: "ghglance", Error: "The form could not be read.", Form: defaultForm()})
|
||||
return
|
||||
}
|
||||
sub, form, err := parseSubmission(r.PostForm.Get)
|
||||
if !s.limiter.Allow(clientKey(r)) {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int(submitInterval.Seconds())))
|
||||
s.render(w, http.StatusTooManyRequests, "index", pageData{
|
||||
Title: "ghglance",
|
||||
Error: "Too many submissions from your address. Wait a couple of minutes and try again.",
|
||||
Form: form,
|
||||
})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.render(w, http.StatusBadRequest, "index", pageData{Title: "ghglance", Error: capitalize(err.Error()) + ".", Form: form})
|
||||
return
|
||||
}
|
||||
|
||||
if sub.Token == "" && s.cfg.Token == "" {
|
||||
s.render(w, http.StatusBadRequest, "index", pageData{
|
||||
Title: "ghglance",
|
||||
Error: "This server has no GitHub token of its own. Add yours under Options.",
|
||||
Form: form,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
target := "/u/" + url.PathEscape(userKey(sub.Login))
|
||||
if s.queue.Status(sub.Login).Active() {
|
||||
http.Redirect(w, r, target+"?notice=pending", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if sub.Token == "" && s.store.cooldownLeft(sub.Login, s.cfg.Cooldown, time.Now()) > 0 {
|
||||
http.Redirect(w, r, target+"?notice=fresh", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
created, err := s.queue.Submit(sub)
|
||||
if err != nil {
|
||||
s.render(w, http.StatusServiceUnavailable, "index", pageData{Title: "ghglance", Error: capitalize(err.Error()) + ".", Form: form})
|
||||
return
|
||||
}
|
||||
if !created {
|
||||
target += "?notice=pending"
|
||||
}
|
||||
http.Redirect(w, r, target, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleUser(w http.ResponseWriter, r *http.Request) {
|
||||
login := r.PathValue("user")
|
||||
if !validUsername(login) {
|
||||
s.renderMessage(w, http.StatusNotFound, "Not a GitHub username", "GitHub usernames use letters, digits and single hyphens, up to 39 characters.")
|
||||
return
|
||||
}
|
||||
meta, err := s.store.Meta(login)
|
||||
if err != nil && !isNotExist(err) {
|
||||
log.Printf("read meta %s: %v", userKey(login), err)
|
||||
s.renderMessage(w, http.StatusInternalServerError, "Something went wrong", "The stored cards could not be read.")
|
||||
return
|
||||
}
|
||||
job := s.queue.Status(login)
|
||||
if meta == nil && job.State == stateNone {
|
||||
form := defaultForm()
|
||||
form.User = login
|
||||
s.render(w, http.StatusNotFound, "index", pageData{
|
||||
Title: "ghglance",
|
||||
Message: fmt.Sprintf("No cards for %s yet. Generate them below.", login),
|
||||
Form: form,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
d := pageData{
|
||||
Title: login + " · ghglance",
|
||||
Login: login,
|
||||
Key: userKey(login),
|
||||
Meta: meta,
|
||||
Job: job,
|
||||
Themes: theme.IDs(),
|
||||
Theme: defaultTheme,
|
||||
Form: defaultForm(),
|
||||
}
|
||||
d.Form.User = login
|
||||
if t := r.URL.Query().Get("theme"); validTheme(t) {
|
||||
d.Theme = t
|
||||
}
|
||||
switch r.URL.Query().Get("notice") {
|
||||
case "fresh":
|
||||
d.Notice = "These cards are recent, so they were not regenerated."
|
||||
case "pending":
|
||||
if job.Active() {
|
||||
d.Notice = "A generation for this user is already in progress."
|
||||
}
|
||||
}
|
||||
if meta != nil {
|
||||
d.Login = meta.Login
|
||||
d.Form = formFromOptions(meta.Login, meta.Options)
|
||||
if left := s.store.cooldownLeft(login, s.cfg.Cooldown, time.Now()); left > 0 {
|
||||
d.CooldownLeft = humanDuration(left)
|
||||
}
|
||||
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
|
||||
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.Markdown = md.String()
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
s.render(w, http.StatusOK, "user", d)
|
||||
}
|
||||
|
||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
login := r.PathValue("user")
|
||||
if !validUsername(login) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
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"))
|
||||
if err != nil {
|
||||
if !isNotExist(err) {
|
||||
log.Printf("open card %s: %v", r.URL.Path, err)
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
st, err := f.Stat()
|
||||
if err != nil || !st.Mode().IsRegular() {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
func (s *Server) render(w http.ResponseWriter, status int, page string, d pageData) {
|
||||
var buf strings.Builder
|
||||
if err := s.pages[page].ExecuteTemplate(&buf, "layout", d); err != nil {
|
||||
log.Printf("render %s: %v", page, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
fmt.Fprint(w, buf.String())
|
||||
}
|
||||
|
||||
func (s *Server) renderMessage(w http.ResponseWriter, status int, heading, message string) {
|
||||
s.render(w, status, "message", pageData{Title: heading + " · ghglance", Heading: heading, Message: message})
|
||||
}
|
||||
|
||||
func parsePages() (map[string]*template.Template, error) {
|
||||
funcs := template.FuncMap{
|
||||
"fmtTime": func(t time.Time) string { return t.UTC().Format("2006-01-02 15:04 UTC") },
|
||||
}
|
||||
pages := map[string]*template.Template{}
|
||||
for _, name := range []string{"index", "user", "message"} {
|
||||
t, err := template.New(name).Funcs(funcs).ParseFS(assets,
|
||||
"templates/layout.html", "templates/form.html", "templates/"+name+".html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse %s template: %w", name, err)
|
||||
}
|
||||
pages[name] = t
|
||||
}
|
||||
return pages, nil
|
||||
}
|
||||
|
||||
// formFromOptions pre-fills the regenerate form with a set's last options.
|
||||
// Private scope stays ticked: it only takes effect with a token anyway.
|
||||
func formFromOptions(login string, o Options) formValues {
|
||||
return formValues{
|
||||
User: login,
|
||||
TZ: o.TZ,
|
||||
StartOfWeek: o.StartOfWeek,
|
||||
IncludeForks: o.IncludeForks,
|
||||
IncludeOrgRepos: o.IncludeOrgRepos,
|
||||
IncludePrivate: true,
|
||||
CommitsPerRepo: strconv.Itoa(o.CommitsPerRepo),
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
switch {
|
||||
case h > 0 && m > 0:
|
||||
return fmt.Sprintf("%dh %dm", h, m)
|
||||
case h > 0:
|
||||
return fmt.Sprintf("%dh", h)
|
||||
case m > 0:
|
||||
return fmt.Sprintf("%dm", m)
|
||||
}
|
||||
return "under a minute"
|
||||
}
|
||||
|
||||
func capitalize(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
return strings.ToUpper(s[:1]) + s[1:]
|
||||
}
|
||||
|
||||
// expireInterval is how often expired cards are swept while serving.
|
||||
const expireInterval = time.Hour
|
||||
|
||||
// expireLoop deletes cards older than the retention once at startup and then
|
||||
// every expireInterval until ctx is cancelled.
|
||||
func (s *Server) expireLoop(ctx context.Context) {
|
||||
if s.cfg.Retention <= 0 {
|
||||
return
|
||||
}
|
||||
t := time.NewTicker(expireInterval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
if n := s.store.Expire(s.cfg.Retention, time.Now()); n > 0 {
|
||||
log.Printf("expired cards for %d user(s) older than %s", n, s.cfg.Retention)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// ghglance web UI enhancements. Every page works without JavaScript; this
|
||||
// adds browser-timezone detection, token-aware checkboxes, copy buttons and
|
||||
// job-status polling.
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Fills a timezone field still at its default with the browser's zone.
|
||||
* @param {HTMLInputElement} input
|
||||
*/
|
||||
function detectTimezone(input) {
|
||||
try {
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
if (tz && (input.value === '' || input.value === 'UTC')) input.value = tz;
|
||||
} catch (_) {
|
||||
// Older browsers without Intl time zones keep the server default.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables the private/org checkboxes only while a token is entered, and
|
||||
* ticks private repos the first time a token appears.
|
||||
* @param {HTMLFormElement} form
|
||||
*/
|
||||
function wireTokenScope(form) {
|
||||
const token = /** @type {HTMLInputElement|null} */ (form.querySelector('input[name="token"]'));
|
||||
if (!token) return;
|
||||
const scoped = /** @type {NodeListOf<HTMLInputElement>} */ (form.querySelectorAll('input[data-needs-token]'));
|
||||
let hadToken = false;
|
||||
const sync = () => {
|
||||
const hasToken = token.value.trim() !== '';
|
||||
scoped.forEach((box) => {
|
||||
box.disabled = !hasToken;
|
||||
if (hasToken && !hadToken && box.name === 'include_private') box.checked = true;
|
||||
});
|
||||
hadToken = hasToken;
|
||||
};
|
||||
token.addEventListener('input', 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.
|
||||
* @param {number} secs
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatElapsed(secs) {
|
||||
const m = Math.floor(secs / 60);
|
||||
const s = secs % 60;
|
||||
return m > 0 ? `${m}m ${String(s).padStart(2, '0')}s` : `${s}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls the job-status endpoint and reloads once the job leaves the queue,
|
||||
* so the server renders either the finished cards or the failure. The
|
||||
* announced line (a live region) changes only with the state or stage; the
|
||||
* ticking elapsed time sits outside it so screen readers are not re-read
|
||||
* the line every poll.
|
||||
* @param {HTMLElement} box
|
||||
*/
|
||||
function pollStatus(box) {
|
||||
const url = box.dataset.statusUrl;
|
||||
const text = document.getElementById('progress-text');
|
||||
const clock = document.getElementById('progress-elapsed');
|
||||
if (!url || !text) return;
|
||||
let delay = 3000;
|
||||
const tick = () => {
|
||||
fetch(url, { cache: 'no-store', headers: { Accept: 'application/json' } })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.then((/** @type {{state: string, stage?: string, position?: number, elapsed_seconds?: number}} */ st) => {
|
||||
if (st.state !== 'queued' && st.state !== 'running') {
|
||||
// Drop one-off notices such as ?notice=pending, which no longer
|
||||
// describe the page once the job is over.
|
||||
const next = new URL(window.location.href);
|
||||
next.searchParams.delete('notice');
|
||||
window.location.replace(next.toString());
|
||||
return;
|
||||
}
|
||||
const line = st.state === 'queued'
|
||||
? `Queued${st.position ? `, position ${st.position}` : ''}`
|
||||
: `Running: ${st.stage || 'working'}`;
|
||||
if (text.textContent !== line) text.textContent = line;
|
||||
if (clock) clock.textContent = st.elapsed_seconds ? `Elapsed ${formatElapsed(st.elapsed_seconds)}` : '';
|
||||
delay = 3000;
|
||||
setTimeout(tick, delay);
|
||||
})
|
||||
.catch(() => {
|
||||
delay = Math.min(delay * 2, 30000);
|
||||
setTimeout(tick, delay);
|
||||
});
|
||||
};
|
||||
setTimeout(tick, delay);
|
||||
}
|
||||
|
||||
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) => wireTokenScope(/** @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);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#6d4aff"/><rect x="7" y="17" width="4" height="8" rx="1" fill="#fff"/><rect x="14" y="11" width="4" height="14" rx="1" fill="#fff"/><rect x="21" y="7" width="4" height="18" rx="1" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 302 B |
@@ -0,0 +1,196 @@
|
||||
/* ghglance web UI. Light by default, dark when the system asks for it. */
|
||||
:root {
|
||||
--bg: #f6f7f9;
|
||||
--surface: #ffffff;
|
||||
--border: #d9dde3;
|
||||
--text: #1c2026;
|
||||
--muted: #5b6370;
|
||||
--accent: #5b3fe0;
|
||||
--accent-text: #ffffff;
|
||||
--focus: #2f6feb;
|
||||
--info-bg: #eef3ff;
|
||||
--info-border: #b9ccf7;
|
||||
--error-bg: #fdeeee;
|
||||
--error-border: #f0b4b4;
|
||||
--error-text: #8a1c1c;
|
||||
--card-bg: #eceef2;
|
||||
--radius: 10px;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0f1115;
|
||||
--surface: #171a21;
|
||||
--border: #2b303b;
|
||||
--text: #e6e8ec;
|
||||
--muted: #9aa3b2;
|
||||
--accent: #9b85ff;
|
||||
--accent-text: #120c2b;
|
||||
--focus: #79a6ff;
|
||||
--info-bg: #17213a;
|
||||
--info-border: #2d4170;
|
||||
--error-bg: #351818;
|
||||
--error-border: #6b2a2a;
|
||||
--error-text: #ffb4b4;
|
||||
--card-bg: #11141a;
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 16px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a { color: var(--accent); }
|
||||
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.92em; }
|
||||
|
||||
:focus-visible {
|
||||
outline: 3px solid var(--focus);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.wrap { width: 100%; max-width: 1180px; margin: 0 auto; padding: 0 16px; }
|
||||
main.wrap { flex: 1; padding-top: 32px; padding-bottom: 48px; }
|
||||
|
||||
.skip {
|
||||
position: absolute; left: -9999px; top: 8px;
|
||||
background: var(--surface); color: var(--text); padding: 8px 12px; border-radius: 6px;
|
||||
}
|
||||
.skip:focus { left: 8px; z-index: 10; }
|
||||
|
||||
header.site { border-bottom: 1px solid var(--border); background: var(--surface); }
|
||||
header.site .wrap { display: flex; align-items: baseline; gap: 12px; padding-top: 14px; padding-bottom: 14px; }
|
||||
.brand { font-weight: 700; font-size: 1.15rem; text-decoration: none; color: var(--text); }
|
||||
.tagline { color: var(--muted); font-size: 0.9rem; }
|
||||
|
||||
footer.site { border-top: 1px solid var(--border); color: var(--muted); font-size: 0.875rem; }
|
||||
footer.site .wrap { padding-top: 16px; padding-bottom: 16px; }
|
||||
|
||||
h1 { font-size: clamp(1.6rem, 4vw, 2.2rem); line-height: 1.2; margin: 0 0 12px; overflow-wrap: anywhere; }
|
||||
h2 { font-size: 1.2rem; margin: 0 0 12px; }
|
||||
|
||||
.hero { max-width: 720px; }
|
||||
.lead { color: var(--muted); font-size: 1.05rem; margin: 0 0 24px; }
|
||||
.hint { color: var(--muted); font-size: 0.85rem; margin: 6px 0 0; }
|
||||
.optional { color: var(--muted); font-weight: 400; }
|
||||
|
||||
.alert {
|
||||
border: 1px solid var(--info-border);
|
||||
background: var(--info-bg);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 16px;
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
.alert-error { border-color: var(--error-border); background: var(--error-bg); color: var(--error-text); }
|
||||
.alert-progress { display: flex; gap: 14px; align-items: flex-start; }
|
||||
.alert-progress p { margin: 0; }
|
||||
|
||||
.spinner {
|
||||
flex: none;
|
||||
width: 20px; height: 20px; margin-top: 3px;
|
||||
border: 3px solid var(--info-border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
|
||||
|
||||
form.gen, .panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
}
|
||||
.panel { margin-top: 32px; }
|
||||
.panel form.gen { border: 0; padding: 0; }
|
||||
|
||||
.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 {
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
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; }
|
||||
|
||||
details.options { margin-bottom: 18px; }
|
||||
details.options summary { cursor: pointer; font-weight: 600; padding: 4px 0; width: max-content; }
|
||||
.options-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 0 20px; margin-top: 14px; }
|
||||
.field-wide { grid-column: 1 / -1; }
|
||||
|
||||
fieldset.checks { border: 0; padding: 0; margin: 0 0 16px; display: flex; flex-direction: column; gap: 6px; }
|
||||
fieldset.checks label { font-weight: 400; display: flex; gap: 8px; align-items: center; }
|
||||
fieldset.checks input { width: 18px; height: 18px; margin: 0; accent-color: var(--accent); }
|
||||
fieldset.checks label:has(input:disabled) { color: var(--muted); }
|
||||
|
||||
button, .button {
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-radius: 8px;
|
||||
padding: 9px 16px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
button:hover, .button:hover { border-color: var(--accent); }
|
||||
button.primary, .button { background: var(--accent); color: var(--accent-text); border-color: var(--accent); }
|
||||
button.primary:hover { filter: brightness(1.08); }
|
||||
.token-create { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; margin: 8px 0 0; }
|
||||
.token-create .button { background: var(--surface); color: var(--text); border-color: var(--border); }
|
||||
.token-create .button:hover { border-color: var(--accent); }
|
||||
.token-create .hint { margin: 0; flex: 1 1 240px; }
|
||||
|
||||
.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 select { width: auto; min-width: 220px; max-width: 100%; }
|
||||
|
||||
.cards {
|
||||
list-style: none; margin: 0; padding: 0;
|
||||
display: grid; gap: 18px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 340px), 1fr));
|
||||
}
|
||||
.card figure {
|
||||
margin: 0; height: 100%;
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-image {
|
||||
flex: 1;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
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-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; }
|
||||
@@ -0,0 +1,252 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tiennm99/ghglance/internal/card"
|
||||
"github.com/tiennm99/ghglance/internal/github"
|
||||
"github.com/tiennm99/ghglance/internal/theme"
|
||||
)
|
||||
|
||||
// genDir holds every rendered generation. <data>/<user> is a symlink to the
|
||||
// current one, so publishing a new set is a single atomic rename of that
|
||||
// link and readers never see a half-written directory. Logins cannot start
|
||||
// with a dot, so neither name collides with a user.
|
||||
const genDir = ".gen"
|
||||
|
||||
const metaFile = "meta.json"
|
||||
|
||||
// Meta is the small record written beside a user's cards.
|
||||
type Meta struct {
|
||||
Login string `json:"login"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
// Scope is "private" when the submitter's own token rendered private
|
||||
// repos, otherwise "public".
|
||||
Scope string `json:"scope"`
|
||||
Options Options `json:"options"`
|
||||
}
|
||||
|
||||
// Store reads and publishes card sets under one data directory. Reads go
|
||||
// through an os.Root, so even a path that slipped past validation cannot
|
||||
// leave the data directory.
|
||||
type Store struct {
|
||||
dir string
|
||||
root *os.Root
|
||||
// mu serializes link swaps in Publish with removals in Expire, so an
|
||||
// expiry never deletes a set that was republished after it was checked.
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// OpenStore creates dir if needed and clears generations a crash left
|
||||
// unpublished.
|
||||
func OpenStore(dir string) (*Store, error) {
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(abs, genDir), 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create data dir: %w", err)
|
||||
}
|
||||
root, err := os.OpenRoot(abs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Store{dir: abs, root: root}
|
||||
s.sweep()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Close releases the data directory handle.
|
||||
func (s *Store) Close() error { return s.root.Close() }
|
||||
|
||||
// userKey is the on-disk name for a login. GitHub logins are
|
||||
// case-insensitive, so every lookup goes through the lowercased form.
|
||||
func userKey(login string) string { return strings.ToLower(login) }
|
||||
|
||||
// Meta returns the published record for login, or fs.ErrNotExist.
|
||||
func (s *Store) Meta(login string) (*Meta, error) {
|
||||
if !validUsername(login) {
|
||||
return nil, fs.ErrNotExist
|
||||
}
|
||||
raw, err := s.root.ReadFile(path.Join(userKey(login), metaFile))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m Meta
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("decode meta: %w", err)
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// OpenCard opens one rendered SVG. Every segment is validated against the
|
||||
// known logins, themes and cards before it touches the filesystem.
|
||||
func (s *Store) OpenCard(login, themeID, file string) (*os.File, error) {
|
||||
if !validUsername(login) || !validTheme(themeID) || !validCard(file) {
|
||||
return nil, fs.ErrNotExist
|
||||
}
|
||||
return s.root.Open(path.Join(userKey(login), themeID, file))
|
||||
}
|
||||
|
||||
// Publish renders every theme for p into a fresh generation directory,
|
||||
// writes meta.json, then atomically repoints <data>/<user> at it and removes
|
||||
// the generation it replaced.
|
||||
func (s *Store) Publish(p *github.Profile, meta Meta) error {
|
||||
if !validUsername(meta.Login) {
|
||||
return fmt.Errorf("invalid login %q", meta.Login)
|
||||
}
|
||||
key := userKey(meta.Login)
|
||||
gen := key + "-" + strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
genRel := path.Join(genDir, gen)
|
||||
genAbs := filepath.Join(s.dir, genDir, gen)
|
||||
|
||||
ok := false
|
||||
defer func() {
|
||||
if !ok {
|
||||
os.RemoveAll(genAbs)
|
||||
}
|
||||
}()
|
||||
|
||||
for _, id := range theme.IDs() {
|
||||
t, _ := theme.Lookup(id)
|
||||
if err := card.RenderAll(p, t, genAbs); err != nil {
|
||||
return fmt.Errorf("render %s: %w", id, err)
|
||||
}
|
||||
}
|
||||
raw, err := json.MarshalIndent(meta, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(genAbs, metaFile), raw, 0o644); err != nil {
|
||||
return fmt.Errorf("write meta: %w", err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
old, _ := s.root.Readlink(key)
|
||||
tmpLink := ".link-" + gen
|
||||
if err := s.root.Symlink(genRel, tmpLink); err != nil {
|
||||
return fmt.Errorf("link generation: %w", err)
|
||||
}
|
||||
if err := s.root.Rename(tmpLink, key); err != nil {
|
||||
s.root.Remove(tmpLink)
|
||||
return fmt.Errorf("publish generation: %w", err)
|
||||
}
|
||||
ok = true
|
||||
if old != "" && old != genRel && isGenTarget(old) {
|
||||
s.root.RemoveAll(old)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isGenTarget reports whether a link target is a generation directory this
|
||||
// store created, so cleanup never follows a link anywhere else.
|
||||
func isGenTarget(target string) bool {
|
||||
dir, name := path.Split(target)
|
||||
return dir == genDir+"/" && name != "" && name != "." && name != ".."
|
||||
}
|
||||
|
||||
// sweep removes generations no user link points at and leftover temporary
|
||||
// links; both are what a crash between render and publish leaves behind.
|
||||
func (s *Store) sweep() {
|
||||
entries, err := os.ReadDir(s.dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
live := map[string]bool{}
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if strings.HasPrefix(name, ".link-") {
|
||||
s.root.Remove(name)
|
||||
continue
|
||||
}
|
||||
if e.Type()&fs.ModeSymlink == 0 {
|
||||
continue
|
||||
}
|
||||
if target, err := s.root.Readlink(name); err == nil && isGenTarget(target) {
|
||||
live[path.Base(target)] = true
|
||||
}
|
||||
}
|
||||
gens, err := os.ReadDir(filepath.Join(s.dir, genDir))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, g := range gens {
|
||||
if !live[g.Name()] {
|
||||
s.root.RemoveAll(path.Join(genDir, g.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Expire removes every user whose cards were generated more than retention
|
||||
// before now, along with their generation directory. retention <= 0 keeps
|
||||
// cards forever. It returns how many users were removed.
|
||||
func (s *Store) Expire(retention time.Duration, now time.Time) int {
|
||||
if retention <= 0 {
|
||||
return 0
|
||||
}
|
||||
entries, err := os.ReadDir(s.dir)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
removed := 0
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.Type()&fs.ModeSymlink == 0 || !validUsername(name) {
|
||||
continue
|
||||
}
|
||||
if s.expireOne(name, retention, now) {
|
||||
removed++
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
// expireOne removes one user's link and generation when its meta is older
|
||||
// than retention, re-reading both under the lock.
|
||||
func (s *Store) expireOne(key string, retention time.Duration, now time.Time) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
m, err := s.Meta(key)
|
||||
if err != nil || now.Sub(m.GeneratedAt) <= retention {
|
||||
return false
|
||||
}
|
||||
target, err := s.root.Readlink(key)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if err := s.root.Remove(key); err != nil {
|
||||
return false
|
||||
}
|
||||
if isGenTarget(target) {
|
||||
s.root.RemoveAll(target)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// cooldownLeft is how long until login's cards may be regenerated without
|
||||
// the submitter's own token; zero when they may be regenerated now.
|
||||
func (s *Store) cooldownLeft(login string, cooldown time.Duration, now time.Time) time.Duration {
|
||||
m, err := s.Meta(login)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
left := m.GeneratedAt.Add(cooldown).Sub(now)
|
||||
if left < 0 {
|
||||
return 0
|
||||
}
|
||||
return left
|
||||
}
|
||||
|
||||
// isNotExist reports a missing user, theme or card.
|
||||
func isNotExist(err error) bool { return errors.Is(err, fs.ErrNotExist) }
|
||||
@@ -0,0 +1,70 @@
|
||||
{{define "form"}}
|
||||
<form class="gen" method="post" action="/generate">
|
||||
<div class="field">
|
||||
<label for="user">GitHub username</label>
|
||||
<input id="user" name="user" type="text" required maxlength="39"
|
||||
pattern="[A-Za-z0-9]+(-[A-Za-z0-9]+)*" title="Letters, digits and single hyphens"
|
||||
autocomplete="off" autocapitalize="none" spellcheck="false"
|
||||
placeholder="octocat" value="{{.Form.User}}">
|
||||
</div>
|
||||
|
||||
<details class="options">
|
||||
<summary>Options</summary>
|
||||
<div class="options-grid">
|
||||
<div class="field">
|
||||
<label for="tz">Timezone</label>
|
||||
<input id="tz" name="tz" type="text" maxlength="64" value="{{.Form.TZ}}"
|
||||
{{if not .Meta}}data-autotz{{end}} aria-describedby="tz-hint" spellcheck="false">
|
||||
<p class="hint" id="tz-hint">IANA name such as Asia/Saigon. Buckets commit hours on the productive-time cards.</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="start_of_week">Week starts on</label>
|
||||
<select id="start_of_week" name="start_of_week">
|
||||
<option value="sunday"{{if eq .Form.StartOfWeek "sunday"}} selected{{end}}>Sunday</option>
|
||||
<option value="monday"{{if eq .Form.StartOfWeek "monday"}} selected{{end}}>Monday</option>
|
||||
<option value="tuesday"{{if eq .Form.StartOfWeek "tuesday"}} selected{{end}}>Tuesday</option>
|
||||
<option value="wednesday"{{if eq .Form.StartOfWeek "wednesday"}} selected{{end}}>Wednesday</option>
|
||||
<option value="thursday"{{if eq .Form.StartOfWeek "thursday"}} selected{{end}}>Thursday</option>
|
||||
<option value="friday"{{if eq .Form.StartOfWeek "friday"}} selected{{end}}>Friday</option>
|
||||
<option value="saturday"{{if eq .Form.StartOfWeek "saturday"}} selected{{end}}>Saturday</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="commits_per_repo">Commits sampled per repo</label>
|
||||
<input id="commits_per_repo" name="commits_per_repo" type="number" min="0" max="5000" step="1"
|
||||
value="{{.Form.CommitsPerRepo}}" aria-describedby="cpr-hint">
|
||||
<p class="hint" id="cpr-hint">0 samples every commit and needs your own token.</p>
|
||||
</div>
|
||||
|
||||
<fieldset class="checks">
|
||||
<legend>Repositories</legend>
|
||||
<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" data-needs-token{{if .Form.IncludePrivate}} checked{{end}}> Include private repos</label>
|
||||
<label><input type="checkbox" name="include_org_repos" value="1" data-needs-token{{if .Form.IncludeOrgRepos}} checked{{end}}> Include org repos you administer</label>
|
||||
<p class="hint">Private and org repos are only counted with your own token.</p>
|
||||
</fieldset>
|
||||
|
||||
<div class="field field-wide">
|
||||
<label for="token">Your GitHub token <span class="optional">(optional)</span></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 only for this one generation, then dropped: never stored, never logged.
|
||||
With a token for your own account, private repos count unless you untick them, and the regenerate cooldown is skipped.
|
||||
A token for another account renders public data only.
|
||||
<strong>The resulting cards are public on this site</strong>, including totals drawn from private repos.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<button type="submit" class="primary">{{if .Meta}}Regenerate cards{{else}}Generate cards{{end}}</button>
|
||||
</form>
|
||||
{{end}}
|
||||
@@ -0,0 +1,13 @@
|
||||
{{define "content"}}
|
||||
<section class="hero">
|
||||
<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>.
|
||||
</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}}
|
||||
{{template "form" .}}
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,30 @@
|
||||
{{define "layout"}}<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>{{.Title}}</title>
|
||||
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<script src="/static/app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip" href="#main">Skip to content</a>
|
||||
<header class="site">
|
||||
<div class="wrap">
|
||||
<a class="brand" href="/">ghglance</a>
|
||||
<span class="tagline">GitHub profile cards</span>
|
||||
</div>
|
||||
</header>
|
||||
<main id="main" class="wrap">
|
||||
{{template "content" .}}
|
||||
</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.
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,7 @@
|
||||
{{define "content"}}
|
||||
<section class="hero">
|
||||
<h1>{{.Heading}}</h1>
|
||||
<p class="lead">{{.Message}}</p>
|
||||
<p><a class="button" href="/">Back to the start</a></p>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,71 @@
|
||||
{{define "content"}}
|
||||
<section class="user-head">
|
||||
<h1>{{.Login}}</h1>
|
||||
<p class="meta">
|
||||
{{if .Meta}}Generated {{fmtTime .Meta.GeneratedAt}}
|
||||
· {{.Meta.Scope}} data
|
||||
· {{.Meta.Options.TZ}}
|
||||
· week starts {{.Meta.Options.StartOfWeek}}
|
||||
· {{end}}<a href="https://github.com/{{.Key}}" rel="noopener">GitHub profile</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{{if .Notice}}<p class="alert alert-info" role="status">{{.Notice}}</p>{{end}}
|
||||
|
||||
{{if .Job.Active}}
|
||||
<div class="alert alert-progress" id="progress" data-status-url="/u/{{.Key}}/status">
|
||||
<span class="spinner" aria-hidden="true"></span>
|
||||
<div>
|
||||
<p id="progress-text" role="status" aria-live="polite">{{if eq .Job.State "queued"}}Queued{{if .Job.Position}}, position {{.Job.Position}}{{end}}{{else}}Running: {{.Job.Stage}}{{end}}</p>
|
||||
<p class="hint" id="progress-elapsed"></p>
|
||||
<p class="hint">Large profiles take a few minutes. This page refreshes when the cards are ready.</p>
|
||||
</div>
|
||||
</div>
|
||||
{{else if eq .Job.State "failed"}}
|
||||
<p class="alert alert-error" role="alert">The last generation failed: {{.Job.Error}}</p>
|
||||
{{end}}
|
||||
|
||||
{{if .Meta}}
|
||||
<form class="toolbar" method="get" action="/u/{{.Key}}">
|
||||
<label for="theme">Theme</label>
|
||||
<select id="theme" name="theme">
|
||||
{{range .Themes}}<option value="{{.}}"{{if eq . $.Theme}} selected{{end}}>{{.}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<button type="submit">Apply</button>
|
||||
</form>
|
||||
|
||||
<ul class="cards">
|
||||
{{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>
|
||||
</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">Without a token these cards can be regenerated in {{.CooldownLeft}}. With your own token you can regenerate now.</p>{{end}}
|
||||
{{template "form" .}}
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,183 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tiennm99/ghglance/internal/card"
|
||||
"github.com/tiennm99/ghglance/internal/github"
|
||||
"github.com/tiennm99/ghglance/internal/theme"
|
||||
)
|
||||
|
||||
// GitHub logins: alphanumerics separated by single hyphens, 1–39 chars, no
|
||||
// leading or trailing hyphen. Anything passing this is also a safe single
|
||||
// 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]+)*$`)
|
||||
|
||||
// Tokens are only ever forwarded in an Authorization header; restricting
|
||||
// the charset rules out header injection and pasted whitespace.
|
||||
var tokenRE = regexp.MustCompile(`^[A-Za-z0-9_]{20,255}$`)
|
||||
|
||||
// IANA zone names: letters, digits and _ + - / only. time.LoadLocation
|
||||
// refuses ".." itself; the charset keeps odd input out of the zoneinfo
|
||||
// lookup entirely.
|
||||
var tzRE = regexp.MustCompile(`^[A-Za-z0-9_+\-/]{1,64}$`)
|
||||
|
||||
const (
|
||||
defaultTheme = "dracula"
|
||||
defaultCommitsPerRepo = 500
|
||||
maxCommitsPerRepo = 5000
|
||||
)
|
||||
|
||||
// cardFiles is the set of servable card basenames, built from the renderer.
|
||||
var cardFiles = func() map[string]bool {
|
||||
m := map[string]bool{}
|
||||
for _, f := range card.Filenames() {
|
||||
m[f] = true
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// validUsername reports whether s is a syntactically valid GitHub login.
|
||||
func validUsername(s string) bool {
|
||||
return len(s) >= 1 && len(s) <= 39 && usernameRE.MatchString(s)
|
||||
}
|
||||
|
||||
// validTheme reports whether id names a registered theme.
|
||||
func validTheme(id string) bool {
|
||||
_, ok := theme.Lookup(id)
|
||||
return ok
|
||||
}
|
||||
|
||||
// validCard reports whether name is a rendered card basename ("stats.svg").
|
||||
func validCard(name string) bool {
|
||||
return cardFiles[name]
|
||||
}
|
||||
|
||||
// Options are the generation settings recorded in meta.json. They never
|
||||
// include the submitter's token.
|
||||
type Options struct {
|
||||
TZ string `json:"tz"`
|
||||
StartOfWeek string `json:"start_of_week"`
|
||||
IncludeForks bool `json:"include_forks"`
|
||||
IncludeOrgRepos bool `json:"include_org_repos"`
|
||||
IncludePrivate bool `json:"include_private"`
|
||||
CommitsPerRepo int `json:"commits_per_repo"`
|
||||
}
|
||||
|
||||
// submission is a validated form post.
|
||||
type submission struct {
|
||||
Login string
|
||||
Token string
|
||||
Options Options
|
||||
}
|
||||
|
||||
// formValues is the raw form, kept so a rejected post re-renders as typed.
|
||||
// It never carries the token back to the page.
|
||||
type formValues struct {
|
||||
User string
|
||||
TZ string
|
||||
StartOfWeek string
|
||||
IncludeForks bool
|
||||
IncludeOrgRepos bool
|
||||
IncludePrivate bool
|
||||
CommitsPerRepo string
|
||||
}
|
||||
|
||||
func defaultForm() formValues {
|
||||
return formValues{
|
||||
TZ: "UTC",
|
||||
StartOfWeek: "sunday",
|
||||
IncludeForks: true,
|
||||
IncludePrivate: true,
|
||||
CommitsPerRepo: strconv.Itoa(defaultCommitsPerRepo),
|
||||
}
|
||||
}
|
||||
|
||||
// parseSubmission validates a submitted form. Without the submitter's own
|
||||
// token, private and org-repo scope are forced off: the server token must
|
||||
// never surface its owner's private repos or the orgs it administers.
|
||||
func parseSubmission(get func(string) string) (submission, formValues, error) {
|
||||
f := formValues{
|
||||
User: strings.TrimSpace(get("user")),
|
||||
TZ: strings.TrimSpace(get("tz")),
|
||||
StartOfWeek: strings.TrimSpace(get("start_of_week")),
|
||||
IncludeForks: get("include_forks") != "",
|
||||
IncludeOrgRepos: get("include_org_repos") != "",
|
||||
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 == "" {
|
||||
tz = "UTC"
|
||||
}
|
||||
if tz == "Local" || !tzRE.MatchString(tz) {
|
||||
return submission{}, f, fmt.Errorf("unknown timezone %q", tz)
|
||||
}
|
||||
if _, err := time.LoadLocation(tz); err != nil {
|
||||
return submission{}, f, fmt.Errorf("unknown timezone %q", tz)
|
||||
}
|
||||
|
||||
wd, err := github.ParseWeekday(f.StartOfWeek)
|
||||
if err != nil {
|
||||
return submission{}, f, errors.New("unknown start of week")
|
||||
}
|
||||
|
||||
perRepo := defaultCommitsPerRepo
|
||||
if f.CommitsPerRepo != "" {
|
||||
n, err := strconv.Atoi(f.CommitsPerRepo)
|
||||
if err != nil || n < 0 || n > maxCommitsPerRepo {
|
||||
return submission{}, f, fmt.Errorf("commits per repo must be between 0 and %d", maxCommitsPerRepo)
|
||||
}
|
||||
perRepo = n
|
||||
}
|
||||
if perRepo == 0 && token == "" {
|
||||
return submission{}, f, errors.New("sampling every commit (0) needs your own token")
|
||||
}
|
||||
|
||||
opts := Options{
|
||||
TZ: tz,
|
||||
StartOfWeek: strings.ToLower(wd.String()),
|
||||
IncludeForks: f.IncludeForks,
|
||||
IncludeOrgRepos: f.IncludeOrgRepos,
|
||||
IncludePrivate: f.IncludePrivate,
|
||||
CommitsPerRepo: perRepo,
|
||||
}
|
||||
if token == "" {
|
||||
opts.IncludePrivate = false
|
||||
opts.IncludeOrgRepos = false
|
||||
}
|
||||
return submission{Login: f.User, Token: token, Options: opts}, f, nil
|
||||
}
|
||||
|
||||
// collectConfig maps validated options onto the shared fetch pipeline.
|
||||
func (o Options) collectConfig() github.CollectConfig {
|
||||
loc, err := time.LoadLocation(o.TZ)
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
wd, _ := github.ParseWeekday(o.StartOfWeek)
|
||||
return github.CollectConfig{
|
||||
Options: github.FetchOptions{
|
||||
IncludeForks: o.IncludeForks,
|
||||
IncludePrivate: o.IncludePrivate,
|
||||
IncludeOrgRepos: o.IncludeOrgRepos,
|
||||
},
|
||||
Location: loc,
|
||||
WeekStart: wd,
|
||||
CommitsPerRepo: o.CommitsPerRepo,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,786 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tiennm99/ghglance/internal/github"
|
||||
)
|
||||
|
||||
// testToken is a syntactically valid token that must never reach disk.
|
||||
const testToken = "ghp_testTOKENvalue0123456789abcdef"
|
||||
|
||||
type fetchCall struct {
|
||||
token string
|
||||
login string
|
||||
cfg github.CollectConfig
|
||||
}
|
||||
|
||||
// fakeFetcher stands in for GitHub. When gate is set, Fetch blocks until
|
||||
// it is closed, so tests can observe a running job. When stall is set,
|
||||
// Fetch waits out the job deadline and then returns a partial profile with
|
||||
// no error, as Collect does when it only warns about a late stage.
|
||||
// tokens maps a token to the account behind it; an unknown token belongs
|
||||
// to viewer and cannot read private repos.
|
||||
type fakeFetcher struct {
|
||||
mu sync.Mutex
|
||||
calls []fetchCall
|
||||
viewer string
|
||||
tokens map[string]github.TokenInfo
|
||||
gate chan struct{}
|
||||
stall bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeFetcher) Fetch(ctx context.Context, token, login string, cfg github.CollectConfig) (*github.Profile, error) {
|
||||
f.mu.Lock()
|
||||
f.calls = append(f.calls, fetchCall{token: token, login: login, cfg: cfg})
|
||||
gate, err, stall := f.gate, f.err, f.stall
|
||||
f.mu.Unlock()
|
||||
if stall {
|
||||
<-ctx.Done()
|
||||
return &github.Profile{Login: login}, nil
|
||||
}
|
||||
if gate != nil {
|
||||
select {
|
||||
case <-gate:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &github.Profile{Login: login, Name: "Test User", WeekStart: cfg.WeekStart}, nil
|
||||
}
|
||||
|
||||
func (f *fakeFetcher) TokenInfo(_ context.Context, token string) (github.TokenInfo, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if info, ok := f.tokens[token]; ok {
|
||||
return info, nil
|
||||
}
|
||||
return github.TokenInfo{Login: f.viewer}, nil
|
||||
}
|
||||
|
||||
func (f *fakeFetcher) callCount() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.calls)
|
||||
}
|
||||
|
||||
func (f *fakeFetcher) lastCall() fetchCall {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.calls[len(f.calls)-1]
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T, f *fakeFetcher) *Server {
|
||||
t.Helper()
|
||||
return newTestServerTimeout(t, f, time.Minute)
|
||||
}
|
||||
|
||||
func newTestServerTimeout(t *testing.T, f *fakeFetcher, timeout time.Duration) *Server {
|
||||
t.Helper()
|
||||
s, err := New(Config{
|
||||
DataDir: t.TempDir(),
|
||||
Cooldown: time.Hour,
|
||||
Workers: 2,
|
||||
JobTimeout: timeout,
|
||||
Token: "server-token",
|
||||
Fetcher: f,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(s.Close)
|
||||
return s
|
||||
}
|
||||
|
||||
// waitIdle blocks until login's job has finished.
|
||||
func waitIdle(t *testing.T, q *Queue, login string) JobStatus {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if st := q.Status(login); !st.Active() {
|
||||
return st
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("job for %s did not finish", login)
|
||||
return JobStatus{}
|
||||
}
|
||||
|
||||
func form(kv ...string) func(string) string {
|
||||
v := url.Values{}
|
||||
for i := 0; i+1 < len(kv); i += 2 {
|
||||
v.Set(kv[i], kv[i+1])
|
||||
}
|
||||
return v.Get
|
||||
}
|
||||
|
||||
func TestValidUsername(t *testing.T) {
|
||||
good := []string{"a", "octocat", "tiennm99", "a-b", "A1-b2-C3", strings.Repeat("a", 39)}
|
||||
bad := []string{"", "-a", "a-", "a--b", strings.Repeat("a", 40), "..", "../etc", "a/b", `a\b`, "a.b", "a_b", ".gen", "a b", "%2e%2e", "é"}
|
||||
for _, s := range good {
|
||||
if !validUsername(s) {
|
||||
t.Errorf("validUsername(%q) = false, want true", s)
|
||||
}
|
||||
}
|
||||
for _, s := range bad {
|
||||
if validUsername(s) {
|
||||
t.Errorf("validUsername(%q) = true, want false", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidThemeAndCard(t *testing.T) {
|
||||
if !validTheme("dracula") || !validTheme("github_dark") {
|
||||
t.Error("known themes rejected")
|
||||
}
|
||||
for _, s := range []string{"", "..", "../dracula", "Dracula", "dracula/"} {
|
||||
if validTheme(s) {
|
||||
t.Errorf("validTheme(%q) = true", s)
|
||||
}
|
||||
}
|
||||
if !validCard("stats.svg") || !validCard("profile-details.svg") {
|
||||
t.Error("known cards rejected")
|
||||
}
|
||||
for _, s := range []string{"", "stats", "meta.json", "../meta.json", "stats.svg/..", "STATS.svg"} {
|
||||
if validCard(s) {
|
||||
t.Errorf("validCard(%q) = true", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSubmissionForcesPrivacyWithoutToken(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",
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sub.Options.IncludePrivate || sub.Options.IncludeOrgRepos {
|
||||
t.Errorf("server-token submission kept private=%v org=%v", sub.Options.IncludePrivate, sub.Options.IncludeOrgRepos)
|
||||
}
|
||||
if !sub.Options.IncludeForks || sub.Options.StartOfWeek != "monday" || sub.Options.TZ != "Asia/Saigon" {
|
||||
t.Errorf("options = %+v", sub.Options)
|
||||
}
|
||||
if sub.Options.CommitsPerRepo != defaultCommitsPerRepo {
|
||||
t.Errorf("commits per repo = %d, want default", sub.Options.CommitsPerRepo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSubmissionHonorsOwnToken(t *testing.T) {
|
||||
sub, _, err := parseSubmission(form(
|
||||
"user", "octocat", "token", testToken, "include_private", "1", "include_org_repos", "1", "commits_per_repo", "0",
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sub.Options.IncludePrivate || !sub.Options.IncludeOrgRepos || sub.Token != testToken {
|
||||
t.Errorf("own-token submission = %+v", sub)
|
||||
}
|
||||
if sub.Options.CommitsPerRepo != 0 {
|
||||
t.Errorf("commits per repo = %d, want 0", sub.Options.CommitsPerRepo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSubmissionRejects(t *testing.T) {
|
||||
cases := map[string]func(string) string{
|
||||
"bad user": form("user", "../etc"),
|
||||
"empty user": form("user", ""),
|
||||
"bad tz": form("user", "a", "tz", "../../etc/passwd"),
|
||||
"local tz": form("user", "a", "tz", "Local"),
|
||||
"unknown tz": form("user", "a", "tz", "Mars/Olympus"),
|
||||
"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"),
|
||||
"every commit, no t": form("user", "a", "commits_per_repo", "0"),
|
||||
"header injection": form("user", "a", "token", "ghp_abcdefghijklmnopqrstuvwxyz\r\nX: y"),
|
||||
}
|
||||
for name, get := range cases {
|
||||
if _, _, err := parseSubmission(get); err == nil {
|
||||
t.Errorf("%s: accepted", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func publishTest(t *testing.T, s *Store, login string) {
|
||||
t.Helper()
|
||||
err := s.Publish(&github.Profile{Login: login}, Meta{Login: login, GeneratedAt: time.Now().UTC(), Scope: "public"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorePublishReplacesAtomically(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := OpenStore(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
publishTest(t, s, "Octo-Cat")
|
||||
first, _ := os.Readlink(filepath.Join(dir, "octo-cat"))
|
||||
publishTest(t, s, "octo-cat")
|
||||
second, _ := os.Readlink(filepath.Join(dir, "octo-cat"))
|
||||
if first == "" || first == second {
|
||||
t.Fatalf("link not replaced: %q -> %q", first, second)
|
||||
}
|
||||
gens, _ := os.ReadDir(filepath.Join(dir, genDir))
|
||||
if len(gens) != 1 {
|
||||
t.Errorf("%d generations left, want 1", len(gens))
|
||||
}
|
||||
f, err := s.OpenCard("OCTO-CAT", "dracula", "stats.svg")
|
||||
if err != nil {
|
||||
t.Fatalf("open card: %v", err)
|
||||
}
|
||||
f.Close()
|
||||
if m, err := s.Meta("octo-cat"); err != nil || m.Login != "octo-cat" {
|
||||
t.Errorf("meta = %+v, %v", m, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRejectsTraversal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(filepath.Dir(dir), "secret.svg"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := OpenStore(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
publishTest(t, s, "octocat")
|
||||
|
||||
probes := [][3]string{
|
||||
{"..", "dracula", "stats.svg"},
|
||||
{"octocat", "..", "secret.svg"},
|
||||
{"octocat", "dracula", "../meta.json"},
|
||||
{"octocat", "dracula", "meta.json"},
|
||||
{".gen", "dracula", "stats.svg"},
|
||||
{"octocat/..", "dracula", "stats.svg"},
|
||||
}
|
||||
for _, p := range probes {
|
||||
if f, err := s.OpenCard(p[0], p[1], p[2]); !errors.Is(err, fs.ErrNotExist) {
|
||||
if f != nil {
|
||||
f.Close()
|
||||
}
|
||||
t.Errorf("OpenCard(%q) err = %v, want not-exist", p, err)
|
||||
}
|
||||
}
|
||||
if _, err := s.Meta("../octocat"); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Errorf("Meta(traversal) err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreSweepsOrphans(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := OpenStore(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
publishTest(t, s, "octocat")
|
||||
s.Close()
|
||||
|
||||
orphan := filepath.Join(dir, genDir, "ghost-1")
|
||||
if err := os.MkdirAll(orphan, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(".gen/ghost-1", filepath.Join(dir, ".link-ghost-1")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err = OpenStore(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
if _, err := os.Stat(orphan); !os.IsNotExist(err) {
|
||||
t.Error("orphan generation survived")
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(dir, ".link-ghost-1")); !os.IsNotExist(err) {
|
||||
t.Error("temporary link survived")
|
||||
}
|
||||
if _, err := s.Meta("octocat"); err != nil {
|
||||
t.Errorf("live generation swept: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreExpiresOldCards(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := OpenStore(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
publishTest(t, s, "fresh")
|
||||
old := time.Now().UTC().Add(-25 * time.Hour)
|
||||
if err := s.Publish(&github.Profile{Login: "stale"}, Meta{Login: "stale", GeneratedAt: old, Scope: "public"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if n := s.Expire(0, time.Now()); n != 0 {
|
||||
t.Errorf("retention 0 removed %d users", n)
|
||||
}
|
||||
if n := s.Expire(24*time.Hour, time.Now()); n != 1 {
|
||||
t.Errorf("removed %d users, want 1", n)
|
||||
}
|
||||
if _, err := s.Meta("stale"); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Errorf("stale meta err = %v, want not-exist", err)
|
||||
}
|
||||
if _, err := s.Meta("fresh"); err != nil {
|
||||
t.Errorf("fresh cards expired: %v", err)
|
||||
}
|
||||
gens, _ := os.ReadDir(filepath.Join(dir, genDir))
|
||||
if len(gens) != 1 {
|
||||
t.Errorf("%d generations left, want 1", len(gens))
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueDedupsPerUser(t *testing.T) {
|
||||
f := &fakeFetcher{gate: make(chan struct{})}
|
||||
s := newTestServer(t, f)
|
||||
|
||||
sub, _, _ := parseSubmission(form("user", "octocat"))
|
||||
if created, err := s.queue.Submit(sub); !created || err != nil {
|
||||
t.Fatalf("first submit = %v, %v", created, err)
|
||||
}
|
||||
again, _, _ := parseSubmission(form("user", "OctoCat"))
|
||||
if created, err := s.queue.Submit(again); created || err != nil {
|
||||
t.Fatalf("duplicate submit = %v, %v; want deduped", created, err)
|
||||
}
|
||||
close(f.gate)
|
||||
if st := waitIdle(t, s.queue, "octocat"); st.State != stateDone {
|
||||
t.Fatalf("state = %+v", st)
|
||||
}
|
||||
if n := f.callCount(); n != 1 {
|
||||
t.Errorf("fetched %d times, want 1", n)
|
||||
}
|
||||
if created, _ := s.queue.Submit(sub); !created {
|
||||
t.Error("finished job blocked a new one")
|
||||
}
|
||||
waitIdle(t, s.queue, "octocat")
|
||||
}
|
||||
|
||||
func TestQueueTokenHandling(t *testing.T) {
|
||||
f := &fakeFetcher{viewer: "Boss", tokens: map[string]github.TokenInfo{
|
||||
testToken: {Login: "boss", CanReadPrivate: true},
|
||||
}}
|
||||
s := newTestServer(t, f)
|
||||
|
||||
// The server token's owner is refused without their own token.
|
||||
sub, _, _ := parseSubmission(form("user", "boss"))
|
||||
s.queue.Submit(sub)
|
||||
if st := waitIdle(t, s.queue, "boss"); st.State != stateFailed || st.Error != errOwner.Error() {
|
||||
t.Fatalf("owner job = %+v", st)
|
||||
}
|
||||
if f.callCount() != 0 {
|
||||
t.Fatal("owner was fetched with the server token")
|
||||
}
|
||||
|
||||
// Anyone else without a token uses the server token, public scope only.
|
||||
sub, _, _ = parseSubmission(form("user", "octocat", "include_private", "1"))
|
||||
s.queue.Submit(sub)
|
||||
waitIdle(t, s.queue, "octocat")
|
||||
c := f.lastCall()
|
||||
if c.token != "server-token" || c.cfg.Options.IncludePrivate || c.cfg.Options.IncludeOrgRepos {
|
||||
t.Errorf("server-token call = %+v", c)
|
||||
}
|
||||
|
||||
// A submitter's token is used for their job and never persisted.
|
||||
sub, _, _ = parseSubmission(form("user", "boss", "token", testToken, "include_private", "1"))
|
||||
s.queue.Submit(sub)
|
||||
if st := waitIdle(t, s.queue, "boss"); st.State != stateDone {
|
||||
t.Fatalf("own-token job = %+v", st)
|
||||
}
|
||||
c = f.lastCall()
|
||||
if c.token != testToken || !c.cfg.Options.IncludePrivate || !c.cfg.Strict {
|
||||
t.Errorf("own-token call = %+v", c)
|
||||
}
|
||||
m, err := s.store.Meta("boss")
|
||||
if err != nil || m.Scope != "private" {
|
||||
t.Errorf("meta = %+v, %v", m, err)
|
||||
}
|
||||
raw, _ := os.ReadFile(filepath.Join(s.store.dir, "boss", metaFile))
|
||||
if strings.Contains(string(raw), testToken) {
|
||||
t.Error("token written to meta.json")
|
||||
}
|
||||
s.queue.mu.Lock()
|
||||
leftover := s.queue.jobs["boss"].token
|
||||
s.queue.mu.Unlock()
|
||||
if leftover != "" {
|
||||
t.Error("token kept in memory after the job ended")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCooldownAndTokenBypass(t *testing.T) {
|
||||
f := &fakeFetcher{tokens: map[string]github.TokenInfo{testToken: {Login: "octocat"}}}
|
||||
s := newTestServer(t, f)
|
||||
h := s.Handler()
|
||||
post := func(v url.Values, ip string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, "/generate", strings.NewReader(v.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.RemoteAddr = ip + ":1234"
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
rec := post(url.Values{"user": {"octocat"}}, "203.0.113.1")
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/u/octocat" {
|
||||
t.Fatalf("first post = %d %q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
waitIdle(t, s.queue, "octocat")
|
||||
|
||||
rec = post(url.Values{"user": {"octocat"}}, "203.0.113.2")
|
||||
if loc := rec.Header().Get("Location"); loc != "/u/octocat?notice=fresh" {
|
||||
t.Fatalf("cooldown post redirected to %q", loc)
|
||||
}
|
||||
if n := f.callCount(); n != 1 {
|
||||
t.Fatalf("cooldown still fetched: %d calls", n)
|
||||
}
|
||||
|
||||
rec = post(url.Values{"user": {"octocat"}, "token": {testToken}}, "203.0.113.3")
|
||||
if loc := rec.Header().Get("Location"); loc != "/u/octocat" {
|
||||
t.Fatalf("token post redirected to %q", loc)
|
||||
}
|
||||
waitIdle(t, s.queue, "octocat")
|
||||
if n := f.callCount(); n != 2 {
|
||||
t.Fatalf("token bypass did not fetch: %d calls", n)
|
||||
}
|
||||
|
||||
// Cooldown expiry lets a token-less submission through again.
|
||||
if left := s.store.cooldownLeft("octocat", time.Hour, time.Now().Add(2*time.Hour)); left != 0 {
|
||||
t.Errorf("cooldown after expiry = %s", left)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlers(t *testing.T) {
|
||||
f := &fakeFetcher{}
|
||||
s := newTestServer(t, f)
|
||||
h := s.Handler()
|
||||
get := func(path string) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
return rec
|
||||
}
|
||||
|
||||
rec := get("/")
|
||||
if rec.Code != 200 || !strings.Contains(rec.Body.String(), `action="/generate"`) {
|
||||
t.Fatalf("index = %d", rec.Code)
|
||||
}
|
||||
if rec.Header().Get("Content-Security-Policy") == "" || rec.Header().Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Error("index missing security headers")
|
||||
}
|
||||
if rec := get("/healthz"); rec.Code != 200 || strings.TrimSpace(rec.Body.String()) != "ok" {
|
||||
t.Errorf("healthz = %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := get("/static/app.js"); rec.Code != 200 {
|
||||
t.Errorf("static = %d", rec.Code)
|
||||
}
|
||||
|
||||
if rec := get("/u/nobody"); rec.Code != 404 || !strings.Contains(rec.Body.String(), "No cards for nobody") {
|
||||
t.Errorf("unknown user = %d", rec.Code)
|
||||
}
|
||||
if rec := get("/u/bad--name"); rec.Code != 404 {
|
||||
t.Errorf("invalid user = %d", rec.Code)
|
||||
}
|
||||
|
||||
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") {
|
||||
t.Fatalf("user page = %d", rec.Code)
|
||||
}
|
||||
if rec := get("/u/octocat?theme=../../etc"); !strings.Contains(rec.Body.String(), "/u/octocat/dracula/stats.svg") {
|
||||
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")
|
||||
}
|
||||
|
||||
for _, p := range []string{
|
||||
"/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("/u/octocat/status")
|
||||
if rec.Code != 200 || !strings.Contains(rec.Body.String(), `"state":"none"`) {
|
||||
t.Errorf("status = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateValidationAndLimits(t *testing.T) {
|
||||
s := newTestServer(t, &fakeFetcher{})
|
||||
h := s.Handler()
|
||||
post := func(body, ip string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, "/generate", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.RemoteAddr = ip + ":1"
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
if rec := post("user=..%2Fetc", "198.51.100.1"); rec.Code != 400 {
|
||||
t.Errorf("invalid user = %d", rec.Code)
|
||||
}
|
||||
if rec := post("user=a&token="+testToken, "198.51.100.2"); strings.Contains(rec.Body.String(), testToken) {
|
||||
t.Error("token echoed back into the page")
|
||||
}
|
||||
big := "user=octocat&pad=" + strings.Repeat("x", maxFormBytes)
|
||||
if rec := post(big, "198.51.100.3"); rec.Code != 400 {
|
||||
t.Errorf("oversized body = %d", rec.Code)
|
||||
}
|
||||
|
||||
var last int
|
||||
for range submitBurst + 1 {
|
||||
last = post("user=--bad", "198.51.100.9").Code
|
||||
}
|
||||
if last != http.StatusTooManyRequests {
|
||||
t.Errorf("post past burst = %d, want 429", last)
|
||||
}
|
||||
if rec := post("user=--bad", "198.51.100.10"); rec.Code == http.StatusTooManyRequests {
|
||||
t.Error("rate limit leaked across clients")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiterRefills(t *testing.T) {
|
||||
now := time.Unix(0, 0)
|
||||
l := newRateLimiter(2, time.Minute)
|
||||
l.now = func() time.Time { return now }
|
||||
if !l.Allow("a") || !l.Allow("a") || l.Allow("a") {
|
||||
t.Fatal("burst not enforced")
|
||||
}
|
||||
now = now.Add(time.Minute)
|
||||
if !l.Allow("a") || l.Allow("a") {
|
||||
t.Error("refill not one per interval")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientKey(t *testing.T) {
|
||||
cases := []struct {
|
||||
remote, xff, want string
|
||||
}{
|
||||
{"203.0.113.5:80", "", "203.0.113.5"},
|
||||
{"203.0.113.5:80", "1.2.3.4", "203.0.113.5"}, // public peer: header ignored
|
||||
{"10.0.1.2:80", "1.2.3.4, 198.51.100.7", "198.51.100.7"},
|
||||
{"127.0.0.1:80", "garbage", "127.0.0.1"},
|
||||
// IPv6 clients share a bucket per /64, however they rotate.
|
||||
{"[2001:db8:1:2:aaaa::1]:80", "", "2001:db8:1:2::/64"},
|
||||
{"[2001:db8:1:2:bbbb::9]:80", "", "2001:db8:1:2::/64"},
|
||||
{"10.0.1.2:80", "2001:db8:5:6:7::1", "2001:db8:5:6::/64"},
|
||||
{"10.0.1.2:80", "::ffff:198.51.100.7", "198.51.100.7"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.RemoteAddr = c.remote
|
||||
if c.xff != "" {
|
||||
r.Header.Set("X-Forwarded-For", c.xff)
|
||||
}
|
||||
if got := clientKey(r); got != c.want {
|
||||
t.Errorf("clientKey(%s, %q) = %s, want %s", c.remote, c.xff, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateNeedsSomeToken(t *testing.T) {
|
||||
f := &fakeFetcher{}
|
||||
s, err := New(Config{DataDir: t.TempDir(), Workers: 1, Fetcher: f})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
req := httptest.NewRequest(http.MethodPost, "/generate", strings.NewReader("user=octocat"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest || f.callCount() != 0 {
|
||||
t.Errorf("token-less post on a token-less server = %d, %d fetches", rec.Code, f.callCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiterHardCap(t *testing.T) {
|
||||
now := time.Unix(0, 0)
|
||||
l := newRateLimiter(1, time.Minute)
|
||||
l.now = func() time.Time { return now }
|
||||
for i := range maxBuckets {
|
||||
l.Allow(strconv.Itoa(i))
|
||||
}
|
||||
if l.Allow("new") {
|
||||
t.Fatal("new client admitted past the cap while every bucket is busy")
|
||||
}
|
||||
if len(l.buckets) != maxBuckets {
|
||||
t.Fatalf("table grew to %d", len(l.buckets))
|
||||
}
|
||||
// Once the old buckets have refilled, a sweep frees room again.
|
||||
now = now.Add(time.Hour)
|
||||
if !l.Allow("new") {
|
||||
t.Error("new client refused after idle buckets could be swept")
|
||||
}
|
||||
if len(l.buckets) != 1 {
|
||||
t.Errorf("sweep left %d buckets", len(l.buckets))
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerTokenThatReadsPrivateIsRefused(t *testing.T) {
|
||||
f := &fakeFetcher{tokens: map[string]github.TokenInfo{
|
||||
"server-token": {Login: "operator", CanReadPrivate: true},
|
||||
}}
|
||||
s := newTestServer(t, f)
|
||||
sub, _, _ := parseSubmission(form("user", "coworker"))
|
||||
s.queue.Submit(sub)
|
||||
if st := waitIdle(t, s.queue, "coworker"); st.State != stateFailed || st.Error != errServerPrivate.Error() {
|
||||
t.Fatalf("job = %+v", st)
|
||||
}
|
||||
if f.callCount() != 0 {
|
||||
t.Error("fetched with a private-capable server token")
|
||||
}
|
||||
if _, err := s.store.Meta("coworker"); !isNotExist(err) {
|
||||
t.Errorf("cards published: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForeignTokenScope(t *testing.T) {
|
||||
const publicToken = "ghp_publicONLYvalue0123456789abcdef"
|
||||
f := &fakeFetcher{tokens: map[string]github.TokenInfo{
|
||||
testToken: {Login: "alice", CanReadPrivate: true},
|
||||
publicToken: {Login: "alice"},
|
||||
}}
|
||||
s := newTestServer(t, f)
|
||||
|
||||
// A private-capable token is only good for its own account.
|
||||
sub, _, _ := parseSubmission(form("user", "xavier", "token", testToken, "include_private", "1"))
|
||||
s.queue.Submit(sub)
|
||||
if st := waitIdle(t, s.queue, "xavier"); st.State != stateFailed || !strings.Contains(st.Error, "belongs to alice") {
|
||||
t.Fatalf("private-capable foreign token job = %+v", st)
|
||||
}
|
||||
if f.callCount() != 0 {
|
||||
t.Fatal("fetched another user with a private-capable token")
|
||||
}
|
||||
|
||||
// A public-only token renders public scope for someone else.
|
||||
sub, _, _ = parseSubmission(form("user", "xavier", "token", publicToken, "include_private", "1", "include_org_repos", "1"))
|
||||
s.queue.Submit(sub)
|
||||
if st := waitIdle(t, s.queue, "xavier"); st.State != stateDone {
|
||||
t.Fatalf("public foreign token job = %+v", st)
|
||||
}
|
||||
if c := f.lastCall(); c.cfg.Options.IncludePrivate || c.cfg.Options.IncludeOrgRepos {
|
||||
t.Errorf("foreign token kept private scope: %+v", c.cfg.Options)
|
||||
}
|
||||
if m, err := s.store.Meta("xavier"); err != nil || m.Scope != "public" || m.Options.IncludePrivate {
|
||||
t.Errorf("meta = %+v, %v", m, err)
|
||||
}
|
||||
|
||||
// ...and does not skip the cooldown on that account.
|
||||
s.queue.Submit(sub)
|
||||
if st := waitIdle(t, s.queue, "xavier"); st.State != stateFailed || st.Error != errFresh.Error() {
|
||||
t.Fatalf("foreign token during cooldown = %+v", st)
|
||||
}
|
||||
if n := f.callCount(); n != 1 {
|
||||
t.Errorf("fetched %d times, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartialFetchIsNotPublished(t *testing.T) {
|
||||
f := &fakeFetcher{tokens: map[string]github.TokenInfo{testToken: {Login: "octocat"}}}
|
||||
s := newTestServerTimeout(t, f, 50*time.Millisecond)
|
||||
publishTest(t, s.store, "octocat")
|
||||
before, err := s.store.Meta("octocat")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f.mu.Lock()
|
||||
f.stall = true
|
||||
f.mu.Unlock()
|
||||
sub, _, _ := parseSubmission(form("user", "octocat", "token", testToken))
|
||||
s.queue.Submit(sub)
|
||||
if st := waitIdle(t, s.queue, "octocat"); st.State != stateFailed || !strings.Contains(st.Error, "timed out") {
|
||||
t.Fatalf("stalled job = %+v", st)
|
||||
}
|
||||
after, err := s.store.Meta("octocat")
|
||||
if err != nil || !after.GeneratedAt.Equal(before.GeneratedAt) {
|
||||
t.Errorf("complete set replaced by a partial one: %+v, %v", after, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingNoticeOnlyWhileActive(t *testing.T) {
|
||||
f := &fakeFetcher{gate: make(chan struct{}), tokens: map[string]github.TokenInfo{testToken: {Login: "octocat"}}}
|
||||
s := newTestServer(t, f)
|
||||
h := s.Handler()
|
||||
publishTest(t, s.store, "octocat")
|
||||
const notice = "already in progress"
|
||||
get := func() string {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/u/octocat?notice=pending", nil))
|
||||
return rec.Body.String()
|
||||
}
|
||||
|
||||
sub, _, _ := parseSubmission(form("user", "octocat", "token", testToken))
|
||||
s.queue.Submit(sub)
|
||||
if !strings.Contains(get(), notice) {
|
||||
t.Error("pending notice missing while the job runs")
|
||||
}
|
||||
close(f.gate)
|
||||
waitIdle(t, s.queue, "octocat")
|
||||
if strings.Contains(get(), notice) {
|
||||
t.Error("pending notice still shown after the job finished")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitedPostKeepsForm(t *testing.T) {
|
||||
s := newTestServer(t, &fakeFetcher{})
|
||||
h := s.Handler()
|
||||
var rec *httptest.ResponseRecorder
|
||||
for range submitBurst + 1 {
|
||||
req := httptest.NewRequest(http.MethodPost, "/generate",
|
||||
strings.NewReader("user=--bad&tz=Asia%2FSaigon&commits_per_repo=123&token="+testToken))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.RemoteAddr = "198.51.100.20:1"
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(body, "--bad") || !strings.Contains(body, "Asia/Saigon") || !strings.Contains(body, `value="123"`) {
|
||||
t.Error("429 page dropped the submitted form")
|
||||
}
|
||||
if strings.Contains(body, testToken) {
|
||||
t.Error("token echoed back into the 429 page")
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/tiennm99/ghglance/internal/card"
|
||||
"github.com/tiennm99/ghglance/internal/github"
|
||||
"github.com/tiennm99/ghglance/internal/theme"
|
||||
"github.com/tiennm99/ghglance/internal/web"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -28,9 +29,14 @@ func main() {
|
||||
includeForks = flag.Bool("include-forks", true, "include forked repos in stats and commit probing")
|
||||
includePrivate = flag.Bool("include-private", true, "include private repos (requires PAT with repo scope; silently no-op otherwise)")
|
||||
includeOrgs = flag.Bool("include-org-repos", false, "count org-owned repos you administer toward stars, repo count, repos-per-language and top-starred")
|
||||
timeout = flag.Duration("timeout", 30*time.Minute, "overall deadline for fetch phase (0 = no limit)")
|
||||
timeout = flag.Duration("timeout", 30*time.Minute, "overall deadline for fetch phase; per generation job under -serve (0 = no limit)")
|
||||
startOfWeek = flag.String("start-of-week", "sunday", "first day of week for heatmap rows and weekday bars (sunday|monday|tuesday|…)")
|
||||
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 they can be regenerated without the submitter's own token")
|
||||
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")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
@@ -41,6 +47,25 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
if *serve != "" {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
err := web.Run(ctx, web.Config{
|
||||
Addr: *serve,
|
||||
DataDir: *dataDir,
|
||||
Cooldown: *cooldown,
|
||||
Retention: *retention,
|
||||
Workers: *workers,
|
||||
JobTimeout: *timeout,
|
||||
Token: *token,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if *user == "" {
|
||||
fmt.Fprintln(os.Stderr, "error: -user is required")
|
||||
flag.Usage()
|
||||
@@ -59,7 +84,7 @@ func main() {
|
||||
loc = time.UTC
|
||||
}
|
||||
|
||||
weekStart, err := parseWeekday(*startOfWeek)
|
||||
weekStart, err := github.ParseWeekday(*startOfWeek)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warn: %v, falling back to Sunday\n", err)
|
||||
weekStart = time.Sunday
|
||||
@@ -86,33 +111,20 @@ func main() {
|
||||
cancel()
|
||||
}()
|
||||
|
||||
client := github.NewClient(*token)
|
||||
profile, err := client.FetchProfile(ctx, *user, opts)
|
||||
profile, err := github.Collect(ctx, github.NewClient(*token), *user, github.CollectConfig{
|
||||
Options: opts,
|
||||
Location: loc,
|
||||
WeekStart: weekStart,
|
||||
TopRepos: *topRepos,
|
||||
CommitsPerRepo: *perRepo,
|
||||
Warnf: func(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, "warn: "+format+"\n", args...)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: fetch profile: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
profile.UTCOffsetLabel = utcOffsetLabel(loc)
|
||||
profile.WeekStart = weekStart
|
||||
|
||||
// Year-loop fetch populates SeedRepos from commitContributionsByRepository
|
||||
// plus the all-time contribution calendar; must precede FetchProductive so
|
||||
// commit-history probes land on repos where the user actually committed.
|
||||
if len(profile.ContributionYears) > 0 {
|
||||
if err := client.FetchContributionsAllTime(ctx, profile, opts); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warn: all-time contributions fetch: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
if profile.ID != "" && len(profile.SeedRepos) > 0 {
|
||||
repos := profile.SeedRepos
|
||||
if *topRepos > 0 && len(repos) > *topRepos {
|
||||
repos = repos[:*topRepos]
|
||||
}
|
||||
if err := client.FetchProductive(ctx, profile, repos, loc, *perRepo); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warn: productive-time + commits-per-language fetch: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, t := range selected {
|
||||
if err := card.RenderAll(profile, t, *out); err != nil {
|
||||
@@ -123,52 +135,6 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// utcOffsetLabel formats the location's current offset from UTC compactly:
|
||||
//
|
||||
// integer hours → "UTC+7" (no ".00" padding — 3 chars shorter than
|
||||
// the old "UTC+7.00" format, keeps the
|
||||
// productive-time title at 15 px)
|
||||
// half-hour zone → "UTC+5:30" (India)
|
||||
// quarter-hour → "UTC+5:45" (Nepal)
|
||||
// negative zone → "UTC-3" / "UTC-3:30"
|
||||
func utcOffsetLabel(loc *time.Location) string {
|
||||
_, offsetSec := time.Now().In(loc).Zone()
|
||||
sign := "+"
|
||||
if offsetSec < 0 {
|
||||
sign = "-"
|
||||
offsetSec = -offsetSec
|
||||
}
|
||||
hours := offsetSec / 3600
|
||||
minutes := (offsetSec % 3600) / 60
|
||||
if minutes == 0 {
|
||||
return fmt.Sprintf("UTC%s%d", sign, hours)
|
||||
}
|
||||
return fmt.Sprintf("UTC%s%d:%02d", sign, hours, minutes)
|
||||
}
|
||||
|
||||
// parseWeekday maps a case-insensitive English weekday name (full or 3-letter)
|
||||
// to time.Weekday. Empty input → Sunday so a blank action input still works.
|
||||
func parseWeekday(s string) (time.Weekday, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "", "sun", "sunday":
|
||||
return time.Sunday, nil
|
||||
case "mon", "monday":
|
||||
return time.Monday, nil
|
||||
case "tue", "tuesday":
|
||||
return time.Tuesday, nil
|
||||
case "wed", "wednesday":
|
||||
return time.Wednesday, nil
|
||||
case "thu", "thursday":
|
||||
return time.Thursday, nil
|
||||
case "fri", "friday":
|
||||
return time.Friday, nil
|
||||
case "sat", "saturday":
|
||||
return time.Saturday, nil
|
||||
default:
|
||||
return time.Sunday, fmt.Errorf("unknown start-of-week %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
func resolveThemes(spec string) ([]theme.Theme, error) {
|
||||
spec = strings.TrimSpace(spec)
|
||||
if spec == "" {
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestParseWeekday covers the common case-insensitive inputs and confirms
|
||||
// an unknown value errors (main.go then falls back to Sunday with a warn).
|
||||
func TestParseWeekday(t *testing.T) {
|
||||
ok := []struct {
|
||||
in string
|
||||
want time.Weekday
|
||||
}{
|
||||
{"", time.Sunday},
|
||||
{"sunday", time.Sunday},
|
||||
{"SUNDAY", time.Sunday},
|
||||
{"Sun", time.Sunday},
|
||||
{" monday ", time.Monday},
|
||||
{"Mon", time.Monday},
|
||||
{"saturday", time.Saturday},
|
||||
}
|
||||
for _, c := range ok {
|
||||
got, err := parseWeekday(c.in)
|
||||
if err != nil {
|
||||
t.Errorf("parseWeekday(%q) err=%v", c.in, err)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("parseWeekday(%q)=%v want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
if _, err := parseWeekday("moonday"); err == nil {
|
||||
t.Error("parseWeekday(moonday): want error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUTCOffsetLabel checks the compact format: integer hours drop the
|
||||
// minutes suffix, non-zero offsets render as `UTC±H:MM`.
|
||||
func TestUTCOffsetLabel(t *testing.T) {
|
||||
cases := []struct {
|
||||
zone string
|
||||
want string // must appear in the label; exact value varies by DST
|
||||
}{
|
||||
{"UTC", "UTC+0"},
|
||||
{"Asia/Saigon", "UTC+7"},
|
||||
{"Asia/Kolkata", "UTC+5:30"}, // half-hour zone
|
||||
{"Asia/Kathmandu", "UTC+5:45"}, // quarter-hour zone
|
||||
}
|
||||
for _, tc := range cases {
|
||||
loc, err := time.LoadLocation(tc.zone)
|
||||
if err != nil {
|
||||
t.Skipf("%s unavailable: %v", tc.zone, err)
|
||||
}
|
||||
got := utcOffsetLabel(loc)
|
||||
if !strings.Contains(got, tc.want) {
|
||||
t.Errorf("utcOffsetLabel(%q) = %q, want prefix %q", tc.zone, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user