2026-07-27 08:16:20 +12:00
|
|
|
package api
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"crypto/subtle"
|
|
|
|
|
_ "embed"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"net/http"
|
2026-07-29 15:26:27 +12:00
|
|
|
"strconv"
|
2026-07-27 08:16:20 +12:00
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
2026-07-27 08:34:04 +12:00
|
|
|
"github.com/ponzischeme89/memby/server/internal/appupdate"
|
2026-07-27 08:16:20 +12:00
|
|
|
"github.com/ponzischeme89/memby/server/internal/library"
|
|
|
|
|
"github.com/ponzischeme89/memby/server/internal/store"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
//go:embed admin.html
|
|
|
|
|
var adminPage []byte
|
|
|
|
|
|
2026-07-29 15:26:27 +12:00
|
|
|
const adminCookieName = "memby_admin"
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
// 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.
|
|
|
|
|
func (s *Server) adminRoutes() http.Handler {
|
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
|
|
|
|
|
|
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))
|
2026-07-29 15:26:27 +12:00
|
|
|
mux.Handle("GET /admin/api/events", s.adminAuth(s.handleAdminEvents))
|
2026-07-27 08:16:20 +12:00
|
|
|
mux.Handle("POST /admin/api/sync", s.adminAuth(s.handleAdminSync))
|
2026-07-29 15:26:27 +12:00
|
|
|
mux.Handle("POST /admin/api/for-you", s.adminAuth(s.handleAdminForYou))
|
2026-07-27 08:16:20 +12:00
|
|
|
mux.Handle("POST /admin/api/maintenance", s.adminAuth(s.handleAdminMaintenance))
|
2026-07-27 08:34:04 +12:00
|
|
|
mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy))
|
2026-07-27 21:06:51 +12:00
|
|
|
mux.Handle("POST /admin/api/release", s.releasePublishAuth(s.handleReleasePublish))
|
2026-07-27 08:16:20 +12:00
|
|
|
|
|
|
|
|
return mux
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 15:26:27 +12:00
|
|
|
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.
|
2026-07-27 08:16:20 +12:00
|
|
|
func (s *Server) adminAuth(h http.HandlerFunc) http.Handler {
|
|
|
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if s.cfg.AdminToken == "" {
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
presented := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
|
2026-07-29 15:26:27 +12:00
|
|
|
if presented == "" {
|
|
|
|
|
if cookie, err := r.Cookie(adminCookieName); err == nil {
|
|
|
|
|
presented = cookie.Value
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-27 08:16:20 +12:00
|
|
|
if subtle.ConstantTimeCompare([]byte(presented), []byte(s.cfg.AdminToken)) != 1 {
|
|
|
|
|
writeError(w, http.StatusUnauthorized, "invalid admin token")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
h(w, r)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleAdminPage(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if s.cfg.AdminToken == "" {
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-07-29 15:26:27 +12:00
|
|
|
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,
|
|
|
|
|
})
|
2026-07-27 08:16:20 +12:00
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
|
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
|
|
|
_, _ = w.Write(adminPage)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type adminStatus struct {
|
2026-07-29 15:26:27 +12:00
|
|
|
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"`
|
2026-07-27 08:16:20 +12:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
ctx := r.Context()
|
|
|
|
|
|
|
|
|
|
stats, err := s.store.LibraryStats(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.log.Error("library stats failed", "error", err)
|
|
|
|
|
writeError(w, http.StatusInternalServerError, "could not read library stats")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
runs, err := s.store.RecentSyncRuns(ctx, 10)
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.log.Error("sync history failed", "error", err)
|
|
|
|
|
writeError(w, http.StatusInternalServerError, "could not read sync history")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 15:26:27 +12:00
|
|
|
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()
|
|
|
|
|
}
|
2026-07-27 08:16:20 +12:00
|
|
|
writeJSON(w, http.StatusOK, adminStatus{
|
2026-07-29 15:26:27 +12:00
|
|
|
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,
|
2026-07-27 08:16:20 +12:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 08:34:04 +12:00
|
|
|
type updatePolicyRequest struct {
|
|
|
|
|
Enabled bool `json:"enabled"`
|
|
|
|
|
LatestVersion string `json:"latestVersion"`
|
|
|
|
|
DownloadURL string `json:"downloadUrl"`
|
|
|
|
|
Notes string `json:"notes"`
|
|
|
|
|
// Required makes this release mandatory for everyone below it. The page offers a
|
|
|
|
|
// toggle rather than exposing "minimum version" directly, because "force this
|
|
|
|
|
// update" is the decision an operator actually wants to make.
|
|
|
|
|
Required bool `json:"required"`
|
|
|
|
|
// MinimumVersion is honoured when set explicitly, for staged rollouts where the
|
|
|
|
|
// forced floor is older than the latest build.
|
|
|
|
|
MinimumVersion string `json:"minimumVersion"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleAdminUpdatePolicy(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
var req updatePolicyRequest
|
|
|
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&req); err != nil {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "malformed request body")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
policy := appupdate.Policy{
|
|
|
|
|
Enabled: req.Enabled,
|
|
|
|
|
LatestVersion: strings.TrimSpace(req.LatestVersion),
|
|
|
|
|
MinimumVersion: strings.TrimSpace(req.MinimumVersion),
|
|
|
|
|
DownloadURL: strings.TrimSpace(req.DownloadURL),
|
|
|
|
|
Notes: strings.TrimSpace(req.Notes),
|
|
|
|
|
}
|
|
|
|
|
if req.Required {
|
|
|
|
|
// Forcing means "nobody below the current build", so the floor is the latest.
|
|
|
|
|
policy.MinimumVersion = policy.LatestVersion
|
|
|
|
|
} else if policy.MinimumVersion == policy.LatestVersion {
|
|
|
|
|
// Un-ticking the box must actually release the floor.
|
|
|
|
|
policy.MinimumVersion = ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if policy.Enabled && policy.LatestVersion == "" {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "set the latest version before enabling update prompts")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if policy.Enabled && policy.DownloadURL == "" {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "set the APK download URL before enabling update prompts")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := s.store.SetUpdatePolicy(r.Context(), policy); err != nil {
|
|
|
|
|
s.log.Error("update policy write failed", "error", err)
|
|
|
|
|
writeError(w, http.StatusInternalServerError, "could not save the update policy")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := s.LoadUpdatePolicy(r.Context()); err != nil {
|
|
|
|
|
s.log.Warn("update policy reload failed", "error", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
s.log.Info("update policy changed",
|
|
|
|
|
"enabled", policy.Enabled, "latest", policy.LatestVersion, "minimum", policy.MinimumVersion)
|
|
|
|
|
writeJSON(w, http.StatusOK, s.updatePolicy.get())
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
type syncRequest struct {
|
|
|
|
|
Kind string `json:"kind"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleAdminSync starts an import in the background and returns immediately. A full
|
|
|
|
|
// import of a large library takes minutes; the page polls /admin/api/status for progress.
|
|
|
|
|
func (s *Server) handleAdminSync(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
var req syncRequest
|
|
|
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "malformed request body")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if req.Kind != "full" && req.Kind != "incremental" {
|
|
|
|
|
writeError(w, http.StatusBadRequest, `kind must be "full" or "incremental"`)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if s.syncer.Running() {
|
|
|
|
|
writeError(w, http.StatusConflict, "a sync is already running")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
go func() {
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.WithoutCancel(context.Background()), s.cfg.SyncTimeout)
|
|
|
|
|
defer cancel()
|
|
|
|
|
if _, err := s.syncer.Sync(ctx, req.Kind, "manual"); err != nil {
|
|
|
|
|
s.log.Error("manual sync failed", "kind", req.Kind, "error", err)
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started", "kind": req.Kind})
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 15:26:27 +12:00
|
|
|
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,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
type maintenanceRequest struct {
|
|
|
|
|
Enabled bool `json:"enabled"`
|
|
|
|
|
Message string `json:"message"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleAdminMaintenance(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
var req maintenanceRequest
|
|
|
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "malformed request body")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
state := store.Maintenance{Enabled: req.Enabled, Message: strings.TrimSpace(req.Message)}
|
|
|
|
|
if err := s.store.SetMaintenance(r.Context(), state); err != nil {
|
|
|
|
|
s.log.Error("maintenance write failed", "error", err)
|
|
|
|
|
writeError(w, http.StatusInternalServerError, "could not update maintenance mode")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := s.LoadMaintenance(r.Context()); err != nil {
|
|
|
|
|
s.log.Warn("maintenance reload failed", "error", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
s.log.Warn("maintenance mode changed", "enabled", state.Enabled, "message", state.Message)
|
|
|
|
|
writeJSON(w, http.StatusOK, s.maintenance.get())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleAdminAnalytics(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
days := queryInt(r, "days", 7, 90)
|
|
|
|
|
since := time.Now().UTC().AddDate(0, 0, -days)
|
|
|
|
|
|
|
|
|
|
stats, err := s.store.RowStats(r.Context(), since)
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.log.Error("row stats failed", "error", err)
|
|
|
|
|
writeError(w, http.StatusInternalServerError, "could not read analytics")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"days": days, "rows": stats})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// syncerHandle is the slice of the syncer the API needs, so api does not depend on the
|
|
|
|
|
// concrete type for testing.
|
|
|
|
|
type syncerHandle interface {
|
|
|
|
|
Running() bool
|
|
|
|
|
Sync(ctx context.Context, kind, trigger string) (library.Result, error)
|
|
|
|
|
}
|