Files
ilbinek 56d9e10621
Cross Compile Go / build (push) Canceled after 0s
admin dashboard, metrics and telemetry
- 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
2026-10-03 18:10:57 +02:00

228 lines
6.7 KiB
Go

package admin
import (
"net/http"
"regexp"
"strconv"
"strings"
"time"
"gitea.tbdevent.eu/TBD/reforger_crawler_main/initializers"
"gitea.tbdevent.eu/TBD/reforger_crawler_main/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
var sha512Re = regexp.MustCompile(`^[0-9a-f]{128}$`)
// legacyAddWhitelistedHash keeps the original form-based endpoint working.
func legacyAddWhitelistedHash(c *gin.Context) {
hash := c.PostForm("hash")
if hash == "" {
c.JSON(400, gin.H{"error": "Hash is required"})
return
}
ret := initializers.DB.Find(&models.WhitelistedHash{}, "hash = ?", hash)
if ret.RowsAffected > 0 {
c.JSON(400, gin.H{"error": "Hash already whitelisted"})
return
}
if err := initializers.DB.Create(&models.WhitelistedHash{Hash: hash}).Error; err != nil {
c.JSON(500, gin.H{"error": "Failed to add whitelisted hash"})
return
}
audit(c, "whitelist_add", map[string]any{"added": 1, "legacy": true})
invalidate("overview")
c.JSON(200, gin.H{"message": "Whitelisted hash added successfully"})
}
type whitelistRow struct {
ID uint `json:"id"`
Hash string `json:"hash"`
Note string `json:"note"`
CreatedAt time.Time `json:"createdAt"`
Files int64 `json:"files"`
Addons int64 `json:"addons"`
SamplePath string `json:"samplePath"`
}
func listWhitelist(c *gin.Context) {
limit := queryInt(c, "limit", 50, 1, 500)
offset := queryInt(c, "offset", 0, 0, 1<<30)
q := initializers.DB.Model(&models.WhitelistedHash{})
if s := strings.TrimSpace(strings.ToLower(c.Query("q"))); s != "" {
like := "%" + likeEscape(s) + "%"
q = q.Where(`hash LIKE ? ESCAPE '\' OR LOWER(note) LIKE ? ESCAPE '\'`, likeEscape(s)+"%", like)
}
var total int64
q.Session(&gorm.Session{}).Count(&total)
rows := []whitelistRow{}
if err := q.Select("id, hash, note, created_at").Order("id DESC").Limit(limit).Offset(offset).Scan(&rows).Error; err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
if len(rows) > 0 {
hashes := make([]string, len(rows))
for i, r := range rows {
hashes[i] = r.Hash
}
var usage []struct {
Hash string
Files int64
Addons int64
Sample string
}
initializers.DB.Raw(`SELECT hash, COUNT(*) AS files, COUNT(DISTINCT addon_id) AS addons, MIN(path) AS sample
FROM addon_files WHERE deleted_at IS NULL AND hash IN ? GROUP BY hash`, hashes).Scan(&usage)
byHash := map[string]int{}
for i, u := range usage {
byHash[u.Hash] = i
}
for i := range rows {
if j, ok := byHash[rows[i].Hash]; ok {
rows[i].Files, rows[i].Addons, rows[i].SamplePath = usage[j].Files, usage[j].Addons, usage[j].Sample
}
}
}
c.JSON(http.StatusOK, gin.H{"items": rows, "total": total, "limit": limit, "offset": offset})
}
type skipped struct {
Hash string `json:"hash"`
Reason string `json:"reason"`
}
func addWhitelist(c *gin.Context) {
var body struct {
Hashes []string `json:"hashes"`
Text string `json:"text"`
Note string `json:"note"`
}
if err := c.ShouldBindJSON(&body); err != nil {
fail(c, http.StatusBadRequest, "invalid JSON")
return
}
candidates := append([]string{}, body.Hashes...)
candidates = append(candidates, strings.FieldsFunc(body.Text, func(r rune) bool {
return r == ',' || r == ';' || r == ' ' || r == '\n' || r == '\r' || r == '\t'
})...)
if len(candidates) == 0 {
fail(c, http.StatusBadRequest, "no hashes given")
return
}
if len(candidates) > 5000 {
fail(c, http.StatusBadRequest, "at most 5000 hashes per request")
return
}
note := body.Note
if len(note) > 256 {
note = note[:256]
}
var skips []skipped
seen := map[string]bool{}
valid := []string{}
for _, raw := range candidates {
h := strings.ToLower(strings.TrimSpace(raw))
switch {
case h == "":
continue
case !sha512Re.MatchString(h):
skips = append(skips, skipped{raw, "not a SHA-512 hex hash"})
case seen[h]:
skips = append(skips, skipped{h, "duplicate in request"})
default:
seen[h] = true
valid = append(valid, h)
}
}
added := 0
err := initializers.DB.Transaction(func(tx *gorm.DB) error {
existing := map[string]bool{}
for start := 0; start < len(valid); start += 500 {
end := min(start+500, len(valid))
var found []string
if err := tx.Model(&models.WhitelistedHash{}).Where("hash IN ?", valid[start:end]).Pluck("hash", &found).Error; err != nil {
return err
}
for _, h := range found {
existing[h] = true
}
}
rows := []models.WhitelistedHash{}
for _, h := range valid {
if existing[h] {
skips = append(skips, skipped{h, "already whitelisted"})
continue
}
rows = append(rows, models.WhitelistedHash{Hash: h, Note: note})
}
if len(rows) == 0 {
return nil
}
added = len(rows)
return tx.CreateInBatches(&rows, 200).Error
})
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
if added > 0 {
audit(c, "whitelist_add", map[string]any{"added": added, "note": note})
invalidate("overview")
}
if skips == nil {
skips = []skipped{}
}
c.JSON(http.StatusOK, gin.H{"added": added, "skipped": skips})
}
func deleteWhitelist(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "invalid id")
return
}
var row models.WhitelistedHash
if err := initializers.DB.Unscoped().First(&row, id).Error; err != nil {
fail(c, http.StatusNotFound, "not found")
return
}
// remove every row of this hash, the table has no unique constraint
res := initializers.DB.Unscoped().Where("hash = ?", row.Hash).Delete(&models.WhitelistedHash{})
if res.Error != nil {
fail(c, http.StatusInternalServerError, res.Error.Error())
return
}
audit(c, "whitelist_remove", map[string]any{"hash": row.Hash})
invalidate("overview")
c.JSON(http.StatusOK, gin.H{"deleted": res.RowsAffected})
}
// hashUsage lists the addons that contain a file with the given hash.
func hashUsage(c *gin.Context) {
hash := strings.ToLower(c.Param("hash"))
var total int64
initializers.DB.Model(&models.AddonFile{}).Where("hash = ?", hash).Count(&total)
items := []struct {
AddonID string `json:"addonId"`
Name string `json:"name"`
Path string `json:"path"`
Version string `json:"version"`
}{}
err := initializers.DB.Raw(`SELECT f.addon_id, COALESCE(a.name, '') AS name, f.path, f.version
FROM addon_files f LEFT JOIN addons a ON a.id = f.addon_id
WHERE f.hash = ? AND f.deleted_at IS NULL ORDER BY a.subscriber_count DESC LIMIT 200`, hash).Scan(&items).Error
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
var wl int64
initializers.DB.Model(&models.WhitelistedHash{}).Where("hash = ?", hash).Count(&wl)
c.JSON(http.StatusOK, gin.H{"hash": hash, "whitelisted": wl > 0, "total": total, "items": items})
}