diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..a4bcaaa5 --- /dev/null +++ b/.dockerignore @@ -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/ diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..c099e9fb --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +GHGLANCE_GITHUB_TOKEN= diff --git a/.gitignore b/.gitignore index 247f1a3e..80e7a454 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md index 8f40bad2..64260c42 100644 --- a/README.md +++ b/README.md @@ -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/` 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/` | The user's cards (`?theme=` picks the theme), or job progress while one runs | +| `/u///.svg` | One card, embeddable in a README | +| `/u//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 diff --git a/compose.yml b/compose.yml new file mode 100644 index 00000000..036909bc --- /dev/null +++ b/compose.yml @@ -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: diff --git a/docs/deployment-guide.md b/docs/deployment-guide.md index 4786811c..2fb96d0d 100644 --- a/docs/deployment-guide.md +++ b/docs/deployment-guide.md @@ -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:` on each `v*` release via `.github/workflows/release.yml` (buildx, multi-tag: exact version, major.minor, major, latest). diff --git a/docs/system-architecture.md b/docs/system-architecture.md index 7ae9eed5..6188ac20 100644 --- a/docs/system-architecture.md +++ b/docs/system-architecture.md @@ -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.** `/` (lowercased login) is a symlink into + `/.gen/-/`, which holds `/.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. diff --git a/internal/card/card.go b/internal/card/card.go index ed16a616..067282d5 100644 --- a/internal/card/card.go +++ b/internal/card/card.go @@ -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 +} diff --git a/internal/card/card_test.go b/internal/card/card_test.go index 25c984cf..ec18a8dd 100644 --- a/internal/card/card_test.go +++ b/internal/card/card_test.go @@ -18,13 +18,13 @@ func TestRenderAll(t *testing.T) { // XML-significant chars here exercises escapeXML through the real // rendering pipeline, not just through the unit test below. p := &github.Profile{ - Login: "tiennm99", - Name: `Alice & "quoted"`, - Company: "VNG & ", - Followers: 12, - Following: 7, - RepoCount: 42, - TotalStars: 1234, + Login: "tiennm99", + Name: `Alice & "quoted"`, + Company: "VNG & ", + Followers: 12, + Following: 7, + RepoCount: 42, + TotalStars: 1234, ReposByLanguage: []github.LangStat{ {Name: "Go", Color: "#00ADD8", Value: 5}, {Name: "TypeScript", Color: "#3178c6", Value: 3}, @@ -202,12 +202,12 @@ func TestFitTitleFontSize(t *testing.T) { {"Stats", 15}, {"Streak", 15}, {"Top Starred Repos", 15}, - {"Most Commit Language (all time)", 15}, // 31 chars + {"Most Commit Language (all time)", 15}, // 31 chars {"Contributions by Year", 15}, {"Commits by Hour (last year, UTC+7)", 15}, // 34 chars, common integer-zone case {"Commits by Hour (last year, UTC+5:45)", 14}, // 37 chars, quarter-hour zone (Kathmandu) - {"Commits by Weekday (last year)", 15}, // 30 chars — weekday titles never include UTC - {strings.Repeat("x", 200), 11}, // pathological + {"Commits by Weekday (last year)", 15}, // 30 chars — weekday titles never include UTC + {strings.Repeat("x", 200), 11}, // pathological } for _, c := range cases { got := fitTitleFontSize(c.title, width) @@ -367,9 +367,9 @@ func runeLen(s string) int { // calendar. Kept alongside the stress test so updates stay colocated. func adversarialProfile() *github.Profile { p := &github.Profile{ - Login: "user-with-a-very-long-login-name", - Name: "A Very Long Display Name That Keeps Going", - UTCOffsetLabel: "UTC+12:45", // quarter-hour zone — longest realistic UTC label + Login: "user-with-a-very-long-login-name", + Name: "A Very Long Display Name That Keeps Going", + UTCOffsetLabel: "UTC+12:45", // quarter-hour zone — longest realistic UTC label Company: "A-Company-With-An-Unusually-Long-Name Pty Ltd", Location: "A Place With A Name That Is Way Too Long To Fit", diff --git a/internal/card/streak.go b/internal/card/streak.go index d5632b0d..8648df02 100644 --- a/internal/card/streak.go +++ b/internal/card/streak.go @@ -59,12 +59,12 @@ func (streakCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) { // streakStats is the post-processed daily series summarised for the card. type streakStats struct { - Current int + Current int CurrentStart, CurrentEnd time.Time - Longest int + Longest int LongestStart, LongestEnd time.Time - Active int // days with ≥1 contribution - Total int // total days observed + Active int // days with ≥1 contribution + Total int // total days observed } // computeStreak walks the daily series once. The "current streak" runs diff --git a/internal/card/top_starred_repos.go b/internal/card/top_starred_repos.go index 2d51e615..42ab3a72 100644 --- a/internal/card/top_starred_repos.go +++ b/internal/card/top_starred_repos.go @@ -99,4 +99,3 @@ func ownedNonForkRepos(repos []github.RepoInfo) []github.RepoInfo { } return out } - diff --git a/internal/card/weekday_start_test.go b/internal/card/weekday_start_test.go index 989d1b67..3e10a692 100644 --- a/internal/card/weekday_start_test.go +++ b/internal/card/weekday_start_test.go @@ -22,8 +22,8 @@ func TestPadToWeekGridRotatesByWeekStart(t *testing.T) { weekStart time.Weekday wantOffset int }{ - {"Sunday start", time.Sunday, 4}, // Thu is row 4 of Sun..Sat - {"Monday start", time.Monday, 3}, // Thu is row 3 of Mon..Sun + {"Sunday start", time.Sunday, 4}, // Thu is row 4 of Sun..Sat + {"Monday start", time.Monday, 3}, // Thu is row 3 of Mon..Sun {"Thursday start", time.Thursday, 0}, {"Friday start", time.Friday, 6}, } diff --git a/internal/github/client.go b/internal/github/client.go index 39a131e7..51772722 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -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]) + "…" } - diff --git a/internal/github/collect.go b/internal/github/collect.go new file mode 100644 index 00000000..6f84c165 --- /dev/null +++ b/internal/github/collect.go @@ -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) + } +} diff --git a/internal/github/collect_test.go b/internal/github/collect_test.go new file mode 100644 index 00000000..c7857867 --- /dev/null +++ b/internal/github/collect_test.go @@ -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) + } + } +} diff --git a/internal/github/model.go b/internal/github/model.go index 1126da94..11e99125 100644 --- a/internal/github/model.go +++ b/internal/github/model.go @@ -22,14 +22,14 @@ type Profile struct { RepoCount int // Totals for the stats card. - TotalStars int - TotalForks int - TotalCommits int // last year, from contributionsCollection - TotalCommitsAllTime int // sum across contributionYears - TotalPRs int - TotalIssues int - TotalReviews int - TotalContributedTo int + TotalStars int + TotalForks int + TotalCommits int // last year, from contributionsCollection + TotalCommitsAllTime int // sum across contributionYears + TotalPRs int + TotalIssues int + TotalReviews int + TotalContributedTo int TotalContributionsLastYear int // contributionCalendar.totalContributions + restrictedContributionsCount (last year) // Count of owned repos grouped by primary language, sorted desc by Value. diff --git a/internal/github/queries.go b/internal/github/queries.go index f03279ea..f3b18154 100644 --- a/internal/github/queries.go +++ b/internal/github/queries.go @@ -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 + } + } +}` diff --git a/internal/web/jobs.go b/internal/web/jobs.go new file mode 100644 index 00000000..6f71f98a --- /dev/null +++ b/internal/web/jobs.go @@ -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) +} diff --git a/internal/web/ratelimit.go b/internal/web/ratelimit.go new file mode 100644 index 00000000..3dab1cb5 --- /dev/null +++ b/internal/web/ratelimit.go @@ -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() +} diff --git a/internal/web/server.go b/internal/web/server.go new file mode 100644 index 00000000..c0b18483 --- /dev/null +++ b/internal/web/server.go @@ -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, "![%s](%s)\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: + } + } +} diff --git a/internal/web/static/app.js b/internal/web/static/app.js new file mode 100644 index 00000000..457a26e7 --- /dev/null +++ b/internal/web/static/app.js @@ -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} */ (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} + */ +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); +}); diff --git a/internal/web/static/favicon.svg b/internal/web/static/favicon.svg new file mode 100644 index 00000000..0b2ea5ca --- /dev/null +++ b/internal/web/static/favicon.svg @@ -0,0 +1 @@ + diff --git a/internal/web/static/style.css b/internal/web/static/style.css new file mode 100644 index 00000000..71cdbbe0 --- /dev/null +++ b/internal/web/static/style.css @@ -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; } diff --git a/internal/web/store.go b/internal/web/store.go new file mode 100644 index 00000000..908abdb2 --- /dev/null +++ b/internal/web/store.go @@ -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. / 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 / 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) } diff --git a/internal/web/templates/form.html b/internal/web/templates/form.html new file mode 100644 index 00000000..b812bd23 --- /dev/null +++ b/internal/web/templates/form.html @@ -0,0 +1,70 @@ +{{define "form"}} +
+
+ + +
+ +
+ Options +
+
+ + +

