- 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
This commit is contained in:
1 parent
0ef27b0072
commit
56d9e10621
79 files changed
+13146
-118
No files matched your search
+136
@@ -0,0 +1,136 @@
|
||||
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)
|
||||
}
|
||||
Reference in new issue
Block a user