Files
silo-server/internal/api/handlers/admin.go
T
QuickandClaude Opus 4.7 7e4cacd151 feat(auth): make usernames and emails case-insensitive
Login identifiers were compared case-sensitively, so "John" and "john"
were distinct accounts and a user could not log in unless they matched the
exact casing used at registration.

Convert users.username and users.email to the citext type (migration 165).
citext compares case-insensitively while preserving the originally stored
casing for display, so the existing unique constraints become
case-insensitive and `WHERE username = $1` / `email = $1` lookups match
regardless of case with no change to the query code itself.

Also add auth.NormalizeUsername/NormalizeEmail (trim-only; case preserved),
applied at the repository chokepoints (Create, Update, GetByUsername,
GetByEmail) and before validation in the create paths, so surrounding
whitespace no longer defeats matching or creates lookalike accounts.

Verified non-destructively against the dev DB: mixed-case lookups resolve
to the same row, case-variant inserts are rejected by the unique
constraint, and the down migration cleanly reverts to text.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:44:51 -04:00

1964 lines
64 KiB
Go

package handlers
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/sync/errgroup"
"github.com/Silo-Server/silo-server/internal/access"
"github.com/Silo-Server/silo-server/internal/adminjob"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/cache"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/clientip"
"github.com/Silo-Server/silo-server/internal/markers"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/notifications"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// AdminMetadataRefresher can refresh metadata for individual items.
type AdminMetadataRefresher interface {
RefreshItem(ctx context.Context, contentID string) error
}
// UserRepository defines the operations the AdminHandler needs on users.
type UserRepository interface {
List(ctx context.Context) ([]*models.User, error)
Create(ctx context.Context, input models.CreateUserInput) (*models.User, error)
Update(ctx context.Context, id int, input models.UpdateUserInput) error
Delete(ctx context.Context, id int) error
GetByID(ctx context.Context, id int) (*models.User, error)
}
// ServerSettingsStore provides access to server-wide admin settings.
type ServerSettingsStore interface {
Get(ctx context.Context, key string) (string, error)
Set(ctx context.Context, key, value string) error
GetAll(ctx context.Context) (map[string]string, error)
}
type AdminJobCreator interface {
Create(ctx context.Context, input adminjob.CreateJobInput) (*models.AdminJob, error)
CreateLibraryRefresh(
ctx context.Context,
createdByUserID int,
req adminjob.LibraryRefreshRequest,
message string,
) (*models.AdminJob, error)
}
type ItemRefreshScopeResolver interface {
Resolve(ctx context.Context, contentID string) (*adminjob.ItemRefreshRequest, error)
ResolveWithMode(ctx context.Context, contentID string, mode adminjob.ItemRefreshMode) (*adminjob.ItemRefreshRequest, error)
}
type ImpersonationService interface {
StartImpersonation(ctx context.Context, adminUserID, targetUserID int, deviceName, ip string) (*auth.TokenPair, *models.User, *models.User, error)
}
// AdminHandler handles admin-only HTTP endpoints for user management,
// session listing, unmatched files, and system stats.
type AdminHandler struct {
userRepo UserRepository
pool *pgxpool.Pool
SessionsLoader *PlaybackSessionsLoader
storeProv userstore.UserStoreProvider
accountProvisioner *auth.AccountProvisioner
DetailSvc *catalog.DetailService
StatsSource AdminStatsSource
EventBus cache.EventBus
SettingsRepo ServerSettingsStore
JobRepo AdminJobCreator
ItemRefreshResolver ItemRefreshScopeResolver
ImpersonationService ImpersonationService
RealtimeHub *notifications.Hub
BootstrapSensitiveConfigured map[string]bool
BootstrapSensitiveValues map[string]string
OnUserSessionsRevoked func(ctx context.Context, userID int)
OnServerSettingUpdated func(ctx context.Context, key, value string)
}
// NewAdminHandler creates a new AdminHandler backed by the given
// user repository and database pool.
func NewAdminHandler(
userRepo UserRepository,
pool *pgxpool.Pool,
storeProv userstore.UserStoreProvider,
) *AdminHandler {
return &AdminHandler{
userRepo: userRepo,
pool: pool,
storeProv: storeProv,
accountProvisioner: auth.NewAccountProvisioner(userRepo, storeProv),
}
}
// --- Request/Response types ---
// createUserRequest represents the JSON body for POST /admin/users.
type createUserRequest struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
Role string `json:"role"`
Permissions []string `json:"permissions"`
CreateDefaultProfile bool `json:"create_default_profile"`
DefaultProfileName string `json:"default_profile_name,omitempty"`
LibraryIDs []int `json:"library_ids"`
MaxPlaybackQuality string `json:"max_playback_quality"`
MaxStreams *int `json:"max_streams,omitempty"`
MaxTranscodes *int `json:"max_transcodes,omitempty"`
MaxProfiles *int `json:"max_profiles,omitempty"`
DownloadAllowed *bool `json:"download_allowed,omitempty"`
DownloadTranscodeAllowed *bool `json:"download_transcode_allowed,omitempty"`
}
type updateLibraryIDsField struct {
Set bool
Value []int
}
func (f *updateLibraryIDsField) UnmarshalJSON(data []byte) error {
f.Set = true
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
f.Value = nil
return nil
}
return json.Unmarshal(data, &f.Value)
}
func (f updateLibraryIDsField) Ptr() *[]int {
if !f.Set {
return nil
}
value := append([]int(nil), f.Value...)
return &value
}
type updateStringSliceField struct {
Set bool
Value []string
}
func (f *updateStringSliceField) UnmarshalJSON(data []byte) error {
f.Set = true
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
f.Value = []string{}
return nil
}
return json.Unmarshal(data, &f.Value)
}
func (f updateStringSliceField) Ptr() *[]string {
if !f.Set {
return nil
}
value := append([]string(nil), f.Value...)
return &value
}
// updateUserRequest represents the JSON body for PUT /admin/users/{id}.
type updateUserRequest struct {
Username *string `json:"username,omitempty"`
Email *string `json:"email,omitempty"`
Password *string `json:"password,omitempty"`
Role *string `json:"role,omitempty"`
Permissions updateStringSliceField `json:"permissions,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
LibraryIDs updateLibraryIDsField `json:"library_ids,omitempty"`
MaxPlaybackQuality *string `json:"max_playback_quality,omitempty"`
MaxStreams *int `json:"max_streams,omitempty"`
MaxTranscodes *int `json:"max_transcodes,omitempty"`
MaxProfiles *int `json:"max_profiles,omitempty"`
DownloadAllowed *bool `json:"download_allowed,omitempty"`
DownloadTranscodeAllowed *bool `json:"download_transcode_allowed,omitempty"`
}
// adminUserResponse represents a user in admin JSON responses.
type adminUserResponse struct {
ID int `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Role string `json:"role"`
Permissions []string `json:"permissions"`
Enabled bool `json:"enabled"`
LibraryIDs []int `json:"library_ids"`
MaxPlaybackQuality string `json:"max_playback_quality"`
MaxStreams int `json:"max_streams"`
MaxTranscodes int `json:"max_transcodes"`
MaxProfiles int `json:"max_profiles"`
DownloadAllowed bool `json:"download_allowed"`
DownloadTranscodeAllowed bool `json:"download_transcode_allowed"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LastActiveAt *time.Time `json:"last_active_at,omitempty"`
}
type adminPlaybackHistoryRow struct {
SessionID string `json:"session_id"`
UserID int `json:"user_id"`
Username string `json:"username"`
ProfileID string `json:"profile_id"`
ProfileName string `json:"profile_name"`
MediaItemID string `json:"media_item_id"`
MediaFileID int `json:"media_file_id"`
MediaTitle string `json:"media_title"`
MediaType string `json:"media_type"`
PlayMethod string `json:"play_method"`
StartedAt time.Time `json:"started_at"`
EndedAt time.Time `json:"ended_at"`
WatchedSeconds float64 `json:"watched_seconds"`
DurationSeconds *float64 `json:"duration_seconds"`
Completed bool `json:"completed"`
}
type adminUserProfileRow struct {
ID string `json:"id"`
Name string `json:"name"`
}
// unmatchedFileRow represents a media file with no content_id.
type unmatchedFileRow struct {
ID int `json:"id"`
MediaFolderID int `json:"media_folder_id"`
FilePath string `json:"file_path"`
FileSize int64 `json:"file_size"`
Container string `json:"container"`
}
// --- Helper ---
// presignPosterURL generates a presigned poster URL for admin sessions.
// Returns empty string if no detail service is configured or the path is empty.
func (h *AdminHandler) presignPosterURL(r *http.Request, path string) string {
if h.DetailSvc != nil {
return h.DetailSvc.PresignURL(r.Context(), cardThumbnailPath(path), "card")
}
return ""
}
// toAdminUserResponse converts a User model to an admin API response.
func toAdminUserResponse(u *models.User) adminUserResponse {
return adminUserResponse{
ID: u.ID,
Username: u.Username,
Email: u.Email,
Role: u.Role,
Permissions: append([]string{}, u.Permissions...),
Enabled: u.Enabled,
LibraryIDs: append([]int(nil), u.LibraryIDs...),
MaxPlaybackQuality: access.NormalizePlaybackQuality(u.MaxPlaybackQuality),
MaxStreams: u.MaxStreams,
MaxTranscodes: u.MaxTranscodes,
MaxProfiles: u.MaxProfiles,
DownloadAllowed: u.DownloadAllowed,
DownloadTranscodeAllowed: u.DownloadTranscodeAllowed,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
}
}
func (h *AdminHandler) loadUserLastActiveAt(ctx context.Context, userIDs []int) (map[int]time.Time, error) {
lastActive := make(map[int]time.Time)
if h == nil || h.pool == nil || len(userIDs) == 0 {
return lastActive, nil
}
rows, err := h.pool.Query(ctx, `
SELECT user_id, MAX("timestamp") AS last_active_at
FROM activity_log
WHERE user_id = ANY($1::int[])
GROUP BY user_id`, userIDs)
if err != nil {
return lastActive, fmt.Errorf("loading user last activity: %w", err)
}
defer rows.Close()
for rows.Next() {
var userID int
var timestamp time.Time
if err := rows.Scan(&userID, &timestamp); err != nil {
return lastActive, fmt.Errorf("scanning user last activity: %w", err)
}
lastActive[userID] = timestamp
}
if err := rows.Err(); err != nil {
return lastActive, fmt.Errorf("iterating user last activity: %w", err)
}
return lastActive, nil
}
func applyLastActiveAt(resp *adminUserResponse, lastActive map[int]time.Time) {
if resp == nil {
return
}
if timestamp, ok := lastActive[resp.ID]; ok {
resp.LastActiveAt = &timestamp
}
}
// --- Handler methods ---
// HandleListUsers handles GET /admin/users.
func (h *AdminHandler) HandleListUsers(w http.ResponseWriter, r *http.Request) {
users, err := h.userRepo.List(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list users")
return
}
resp := make([]adminUserResponse, 0, len(users))
userIDs := make([]int, 0, len(users))
for _, u := range users {
userIDs = append(userIDs, u.ID)
resp = append(resp, toAdminUserResponse(u))
}
lastActive, err := h.loadUserLastActiveAt(r.Context(), userIDs)
if err != nil {
slog.Warn("failed to load admin user last activity", "error", err)
}
for i := range resp {
applyLastActiveAt(&resp[i], lastActive)
}
writeJSON(w, http.StatusOK, resp)
}
// HandleGetUser handles GET /admin/users/{id}.
func (h *AdminHandler) HandleGetUser(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.Atoi(idStr)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid user ID")
return
}
user, err := h.userRepo.GetByID(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "User not found")
return
}
resp := toAdminUserResponse(user)
lastActive, err := h.loadUserLastActiveAt(r.Context(), []int{user.ID})
if err != nil {
slog.Warn("failed to load admin user last activity", "user_id", user.ID, "error", err)
}
applyLastActiveAt(&resp, lastActive)
writeJSON(w, http.StatusOK, resp)
}
// HandleCreateUser handles POST /admin/users.
func (h *AdminHandler) HandleCreateUser(w http.ResponseWriter, r *http.Request) {
var req createUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
req.Username = auth.NormalizeUsername(req.Username)
req.Email = auth.NormalizeEmail(req.Email)
if req.Username == "" || req.Email == "" || req.Password == "" || req.Role == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Username, email, password, and role are required")
return
}
maxPlaybackQuality, ok := access.ParsePlaybackQualityPreset(req.MaxPlaybackQuality)
if !ok {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid max_playback_quality")
return
}
if req.MaxProfiles != nil && *req.MaxProfiles < 1 {
writeError(w, http.StatusBadRequest, "bad_request", "max_profiles must be at least 1")
return
}
permissions, err := auth.NormalizePermissions(req.Permissions)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
user, err := h.accountProvisioner.CreateAccount(r.Context(), auth.CreateAccountInput{
User: models.CreateUserInput{
Username: req.Username,
Email: req.Email,
Password: req.Password,
Role: req.Role,
Permissions: permissions,
LibraryIDs: req.LibraryIDs,
MaxPlaybackQuality: maxPlaybackQuality,
MaxStreams: req.MaxStreams,
MaxTranscodes: req.MaxTranscodes,
MaxProfiles: req.MaxProfiles,
DownloadAllowed: req.DownloadAllowed,
DownloadTranscodeAllowed: req.DownloadTranscodeAllowed,
},
DefaultProfile: auth.DefaultProfileOptions{
Enabled: req.CreateDefaultProfile,
Name: req.DefaultProfileName,
},
})
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create user")
return
}
h.invalidateStats(r.Context(), cache.ChannelAdmin, cache.EventAdminStatsInvalidated, strconv.Itoa(user.ID))
writeJSON(w, http.StatusCreated, toAdminUserResponse(user))
}
// HandleUpdateUser handles PUT /admin/users/{id}.
func (h *AdminHandler) HandleUpdateUser(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.Atoi(idStr)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid user ID")
return
}
var req updateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
var maxPlaybackQuality *string
if req.MaxPlaybackQuality != nil {
normalized, ok := access.ParsePlaybackQualityPreset(*req.MaxPlaybackQuality)
if !ok {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid max_playback_quality")
return
}
maxPlaybackQuality = &normalized
}
if req.MaxProfiles != nil && *req.MaxProfiles < 1 {
writeError(w, http.StatusBadRequest, "bad_request", "max_profiles must be at least 1")
return
}
var permissions *[]string
if req.Permissions.Set {
normalized, err := auth.NormalizePermissions(req.Permissions.Value)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
permissions = &normalized
}
err = h.userRepo.Update(r.Context(), id, models.UpdateUserInput{
Username: req.Username,
Email: req.Email,
Password: req.Password,
Role: req.Role,
Permissions: permissions,
Enabled: req.Enabled,
LibraryIDs: req.LibraryIDs.Ptr(),
MaxPlaybackQuality: maxPlaybackQuality,
MaxStreams: req.MaxStreams,
MaxTranscodes: req.MaxTranscodes,
MaxProfiles: req.MaxProfiles,
DownloadAllowed: req.DownloadAllowed,
DownloadTranscodeAllowed: req.DownloadTranscodeAllowed,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update user")
return
}
if updateRequiresSessionRevocation(req) {
if err := h.revokeUserSessions(r.Context(), id); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to revoke updated user sessions")
return
}
}
user, err := h.userRepo.GetByID(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch updated user")
return
}
writeJSON(w, http.StatusOK, toAdminUserResponse(user))
}
// HandleDeleteUser handles DELETE /admin/users/{id}.
func (h *AdminHandler) HandleDeleteUser(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.Atoi(idStr)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid user ID")
return
}
err = h.userRepo.Delete(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete user")
return
}
if err := h.revokeUserSessions(r.Context(), id); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to revoke deleted user sessions")
return
}
h.invalidateStats(r.Context(), cache.ChannelAdmin, cache.EventAdminStatsInvalidated, strconv.Itoa(id))
w.WriteHeader(http.StatusNoContent)
}
// HandleImpersonateUser handles POST /admin/users/{id}/impersonate.
func (h *AdminHandler) HandleImpersonateUser(w http.ResponseWriter, r *http.Request) {
if h.ImpersonationService == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Impersonation service unavailable")
return
}
claims := apimw.GetClaims(r.Context())
if claims == nil {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return
}
if claims.TokenType == auth.TokenTypeAPIKey || claims.SessionID == "" {
writeError(w, http.StatusForbidden, "impersonation_not_allowed", "Impersonation is not allowed")
return
}
targetID, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid user ID")
return
}
pair, impersonator, effectiveUser, err := h.ImpersonationService.StartImpersonation(
auth.WithClaims(r.Context(), claims),
claims.UserID,
targetID,
r.UserAgent(),
clientip.FromContext(r.Context()),
)
if err != nil {
if auth.IsNotFound(err) {
writeError(w, http.StatusNotFound, "not_found", "User not found")
return
}
if errors.Is(err, auth.ErrAlreadyImpersonating) {
writeError(w, http.StatusConflict, "already_impersonating", "An impersonation session is already active")
return
}
if errors.Is(err, auth.ErrImpersonationNotAllowed) {
writeError(w, http.StatusForbidden, "impersonation_not_allowed", "Impersonation is not allowed")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to start impersonation")
return
}
writeJSON(w, http.StatusOK, buildLoginResponse(pair, effectiveUser, impersonator))
}
// HandleListSessions handles GET /admin/sessions.
// Lists active playback sessions enriched with user and media information.
func (h *AdminHandler) HandleListSessions(w http.ResponseWriter, r *http.Request) {
sessions, err := h.loadPlaybackSessions(r.Context(), r)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list sessions")
return
}
writeJSON(w, http.StatusOK, sessions)
}
func (h *AdminHandler) loadPlaybackSessions(ctx context.Context, r *http.Request) ([]playbackSessionRow, error) {
loader, err := resolvePlaybackSessionsLoader(h.SessionsLoader, h.pool, h.storeProv, h.DetailSvc)
if err != nil {
return nil, err
}
return loader.Load(ctx, r, PlaybackSessionsQuery{})
}
// HandleListPlaybackHistory handles GET /admin/playback-history.
func (h *AdminHandler) HandleListPlaybackHistory(w http.ResponseWriter, r *http.Request) {
if h.pool == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Database not configured")
return
}
limit, offset := parsePagination(r)
q := r.URL.Query()
var (
args []any
conditions []string
argIndex = 1
)
if userIDStr := strings.TrimSpace(q.Get("user_id")); userIDStr != "" {
userID, err := strconv.Atoi(userIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid user_id")
return
}
conditions = append(conditions, "h.user_id = $"+strconv.Itoa(argIndex))
args = append(args, userID)
argIndex++
}
if profileID := strings.TrimSpace(q.Get("profile_id")); profileID != "" {
conditions = append(conditions, "h.profile_id = $"+strconv.Itoa(argIndex))
args = append(args, profileID)
argIndex++
}
if mediaItemID := strings.TrimSpace(q.Get("media_item_id")); mediaItemID != "" {
conditions = append(conditions, "h.media_item_id = $"+strconv.Itoa(argIndex))
args = append(args, mediaItemID)
argIndex++
}
switch strings.TrimSpace(q.Get("completed")) {
case "", "all":
case "true":
conditions = append(conditions, "h.completed = TRUE")
case "false":
conditions = append(conditions, "h.completed = FALSE")
default:
writeError(w, http.StatusBadRequest, "bad_request", "Invalid completed filter")
return
}
query := `
SELECT
h.session_id,
h.user_id,
COALESCE(u.username, ''),
h.profile_id,
COALESCE(NULLIF(h.profile_name, ''), h.profile_id),
h.media_item_id,
h.media_file_id,
COALESCE(ep.title, mi.title, ''),
COALESCE(CASE WHEN ep.content_id IS NOT NULL THEN 'episode' ELSE mi.type END, ''),
h.play_method,
h.started_at,
h.ended_at,
h.watched_seconds,
h.duration_seconds,
h.completed
FROM playback_history_admin h
LEFT JOIN users u ON u.id = h.user_id
LEFT JOIN media_items mi ON mi.content_id = h.media_item_id
LEFT JOIN episodes ep ON ep.content_id = h.media_item_id
`
if len(conditions) > 0 {
query += " WHERE " + strings.Join(conditions, " AND ")
}
query += " ORDER BY h.ended_at DESC"
query += " LIMIT $" + strconv.Itoa(argIndex)
args = append(args, limit)
argIndex++
query += " OFFSET $" + strconv.Itoa(argIndex)
args = append(args, offset)
rows, err := h.pool.Query(r.Context(), query, args...)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list playback history")
return
}
defer rows.Close()
history := make([]adminPlaybackHistoryRow, 0)
for rows.Next() {
var row adminPlaybackHistoryRow
if err := rows.Scan(
&row.SessionID,
&row.UserID,
&row.Username,
&row.ProfileID,
&row.ProfileName,
&row.MediaItemID,
&row.MediaFileID,
&row.MediaTitle,
&row.MediaType,
&row.PlayMethod,
&row.StartedAt,
&row.EndedAt,
&row.WatchedSeconds,
&row.DurationSeconds,
&row.Completed,
); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to scan playback history row")
return
}
history = append(history, row)
}
if err := rows.Err(); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to iterate playback history")
return
}
writeJSON(w, http.StatusOK, history)
}
// HandleListUserProfiles handles GET /admin/users/{id}/profiles.
func (h *AdminHandler) HandleListUserProfiles(w http.ResponseWriter, r *http.Request) {
if h.storeProv == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "User store not configured")
return
}
idStr := chi.URLParam(r, "id")
userID, err := strconv.Atoi(idStr)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid user ID")
return
}
store, ok := h.adminUserStore(w, r, userID)
if !ok {
return
}
if store == nil {
writeError(w, http.StatusNotFound, "not_found", "User store not found")
return
}
profiles, err := store.ListProfiles(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list profiles")
return
}
resp := make([]adminUserProfileRow, 0, len(profiles))
for _, profile := range profiles {
resp = append(resp, adminUserProfileRow{ID: profile.ID, Name: profile.Name})
}
writeJSON(w, http.StatusOK, resp)
}
func updateRequiresSessionRevocation(req updateUserRequest) bool {
return req.Password != nil ||
req.Role != nil ||
req.Enabled != nil ||
req.LibraryIDs.Set ||
req.Permissions.Set ||
req.MaxPlaybackQuality != nil
}
func (h *AdminHandler) revokeUserSessions(ctx context.Context, userID int) error {
if h.pool == nil {
return nil
}
sessionRepo := auth.NewSessionRepository(h.pool)
if err := sessionRepo.RevokeAllByUser(ctx, userID); err != nil {
return err
}
if err := sessionRepo.RevokeAllByImpersonator(ctx, userID); err != nil {
return err
}
if h.OnUserSessionsRevoked != nil {
h.OnUserSessionsRevoked(ctx, userID)
}
return nil
}
// HandleListUnmatched handles GET /admin/unmatched.
// Lists media files that have not been matched to content (content_id IS NULL).
func (h *AdminHandler) HandleListUnmatched(w http.ResponseWriter, r *http.Request) {
if h.pool == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Database not configured")
return
}
q := r.URL.Query()
limit := 100
if v := q.Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 500 {
limit = n
}
}
offset := 0
if v := q.Get("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
offset = n
}
}
rows, err := h.pool.Query(r.Context(),
`SELECT id, media_folder_id, file_path, file_size, container
FROM media_files
WHERE content_id IS NULL
ORDER BY id ASC
LIMIT $1 OFFSET $2`, limit, offset)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list unmatched files")
return
}
defer rows.Close()
files := make([]unmatchedFileRow, 0)
for rows.Next() {
var f unmatchedFileRow
if err := rows.Scan(&f.ID, &f.MediaFolderID, &f.FilePath, &f.FileSize, &f.Container); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to scan file")
return
}
files = append(files, f)
}
if err := rows.Err(); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to iterate files")
return
}
writeJSON(w, http.StatusOK, files)
}
// HandleGetStats handles GET /admin/stats.
// Returns system statistics for the admin dashboard.
func (h *AdminHandler) HandleGetStats(w http.ResponseWriter, r *http.Request) {
var resp AdminStats
if h.StatsSource != nil {
stats, err := h.StatsSource.Get(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get stats")
return
}
resp = stats
} else if h.pool != nil {
stats, err := queryAdminStats(r.Context(), h.pool)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get stats")
return
}
resp = stats
} else {
// Fallback: use the user repository when PG pool is not available.
users, err := h.userRepo.List(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count users")
return
}
resp.TotalUsers = len(users)
}
writeJSON(w, http.StatusOK, resp)
}
func (h *AdminHandler) invalidateStats(ctx context.Context, channel, eventType, payload string) {
if h.StatsSource != nil {
h.StatsSource.Invalidate()
}
h.publishStatsEvent(ctx, channel, eventType, payload)
}
func (h *AdminHandler) publishStatsEvent(ctx context.Context, channel, eventType, payload string) {
if h.EventBus == nil {
return
}
if err := h.EventBus.Publish(ctx, channel, cache.Event{Type: eventType, Payload: payload}); err != nil {
slog.Warn("admin: failed to publish stats invalidation event",
"channel", channel,
"type", eventType,
"error", err,
)
}
}
type refreshItemMetadataRequest struct {
Mode string `json:"mode"`
}
// HandleRefreshItemMetadata handles POST /admin/items/{id}/refresh-metadata.
func (h *AdminHandler) HandleRefreshItemMetadata(w http.ResponseWriter, r *http.Request) {
if h.JobRepo == nil || h.ItemRefreshResolver == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Item refresh jobs are not configured")
return
}
contentID := chi.URLParam(r, "id")
if contentID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required")
return
}
mode := adminjob.ItemRefreshModeQuick
if r.Body != nil {
var req refreshItemMetadataRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if req.Mode != "" {
switch adminjob.ItemRefreshMode(req.Mode) {
case adminjob.ItemRefreshModeQuick, adminjob.ItemRefreshModeComplete:
mode = adminjob.ItemRefreshMode(req.Mode)
default:
writeError(w, http.StatusBadRequest, "bad_request", "Invalid refresh mode")
return
}
}
}
payload, err := h.ItemRefreshResolver.ResolveWithMode(r.Context(), contentID, mode)
if err != nil {
var scopeErr *adminjob.ScopeResolutionError
if errors.As(err, &scopeErr) {
code := "bad_request"
if scopeErr.StatusCode == http.StatusNotFound {
code = "not_found"
} else if scopeErr.StatusCode >= http.StatusConflict {
code = "conflict"
}
writeError(w, scopeErr.StatusCode, code, scopeErr.Message)
return
}
slog.Error("admin: resolve item refresh scope failed", "content_id", contentID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve item refresh scope")
return
}
job, err := h.JobRepo.Create(r.Context(), adminjob.CreateJobInput{
JobType: adminjob.JobTypeItemRefresh,
CreatedByUserID: currentAdminUserID(r),
RequestPayload: payload,
Message: "Queued item metadata refresh",
})
if err != nil {
slog.Error("admin: create item refresh job failed", "content_id", contentID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to queue item metadata refresh")
return
}
if h.RealtimeHub != nil {
publishEventJob(r.Context(), h.RealtimeHub.EventsHub(), "job.created", job)
}
writeJSON(w, http.StatusAccepted, adminJobToResponseForClaims(r, job, nil, apimw.GetClaims(r.Context())))
}
// UpdateItemMetadataRequest contains the fields that can be updated via
// PATCH /admin/items/{id}/metadata.
type UpdateItemMetadataRequest struct {
Title *string `json:"title"`
SortTitle *string `json:"sort_title"`
OriginalTitle *string `json:"original_title"`
Overview *string `json:"overview"`
Tagline *string `json:"tagline"`
ContentRating *string `json:"content_rating"`
Year *int `json:"year"`
Runtime *int `json:"runtime"`
Genres *[]string `json:"genres"`
Studios *[]string `json:"studios"`
Networks *[]string `json:"networks"`
Countries *[]string `json:"countries"`
ReleaseDate *string `json:"release_date"`
FirstAirDate *string `json:"first_air_date"`
LastAirDate *string `json:"last_air_date"`
AirTime *string `json:"air_time"`
AirTimezone *string `json:"air_timezone"`
AirDate *string `json:"air_date"`
Status *string `json:"status"`
RatingIMDB *float64 `json:"rating_imdb"`
RatingTMDB *float64 `json:"rating_tmdb"`
RatingRTCritic *int `json:"rating_rt_critic"`
RatingRTAudience *int `json:"rating_rt_audience"`
ImdbID *string `json:"imdb_id"`
TmdbID *string `json:"tmdb_id"`
TvdbID *string `json:"tvdb_id"`
SeasonNumber *int `json:"season_number"`
EpisodeNumber *int `json:"episode_number"`
LockedFields *[]int `json:"locked_fields"`
}
// HandleUpdateItemMetadata handles PATCH /admin/items/{id}/metadata.
func (h *AdminHandler) HandleUpdateItemMetadata(w http.ResponseWriter, r *http.Request) {
contentID := chi.URLParam(r, "id")
if contentID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required")
return
}
var req UpdateItemMetadataRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if req.AirTimezone != nil {
trimmed := strings.TrimSpace(*req.AirTimezone)
req.AirTimezone = &trimmed
if !catalog.ValidateAirTimezone(trimmed) {
writeError(w, http.StatusBadRequest, "bad_request", "air_timezone must be a valid IANA timezone")
return
}
}
upd := catalog.MetadataUpdate{
Title: req.Title, SortTitle: req.SortTitle, OriginalTitle: req.OriginalTitle,
Overview: req.Overview, Tagline: req.Tagline, ContentRating: req.ContentRating,
Year: req.Year, Runtime: req.Runtime,
Genres: req.Genres, Studios: req.Studios, Networks: req.Networks, Countries: req.Countries,
ReleaseDate: req.ReleaseDate, FirstAirDate: req.FirstAirDate, LastAirDate: req.LastAirDate,
AirTime: req.AirTime, AirTimezone: req.AirTimezone,
AirDate: req.AirDate, Status: req.Status,
RatingIMDB: req.RatingIMDB, RatingTMDB: req.RatingTMDB,
RatingRTCritic: req.RatingRTCritic, RatingRTAudience: req.RatingRTAudience,
ImdbID: req.ImdbID, TmdbID: req.TmdbID, TvdbID: req.TvdbID,
SeasonNumber: req.SeasonNumber, EpisodeNumber: req.EpisodeNumber,
LockedFields: req.LockedFields,
}
// Try media_items first, then seasons, then episodes.
if err := h.DetailSvc.UpdateMediaItemMetadata(r.Context(), contentID, &upd); err != nil {
if !errors.Is(err, catalog.ErrItemNotFound) {
slog.Error("admin: update item metadata failed", "content_id", contentID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update metadata")
return
}
if err := h.DetailSvc.UpdateSeasonMetadata(r.Context(), contentID, &upd); err != nil {
if !errors.Is(err, catalog.ErrSeasonNotFound) {
slog.Error("admin: update season metadata failed", "content_id", contentID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update metadata")
return
}
if err := h.DetailSvc.UpdateEpisodeMetadata(r.Context(), contentID, &upd); err != nil {
if errors.Is(err, catalog.ErrEpisodeNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
slog.Error("admin: update episode metadata failed", "content_id", contentID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update metadata")
return
}
}
}
if h.EventBus != nil {
_ = h.EventBus.Publish(r.Context(), cache.ChannelAdmin,
cache.Event{Type: "item:updated", Payload: contentID})
}
if h.RealtimeHub != nil {
publishEventMetadataUpdate(r.Context(), h.RealtimeHub.EventsHub(), 0, contentID)
}
detail, err := h.DetailSvc.GetItemDetail(r.Context(), contentID, catalog.AccessFilter{})
if err != nil {
slog.Error("admin: fetch updated detail failed", "content_id", contentID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Updated but failed to fetch result")
return
}
writeJSON(w, http.StatusOK, detail)
}
// --- Server Settings endpoints ---
var sensitiveSettingKeys = map[string]bool{
"auth.jwt_secret": true,
"s3.public_access_key": true,
"s3.public_secret_key": true,
"s3.public_token_secret": true,
"s3.private_access_key": true,
"s3.private_secret_key": true,
"s3.user_db_access_key": true,
"s3.user_db_secret_key": true,
"redis.url": true,
"recommendations.openai_api_key": true,
"recommendations.embedding_auth_token": true,
"tmdb.api_key": true,
"introdb.api_key": true,
"mdblist.api_key": true,
"requests.radarr.api_key": true,
"requests.sonarr.api_key": true,
"watchsync.trakt.client_id": true,
"watchsync.trakt.client_secret": true,
"watchsync.simkl.client_id": true,
"watchsync.simkl.client_secret": true,
}
// HandleGetSettings handles GET /admin/settings.
func (h *AdminHandler) HandleGetSettings(w http.ResponseWriter, r *http.Request) {
if h.SettingsRepo == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Settings store not configured")
return
}
all, err := h.SettingsRepo.GetAll(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load settings")
return
}
for key := range sensitiveSettingKeys {
delete(all, key)
}
writeJSON(w, http.StatusOK, all)
}
type sensitiveStatusResponse struct {
Configured []string `json:"configured"`
ManagedByEnv []string `json:"managed_by_env,omitempty"`
}
// HandleGetSensitiveStatus handles GET /admin/settings/sensitive-status.
// Returns which sensitive keys are configured and which are managed by env.
func (h *AdminHandler) HandleGetSensitiveStatus(w http.ResponseWriter, r *http.Request) {
if h.SettingsRepo == nil {
writeError(w, http.StatusInternalServerError, "settings_error", "settings not configured")
return
}
all, err := h.SettingsRepo.GetAll(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "settings_error", err.Error())
return
}
configuredSet := make(map[string]struct{})
for key := range sensitiveSettingKeys {
if v, ok := all[key]; ok && v != "" {
configuredSet[key] = struct{}{}
}
}
for key, configured := range h.BootstrapSensitiveConfigured {
if configured && sensitiveSettingKeys[key] {
configuredSet[key] = struct{}{}
}
}
for key, value := range h.BootstrapSensitiveValues {
if value != "" && sensitiveSettingKeys[key] {
configuredSet[key] = struct{}{}
}
}
configured := make([]string, 0, len(configuredSet))
for key := range configuredSet {
configured = append(configured, key)
}
sort.Strings(configured)
managedByEnv := make([]string, 0, len(h.BootstrapSensitiveConfigured))
for key, configured := range h.BootstrapSensitiveConfigured {
if configured && sensitiveSettingKeys[key] {
managedByEnv = append(managedByEnv, key)
}
}
sort.Strings(managedByEnv)
writeJSON(w, http.StatusOK, sensitiveStatusResponse{
Configured: configured,
ManagedByEnv: managedByEnv,
})
}
type adminSettingResponse struct {
Key string `json:"key"`
Value string `json:"value"`
}
type adminSettingsListResponse struct {
Settings []adminSettingResponse `json:"settings"`
}
type adminDeviceSettingResponse struct {
UserID int `json:"user_id"`
ProfileID string `json:"profile_id"`
ProfileName string `json:"profile_name,omitempty"`
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"`
DevicePlatform string `json:"device_platform"`
Key string `json:"key"`
Value string `json:"value"`
UpdatedAt string `json:"updated_at"`
}
type adminDeviceSettingsListResponse struct {
Settings []adminDeviceSettingResponse `json:"settings"`
}
type adminDeviceProfileSummary struct {
ProfileID string `json:"profile_id"`
ProfileName string `json:"profile_name"`
OverrideCount int `json:"override_count"`
LastUpdated string `json:"last_updated"`
}
type adminDeviceSummaryResponse struct {
UserID int `json:"user_id"`
Username string `json:"username"`
Email string `json:"email"`
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"`
DevicePlatform string `json:"device_platform"`
OverrideCount int `json:"override_count"`
ProfileCount int `json:"profile_count"`
Profiles []adminDeviceProfileSummary `json:"profiles"`
LastUpdated string `json:"last_updated"`
}
type adminDevicesListResponse struct {
Devices []adminDeviceSummaryResponse `json:"devices"`
}
type adminDeviceDetailResponse struct {
UserID int `json:"user_id"`
Username string `json:"username"`
Email string `json:"email"`
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"`
DevicePlatform string `json:"device_platform"`
LastUpdated string `json:"last_updated"`
Settings []adminDeviceSettingResponse `json:"settings"`
}
// HandleListUserSettings handles GET /admin/users/{id}/settings.
func (h *AdminHandler) HandleListUserSettings(w http.ResponseWriter, r *http.Request) {
userID, ok := parseAdminUserIDParam(w, r)
if !ok {
return
}
store, ok := h.adminUserStore(w, r, userID)
if !ok {
return
}
entries, err := store.ListSettings(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list settings")
return
}
resp := adminSettingsListResponse{
Settings: make([]adminSettingResponse, 0, len(entries)),
}
for _, entry := range entries {
if !keyUsesUserScope(entry.Key) {
continue
}
resp.Settings = append(resp.Settings, adminSettingResponse{
Key: entry.Key,
Value: entry.Value,
})
}
writeJSON(w, http.StatusOK, resp)
}
// HandleGetUserSetting handles GET /admin/users/{id}/settings/{key}.
func (h *AdminHandler) HandleGetUserSetting(w http.ResponseWriter, r *http.Request) {
userID, ok := parseAdminUserIDParam(w, r)
if !ok {
return
}
key := strings.TrimSpace(chi.URLParam(r, "key"))
if key == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required")
return
}
if !keyUsesUserScope(key) {
writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("%s is not a %s setting", key, scopeUser))
return
}
store, ok := h.adminUserStore(w, r, userID)
if !ok {
return
}
value, err := store.GetSetting(r.Context(), key)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load setting")
return
}
if value == "" {
entries, err := store.ListSettings(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load setting")
return
}
found := false
for _, entry := range entries {
if entry.Key == key {
found = true
break
}
}
if !found {
writeError(w, http.StatusNotFound, "not_found", "Setting not found")
return
}
}
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: value})
}
// HandleUpdateUserSetting handles PUT /admin/users/{id}/settings/{key}.
func (h *AdminHandler) HandleUpdateUserSetting(w http.ResponseWriter, r *http.Request) {
userID, ok := parseAdminUserIDParam(w, r)
if !ok {
return
}
key := strings.TrimSpace(chi.URLParam(r, "key"))
if key == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required")
return
}
if !keyUsesUserScope(key) {
writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("%s is not a %s setting", key, scopeUser))
return
}
var req updateSettingRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if err := validateRegisteredSetting(key, req.Value, scopeUser); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
store, ok := h.adminUserStore(w, r, userID)
if !ok {
return
}
if err := store.SetSetting(r.Context(), key, req.Value); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update setting")
return
}
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: req.Value})
}
// HandleDeleteUserSetting handles DELETE /admin/users/{id}/settings/{key}.
func (h *AdminHandler) HandleDeleteUserSetting(w http.ResponseWriter, r *http.Request) {
userID, ok := parseAdminUserIDParam(w, r)
if !ok {
return
}
key := strings.TrimSpace(chi.URLParam(r, "key"))
if key == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required")
return
}
if !keyUsesUserScope(key) {
writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("%s is not a %s setting", key, scopeUser))
return
}
store, ok := h.adminUserStore(w, r, userID)
if !ok {
return
}
if err := store.DeleteSetting(r.Context(), key); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete setting")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleListUserDeviceSettings handles GET /admin/users/{id}/device-settings.
func (h *AdminHandler) HandleListUserDeviceSettings(w http.ResponseWriter, r *http.Request) {
userID, ok := parseAdminUserIDParam(w, r)
if !ok {
return
}
store, ok := h.adminUserStore(w, r, userID)
if !ok {
return
}
entries, err := store.ListAllDeviceSettings(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list device settings")
return
}
profileNames, err := listProfileNamesByID(r.Context(), store)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list profiles")
return
}
writeJSON(w, http.StatusOK, buildAdminDeviceSettingsResponse(userID, profileNames, entries))
}
// HandleListUserDeviceSettingsByKey handles GET /admin/users/{id}/device-settings/{key}.
func (h *AdminHandler) HandleListUserDeviceSettingsByKey(w http.ResponseWriter, r *http.Request) {
userID, ok := parseAdminUserIDParam(w, r)
if !ok {
return
}
key := strings.TrimSpace(chi.URLParam(r, "key"))
if key == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required")
return
}
store, ok := h.adminUserStore(w, r, userID)
if !ok {
return
}
entries, err := store.ListDeviceSettings(r.Context(), key)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list device settings")
return
}
profileNames, err := listProfileNamesByID(r.Context(), store)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list profiles")
return
}
writeJSON(w, http.StatusOK, buildAdminDeviceSettingsResponse(userID, profileNames, entries))
}
// HandleUpdateUserDeviceSetting handles PUT /admin/users/{id}/profiles/{profile_id}/device-settings/{key}/{device_id}.
func (h *AdminHandler) HandleUpdateUserDeviceSetting(w http.ResponseWriter, r *http.Request) {
userID, ok := parseAdminUserIDParam(w, r)
if !ok {
return
}
profileID := strings.TrimSpace(chi.URLParam(r, "profile_id"))
key := strings.TrimSpace(chi.URLParam(r, "key"))
deviceID := strings.TrimSpace(chi.URLParam(r, "device_id"))
if profileID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Profile id is required")
return
}
if key == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required")
return
}
if deviceID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Device id is required")
return
}
var req updateSettingRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if err := validateRegisteredSetting(key, req.Value, scopeDevice); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
store, ok := h.adminUserStore(w, r, userID)
if !ok {
return
}
if !adminProfileExists(w, r, store, profileID) {
return
}
existing, err := store.GetDeviceSetting(r.Context(), profileID, deviceID, key)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load device setting")
return
}
entry := userstore.DeviceSettingEntry{
ProfileID: profileID,
DeviceID: deviceID,
Key: key,
Value: req.Value,
}
if existing != nil {
entry.DeviceName = existing.DeviceName
entry.DevicePlatform = existing.DevicePlatform
}
if err := store.SetDeviceSetting(r.Context(), entry); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update device setting")
return
}
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: req.Value})
}
// HandleDeleteUserDeviceSetting handles DELETE /admin/users/{id}/profiles/{profile_id}/device-settings/{key}/{device_id}.
func (h *AdminHandler) HandleDeleteUserDeviceSetting(w http.ResponseWriter, r *http.Request) {
userID, ok := parseAdminUserIDParam(w, r)
if !ok {
return
}
profileID := strings.TrimSpace(chi.URLParam(r, "profile_id"))
key := strings.TrimSpace(chi.URLParam(r, "key"))
deviceID := strings.TrimSpace(chi.URLParam(r, "device_id"))
if profileID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Profile id is required")
return
}
if key == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required")
return
}
if deviceID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Device id is required")
return
}
store, ok := h.adminUserStore(w, r, userID)
if !ok {
return
}
if !adminProfileExists(w, r, store, profileID) {
return
}
if err := store.DeleteDeviceSetting(r.Context(), profileID, deviceID, key); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete device setting")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleDeleteAllUserDeviceSettings handles DELETE /admin/users/{id}/profiles/{profile_id}/devices/{device_id}/settings.
func (h *AdminHandler) HandleDeleteAllUserDeviceSettings(w http.ResponseWriter, r *http.Request) {
userID, ok := parseAdminUserIDParam(w, r)
if !ok {
return
}
profileID := strings.TrimSpace(chi.URLParam(r, "profile_id"))
deviceID := strings.TrimSpace(chi.URLParam(r, "device_id"))
if profileID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Profile id is required")
return
}
if deviceID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Device id is required")
return
}
store, ok := h.adminUserStore(w, r, userID)
if !ok {
return
}
if !adminProfileExists(w, r, store, profileID) {
return
}
if err := store.DeleteAllDeviceSettings(r.Context(), profileID, deviceID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete device settings")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleDeleteUserDeviceSettingsByKey handles DELETE /admin/users/{id}/device-settings/{key}.
func (h *AdminHandler) HandleDeleteUserDeviceSettingsByKey(w http.ResponseWriter, r *http.Request) {
userID, ok := parseAdminUserIDParam(w, r)
if !ok {
return
}
key := strings.TrimSpace(chi.URLParam(r, "key"))
if key == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required")
return
}
store, ok := h.adminUserStore(w, r, userID)
if !ok {
return
}
if err := store.DeleteDeviceSettingsByKey(r.Context(), key); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete device settings")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleListDevices handles GET /admin/devices.
func (h *AdminHandler) HandleListDevices(w http.ResponseWriter, r *http.Request) {
if h.userRepo == nil || h.storeProv == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Device settings not configured")
return
}
users, err := h.userRepo.List(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list devices")
return
}
perUser := make([][]adminDeviceSummaryResponse, len(users))
g, gctx := errgroup.WithContext(r.Context())
g.SetLimit(8)
for i, user := range users {
i, user := i, user
g.Go(func() error {
store, err := h.storeProv.ForUser(gctx, user.ID)
if err != nil {
return fmt.Errorf("user store: %w", err)
}
entries, err := store.ListAllDeviceSettings(gctx)
if err != nil {
return fmt.Errorf("list device settings: %w", err)
}
profileNames, err := listProfileNamesByID(gctx, store)
if err != nil {
slog.Warn("admin list devices profile lookup failed",
"user_id", user.ID,
"error", err,
)
profileNames = map[string]string{}
}
perUser[i] = buildAdminDeviceSummaries(user.ID, user.Username, user.Email, entries, profileNames)
return nil
})
}
if err := g.Wait(); err != nil {
slog.Error("admin list devices failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list devices")
return
}
devices := make([]adminDeviceSummaryResponse, 0)
for _, batch := range perUser {
devices = append(devices, batch...)
}
sort.Slice(devices, func(i, j int) bool {
if devices[i].LastUpdated != devices[j].LastUpdated {
return devices[i].LastUpdated > devices[j].LastUpdated
}
if devices[i].Username != devices[j].Username {
return devices[i].Username < devices[j].Username
}
if devices[i].DeviceName != devices[j].DeviceName {
return devices[i].DeviceName < devices[j].DeviceName
}
return devices[i].DeviceID < devices[j].DeviceID
})
writeJSON(w, http.StatusOK, adminDevicesListResponse{Devices: devices})
}
// HandleGetDevice handles GET /admin/devices/{user_id}/{device_id}.
func (h *AdminHandler) HandleGetDevice(w http.ResponseWriter, r *http.Request) {
if h.userRepo == nil || h.storeProv == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Device settings not configured")
return
}
userIDRaw := strings.TrimSpace(chi.URLParam(r, "user_id"))
userID, err := strconv.Atoi(userIDRaw)
if err != nil || userID <= 0 {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid user id")
return
}
deviceID := strings.TrimSpace(chi.URLParam(r, "device_id"))
if deviceID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Device id is required")
return
}
user, err := h.userRepo.GetByID(r.Context(), userID)
if err != nil || user == nil {
writeError(w, http.StatusNotFound, "not_found", "User not found")
return
}
store, ok := h.adminUserStore(w, r, userID)
if !ok {
return
}
entries, err := store.ListAllDeviceSettings(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load device")
return
}
profileNames, err := listProfileNamesByID(r.Context(), store)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list profiles")
return
}
deviceEntries := make([]userstore.DeviceSettingEntry, 0)
for _, entry := range entries {
if entry.DeviceID == deviceID {
deviceEntries = append(deviceEntries, entry)
}
}
if len(deviceEntries) == 0 {
writeError(w, http.StatusNotFound, "not_found", "Device not found")
return
}
summary := buildAdminDeviceSummaries(user.ID, user.Username, user.Email, deviceEntries, profileNames)[0]
writeJSON(w, http.StatusOK, adminDeviceDetailResponse{
UserID: user.ID,
Username: user.Username,
Email: user.Email,
DeviceID: summary.DeviceID,
DeviceName: summary.DeviceName,
DevicePlatform: summary.DevicePlatform,
LastUpdated: summary.LastUpdated,
Settings: buildAdminDeviceSettingsResponse(user.ID, profileNames, deviceEntries).Settings,
})
}
func buildAdminDeviceSettingsResponse(userID int, profileNames map[string]string, entries []userstore.DeviceSettingEntry) adminDeviceSettingsListResponse {
resp := adminDeviceSettingsListResponse{
Settings: make([]adminDeviceSettingResponse, 0, len(entries)),
}
for _, entry := range entries {
resp.Settings = append(resp.Settings, adminDeviceSettingResponse{
UserID: userID,
ProfileID: entry.ProfileID,
ProfileName: profileNames[entry.ProfileID],
DeviceID: entry.DeviceID,
DeviceName: entry.DeviceName,
DevicePlatform: entry.DevicePlatform,
Key: entry.Key,
Value: entry.Value,
UpdatedAt: entry.UpdatedAt,
})
}
return resp
}
func buildAdminDeviceSummaries(
userID int,
username string,
email string,
entries []userstore.DeviceSettingEntry,
profileNames map[string]string,
) []adminDeviceSummaryResponse {
type profileAccumulator struct {
summary adminDeviceProfileSummary
keys map[string]struct{}
}
type summary struct {
device adminDeviceSummaryResponse
keys map[string]struct{}
profiles map[string]*profileAccumulator
}
byDevice := make(map[string]*summary)
for _, entry := range entries {
deviceID := strings.TrimSpace(entry.DeviceID)
if deviceID == "" {
continue
}
current, ok := byDevice[deviceID]
if !ok {
current = &summary{
device: adminDeviceSummaryResponse{
UserID: userID,
Username: username,
Email: email,
DeviceID: deviceID,
DeviceName: entry.DeviceName,
DevicePlatform: entry.DevicePlatform,
LastUpdated: entry.UpdatedAt,
},
keys: make(map[string]struct{}),
profiles: make(map[string]*profileAccumulator),
}
byDevice[deviceID] = current
}
current.keys[entry.ProfileID+":"+entry.Key] = struct{}{}
profileID := strings.TrimSpace(entry.ProfileID)
if profileID != "" {
profile, exists := current.profiles[profileID]
if !exists {
profile = &profileAccumulator{
summary: adminDeviceProfileSummary{
ProfileID: profileID,
ProfileName: profileNames[profileID],
LastUpdated: entry.UpdatedAt,
},
keys: make(map[string]struct{}),
}
current.profiles[profileID] = profile
}
profile.keys[entry.Key] = struct{}{}
if entry.UpdatedAt > profile.summary.LastUpdated {
profile.summary.LastUpdated = entry.UpdatedAt
}
}
if current.device.DeviceName == "" && entry.DeviceName != "" {
current.device.DeviceName = entry.DeviceName
}
if current.device.DevicePlatform == "" && entry.DevicePlatform != "" {
current.device.DevicePlatform = entry.DevicePlatform
}
if entry.UpdatedAt > current.device.LastUpdated {
current.device.LastUpdated = entry.UpdatedAt
if entry.DeviceName != "" {
current.device.DeviceName = entry.DeviceName
}
if entry.DevicePlatform != "" {
current.device.DevicePlatform = entry.DevicePlatform
}
}
}
devices := make([]adminDeviceSummaryResponse, 0, len(byDevice))
for _, current := range byDevice {
current.device.OverrideCount = len(current.keys)
current.device.ProfileCount = len(current.profiles)
profiles := make([]adminDeviceProfileSummary, 0, len(current.profiles))
for _, profile := range current.profiles {
profile.summary.OverrideCount = len(profile.keys)
profiles = append(profiles, profile.summary)
}
sort.Slice(profiles, func(i, j int) bool {
if profiles[i].LastUpdated != profiles[j].LastUpdated {
return profiles[i].LastUpdated > profiles[j].LastUpdated
}
a := profiles[i].ProfileName
b := profiles[j].ProfileName
if a == "" {
a = profiles[i].ProfileID
}
if b == "" {
b = profiles[j].ProfileID
}
return a < b
})
current.device.Profiles = profiles
devices = append(devices, current.device)
}
return devices
}
func listProfileNamesByID(ctx context.Context, store userstore.UserStore) (map[string]string, error) {
profiles, err := store.ListProfiles(ctx)
if err != nil {
return nil, err
}
profileNames := make(map[string]string, len(profiles))
for _, profile := range profiles {
profileNames[profile.ID] = strings.TrimSpace(profile.Name)
}
return profileNames, nil
}
func adminProfileExists(w http.ResponseWriter, r *http.Request, store userstore.UserStore, profileID string) bool {
profile, err := store.GetProfile(r.Context(), profileID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load profile")
return false
}
if profile == nil {
writeError(w, http.StatusNotFound, "not_found", "Profile not found")
return false
}
return true
}
func (h *AdminHandler) adminUserStore(w http.ResponseWriter, r *http.Request, userID int) (userstore.UserStore, bool) {
if h.storeProv == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "User store not configured")
return nil, false
}
store, err := h.storeProv.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return nil, false
}
if store == nil {
writeError(w, http.StatusNotFound, "not_found", "User store not found")
return nil, false
}
return store, true
}
func parseAdminUserIDParam(w http.ResponseWriter, r *http.Request) (int, bool) {
raw := chi.URLParam(r, "id")
userID, err := strconv.Atoi(raw)
if err != nil || userID <= 0 {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid user id")
return 0, false
}
return userID, true
}
// HandleGetSetting handles GET /admin/settings/{key}.
func (h *AdminHandler) HandleGetSetting(w http.ResponseWriter, r *http.Request) {
if h.SettingsRepo == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Settings store not configured")
return
}
key := chi.URLParam(r, "key")
if key == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required")
return
}
if sensitiveSettingKeys[key] {
writeError(w, http.StatusNotFound, "not_found", "Setting not found")
return
}
if value, ok := h.BootstrapSensitiveValues[key]; ok && value != "" {
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: value})
return
}
value, err := h.SettingsRepo.Get(r.Context(), key)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load setting")
return
}
if value == "" {
writeError(w, http.StatusNotFound, "not_found", "Setting not found")
return
}
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: value})
}
type updateSettingRequest struct {
Value string `json:"value"`
}
// HandleUpdateSetting handles PUT /admin/settings/{key}.
func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Request) {
if h.SettingsRepo == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Settings store not configured")
return
}
key := chi.URLParam(r, "key")
if key == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required")
return
}
var req updateSettingRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if key == "defaults.max_playback_quality" {
normalized, ok := access.ParsePlaybackQualityPreset(req.Value)
if !ok {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid max_playback_quality")
return
}
req.Value = normalized
}
if key == "defaults.max_profiles" {
n, err := strconv.Atoi(req.Value)
if err != nil || n < 1 {
writeError(w, http.StatusBadRequest, "bad_request", "defaults.max_profiles must be an integer >= 1")
return
}
}
switch key {
case markers.SettingMode, markers.SettingLazyPlayback:
if normalized, err := markers.NormalizeSetting(key, req.Value); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
} else {
req.Value = normalized
}
}
if err := h.SettingsRepo.Set(r.Context(), key, req.Value); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update setting")
return
}
if h.EventBus != nil {
_ = h.EventBus.Publish(r.Context(), cache.ChannelAdmin,
cache.Event{Type: cache.EventSettingsChanged, Payload: key})
}
if h.OnServerSettingUpdated != nil {
h.OnServerSettingUpdated(r.Context(), key, req.Value)
}
if sensitiveSettingKeys[key] {
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key})
return
}
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: req.Value})
}