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:
tiennm99 committed 2026-10-07 11:08:07 +07:00
1 parent af420f0db7
commit ad775adffa
29 files changed
+3255 -152

No files matched your search

+12
View File
@@ -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/
+1
View File
@@ -0,0 +1 @@
GHGLANCE_GITHUB_TOKEN=
+3
View File
@@ -31,6 +31,9 @@ go.work.sum
# in CI) as the reference render instead of committing a sample here.
output/
# Cards stored by a local `ghglance -serve` run (default -data-dir).
data/
# Editor/IDE
# .idea/
# .vscode/
+99 -1
View File
@@ -8,7 +8,8 @@
`ghglance` is a single-binary CLI (and a GitHub Action wrapping it) that fetches
data for a GitHub user and writes a themed set of SVGs you can embed in your
profile README.
profile README. The same binary can also [run as a web UI](#run-the-web-ui)
where anyone submits a username and gets the cards back.
Marketplace listing: **[ghglance](https://github.com/marketplace/actions/ghglance)** · Source: [`tiennm99/ghglance`](https://github.com/tiennm99/ghglance)
@@ -172,7 +173,104 @@ ghglance -user tiennm99 -themes dracula -include-org-repos -out output
| `-include-forks` | `true` | Include forked repos in the stats |
| `-include-private` | `true` | Include private repos (requires `repo` PAT scope; silently no-op otherwise) |
| `-include-org-repos`| `false` | Count org-owned repos you administer toward stars, repo count, languages, top-starred |
| `-timeout` | `30m` | Overall fetch deadline (per generation job under `-serve`), `0` = no limit |
| `-list-themes` | | Print available theme ids and exit |
| `-serve` | | Run the [web UI](#run-the-web-ui) on this address (e.g. `:8080`) instead of generating once |
| `-data-dir` | `data` | Web UI only: directory holding generated cards |
| `-cooldown` | `6h` | Web UI only: minimum age of a user's cards before a token-less submission regenerates them |
| `-retention` | `24h` | Web UI only: delete a user's cards this long after they were generated, `0` = keep forever |
| `-workers` | `2` | Web UI only: concurrent generation jobs |
The five web UI flags are server-only: the Action (`action.yml`,
`entrypoint.sh`) does not expose them.
## Run the web UI
`-serve` turns the binary into a small web app: a form takes a GitHub
username plus options, a background job renders all sixteen cards in every
theme, and `/u/<username>` shows them again with a theme picker and
copyable embed URLs. Cards are stored on disk and survive restarts.
```sh
export GITHUB_TOKEN=ghp_xxx
ghglance -serve :8080 -data-dir data -retention 24h
# open http://localhost:8080
```
| Path | Serves |
| --- | --- |
| `/` | The submission form |
| `/u/<user>` | The user's cards (`?theme=<id>` picks the theme), or job progress while one runs |
| `/u/<user>/<theme>/<card>.svg` | One card, embeddable in a README |
| `/u/<user>/status` | Job status as JSON, polled by the progress page |
| `/healthz` | Liveness probe |
How submissions are handled:
- **Server token.** Submissions without a token use the server's
`GITHUB_TOKEN`, with private repos and org repos forced off. That alone
does not hide private work: GitHub counts every private contribution a
token can see in the totals and the calendar. So the server token must be
public-only (a classic PAT with just `read:user`, or a fine-grained token
with public repositories only); a token with `repo` scope, or one that can
list any private repository, is refused for token-less jobs and logged at
startup. The token owner's own username is refused without a token too.
- **Submitter's token.** An optional token in the form is used for that one
job, then dropped: never logged, never written to disk. When it belongs to
the username being generated, private repos count by default and the
cooldown is skipped. A token that belongs to someone else renders public
data only, does not skip the cooldown, and is refused outright if it can
read private repositories. The cards it renders are public on the site
like any other.
A "Create a token on GitHub" button beside the field opens GitHub's
new-token page with a classic token's `repo` and `read:user` scopes
pre-ticked.
- **Failures.** A job that fails or times out at any fetch stage publishes
nothing, so an earlier complete set stays in place.
- **Cooldown.** Without a token, cards younger than `-cooldown` are shown
instead of regenerated.
- **Retention.** Cards are deleted `-retention` (default `24h`) after they
were generated, checked at startup and hourly. The user's page then
offers a fresh generation, and embedded card URLs return 404 until
someone regenerates them.
- **Limits.** One queued or running job per user, `-workers` jobs at once,
`-timeout` per job, and five submissions per client followed by one
every two minutes. A client is an IPv4 address or an IPv6 /64. Behind a
reverse proxy on a private or loopback address, the client address comes
from the last `X-Forwarded-For` hop.
Each user takes about 9 MB on disk for an active profile (16 cards × every theme).
### Deploy with Docker Compose or Coolify
[`compose.yml`](./compose.yml) builds the repo's `Dockerfile`, runs
`ghglance -serve :8080 -data-dir /data`, keeps cards in the `ghglance-data`
volume, and health-checks `/healthz`. It publishes no host port: Coolify's
proxy routes the domain it generates for `SERVICE_FQDN_GHGLANCE_8080` to
port 8080 in the container.
In Coolify:
1. Create a resource from this Git repository with the **Docker Compose**
build pack and compose file `/compose.yml`.
2. Set `GHGLANCE_GITHUB_TOKEN` (see [`.env.example`](./.env.example)) to a
public-only token: a classic PAT with only `read:user`, never `repo`.
`compose.yml` requires it and passes it to the container as
`GITHUB_TOKEN`. The distinct name keeps a `GITHUB_TOKEN` exported in your
shell from silently replacing it during `docker compose up`.
3. Keep the generated domain or set your own on the `ghglance` service, then
deploy.
On a plain Docker host, copy `.env.example` to `.env`, fill in the token,
add a `ports: ["8080:8080"]` entry to the service, and run (`.dockerignore`
keeps `.env` and `data/` out of the image context):
```sh
docker compose up -d --build
```
The Action image is unchanged: `compose.yml` overrides the entrypoint, so
the Action still runs `entrypoint.sh`.
## How attribution works
+21
View File
@@ -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:
+22
View File
@@ -1,6 +1,7 @@
# Deployment Guide
Three consumption paths: **GitHub Action**, **prebuilt binaries**, **go install**.
The same binary also runs as a self-hosted **web UI** (section 4).
## 1. GitHub Action (recommended for README auto-updates)
@@ -97,6 +98,27 @@ go install github.com/tiennm99/ghglance@latest
Requires Go 1.26+. Puts the binary in `$(go env GOPATH)/bin`.
## 4. Web UI (Docker Compose / Coolify)
`compose.yml` at the repo root builds the `Dockerfile`, overrides its
entrypoint to run `ghglance -serve :8080 -data-dir /data`, stores cards in
the `ghglance-data` volume, and health-checks `/healthz` with busybox
`wget`. It publishes no host port; Coolify routes the domain generated for
`SERVICE_FQDN_GHGLANCE_8080` to container port 8080.
| Variable | Needed for |
| --- | --- |
| `GHGLANCE_GITHUB_TOKEN` | Required by `compose.yml`, which passes it to the container as `GITHUB_TOKEN` for token-less submissions. Must be public-only: a classic PAT with just `read:user`. A token with `repo` scope or any private-repo access is refused for token-less jobs (GitHub would count private contributions in totals and calendars). |
Coolify: create a Docker Compose resource from this repo, compose file
`/compose.yml`, set `GHGLANCE_GITHUB_TOKEN`, assign the domain, deploy. Server flags
(`-cooldown`, `-retention`, `-workers`, `-timeout`) are changed by editing `command:` in
`compose.yml`. Steps for a plain Docker host and the request-handling rules
are in the README's "Run the web UI" section.
Rollback: redeploy the previous commit. Card sets on the volume are
format-stable, so no data migration is involved.
## Docker image
Published to `ghcr.io/tiennm99/ghglance:<tag>` on each `v*` release via `.github/workflows/release.yml` (buildx, multi-tag: exact version, major.minor, major, latest).
+60 -5
View File
@@ -14,14 +14,18 @@ One process, three phases: **flag parsing → data fetch → SVG render**.
api.github.com internal/theme
```
No database, no cache, no background workers. Stateless CLI; Action runtime just sets environment variables + runs the binary.
No database, no cache, no background workers in CLI mode. Stateless CLI; Action runtime just sets environment variables + runs the binary. `-serve` switches the same binary into the long-running web UI described under [Web UI mode](#web-ui-mode).
A root `context.Context` is built in `main.go` with an overall deadline (`-timeout`, default 30m) and cancelled on `SIGINT`/`SIGTERM`. Every fetcher and HTTP request inherits it so a slow run aborts cleanly instead of draining the 6h Action budget.
## Data-fetch sequence
`github.Collect` owns this sequence; the CLI and every web UI job call it, so
the two paths cannot drift. Only the profile fetch is fatal; later stages
report through `CollectConfig.Warnf` and leave partial data.
```
main.go
github.Collect(ctx, client, login, cfg)
│
▼
FetchProfile(ctx, login, opts)
@@ -49,7 +53,7 @@ FetchContributionsAllTime(ctx, profile, opts)
│ TotalCommitsAllTime
│
▼
FetchProductive(ctx, profile, profile.SeedRepos, loc, commitsPerRepo) // 0 = no cap
FetchProductive(ctx, profile, SeedRepos[:cfg.TopRepos], loc, commitsPerRepo) // 0 = no cap
│ commitHistoryQuery × (#seeds × pages)
│ per commit: t = committedDate in loc
│ ProductiveAllTime[t.Hour]++
@@ -60,7 +64,7 @@ FetchProductive(ctx, profile, profile.SeedRepos, loc, commitsPerRepo) // 0 = no
│ CommitsByLanguage, CommitsByLanguageAllTime
│
▼
card.RenderAll(profile, theme, outDir) × len(themes)
card.RenderAll(profile, theme, outDir) × len(themes) // caller's step
```
## GraphQL queries
@@ -141,8 +145,59 @@ Light themes (`default`, `github`, `nord_bright`, etc.) use `StrokeOpacity: 1` w
| Overall timeout (`-timeout`) or Ctrl-C | `ctx` cancels in-flight requests; partial data may render |
| User with 0 commits | Card renders "No data available" |
## Web UI mode
`ghglance -serve :8080` runs `internal/web` (stdlib `net/http`,
`html/template`, `embed`; vanilla JS, no build step) instead of the one-shot
CLI path.
```
POST /generate ─► validate ─► rate limit / cooldown ─► Queue (dedup per user)
│ -workers goroutines
▼
github.Collect ─► Store.Publish (every theme)
│
GET /u/{user} ◄── meta.json + card list ◄─────────────────┘
GET /u/{user}/{theme}/{card}.svg ◄── os.Root read
```
- **Storage.** `<data>/<user>` (lowercased login) is a symlink into
`<data>/.gen/<user>-<nanos>/`, which holds `<theme>/<card>.svg` plus
`meta.json` (generated time, options, `public`/`private` scope; never the
token). Publish renders into a fresh generation directory, then renames a
new symlink over the old one, so a reader sees the old set or the new set,
never a partial one. Startup sweeps generations no link points at.
Sets older than `-retention` (default `24h`) are deleted at startup and
hourly; a store mutex keeps that removal from racing a republish.
- **Path safety.** Logins are checked against GitHub's rule (alphanumerics
and single hyphens, 1–39 chars) and themes and card names against the
registered lists before any path is built; reads go through `os.Root`.
- **Jobs.** In-process queue, at most one queued or running job per user,
`-workers` concurrent, `-timeout` each, capacity 64. `SIGTERM` stops the
HTTP server, cancels running jobs and drops queued ones; nothing is
published mid-render.
- **Tokens.** GitHub folds every private contribution a token can see into
totals and calendars, so repo filters alone cannot keep cards public. Each
job first identifies its token (`viewer` query: login, a one-repo
`privacy: PRIVATE` probe, and a classic token's `X-OAuth-Scopes`).
Token-less jobs use the server's `GITHUB_TOKEN` (identified once and
cached) with private and org-repo scope forced off; they are refused when
that token can read private repos, and for the token owner's own login. A
submitter's token keeps its scope and skips the cooldown only for its own
login; for anyone else it is refused if private-capable, otherwise forced
to public scope under the cooldown. It lives only on the job and is
cleared when it ends.
- **Partial fetches.** The web path runs `github.Collect` with `Strict`, so
a failed all-time or commit-history stage fails the job, and a job whose
deadline passed is failed even if the fetch returned. The CLI keeps
rendering partial data with warnings.
- **HTTP hardening.** Strict CSP on pages, `default-src 'none'` + `sandbox`
on SVGs, `nosniff`, 16 KiB form limit, `http.CrossOriginProtection` on the
POST, per-client token bucket (burst 5, +1 per 2 min) keyed by IPv4
address or IPv6 /64, capped at 10,000 tracked clients.
## Extension points
- **New card**: implement `Card` interface, add to `allCards` in `card.go`.
- **New theme**: add entry to `themes` map in `theme.go`.
- **New fetcher mode** (e.g., REST per-commit): add a new method on `*Client`, call from `main.go`, wire to new `Profile` fields.
- **New fetcher mode** (e.g., REST per-commit): add a new method on `*Client`, call from `github.Collect`, wire to new `Profile` fields.
+9
View File
@@ -57,3 +57,12 @@ func RenderAll(p *github.Profile, t theme.Theme, outDir string) error {
}
return nil
}
// Filenames lists every card's on-disk basename in render order.
func Filenames() []string {
out := make([]string, len(allCards))
for i, c := range allCards {
out[i] = c.Filename()
}
return out
}
-1
View File
@@ -99,4 +99,3 @@ func ownedNonForkRepos(repos []github.RepoInfo) []github.RepoInfo {
}
return out
}
+18 -13
View File
@@ -58,15 +58,21 @@ const maxRateLimitSleep = 5 * time.Minute
// caller's overall budget expires. On a primary-rate-limit 403, honors
// Retry-After / X-RateLimit-Reset once before retrying.
func (c *Client) query(ctx context.Context, q string, vars map[string]any, out any) error {
_, err := c.queryHeader(ctx, q, vars, out)
return err
}
// queryHeader is query that also returns the successful response's headers.
func (c *Client) queryHeader(ctx context.Context, q string, vars map[string]any, out any) (http.Header, error) {
body, err := json.Marshal(gqlRequest{Query: q, Variables: vars})
if err != nil {
return fmt.Errorf("marshal request: %w", err)
return nil, fmt.Errorf("marshal request: %w", err)
}
for attempt := 0; attempt < 2; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("new request: %w", err)
return nil, fmt.Errorf("new request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "ghglance")
@@ -76,51 +82,51 @@ func (c *Client) query(ctx context.Context, q string, vars map[string]any, out a
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("http: %w", err)
return nil, fmt.Errorf("http: %w", err)
}
raw, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return fmt.Errorf("read body: %w", err)
return nil, fmt.Errorf("read body: %w", err)
}
if rateLimited(resp) && attempt == 0 {
wait := rateLimitWait(resp)
if wait > maxRateLimitSleep {
return fmt.Errorf("http %d: rate limit resets in %s (>%s max wait)", resp.StatusCode, wait, maxRateLimitSleep)
return nil, fmt.Errorf("http %d: rate limit resets in %s (>%s max wait)", resp.StatusCode, wait, maxRateLimitSleep)
}
fmt.Fprintf(os.Stderr, "warn: rate-limited, sleeping %s before retry\n", wait.Round(time.Second))
select {
case <-time.After(wait):
case <-ctx.Done():
return ctx.Err()
return nil, ctx.Err()
}
continue
}
if resp.StatusCode >= 400 {
return fmt.Errorf("http %d: %s", resp.StatusCode, truncate(raw, 500))
return nil, fmt.Errorf("http %d: %s", resp.StatusCode, truncate(raw, 500))
}
var r gqlResponse
if err := json.Unmarshal(raw, &r); err != nil {
return fmt.Errorf("decode body: %w", err)
return nil, fmt.Errorf("decode body: %w", err)
}
if len(r.Errors) > 0 {
msgs := make([]string, 0, len(r.Errors))
for _, e := range r.Errors {
msgs = append(msgs, e.Message)
}
return fmt.Errorf("graphql: %s", strings.Join(msgs, "; "))
return nil, fmt.Errorf("graphql: %s", strings.Join(msgs, "; "))
}
if out != nil {
if err := json.Unmarshal(r.Data, out); err != nil {
return fmt.Errorf("decode data: %w", err)
return nil, fmt.Errorf("decode data: %w", err)
}
}
return nil
return resp.Header, nil
}
return fmt.Errorf("http: exceeded retry attempts")
return nil, fmt.Errorf("http: exceeded retry attempts")
}
// rateLimited returns true when the response indicates a GitHub primary or
@@ -176,4 +182,3 @@ func truncate(b []byte, n int) string {
}
return string(b[:cut]) + "…"
}
+166
View File
@@ -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)
}
}
+80
View File
@@ -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)
}
}
}
+12
View File
@@ -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
}
}
}`
+367
View File
@@ -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)
}
+116
View File
@@ -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()
}
+471
View File
@@ -0,0 +1,471 @@
// Package web serves the ghglance web UI: a form that queues card
// generation for any GitHub user, and pages that re-show the stored cards.
package web
import (
"context"
"embed"
"encoding/json"
"fmt"
"html/template"
"io/fs"
"log"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/tiennm99/ghglance/internal/card"
"github.com/tiennm99/ghglance/internal/theme"
)
//go:embed templates static
var assets embed.FS
// maxFormBytes bounds a form post; the real form is well under 1 KiB.
const maxFormBytes = 16 << 10
// Submission rate per client IP: a burst of 5, then one every 2 minutes.
const (
submitBurst = 5
submitInterval = 2 * time.Minute
)
const (
pageCSP = "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; " +
"connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'"
// Cards are static drawings: no scripts, no external fetches. Inline
// style attributes are the only thing they need.
svgCSP = "default-src 'none'; style-src 'unsafe-inline'; sandbox"
)
// Config configures the web server.
type Config struct {
Addr string
DataDir string
Cooldown time.Duration
// Retention is how long generated cards are kept before they are
// deleted (0 = forever).
Retention time.Duration
Workers int
// JobTimeout bounds one generation job (0 = no limit).
JobTimeout time.Duration
// Token is the server's own GitHub token, used for submissions that
// bring none. It never renders private or org-administered data.
Token string
// Fetcher overrides the GitHub fetch; nil uses the real API.
Fetcher Fetcher
}
// Server holds the store, job queue and handlers.
type Server struct {
cfg Config
store *Store
queue *Queue
limiter *rateLimiter
pages map[string]*template.Template
static http.Handler
}
// New opens the data directory and starts the job workers.
func New(cfg Config) (*Server, error) {
if cfg.Fetcher == nil {
cfg.Fetcher = githubFetcher{}
}
store, err := OpenStore(cfg.DataDir)
if err != nil {
return nil, err
}
pages, err := parsePages()
if err != nil {
store.Close()
return nil, err
}
staticFS, err := fs.Sub(assets, "static")
if err != nil {
store.Close()
return nil, err
}
return &Server{
cfg: cfg,
store: store,
queue: newQueue(store, cfg.Fetcher, cfg.Token, cfg.JobTimeout, cfg.Cooldown, cfg.Workers),
limiter: newRateLimiter(submitBurst, submitInterval),
pages: pages,
static: http.FileServerFS(staticFS),
}, nil
}
// Close stops the workers and releases the data directory.
func (s *Server) Close() {
s.queue.Stop()
s.store.Close()
}
// Run serves until ctx is cancelled, then drains in-flight requests and
// stops the job workers. Queued and running jobs are abandoned; nothing
// half-rendered is ever published.
func Run(ctx context.Context, cfg Config) error {
s, err := New(cfg)
if err != nil {
return err
}
defer s.Close()
if cfg.Token == "" {
log.Printf("warn: GITHUB_TOKEN is empty; only submissions with their own token will succeed")
}
ln, err := net.Listen("tcp", cfg.Addr)
if err != nil {
return err
}
srv := &http.Server{
Handler: s.Handler(),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 2 * time.Minute,
}
errc := make(chan error, 1)
go func() { errc <- srv.Serve(ln) }()
log.Printf("serving on %s, data in %s", ln.Addr(), s.store.dir)
// Check the server token up front so a private-capable token is
// reported at startup, not on the first submission.
go s.queue.server(ctx)
go s.expireLoop(ctx)
select {
case err := <-errc:
return err
case <-ctx.Done():
}
log.Printf("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
}
// Handler returns the routed, hardened HTTP handler.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", s.handleIndex)
mux.HandleFunc("POST /generate", s.handleGenerate)
mux.HandleFunc("GET /u/{user}", s.handleUser)
mux.HandleFunc("GET /u/{user}/status", s.handleStatus)
mux.HandleFunc("GET /u/{user}/{theme}/{card}", s.handleCard)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
fmt.Fprintln(w, "ok")
})
mux.Handle("GET /static/", http.StripPrefix("/static/", s.withCache(s.static, "public, max-age=3600")))
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
s.renderMessage(w, http.StatusNotFound, "Page not found", "There is nothing at this address.")
})
cop := http.NewCrossOriginProtection()
return commonHeaders(cop.Handler(mux))
}
func commonHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "no-referrer")
h.Set("Content-Security-Policy", pageCSP)
next.ServeHTTP(w, r)
})
}
func (s *Server) withCache(next http.Handler, value string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", value)
next.ServeHTTP(w, r)
})
}
// pageData feeds every template.
type pageData struct {
Title string
Heading string
Message string
Error string
Form formValues
Login string
Key string
Meta *Meta
Job JobStatus
Notice string
CooldownLeft string
ExpiresIn string
Themes []string
Theme string
Cards []cardView
Markdown string
}
type cardView struct {
Name string
Src string
URL string
}
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
s.render(w, http.StatusOK, "index", pageData{Title: "ghglance", Form: defaultForm()})
}
func (s *Server) handleGenerate(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
if err := r.ParseForm(); err != nil {
s.render(w, http.StatusBadRequest, "index", pageData{Title: "ghglance", Error: "The form could not be read.", Form: defaultForm()})
return
}
sub, form, err := parseSubmission(r.PostForm.Get)
if !s.limiter.Allow(clientKey(r)) {
w.Header().Set("Retry-After", strconv.Itoa(int(submitInterval.Seconds())))
s.render(w, http.StatusTooManyRequests, "index", pageData{
Title: "ghglance",
Error: "Too many submissions from your address. Wait a couple of minutes and try again.",
Form: form,
})
return
}
if err != nil {
s.render(w, http.StatusBadRequest, "index", pageData{Title: "ghglance", Error: capitalize(err.Error()) + ".", Form: form})
return
}
if sub.Token == "" && s.cfg.Token == "" {
s.render(w, http.StatusBadRequest, "index", pageData{
Title: "ghglance",
Error: "This server has no GitHub token of its own. Add yours under Options.",
Form: form,
})
return
}
target := "/u/" + url.PathEscape(userKey(sub.Login))
if s.queue.Status(sub.Login).Active() {
http.Redirect(w, r, target+"?notice=pending", http.StatusSeeOther)
return
}
if sub.Token == "" && s.store.cooldownLeft(sub.Login, s.cfg.Cooldown, time.Now()) > 0 {
http.Redirect(w, r, target+"?notice=fresh", http.StatusSeeOther)
return
}
created, err := s.queue.Submit(sub)
if err != nil {
s.render(w, http.StatusServiceUnavailable, "index", pageData{Title: "ghglance", Error: capitalize(err.Error()) + ".", Form: form})
return
}
if !created {
target += "?notice=pending"
}
http.Redirect(w, r, target, http.StatusSeeOther)
}
func (s *Server) handleUser(w http.ResponseWriter, r *http.Request) {
login := r.PathValue("user")
if !validUsername(login) {
s.renderMessage(w, http.StatusNotFound, "Not a GitHub username", "GitHub usernames use letters, digits and single hyphens, up to 39 characters.")
return
}
meta, err := s.store.Meta(login)
if err != nil && !isNotExist(err) {
log.Printf("read meta %s: %v", userKey(login), err)
s.renderMessage(w, http.StatusInternalServerError, "Something went wrong", "The stored cards could not be read.")
return
}
job := s.queue.Status(login)
if meta == nil && job.State == stateNone {
form := defaultForm()
form.User = login
s.render(w, http.StatusNotFound, "index", pageData{
Title: "ghglance",
Message: fmt.Sprintf("No cards for %s yet. Generate them below.", login),
Form: form,
})
return
}
d := pageData{
Title: login + " · ghglance",
Login: login,
Key: userKey(login),
Meta: meta,
Job: job,
Themes: theme.IDs(),
Theme: defaultTheme,
Form: defaultForm(),
}
d.Form.User = login
if t := r.URL.Query().Get("theme"); validTheme(t) {
d.Theme = t
}
switch r.URL.Query().Get("notice") {
case "fresh":
d.Notice = "These cards are recent, so they were not regenerated."
case "pending":
if job.Active() {
d.Notice = "A generation for this user is already in progress."
}
}
if meta != nil {
d.Login = meta.Login
d.Form = formFromOptions(meta.Login, meta.Options)
if left := s.store.cooldownLeft(login, s.cfg.Cooldown, time.Now()); left > 0 {
d.CooldownLeft = humanDuration(left)
}
if s.cfg.Retention > 0 {
d.ExpiresIn = humanDuration(max(meta.GeneratedAt.Add(s.cfg.Retention).Sub(time.Now()), time.Minute))
}
base := baseURL(r)
version := strconv.FormatInt(meta.GeneratedAt.Unix(), 10)
var md strings.Builder
for _, f := range card.Filenames() {
rel := "/u/" + d.Key + "/" + d.Theme + "/" + f
name := strings.ReplaceAll(strings.TrimSuffix(f, ".svg"), "-", " ")
d.Cards = append(d.Cards, cardView{Name: name, Src: rel + "?v=" + version, URL: base + rel})
fmt.Fprintf(&md, "![%s](%s)\n", name, base+rel)
}
d.Markdown = md.String()
}
w.Header().Set("Cache-Control", "no-store")
s.render(w, http.StatusOK, "user", d)
}
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
login := r.PathValue("user")
if !validUsername(login) {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
json.NewEncoder(w).Encode(s.queue.Status(login))
}
func (s *Server) handleCard(w http.ResponseWriter, r *http.Request) {
f, err := s.store.OpenCard(r.PathValue("user"), r.PathValue("theme"), r.PathValue("card"))
if err != nil {
if !isNotExist(err) {
log.Printf("open card %s: %v", r.URL.Path, err)
}
http.NotFound(w, r)
return
}
defer f.Close()
st, err := f.Stat()
if err != nil || !st.Mode().IsRegular() {
http.NotFound(w, r)
return
}
h := w.Header()
h.Set("Content-Type", "image/svg+xml")
h.Set("Content-Security-Policy", svgCSP)
h.Set("Cache-Control", "public, max-age=3600")
http.ServeContent(w, r, "", st.ModTime(), f)
}
func (s *Server) render(w http.ResponseWriter, status int, page string, d pageData) {
var buf strings.Builder
if err := s.pages[page].ExecuteTemplate(&buf, "layout", d); err != nil {
log.Printf("render %s: %v", page, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
fmt.Fprint(w, buf.String())
}
func (s *Server) renderMessage(w http.ResponseWriter, status int, heading, message string) {
s.render(w, status, "message", pageData{Title: heading + " · ghglance", Heading: heading, Message: message})
}
func parsePages() (map[string]*template.Template, error) {
funcs := template.FuncMap{
"fmtTime": func(t time.Time) string { return t.UTC().Format("2006-01-02 15:04 UTC") },
}
pages := map[string]*template.Template{}
for _, name := range []string{"index", "user", "message"} {
t, err := template.New(name).Funcs(funcs).ParseFS(assets,
"templates/layout.html", "templates/form.html", "templates/"+name+".html")
if err != nil {
return nil, fmt.Errorf("parse %s template: %w", name, err)
}
pages[name] = t
}
return pages, nil
}
// formFromOptions pre-fills the regenerate form with a set's last options.
// Private scope stays ticked: it only takes effect with a token anyway.
func formFromOptions(login string, o Options) formValues {
return formValues{
User: login,
TZ: o.TZ,
StartOfWeek: o.StartOfWeek,
IncludeForks: o.IncludeForks,
IncludeOrgRepos: o.IncludeOrgRepos,
IncludePrivate: true,
CommitsPerRepo: strconv.Itoa(o.CommitsPerRepo),
}
}
// baseURL is the absolute origin for copyable embed links. The scheme
// follows the proxy's X-Forwarded-Proto when one sits in front.
func baseURL(r *http.Request) string {
scheme := "http"
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
return scheme + "://" + r.Host
}
func humanDuration(d time.Duration) string {
d = d.Round(time.Minute)
h, m := int(d.Hours()), int(d.Minutes())%60
switch {
case h > 0 && m > 0:
return fmt.Sprintf("%dh %dm", h, m)
case h > 0:
return fmt.Sprintf("%dh", h)
case m > 0:
return fmt.Sprintf("%dm", m)
}
return "under a minute"
}
func capitalize(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}
// expireInterval is how often expired cards are swept while serving.
const expireInterval = time.Hour
// expireLoop deletes cards older than the retention once at startup and then
// every expireInterval until ctx is cancelled.
func (s *Server) expireLoop(ctx context.Context) {
if s.cfg.Retention <= 0 {
return
}
t := time.NewTicker(expireInterval)
defer t.Stop()
for {
if n := s.store.Expire(s.cfg.Retention, time.Now()); n > 0 {
log.Printf("expired cards for %d user(s) older than %s", n, s.cfg.Retention)
}
select {
case <-ctx.Done():
return
case <-t.C:
}
}
}
+151
View File
@@ -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);
});
+1
View File
@@ -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

