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