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