Files
ghglance/internal/web/jobs.go
T
tiennm99 ad775adffa 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.
2026-10-07 11:08:07 +07:00

368 lines
9.6 KiB
Go

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)
}