IANA name such as Asia/Saigon. Buckets commit hours on the productive-time cards.

+
+ +
+ + +
+ +
+ + +

0 samples every commit and needs your own token.

+
+ +
+ Repositories + + + +

Private and org repos are only counted with your own token.

+
+ +
+ + +

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

+

+ Used 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. + The resulting cards are public on this site, including totals drawn from private repos. +

+
+
+
+ + +
+{{end}} diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html new file mode 100644 index 00000000..9d37cf43 --- /dev/null +++ b/internal/web/templates/index.html @@ -0,0 +1,13 @@ +{{define "content"}} +
+

Profile cards for any GitHub user

+

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

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

{{.Message}}

{{end}} + {{template "form" .}} +
+{{end}} diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html new file mode 100644 index 00000000..6e012cf7 --- /dev/null +++ b/internal/web/templates/layout.html @@ -0,0 +1,30 @@ +{{define "layout"}} + + + + + +{{.Title}} + + + + + + +
+
+ ghglance + GitHub profile cards +
+
+
+{{template "content" .}} +
+
+
+ Rendered by ghglance. Every card on this site is public. +
+
+ + +{{end}} diff --git a/internal/web/templates/message.html b/internal/web/templates/message.html new file mode 100644 index 00000000..53e9529a --- /dev/null +++ b/internal/web/templates/message.html @@ -0,0 +1,7 @@ +{{define "content"}} +
+

