Files
ghglance/internal/web/store.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

253 lines
6.8 KiB
Go

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