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
76 lines
2.6 KiB
Go
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 && 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)
|
|
}
|