{{.Heading}}

+

{{.Message}}

+

Back to the start

+
+{{end}} diff --git a/internal/web/templates/user.html b/internal/web/templates/user.html new file mode 100644 index 00000000..acb10740 --- /dev/null +++ b/internal/web/templates/user.html @@ -0,0 +1,71 @@ +{{define "content"}} +
+

{{.Login}}

+

+ {{if .Meta}}Generated {{fmtTime .Meta.GeneratedAt}} + · {{.Meta.Scope}} data + · {{.Meta.Options.TZ}} + · week starts {{.Meta.Options.StartOfWeek}} + · {{end}}GitHub profile +

+
+ +{{if .Notice}}

{{.Notice}}

{{end}} + +{{if .Job.Active}} +
+ +
+

{{if eq .Job.State "queued"}}Queued{{if .Job.Position}}, position {{.Job.Position}}{{end}}{{else}}Running: {{.Job.Stage}}{{end}}

+

+

Large profiles take a few minutes. This page refreshes when the cards are ready.

+
+
+{{else if eq .Job.State "failed"}} + +{{end}} + +{{if .Meta}} +
+ + + +
+ +
    + {{range .Cards}} +
  • +
    +
    {{.Name}} card for {{$.Login}}
    +
    + {{.Name}} +
    + + +
    +
    +
    +
  • + {{end}} +
+ +
+

Embed every card

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

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

+ {{if .ExpiresIn}}

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

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

Without a token these cards can be regenerated in {{.CooldownLeft}}. With your own token you can regenerate now.

{{end}} + {{template "form" .}} +
+{{end}} +{{end}} diff --git a/internal/web/validate.go b/internal/web/validate.go new file mode 100644 index 00000000..1237ca88 --- /dev/null +++ b/internal/web/validate.go @@ -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, + } +} diff --git a/internal/web/web_test.go b/internal/web/web_test.go new file mode 100644 index 00000000..694ef31f --- /dev/null +++ b/internal/web/web_test.go @@ -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(), " 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 == "" { diff --git a/main_test.go b/main_test.go deleted file mode 100644 index 59b538f6..00000000 --- a/main_test.go +++ /dev/null @@ -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) - } - } -}