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

76 lines
2.6 KiB
Go

package web
import (
"io/fs"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
const notBuiltHTML = `<!doctype html><html><head><meta charset="utf-8"><title>Admin UI not built</title>
<style>body{font-family:system-ui,sans-serif;background:#0b0d10;color:#c9d1d9;display:grid;place-items:center;height:100vh;margin:0}
code{background:#161b22;padding:2px 6px;border-radius:4px}</style></head>
<body><div><h1>Admin UI not built</h1><p>Run <code>npm --prefix web ci &amp;&amp; npm --prefix web run build</code> and rebuild the binary.</p>
<p>The JSON API is available under <code>/admin/api</code>.</p></div></body></html>`
const csp = "default-src 'self'; img-src 'self' https: data:; style-src 'self' 'unsafe-inline'; " +
"script-src 'self'; connect-src 'self'; font-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
func securityHeaders(c *gin.Context) {
c.Header("Content-Security-Policy", csp)
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Referrer-Policy", "no-referrer")
c.Header("X-Frame-Options", "DENY")
}
// Handler returns the SPA handler: real files are served from the embedded
// build, every other path gets index.html so client-side routes deep-link.
func Handler() gin.HandlerFunc {
sub, err := fs.Sub(dist, "dist")
if err != nil {
panic(err)
}
index, err := fs.ReadFile(sub, "index.html")
if err != nil {
index = []byte(notBuiltHTML)
}
files := http.FileServer(http.FS(sub))
return func(c *gin.Context) {
securityHeaders(c)
p := strings.TrimPrefix(c.Param("filepath"), "/")
if p != "" && p != "index.html" {
if st, err := fs.Stat(sub, p); err == nil && !st.IsDir() {
if strings.HasPrefix(p, "assets/") {
c.Header("Cache-Control", "public, max-age=31536000, immutable")
} else {
c.Header("Cache-Control", "no-cache")
}
req := c.Request.Clone(c.Request.Context())
req.URL.Path = "/" + p
files.ServeHTTP(c.Writer, req)
return
}
// a missing hashed asset must not turn into an HTML page
if strings.HasPrefix(p, "assets/") {
c.Status(http.StatusNotFound)
return
}
}
// serve the bytes directly; FileServer would redirect /index.html to ./
c.Header("Cache-Control", "no-cache")
c.Data(http.StatusOK, "text/html; charset=utf-8", index)
}
}
// Register mounts the dashboard at /admin/ui and redirects /admin there.
func Register(r *gin.Engine) {
h := Handler()
r.GET("/admin/ui/*filepath", h)
r.HEAD("/admin/ui/*filepath", h)
redirect := func(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/ui/") }
r.GET("/admin", redirect)
r.GET("/admin/ui", redirect)
}