Files
silo-server/internal/api/middleware/demo_guard.go
T
Quick104andClaude Fable 5 dee46f9398 fix(diagnostics): address round-5 review findings on PR #445
- service: reject supplied child-profile attribution with a distinct
  ErrChildProfileForbidden (403 child_profile_forbidden) instead of
  silently dropping it as if the profile were not found; a profile that
  is simply not the user's still drops attribution unchanged
- repo: add a manifest-free list projection (reportListSelectSQL /
  scanReportSummary) for admin list and retention/stale cleanup queries
  so they no longer drag the full manifest JSONB per row; keep the full
  projection for GetByID/DeleteByID and mark Manifest omitempty
- cleanup: delete/mark the DB row before the blob in retention and stale
  loops so a mid-run DB failure can't leave a ready report pointing at a
  missing bundle; blob-delete failures are logged with bucket/keys for
  orphan cleanup to reap rather than aborting the run (shared helper with
  the admin DeleteReport path)
- admin: reject diagnostics settings where max_bytes_per_user would fall
  below max_bundle_bytes (and the reciprocal), which would make every
  max-size upload fail quota
- router/demo: route POST /diagnostics/reports through DemoGuard and block
  the reports prefix in demo mode while keeping GET /diagnostics/status
  available

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012e3QjbPo96ed9Mn2qRiUkh
2026-07-21 13:36:08 -04:00

96 lines
3.0 KiB
Go

package middleware
import (
"context"
"net/http"
"strings"
)
// DemoSettingsReader is the subset of ServerSettingsStore needed by DemoGuard.
type DemoSettingsReader interface {
Get(ctx context.Context, key string) (string, error)
}
// DemoGuard blocks destructive mutations for non-admin users when demo mode
// is enabled (server setting "demo.enabled" = "true").
//
// Allowed: browsing, playback, favorites, watchlist, ratings, collections,
// profiles, playback progress, watched state.
//
// Blocked: API key management, downloads, history imports, subtitle downloads,
// diagnostics report uploads/deletes.
type DemoGuard struct {
settings DemoSettingsReader
}
// NewDemoGuard creates a new DemoGuard.
func NewDemoGuard(settings DemoSettingsReader) *DemoGuard {
return &DemoGuard{settings: settings}
}
// blockedRoute defines a method + path prefix combination that is blocked in demo mode.
type blockedRoute struct {
methods []string
prefix string
}
// demoBlockedRoutes lists the route patterns blocked for non-admin users in demo mode.
var demoBlockedRoutes = []blockedRoute{
{methods: []string{"POST", "DELETE"}, prefix: "/api/v1/api-keys"},
{methods: []string{"POST", "DELETE"}, prefix: "/api/v1/downloads"},
{methods: []string{"POST"}, prefix: "/api/v1/history-imports"},
{methods: []string{"POST"}, prefix: "/api/v1/subtitles/download"},
{methods: []string{"POST"}, prefix: "/api/v1/subtitles/upload"},
{methods: []string{"DELETE"}, prefix: "/api/v1/subtitles/"},
// Diagnostics report uploads/deletes write DB rows and private-bucket blobs;
// GET /api/v1/diagnostics/status is unaffected (GETs always pass).
{methods: []string{"POST", "DELETE"}, prefix: "/api/v1/diagnostics/reports"},
}
// Guard is an HTTP middleware that enforces demo mode restrictions.
func (dg *DemoGuard) Guard(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Read-only methods always pass.
if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions {
next.ServeHTTP(w, r)
return
}
// Check demo mode (fast path: setting not set means disabled).
enabled, _ := dg.settings.Get(r.Context(), "demo.enabled")
if enabled != "true" {
next.ServeHTTP(w, r)
return
}
// Admins bypass all demo restrictions.
claims := GetClaims(r.Context())
if claims != nil && claims.Role == "admin" {
next.ServeHTTP(w, r)
return
}
// Check if this request matches a blocked route.
path := r.URL.Path
for _, br := range demoBlockedRoutes {
if !strings.HasPrefix(path, br.prefix) {
continue
}
for _, m := range br.methods {
if r.Method == m {
writeDemoBlocked(w)
return
}
}
}
next.ServeHTTP(w, r)
})
}
func writeDemoBlocked(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"error":"demo_restricted","message":"This action is not available in demo mode."}`))
}