Files
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

117 lines
2.9 KiB
Go

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