Big changes

This commit is contained in:
ponzischeme89
2026-07-29 15:26:27 +12:00
parent 8d6cf2f5a1
commit 70914400b4
62 changed files with 7501 additions and 744 deletions
+107 -15
View File
@@ -6,6 +6,7 @@ import (
_ "embed"
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
@@ -17,6 +18,8 @@ import (
//go:embed admin.html
var adminPage []byte
const adminCookieName = "memby_admin"
// adminRoutes is the operator interface: library imports, the maintenance switch, and
// row engagement. Disabled entirely when MEMBY_ADMIN_TOKEN is unset, so it cannot be
// left exposed by accident.
@@ -26,7 +29,9 @@ func (s *Server) adminRoutes() http.Handler {
mux.HandleFunc("GET /admin/{$}", s.handleAdminPage)
mux.Handle("GET /admin/api/status", s.adminAuth(s.handleAdminStatus))
mux.Handle("GET /admin/api/analytics", s.adminAuth(s.handleAdminAnalytics))
mux.Handle("GET /admin/api/events", s.adminAuth(s.handleAdminEvents))
mux.Handle("POST /admin/api/sync", s.adminAuth(s.handleAdminSync))
mux.Handle("POST /admin/api/for-you", s.adminAuth(s.handleAdminForYou))
mux.Handle("POST /admin/api/maintenance", s.adminAuth(s.handleAdminMaintenance))
mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy))
mux.Handle("POST /admin/api/release", s.releasePublishAuth(s.handleReleasePublish))
@@ -34,7 +39,22 @@ func (s *Server) adminRoutes() http.Handler {
return mux
}
// adminAuth guards the admin API with a shared token, compared in constant time.
func (s *Server) handleAdminEvents(w http.ResponseWriter, r *http.Request) {
if s.events == nil {
writeJSON(w, http.StatusOK, map[string]any{
"events": []any{}, "next": 0, "oldest": 0, "latest": 0,
"dropped": 0, "hasMore": false,
})
return
}
after, _ := strconv.ParseInt(r.URL.Query().Get("after"), 10, 64)
limit := queryInt(r, "limit", 500, 1000)
writeJSON(w, http.StatusOK, s.events.Events(after, limit))
}
// adminAuth guards the admin API with the shared token. Browser requests use the
// persistent HttpOnly cookie established by the admin page; automation can continue to
// send the token as a Bearer header.
func (s *Server) adminAuth(h http.HandlerFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.cfg.AdminToken == "" {
@@ -42,6 +62,11 @@ func (s *Server) adminAuth(h http.HandlerFunc) http.Handler {
return
}
presented := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
if presented == "" {
if cookie, err := r.Cookie(adminCookieName); err == nil {
presented = cookie.Value
}
}
if subtle.ConstantTimeCompare([]byte(presented), []byte(s.cfg.AdminToken)) != 1 {
writeError(w, http.StatusUnauthorized, "invalid admin token")
return
@@ -55,20 +80,30 @@ func (s *Server) handleAdminPage(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
secure := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
http.SetCookie(w, &http.Cookie{
Name: adminCookieName,
Value: s.cfg.AdminToken,
Path: "/admin",
MaxAge: 10 * 365 * 24 * 60 * 60,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
})
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// The page holds no secrets; the token is entered by the operator and kept in the
// browser's local storage.
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(adminPage)
}
type adminStatus struct {
Maintenance store.Maintenance `json:"maintenance"`
UpdatePolicy appupdate.Policy `json:"updatePolicy"`
Library store.LibraryStats `json:"library"`
SyncRunning bool `json:"syncRunning"`
Runs []store.SyncRun `json:"runs"`
SyncEvery string `json:"syncEvery"`
Maintenance store.Maintenance `json:"maintenance"`
UpdatePolicy appupdate.Policy `json:"updatePolicy"`
Library store.LibraryStats `json:"library"`
SyncRunning bool `json:"syncRunning"`
Runs []store.SyncRun `json:"runs"`
SyncEvery string `json:"syncEvery"`
ForYou store.ForYouStats `json:"forYou"`
ForYouRunning bool `json:"forYouRunning"`
}
func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
@@ -87,13 +122,24 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
return
}
var forYouStats store.ForYouStats
forYouRunning := false
if s.forYou != nil {
forYouStats, err = s.forYou.Stats(ctx)
if err != nil {
s.log.Warn("For You stats failed", "error", err)
}
forYouRunning = s.forYou.Running()
}
writeJSON(w, http.StatusOK, adminStatus{
Maintenance: s.maintenance.get(),
UpdatePolicy: s.updatePolicy.get(),
Library: stats,
SyncRunning: s.syncer.Running(),
Runs: runs,
SyncEvery: s.cfg.SyncInterval.String(),
Maintenance: s.maintenance.get(),
UpdatePolicy: s.updatePolicy.get(),
Library: stats,
SyncRunning: s.syncer.Running(),
Runs: runs,
SyncEvery: s.cfg.SyncInterval.String(),
ForYou: forYouStats,
ForYouRunning: forYouRunning,
})
}
@@ -188,6 +234,52 @@ func (s *Server) handleAdminSync(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started", "kind": req.Kind})
}
type forYouAdminRequest struct {
Action string `json:"action"`
}
// handleAdminForYou provides the recovery controls needed for an idempotent backfill:
// import all Tracearr sessions again, or rebuild every active user's derived pool.
func (s *Server) handleAdminForYou(w http.ResponseWriter, r *http.Request) {
if s.forYou == nil {
writeError(w, http.StatusServiceUnavailable, "Tracearr is not configured")
return
}
var req forYouAdminRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
switch req.Action {
case "incremental-import", "full-import", "rebuild-all":
default:
writeError(w, http.StatusBadRequest,
`action must be "incremental-import", "full-import", or "rebuild-all"`)
return
}
if s.forYou.Running() {
writeError(w, http.StatusConflict, "For You maintenance is already running")
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), s.cfg.SyncTimeout)
defer cancel()
if req.Action != "rebuild-all" {
_, err := s.forYou.Import(ctx, req.Action == "full-import")
if err != nil {
s.log.Error("manual Tracearr import failed", "action", req.Action, "error", err)
return
}
}
if err := s.forYou.RebuildAll(ctx, true); err != nil {
s.log.Error("manual For You rebuild failed", "action", req.Action, "error", err)
}
}()
writeJSON(w, http.StatusAccepted, map[string]string{
"status": "started", "action": req.Action,
})
}
type maintenanceRequest struct {
Enabled bool `json:"enabled"`
Message string `json:"message"`