+196
View File
@@ -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; }
+252
View File
@@ -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) }
+70
View File
@@ -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&amp;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}}
+13
View File
@@ -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/&lt;username&gt;</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}}
+30
View File
@@ -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}}
+7
View File
@@ -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}}
+71
View File
@@ -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}}
+183
View File
@@ -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,
}
}
+786
View File
@@ -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")
}
}
+38 -72
View File
@@ -14,6 +14,7 @@ import (
"github.com/tiennm99/ghglance/internal/card"
"github.com/tiennm99/ghglance/internal/github"
"github.com/tiennm99/ghglance/internal/theme"
"github.com/tiennm99/ghglance/internal/web"
)
func main() {
@@ -28,9 +29,14 @@ func main() {
includeForks = flag.Bool("include-forks", true, "include forked repos in stats and commit probing")
includePrivate = flag.Bool("include-private", true, "include private repos (requires PAT with repo scope; silently no-op otherwise)")
includeOrgs = flag.Bool("include-org-repos", false, "count org-owned repos you administer toward stars, repo count, repos-per-language and top-starred")
timeout = flag.Duration("timeout", 30*time.Minute, "overall deadline for fetch phase (0 = no limit)")
timeout = flag.Duration("timeout", 30*time.Minute, "overall deadline for fetch phase; per generation job under -serve (0 = no limit)")
startOfWeek = flag.String("start-of-week", "sunday", "first day of week for heatmap rows and weekday bars (sunday|monday|tuesday|…)")
listThemes = flag.Bool("list-themes", false, "print available theme ids and exit")
serve = flag.String("serve", "", "run the web UI on this address (e.g. :8080) instead of generating once")
dataDir = flag.String("data-dir", "data", "web UI: directory holding generated cards")
cooldown = flag.Duration("cooldown", 6*time.Hour, "web UI: minimum age of a user's cards before they can be regenerated without the submitter's own token")
retention = flag.Duration("retention", 24*time.Hour, "web UI: delete a user's generated cards this long after they were generated (0 = keep forever)")
workers = flag.Int("workers", 2, "web UI: concurrent generation jobs")
)
flag.Parse()
@@ -41,6 +47,25 @@ func main() {
return
}
if *serve != "" {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
err := web.Run(ctx, web.Config{
Addr: *serve,
DataDir: *dataDir,
Cooldown: *cooldown,
Retention: *retention,
Workers: *workers,
JobTimeout: *timeout,
Token: *token,
})
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
return
}
if *user == "" {
fmt.Fprintln(os.Stderr, "error: -user is required")
flag.Usage()
@@ -59,7 +84,7 @@ func main() {
loc = time.UTC
}
weekStart, err := parseWeekday(*startOfWeek)
weekStart, err := github.ParseWeekday(*startOfWeek)
if err != nil {
fmt.Fprintf(os.Stderr, "warn: %v, falling back to Sunday\n", err)
weekStart = time.Sunday
@@ -86,33 +111,20 @@ func main() {
cancel()
}()
client := github.NewClient(*token)
profile, err := client.FetchProfile(ctx, *user, opts)
profile, err := github.Collect(ctx, github.NewClient(*token), *user, github.CollectConfig{
Options: opts,
Location: loc,
WeekStart: weekStart,
TopRepos: *topRepos,
CommitsPerRepo: *perRepo,
Warnf: func(format string, args ...any) {
fmt.Fprintf(os.Stderr, "warn: "+format+"\n", args...)
},
})
if err != nil {
fmt.Fprintf(os.Stderr, "error: fetch profile: %v\n", err)
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
profile.UTCOffsetLabel = utcOffsetLabel(loc)
profile.WeekStart = weekStart
// Year-loop fetch populates SeedRepos from commitContributionsByRepository
// plus the all-time contribution calendar; must precede FetchProductive so
// commit-history probes land on repos where the user actually committed.
if len(profile.ContributionYears) > 0 {
if err := client.FetchContributionsAllTime(ctx, profile, opts); err != nil {
fmt.Fprintf(os.Stderr, "warn: all-time contributions fetch: %v\n", err)
}
}
if profile.ID != "" && len(profile.SeedRepos) > 0 {
repos := profile.SeedRepos
if *topRepos > 0 && len(repos) > *topRepos {
repos = repos[:*topRepos]
}
if err := client.FetchProductive(ctx, profile, repos, loc, *perRepo); err != nil {
fmt.Fprintf(os.Stderr, "warn: productive-time + commits-per-language fetch: %v\n", err)
}
}
for _, t := range selected {
if err := card.RenderAll(profile, t, *out); err != nil {
@@ -123,52 +135,6 @@ func main() {
}
}
// utcOffsetLabel formats the location's current offset from UTC compactly:
//
// integer hours → "UTC+7" (no ".00" padding — 3 chars shorter than
// the old "UTC+7.00" format, keeps the
// productive-time title at 15 px)
// half-hour zone → "UTC+5:30" (India)
// quarter-hour → "UTC+5:45" (Nepal)
// negative zone → "UTC-3" / "UTC-3:30"
func utcOffsetLabel(loc *time.Location) string {
_, offsetSec := time.Now().In(loc).Zone()
sign := "+"
if offsetSec < 0 {
sign = "-"
offsetSec = -offsetSec
}
hours := offsetSec / 3600
minutes := (offsetSec % 3600) / 60
if minutes == 0 {
return fmt.Sprintf("UTC%s%d", sign, hours)
}
return fmt.Sprintf("UTC%s%d:%02d", sign, hours, minutes)
}
// parseWeekday maps a case-insensitive English weekday name (full or 3-letter)
// to time.Weekday. Empty input → Sunday so a blank action input still works.
func parseWeekday(s string) (time.Weekday, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "", "sun", "sunday":
return time.Sunday, nil
case "mon", "monday":
return time.Monday, nil
case "tue", "tuesday":
return time.Tuesday, nil
case "wed", "wednesday":
return time.Wednesday, nil
case "thu", "thursday":
return time.Thursday, nil
case "fri", "friday":
return time.Friday, nil
case "sat", "saturday":
return time.Saturday, nil
default:
return time.Sunday, fmt.Errorf("unknown start-of-week %q", s)
}
}
func resolveThemes(spec string) ([]theme.Theme, error) {
spec = strings.TrimSpace(spec)
if spec == "" {
-60
View File
@@ -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)
}
}
}