- 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
96 lines
3.0 KiB
Go
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."}`))
|
|
}
|