Cross Compile Go / build (push) Canceled after 0s
- embedded React admin UI at /admin/ui (go:embed), login with ADMIN_SECRET via signed HttpOnly session cookie, X-ADMIN-KEY still works for scripts - admin API: overview stats, indexing queue, addon management (priority, reindex, delete/restore/purge, bulk), whitelist CRUD, duplicate hashes, traffic, metrics explorer, services, events/audit log, system info - built-in metrics: request latency per route, visitors, top addons and searches, indexer/scraper counters; minute buckets in the DB with hourly rollups and retention; optional Prometheus /metrics (METRICS_TOKEN) - POST /back/telemetry for indexers/scrapers (metrics, events, heartbeat) and a stdlib-only Go client in telemetry/ - auth hardening: empty secrets no longer allow access, constant-time compare, CORS not applied to /admin and /back, TRUSTED_PROXIES setting - Dockerfile builds the UI and no longer bakes config.yaml into the image; CI builds the UI, runs tests and builds arm64 with CGO
137 lines
3.1 KiB
Go
137 lines
3.1 KiB
Go
package admin
|
|
|
|
import (
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"gitea.tbdevent.eu/TBD/reforger_crawler_main/initializers"
|
|
"gitea.tbdevent.eu/TBD/reforger_crawler_main/metrics"
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// ---- TTL cache with request coalescing -------------------------------------
|
|
|
|
type cacheEntry struct {
|
|
val any
|
|
at time.Time
|
|
}
|
|
|
|
type inflight struct {
|
|
wg sync.WaitGroup
|
|
val any
|
|
err error
|
|
}
|
|
|
|
var (
|
|
cacheMu sync.Mutex
|
|
cacheData = map[string]cacheEntry{}
|
|
cacheRun = map[string]*inflight{}
|
|
)
|
|
|
|
// cached returns a cached value younger than ttl, or computes it once even if
|
|
// many requests ask at the same time. It also returns when the value was made.
|
|
func cached[T any](key string, ttl time.Duration, fn func() (T, error)) (T, time.Time, error) {
|
|
cacheMu.Lock()
|
|
if e, ok := cacheData[key]; ok && time.Since(e.at) < ttl {
|
|
cacheMu.Unlock()
|
|
return e.val.(T), e.at, nil
|
|
}
|
|
if call, ok := cacheRun[key]; ok {
|
|
cacheMu.Unlock()
|
|
call.wg.Wait()
|
|
var zero T
|
|
if call.err != nil {
|
|
return zero, time.Time{}, call.err
|
|
}
|
|
return call.val.(T), time.Now(), nil
|
|
}
|
|
call := &inflight{}
|
|
call.wg.Add(1)
|
|
cacheRun[key] = call
|
|
cacheMu.Unlock()
|
|
|
|
val, err := fn()
|
|
call.val, call.err = val, err
|
|
now := time.Now()
|
|
cacheMu.Lock()
|
|
delete(cacheRun, key)
|
|
if err == nil {
|
|
cacheData[key] = cacheEntry{val: val, at: now}
|
|
}
|
|
cacheMu.Unlock()
|
|
call.wg.Done()
|
|
return val, now, err
|
|
}
|
|
|
|
// invalidate drops cached values whose key starts with one of the prefixes.
|
|
func invalidate(prefixes ...string) {
|
|
cacheMu.Lock()
|
|
defer cacheMu.Unlock()
|
|
for k := range cacheData {
|
|
for _, p := range prefixes {
|
|
if strings.HasPrefix(k, p) {
|
|
delete(cacheData, k)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- request helpers -----------------------------------------------------
|
|
|
|
func fail(c *gin.Context, code int, msg string) {
|
|
c.AbortWithStatusJSON(code, gin.H{"error": msg})
|
|
}
|
|
|
|
func queryInt(c *gin.Context, name string, def, lo, hi int) int {
|
|
v, err := strconv.Atoi(c.Query(name))
|
|
if err != nil {
|
|
return def
|
|
}
|
|
return max(lo, min(hi, v))
|
|
}
|
|
|
|
func queryInt64(c *gin.Context, name string, def int64) int64 {
|
|
v, err := strconv.ParseInt(c.Query(name), 10, 64)
|
|
if err != nil {
|
|
return def
|
|
}
|
|
return v
|
|
}
|
|
|
|
// timeRange reads from/to (unix seconds) with a default window ending now.
|
|
func timeRange(c *gin.Context, def time.Duration) (int64, int64) {
|
|
now := time.Now().Unix()
|
|
to := queryInt64(c, "to", now)
|
|
if to > now {
|
|
to = now
|
|
}
|
|
from := queryInt64(c, "from", to-int64(def.Seconds()))
|
|
if from >= to {
|
|
from = to - int64(def.Seconds())
|
|
}
|
|
return from, to
|
|
}
|
|
|
|
func isPostgres() bool {
|
|
return initializers.DB.Dialector.Name() == "postgres"
|
|
}
|
|
|
|
// likeEscape escapes LIKE wildcards in user input.
|
|
func likeEscape(s string) string {
|
|
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
|
|
}
|
|
|
|
// audit records an admin action as a metric and an event.
|
|
func audit(c *gin.Context, action string, fields map[string]any) {
|
|
if fields == nil {
|
|
fields = map[string]any{}
|
|
}
|
|
fields["kind"] = "audit"
|
|
fields["ip"] = c.ClientIP()
|
|
fields["auth"] = c.GetString("adminAuth")
|
|
metrics.AdminActions.Inc(action)
|
|
metrics.Event("info", "admin: "+action, fields)
|
|
}
|