Replace the ten string-registry /admin/users/{id}/settings* and
device-settings* routes with the canonical contract surface: one list of
every explicit value the target user has stored across all scopes, and
set/delete at an explicit scope named in the query string.
The admin handlers live on SettingValuesHandler and share the session
routes' implementation rather than duplicating it — the same key/scope
parsing, identity validation, contract scope allowance, value
normalization and mutation-receipt idempotency, factored into
keyedScopeFromRequest/completeIdentity and setValueAt/deleteValueAt.
The only admin-specific parts are the target user coming from the path,
profile and device ids coming from the query (an admin holds no session
claim to the user being inspected, so its named profile is checked to
exist), and change events attributed to the target user so their
clients refresh.
The list is a new UserStore read, ListAllSettingValues, implemented in
both backends and pinned by the shared storetest conformance suite:
the admin surface wants the stored truth (which overrides exist, for a
per-row reset affordance), which no resolution-shaped read answers.
The ten removed routes are recorded in the pre-lock removals table in
docs/architecture/v1-scope.md per the v1 API rules; the web admin
device-overrides page moves onto the new surface in the Phase B
rewrite inside this same unmerged PR.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2656 lines
89 KiB
Go
2656 lines
89 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"maps"
|
|
"math"
|
|
"net/http"
|
|
"net/url"
|
|
"slices"
|
|
"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"
|
|
"github.com/Silo-Server/silo-server/internal/ai/llm"
|
|
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/config"
|
|
"github.com/Silo-Server/silo-server/internal/diagnostics"
|
|
evt "github.com/Silo-Server/silo-server/internal/events"
|
|
"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/policy"
|
|
subtitleai "github.com/Silo-Server/silo-server/internal/subtitles/ai"
|
|
"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)
|
|
}
|
|
|
|
type AccessGroupValidator interface {
|
|
Get(ctx context.Context, id int64) (*access.Group, 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 serverSettingsAtomicUpdater interface {
|
|
UpdateAtomic(
|
|
ctx context.Context,
|
|
update func(current map[string]string) (map[string]string, error),
|
|
) error
|
|
}
|
|
|
|
func updateServerSettingsAtomically(
|
|
ctx context.Context,
|
|
store ServerSettingsStore,
|
|
update func(current map[string]string) (map[string]string, error),
|
|
) error {
|
|
if updater, ok := store.(serverSettingsAtomicUpdater); ok {
|
|
return updater.UpdateAtomic(ctx, update)
|
|
}
|
|
return errors.New("settings store does not support atomic updates")
|
|
}
|
|
|
|
type DiagnosticsEnablementStore interface {
|
|
PutStream(ctx context.Context, bucket, key string, r io.Reader, contentType string) error
|
|
DeleteObject(ctx context.Context, bucket, key string) error
|
|
Bucket() string
|
|
}
|
|
|
|
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
|
|
Config *config.Config
|
|
EventBus cache.EventBus
|
|
EventsHub *evt.Hub
|
|
SettingsRepo ServerSettingsStore
|
|
DiagnosticsStore DiagnosticsEnablementStore
|
|
JobRepo AdminJobCreator
|
|
ItemRefreshResolver ItemRefreshScopeResolver
|
|
ImpersonationService ImpersonationService
|
|
RealtimeHub *notifications.Hub
|
|
AccessGroups AccessGroupValidator
|
|
BootstrapSensitiveConfigured map[string]bool
|
|
BootstrapSensitiveValues map[string]string
|
|
RedisBootstrapAvailable bool
|
|
OnUserSessionsRevoked func(ctx context.Context, userID int)
|
|
OnServerSettingUpdated func(ctx context.Context, key, value string)
|
|
RestartStatus *ServerRestartStatusTracker
|
|
CatalogSearchStatus catalog.CatalogSearchStatusProvider
|
|
}
|
|
|
|
// 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 createStringSliceField `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"`
|
|
TranscodeAllowed *bool `json:"transcode_allowed,omitempty"`
|
|
AudioTranscodeAllowed *bool `json:"audio_transcode_allowed,omitempty"`
|
|
MaxProfiles *int `json:"max_profiles,omitempty"`
|
|
DownloadAllowed *bool `json:"download_allowed,omitempty"`
|
|
DownloadTranscodeAllowed *bool `json:"download_transcode_allowed,omitempty"`
|
|
}
|
|
|
|
type createStringSliceField struct {
|
|
Set bool
|
|
Value []string
|
|
}
|
|
|
|
func (f *createStringSliceField) 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)
|
|
}
|
|
|
|
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"`
|
|
TranscodeAllowed *bool `json:"transcode_allowed,omitempty"`
|
|
AudioTranscodeAllowed *bool `json:"audio_transcode_allowed,omitempty"`
|
|
MaxProfiles *int `json:"max_profiles,omitempty"`
|
|
DownloadAllowed *bool `json:"download_allowed,omitempty"`
|
|
DownloadTranscodeAllowed *bool `json:"download_transcode_allowed,omitempty"`
|
|
AccessGroupID updateAccessGroupField `json:"access_group_id,omitempty"`
|
|
}
|
|
|
|
type updateAccessGroupField struct {
|
|
Set bool
|
|
Value *int64
|
|
}
|
|
|
|
func (f *updateAccessGroupField) UnmarshalJSON(data []byte) error {
|
|
f.Set = true
|
|
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
|
|
f.Value = nil
|
|
return nil
|
|
}
|
|
var value int64
|
|
if err := json.Unmarshal(data, &value); err != nil {
|
|
return err
|
|
}
|
|
f.Value = &value
|
|
return nil
|
|
}
|
|
|
|
// 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"`
|
|
TranscodeAllowed bool `json:"transcode_allowed"`
|
|
AudioTranscodeAllowed bool `json:"audio_transcode_allowed"`
|
|
MaxProfiles int `json:"max_profiles"`
|
|
DownloadAllowed bool `json:"download_allowed"`
|
|
DownloadTranscodeAllowed bool `json:"download_transcode_allowed"`
|
|
AccessGroupID *int64 `json:"access_group_id"`
|
|
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 {
|
|
resp := 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,
|
|
TranscodeAllowed: u.TranscodeAllowed,
|
|
AudioTranscodeAllowed: u.AudioTranscodeAllowed,
|
|
MaxProfiles: u.MaxProfiles,
|
|
DownloadAllowed: u.DownloadAllowed,
|
|
DownloadTranscodeAllowed: u.DownloadTranscodeAllowed,
|
|
CreatedAt: u.CreatedAt,
|
|
UpdatedAt: u.UpdatedAt,
|
|
}
|
|
if u.AccessGroupID != nil {
|
|
id := *u.AccessGroupID
|
|
resp.AccessGroupID = &id
|
|
}
|
|
return resp
|
|
}
|
|
|
|
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, ×tamp); 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 = ×tamp
|
|
}
|
|
}
|
|
|
|
// --- 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.WarnContext(r.Context(), "failed to load admin user last activity", "component", "api", "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.WarnContext(r.Context(), "failed to load admin user last activity", "component", "api", "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 := auth.DefaultUserPermissions()
|
|
if req.Permissions.Set {
|
|
permissions = req.Permissions.Value
|
|
}
|
|
permissions, err := auth.NormalizePermissions(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,
|
|
TranscodeAllowed: req.TranscodeAllowed,
|
|
AudioTranscodeAllowed: req.AudioTranscodeAllowed,
|
|
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
|
|
}
|
|
if req.AccessGroupID.Set {
|
|
if req.AccessGroupID.Value != nil && *req.AccessGroupID.Value <= 0 {
|
|
writeError(w, http.StatusUnprocessableEntity, "unprocessable_entity", "Invalid access_group_id")
|
|
return
|
|
}
|
|
if req.AccessGroupID.Value != nil {
|
|
if h.AccessGroups == nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Access groups are not configured")
|
|
return
|
|
}
|
|
if _, err := h.AccessGroups.Get(r.Context(), *req.AccessGroupID.Value); err != nil {
|
|
if errors.Is(err, access.ErrGroupNotFound) {
|
|
writeError(w, http.StatusUnprocessableEntity, "unprocessable_entity", "Invalid access_group_id")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to validate access group")
|
|
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
|
|
}
|
|
|
|
updateInput := 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,
|
|
TranscodeAllowed: req.TranscodeAllowed,
|
|
AudioTranscodeAllowed: req.AudioTranscodeAllowed,
|
|
MaxProfiles: req.MaxProfiles,
|
|
DownloadAllowed: req.DownloadAllowed,
|
|
DownloadTranscodeAllowed: req.DownloadTranscodeAllowed,
|
|
AccessGroupIDSet: req.AccessGroupID.Set,
|
|
AccessGroupID: req.AccessGroupID.Value,
|
|
}
|
|
|
|
var currentUser *models.User
|
|
if updateMayRequireSessionRevocation(updateInput) {
|
|
currentUser, err = h.userRepo.GetByID(r.Context(), id)
|
|
if err != nil {
|
|
if auth.IsNotFound(err) {
|
|
writeError(w, http.StatusNotFound, "not_found", "User not found")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch user")
|
|
return
|
|
}
|
|
}
|
|
|
|
err = h.userRepo.Update(r.Context(), id, updateInput)
|
|
if err != nil {
|
|
if auth.IsNotFound(err) {
|
|
writeError(w, http.StatusNotFound, "not_found", "User not found")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update user")
|
|
return
|
|
}
|
|
if updateRequiresSessionRevocation(currentUser, updateInput) {
|
|
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 updateMayRequireSessionRevocation(input models.UpdateUserInput) bool {
|
|
return input.Password != nil ||
|
|
input.Role != nil ||
|
|
input.Enabled != nil ||
|
|
input.Permissions != nil ||
|
|
input.MaxPlaybackQuality != nil ||
|
|
input.AccessGroupIDSet
|
|
}
|
|
|
|
func updateRequiresSessionRevocation(current *models.User, input models.UpdateUserInput) bool {
|
|
if input.Password != nil {
|
|
return true
|
|
}
|
|
if current == nil {
|
|
return updateMayRequireSessionRevocation(input)
|
|
}
|
|
if input.Role != nil && *input.Role != current.Role {
|
|
return true
|
|
}
|
|
if input.Enabled != nil && *input.Enabled != current.Enabled {
|
|
return true
|
|
}
|
|
if input.Permissions != nil && !slices.Equal(*input.Permissions, current.Permissions) {
|
|
return true
|
|
}
|
|
if input.MaxPlaybackQuality != nil &&
|
|
access.NormalizePlaybackQuality(*input.MaxPlaybackQuality) != access.NormalizePlaybackQuality(current.MaxPlaybackQuality) {
|
|
return true
|
|
}
|
|
if input.AccessGroupIDSet && !accessGroupIDEqual(input.AccessGroupID, current.AccessGroupID) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func accessGroupIDEqual(a, b *int64) bool {
|
|
if a == nil || b == nil {
|
|
return a == b
|
|
}
|
|
return *a == *b
|
|
}
|
|
|
|
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 AND extra_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 {
|
|
if isTruthyQuery(r.URL.Query().Get("refresh")) {
|
|
h.StatsSource.Invalidate()
|
|
}
|
|
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 isTruthyQuery(value string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
case "1", "true", "yes", "on":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
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.WarnContext(ctx, "admin: failed to publish stats invalidation event", "component", "api",
|
|
"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.ErrorContext(r.Context(), "admin: resolve item refresh scope failed", "component", "api", "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.ErrorContext(r.Context(), "admin: create item refresh job failed", "component", "api", "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.ErrorContext(r.Context(), "admin: update item metadata failed", "component", "api", "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.ErrorContext(r.Context(), "admin: update season metadata failed", "component", "api", "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.ErrorContext(r.Context(), "admin: update episode metadata failed", "component", "api", "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.ErrorContext(r.Context(), "admin: fetch updated detail failed", "component", "api", "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 ---
|
|
|
|
// sensitiveSettingKeys is the audited allowlist of secret-bearing settings
|
|
// keys, shared with the at-rest encryption decorator so redaction and
|
|
// encryption can never drift apart. See catalog.SensitiveSettingKeys.
|
|
var sensitiveSettingKeys = catalog.SensitiveSettingKeys
|
|
|
|
// 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)
|
|
}
|
|
|
|
// HandleGetEffectiveSettings handles GET /admin/settings/effective. Unlike the
|
|
// legacy raw endpoint, missing rows are populated with the exact defaults used
|
|
// by runtime readers so an untouched form always describes active behavior.
|
|
func (h *AdminHandler) HandleGetEffectiveSettings(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
|
|
}
|
|
effective := h.effectiveAdminSettings(all)
|
|
for key := range sensitiveSettingKeys {
|
|
delete(effective, key)
|
|
}
|
|
writeJSON(w, http.StatusOK, effective)
|
|
}
|
|
|
|
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 {
|
|
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"`
|
|
// RestartRequired reports whether the saved value only takes effect
|
|
// after a server restart (set on update responses only).
|
|
RestartRequired bool `json:"restart_required,omitempty"`
|
|
}
|
|
|
|
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"`
|
|
OverrideCount int `json:"override_count"`
|
|
ProfileCount int `json:"profile_count"`
|
|
Profiles []adminDeviceProfileSummary `json:"profiles"`
|
|
LastUpdated string `json:"last_updated"`
|
|
Settings []adminDeviceSettingResponse `json:"settings"`
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
devices, err := listRegisteredDevices(gctx, store)
|
|
if err != nil {
|
|
return fmt.Errorf("list devices: %w", err)
|
|
}
|
|
profileNames, err := listProfileNamesByID(gctx, store)
|
|
if err != nil {
|
|
slog.WarnContext(r.Context(), "admin list devices profile lookup failed", "component", "api",
|
|
"user_id", user.ID,
|
|
"error", err,
|
|
)
|
|
profileNames = map[string]string{}
|
|
}
|
|
perUser[i] = buildAdminDeviceSummaries(
|
|
user.ID,
|
|
user.Username,
|
|
user.Email,
|
|
entries,
|
|
devices,
|
|
profileNames,
|
|
)
|
|
return nil
|
|
})
|
|
}
|
|
if err := g.Wait(); err != nil {
|
|
slog.ErrorContext(r.Context(), "admin list devices failed", "component", "api", "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
|
|
}
|
|
registeredDevices, err := listRegisteredDevices(r.Context(), store)
|
|
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)
|
|
}
|
|
}
|
|
deviceRegistrations := make([]userstore.DeviceEntry, 0)
|
|
for _, entry := range registeredDevices {
|
|
if entry.DeviceID == deviceID {
|
|
deviceRegistrations = append(deviceRegistrations, entry)
|
|
}
|
|
}
|
|
summaries := buildAdminDeviceSummaries(
|
|
user.ID,
|
|
user.Username,
|
|
user.Email,
|
|
deviceEntries,
|
|
deviceRegistrations,
|
|
profileNames,
|
|
)
|
|
if len(summaries) == 0 {
|
|
writeError(w, http.StatusNotFound, "not_found", "Device not found")
|
|
return
|
|
}
|
|
|
|
summary := summaries[0]
|
|
writeJSON(w, http.StatusOK, adminDeviceDetailResponse{
|
|
UserID: user.ID,
|
|
Username: user.Username,
|
|
Email: user.Email,
|
|
DeviceID: summary.DeviceID,
|
|
DeviceName: summary.DeviceName,
|
|
DevicePlatform: summary.DevicePlatform,
|
|
OverrideCount: summary.OverrideCount,
|
|
ProfileCount: summary.ProfileCount,
|
|
Profiles: summary.Profiles,
|
|
LastUpdated: summary.LastUpdated,
|
|
Settings: buildAdminDeviceSettingsResponse(user.ID, profileNames, deviceEntries).Settings,
|
|
})
|
|
}
|
|
|
|
func listRegisteredDevices(ctx context.Context, store userstore.UserStore) ([]userstore.DeviceEntry, error) {
|
|
registry, ok := store.(userstore.DeviceRegistry)
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
return registry.ListDevices(ctx)
|
|
}
|
|
|
|
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,
|
|
registeredDevices []userstore.DeviceEntry,
|
|
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)
|
|
|
|
ensureDevice := func(deviceID, deviceName, devicePlatform, lastUpdated string) *summary {
|
|
if deviceID == "" {
|
|
return nil
|
|
}
|
|
current, ok := byDevice[deviceID]
|
|
if !ok {
|
|
current = &summary{
|
|
device: adminDeviceSummaryResponse{
|
|
UserID: userID,
|
|
Username: username,
|
|
Email: email,
|
|
DeviceID: deviceID,
|
|
DeviceName: deviceName,
|
|
DevicePlatform: devicePlatform,
|
|
LastUpdated: lastUpdated,
|
|
},
|
|
keys: make(map[string]struct{}),
|
|
profiles: make(map[string]*profileAccumulator),
|
|
}
|
|
byDevice[deviceID] = current
|
|
}
|
|
if current.device.DeviceName == "" && deviceName != "" {
|
|
current.device.DeviceName = deviceName
|
|
}
|
|
if current.device.DevicePlatform == "" && devicePlatform != "" {
|
|
current.device.DevicePlatform = devicePlatform
|
|
}
|
|
if lastUpdated > current.device.LastUpdated {
|
|
current.device.LastUpdated = lastUpdated
|
|
if deviceName != "" {
|
|
current.device.DeviceName = deviceName
|
|
}
|
|
if devicePlatform != "" {
|
|
current.device.DevicePlatform = devicePlatform
|
|
}
|
|
}
|
|
return current
|
|
}
|
|
|
|
ensureProfile := func(current *summary, profileID, lastUpdated string) *profileAccumulator {
|
|
if current == nil || profileID == "" {
|
|
return nil
|
|
}
|
|
profile, exists := current.profiles[profileID]
|
|
if !exists {
|
|
profile = &profileAccumulator{
|
|
summary: adminDeviceProfileSummary{
|
|
ProfileID: profileID,
|
|
ProfileName: profileNames[profileID],
|
|
LastUpdated: lastUpdated,
|
|
},
|
|
keys: make(map[string]struct{}),
|
|
}
|
|
current.profiles[profileID] = profile
|
|
return profile
|
|
}
|
|
if lastUpdated > profile.summary.LastUpdated {
|
|
profile.summary.LastUpdated = lastUpdated
|
|
}
|
|
return profile
|
|
}
|
|
|
|
for _, device := range registeredDevices {
|
|
deviceID := strings.TrimSpace(device.DeviceID)
|
|
profileID := strings.TrimSpace(device.ProfileID)
|
|
current := ensureDevice(
|
|
deviceID,
|
|
device.DeviceName,
|
|
device.DevicePlatform,
|
|
device.LastSeenAt,
|
|
)
|
|
ensureProfile(current, profileID, device.LastSeenAt)
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
deviceID := strings.TrimSpace(entry.DeviceID)
|
|
profileID := strings.TrimSpace(entry.ProfileID)
|
|
current := ensureDevice(
|
|
deviceID,
|
|
entry.DeviceName,
|
|
entry.DevicePlatform,
|
|
entry.UpdatedAt,
|
|
)
|
|
if current == nil {
|
|
continue
|
|
}
|
|
if profileID != "" && entry.Key != "" {
|
|
current.keys[profileID+":"+entry.Key] = struct{}{}
|
|
}
|
|
profile := ensureProfile(current, profileID, entry.UpdatedAt)
|
|
if profile != nil && entry.Key != "" {
|
|
profile.keys[entry.Key] = struct{}{}
|
|
}
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
type updateSettingsRequest struct {
|
|
Values map[string]string `json:"values"`
|
|
}
|
|
|
|
type updateSettingsResponse struct {
|
|
Values map[string]string `json:"values"`
|
|
RestartRequired bool `json:"restart_required"`
|
|
RestartRequiredKeys []string `json:"restart_required_keys,omitempty"`
|
|
}
|
|
|
|
func (h *AdminHandler) normalizeBatchSetting(ctx context.Context, key, value string) (string, string, error) {
|
|
if strings.HasPrefix(key, "ratelimit.") {
|
|
return "", "bad_request", fmt.Errorf("%s is managed by /admin/rate-limits/config", key)
|
|
}
|
|
normalized, err := config.NormalizeAdminSetting(key, value)
|
|
if err != nil {
|
|
return "", "bad_request", err
|
|
}
|
|
|
|
switch key {
|
|
case markers.SettingMode, markers.SettingLazyPlayback:
|
|
normalized, err = markers.NormalizeSetting(key, normalized)
|
|
case clientip.SettingTrustedProxies:
|
|
normalized, err = clientip.NormalizeCIDRList(normalized)
|
|
if err != nil {
|
|
err = fmt.Errorf("clientip.trusted_proxies must be a comma-separated list of CIDRs: %w", err)
|
|
}
|
|
case "ai.asr_base_url":
|
|
if llm.IsChatOnlyGateway(normalized) {
|
|
err = errors.New("this endpoint cannot produce timestamped transcriptions; use a Whisper-compatible transcription endpoint")
|
|
}
|
|
case diagnostics.KeyUploadsEnabled:
|
|
if normalized == "true" {
|
|
if err = h.validateDiagnosticsUploadsEnabled(ctx); err != nil {
|
|
return "", "storage_unavailable", err
|
|
}
|
|
}
|
|
case diagnostics.KeyMaxBundleBytes,
|
|
diagnostics.KeyMaxUncompressedBytes,
|
|
diagnostics.KeyMaxReportsPerUserDay,
|
|
diagnostics.KeyRetentionDays,
|
|
diagnostics.KeyMaxBytesPerUser:
|
|
var numericValue int64
|
|
numericValue, err = normalizeDiagnosticsNumericSettingValue(key, normalized)
|
|
if err == nil {
|
|
normalized = strconv.FormatInt(numericValue, 10)
|
|
}
|
|
case diagnostics.KeyConsentNoticeVersion:
|
|
var n int
|
|
n, err = strconv.Atoi(normalized)
|
|
if err == nil && n < 1 {
|
|
err = fmt.Errorf("%s must be an integer greater than 0", key)
|
|
}
|
|
if err == nil {
|
|
normalized = strconv.Itoa(n)
|
|
}
|
|
case notifications.SettingPushRelayURL,
|
|
notifications.SettingPushRelayDeploymentID,
|
|
notifications.SettingPushRelayAPIKey,
|
|
notifications.SettingPushRelayExpiresAt,
|
|
notifications.SettingPushRelayKeyPrefix,
|
|
notifications.SettingPushRelayReregister:
|
|
err = fmt.Errorf("%s is managed by the push relay registration flow", key)
|
|
case catalog.SearchSettingMeilisearchIndex:
|
|
if normalized == "" {
|
|
err = fmt.Errorf("%s is required", key)
|
|
}
|
|
case catalog.SearchSettingMeilisearchIndexTypes:
|
|
var itemTypes []string
|
|
itemTypes, err = catalog.NormalizeCatalogSearchIndexTypesValue(normalized)
|
|
if err == nil {
|
|
normalized = catalog.FormatCatalogSearchIndexTypesValue(itemTypes)
|
|
}
|
|
case catalog.SearchSettingMeilisearchEmbedder:
|
|
normalized, err = catalog.NormalizeCatalogSearchEmbedderName(normalized)
|
|
}
|
|
if err != nil {
|
|
return "", "bad_request", err
|
|
}
|
|
return normalized, "", nil
|
|
}
|
|
|
|
func validateProspectiveAdminSettings(values map[string]string, redisBootstrapAvailable bool) error {
|
|
if err := config.ValidateAdminSettingsWithCapabilities(values, config.AdminSettingsCapabilities{
|
|
RedisBootstrapAvailable: redisBootstrapAvailable,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if _, err := catalog.CatalogSearchSettingsFromMap(values); err != nil {
|
|
return err
|
|
}
|
|
return validateProspectiveDiagnosticsSettings(values)
|
|
}
|
|
|
|
var adminSettingDependencyGroups = [][]string{
|
|
{"auth.access_token_expiry", "auth.refresh_token_expiry"},
|
|
{"playback.watched_threshold", "playback.min_resume_threshold"},
|
|
{"s3.public_endpoint", "s3.public_bucket"},
|
|
{"s3.public_access_key", "s3.public_secret_key"},
|
|
{"s3.private_endpoint", "s3.private_bucket"},
|
|
{"s3.private_access_key", "s3.private_secret_key"},
|
|
{"s3.public_url_auth", "s3.public_read_endpoint", "s3.public_token_secret"},
|
|
{"email.enabled", "email.smtp_host", "email.from_address"},
|
|
{"watchsync.trakt.client_id", "watchsync.trakt.client_secret"},
|
|
{"watchsync.simkl.client_id", "watchsync.simkl.client_secret"},
|
|
{"ratelimit.backend", "redis.url"},
|
|
{"download.max_per_period", "download.period_duration"},
|
|
{"matcher.enable_tv_series_root_queue", "matcher.enable_tv_series_group_queue"},
|
|
{"ai.max_concurrent_jobs", "subtitle_ai.max_concurrent_jobs"},
|
|
{
|
|
diagnostics.KeyMaxBundleBytes,
|
|
diagnostics.KeyMaxUncompressedBytes,
|
|
diagnostics.KeyMaxReportsPerUserDay,
|
|
diagnostics.KeyRetentionDays,
|
|
diagnostics.KeyMaxBytesPerUser,
|
|
},
|
|
}
|
|
|
|
// adminSettingsValidationSnapshot validates exactly the requested changes and
|
|
// the current values they depend on. The legacy single-key endpoint predates
|
|
// cross-field validation, so any relationship (or catalog value) can already
|
|
// be invalid in storage. Untouched legacy state must not poison an unrelated
|
|
// batch, while touching any member pulls the complete dependency group into the
|
|
// snapshot so a new or still-invalid relationship is rejected.
|
|
func adminSettingsValidationSnapshot(
|
|
prospective map[string]string,
|
|
changed map[string]string,
|
|
) map[string]string {
|
|
snapshot := config.EffectiveAdminSettings(nil)
|
|
effectiveProspective := config.EffectiveAdminSettings(prospective)
|
|
for key, value := range changed {
|
|
snapshot[key] = value
|
|
}
|
|
for _, group := range adminSettingDependencyGroups {
|
|
touched := false
|
|
for _, key := range group {
|
|
if _, ok := changed[key]; ok {
|
|
touched = true
|
|
break
|
|
}
|
|
}
|
|
if !touched {
|
|
continue
|
|
}
|
|
for _, key := range group {
|
|
snapshot[key] = effectiveProspective[key]
|
|
}
|
|
}
|
|
|
|
// Operational S3 values are legacy fallbacks shared by the public and
|
|
// private configurations. When one changes, validate the canonical values
|
|
// that LoadFromDB will actually consume, including unchanged legacy peers.
|
|
legacyS3Changed := false
|
|
for key := range changed {
|
|
if strings.HasPrefix(key, "s3.operational_") {
|
|
legacyS3Changed = true
|
|
break
|
|
}
|
|
}
|
|
if legacyS3Changed {
|
|
//nolint:goconst // Keep the complete canonical validation set readable as a contract.
|
|
for _, key := range []string{
|
|
"s3.public_endpoint",
|
|
"s3.public_read_endpoint",
|
|
"s3.public_region",
|
|
"s3.public_path_style",
|
|
"s3.public_bucket",
|
|
"s3.public_key_prefix",
|
|
"s3.public_access_key",
|
|
"s3.public_secret_key",
|
|
"s3.public_url_auth",
|
|
"s3.public_token_secret",
|
|
"s3.public_token_param",
|
|
"s3.public_token_ttl",
|
|
"s3.private_endpoint",
|
|
"s3.private_region",
|
|
"s3.private_path_style",
|
|
"s3.private_bucket",
|
|
"s3.private_key_prefix",
|
|
"s3.private_access_key",
|
|
"s3.private_secret_key",
|
|
} {
|
|
snapshot[key] = effectiveProspective[key]
|
|
}
|
|
}
|
|
return snapshot
|
|
}
|
|
|
|
// activeAdminSettings overlays values owned by the process environment onto a
|
|
// stored snapshot. Updates never persist these values, but cross-field
|
|
// validation and effective-value comparisons must use the same configuration
|
|
// the runtime is actually consuming.
|
|
func (h *AdminHandler) activeAdminSettings(stored map[string]string) map[string]string {
|
|
active := make(map[string]string, len(stored)+len(h.BootstrapSensitiveValues))
|
|
for key, value := range stored {
|
|
active[key] = value
|
|
}
|
|
for key, value := range h.BootstrapSensitiveValues {
|
|
if h.BootstrapSensitiveConfigured[key] && value != "" {
|
|
active[key] = value
|
|
}
|
|
}
|
|
return active
|
|
}
|
|
|
|
func (h *AdminHandler) effectiveAdminSettings(stored map[string]string) map[string]string {
|
|
return config.EffectiveAdminSettings(h.activeAdminSettings(stored))
|
|
}
|
|
|
|
func shouldPersistAdminSetting(stored map[string]string, key, normalized string, effectiveChanged bool) bool {
|
|
current, exists := stored[key]
|
|
if exists {
|
|
return current != normalized
|
|
}
|
|
// Do not create a row merely because a client resubmitted an untouched
|
|
// runtime default. Non-default values still need a row, while clearing an
|
|
// already-absent override is a storage no-op.
|
|
return normalized != "" && effectiveChanged
|
|
}
|
|
|
|
// HandleUpdateSettings handles PUT /admin/settings. Every requested value is
|
|
// normalized and validated with the prospective values it depends on before
|
|
// SetMany performs one transaction, so a multi-field save is all-or-nothing.
|
|
func (h *AdminHandler) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
|
if h.SettingsRepo == nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Settings store not configured")
|
|
return
|
|
}
|
|
|
|
var req updateSettingsRequest
|
|
decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
if len(req.Values) == 0 {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "At least one setting is required")
|
|
return
|
|
}
|
|
if len(req.Values) > 250 {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "A settings update may contain at most 250 values")
|
|
return
|
|
}
|
|
|
|
keys := make([]string, 0, len(req.Values))
|
|
for key := range req.Values {
|
|
if strings.TrimSpace(key) == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required")
|
|
return
|
|
}
|
|
if h.BootstrapSensitiveConfigured[key] {
|
|
writeError(w, http.StatusBadRequest, "managed_by_environment", key+" is managed by an environment variable")
|
|
return
|
|
}
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
normalized := make(map[string]string, len(req.Values))
|
|
for _, key := range keys {
|
|
value, code, err := h.normalizeBatchSetting(r.Context(), key, req.Values[key])
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, code, err.Error())
|
|
return
|
|
}
|
|
normalized[key] = value
|
|
}
|
|
|
|
var (
|
|
after map[string]string
|
|
effectiveChanges map[string]bool
|
|
validationErr error
|
|
)
|
|
err := updateServerSettingsAtomically(r.Context(), h.SettingsRepo,
|
|
func(stored map[string]string) (map[string]string, error) {
|
|
prospective := maps.Clone(stored)
|
|
for key, value := range normalized {
|
|
prospective[key] = value
|
|
}
|
|
activeProspective := h.activeAdminSettings(prospective)
|
|
validationSnapshot := adminSettingsValidationSnapshot(activeProspective, normalized)
|
|
if err := validateProspectiveAdminSettings(validationSnapshot, h.RedisBootstrapAvailable); err != nil {
|
|
validationErr = err
|
|
return nil, err
|
|
}
|
|
before := h.effectiveAdminSettings(stored)
|
|
after = h.effectiveAdminSettings(prospective)
|
|
writes := make(map[string]string, len(normalized))
|
|
effectiveChanges = make(map[string]bool, len(normalized))
|
|
for key, value := range normalized {
|
|
effectiveChanged := before[key] != after[key]
|
|
if shouldPersistAdminSetting(stored, key, value, effectiveChanged) {
|
|
writes[key] = value
|
|
}
|
|
if effectiveChanged {
|
|
effectiveChanges[key] = true
|
|
}
|
|
}
|
|
return writes, nil
|
|
})
|
|
if validationErr != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_settings", validationErr.Error())
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update settings")
|
|
return
|
|
}
|
|
|
|
responseValues := make(map[string]string, len(normalized))
|
|
restartKeys := make([]string, 0, len(normalized))
|
|
for _, key := range keys {
|
|
if !sensitiveSettingKeys[key] {
|
|
responseValues[key] = after[key]
|
|
}
|
|
if !effectiveChanges[key] {
|
|
continue
|
|
}
|
|
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, after[key])
|
|
}
|
|
if config.RestartRequired(key) {
|
|
restartKeys = append(restartKeys, key)
|
|
}
|
|
}
|
|
if len(restartKeys) > 0 {
|
|
h.markServerRestartRequired("server_settings")
|
|
}
|
|
writeJSON(w, http.StatusOK, updateSettingsResponse{
|
|
Values: responseValues,
|
|
RestartRequired: len(restartKeys) > 0,
|
|
RestartRequiredKeys: restartKeys,
|
|
})
|
|
}
|
|
|
|
// 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
|
|
}
|
|
if h.BootstrapSensitiveConfigured[key] {
|
|
writeError(w, http.StatusBadRequest, "managed_by_environment", key+" is managed by an environment variable")
|
|
return
|
|
}
|
|
if strings.HasPrefix(key, "ratelimit.") {
|
|
writeError(w, http.StatusBadRequest, "bad_request", key+" is managed by /admin/rate-limits/config")
|
|
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 normalized, err := config.NormalizeAdminSetting(key, req.Value); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
} else {
|
|
req.Value = normalized
|
|
}
|
|
|
|
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
|
|
}
|
|
case clientip.SettingTrustedProxies:
|
|
normalized, err := clientip.NormalizeCIDRList(req.Value)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request",
|
|
"clientip.trusted_proxies must be a comma-separated list of CIDRs: "+err.Error())
|
|
return
|
|
}
|
|
req.Value = normalized
|
|
case "ai.asr_base_url":
|
|
if llm.IsChatOnlyGateway(req.Value) {
|
|
writeError(w, http.StatusBadRequest, "bad_request",
|
|
"This endpoint cannot produce timestamped transcriptions (chat-only gateway). "+
|
|
"Use a self-hosted Whisper server (faster-whisper/speaches), api.groq.com/openai, or api.openai.com.")
|
|
return
|
|
}
|
|
case "metadata_ai.on_view":
|
|
switch req.Value {
|
|
case "off", "button", "auto":
|
|
default:
|
|
writeError(w, http.StatusBadRequest, "bad_request",
|
|
"metadata_ai.on_view must be off, button, or auto")
|
|
return
|
|
}
|
|
case policy.SettingDecisionLogVerbosity:
|
|
switch strings.TrimSpace(strings.ToLower(req.Value)) {
|
|
case policy.DecisionLogVerbosityDigest, policy.DecisionLogVerbosityVerbose:
|
|
req.Value = strings.TrimSpace(strings.ToLower(req.Value))
|
|
default:
|
|
writeError(w, http.StatusBadRequest, "bad_request",
|
|
"policy.decision_log_verbosity must be digest or verbose")
|
|
return
|
|
}
|
|
case "policy.editor_enabled":
|
|
enabled, err := strconv.ParseBool(strings.TrimSpace(req.Value))
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "policy.editor_enabled must be true or false")
|
|
return
|
|
}
|
|
req.Value = strconv.FormatBool(enabled)
|
|
case diagnostics.KeyUploadsEnabled:
|
|
enabled, err := strconv.ParseBool(strings.TrimSpace(req.Value))
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", diagnostics.KeyUploadsEnabled+" must be true or false")
|
|
return
|
|
}
|
|
req.Value = strconv.FormatBool(enabled)
|
|
if enabled {
|
|
if err := h.validateDiagnosticsUploadsEnabled(r.Context()); err != nil {
|
|
writeError(w, http.StatusBadRequest, "storage_unavailable", err.Error())
|
|
return
|
|
}
|
|
}
|
|
case diagnostics.KeyMaxBundleBytes,
|
|
diagnostics.KeyMaxUncompressedBytes,
|
|
diagnostics.KeyMaxReportsPerUserDay,
|
|
diagnostics.KeyRetentionDays,
|
|
diagnostics.KeyMaxBytesPerUser:
|
|
normalized, err := h.normalizeDiagnosticsNumericSetting(r.Context(), key, req.Value)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
req.Value = normalized
|
|
case diagnostics.KeyConsentNoticeVersion:
|
|
n, err := strconv.Atoi(strings.TrimSpace(req.Value))
|
|
if err != nil || n < 1 {
|
|
writeError(w, http.StatusBadRequest, "bad_request",
|
|
diagnostics.KeyConsentNoticeVersion+" must be an integer greater than 0")
|
|
return
|
|
}
|
|
req.Value = strconv.Itoa(n)
|
|
case policy.SettingDecisionLogScopeSampleRate:
|
|
n, err := strconv.Atoi(strings.TrimSpace(req.Value))
|
|
if err != nil || n <= 0 {
|
|
writeError(w, http.StatusBadRequest, "bad_request",
|
|
"policy.decision_log_scope_sample_rate must be an integer greater than 0")
|
|
return
|
|
}
|
|
req.Value = strconv.Itoa(n)
|
|
case policy.SettingDecisionLogRetentionDays:
|
|
n, err := strconv.Atoi(strings.TrimSpace(req.Value))
|
|
if err != nil || n <= 0 {
|
|
writeError(w, http.StatusBadRequest, "bad_request",
|
|
"policy.decision_log_retention_days must be an integer greater than 0")
|
|
return
|
|
}
|
|
req.Value = strconv.Itoa(n)
|
|
case "subtitle_ai.transcribe_quota_jobs":
|
|
if n, err := strconv.Atoi(req.Value); err != nil || n < 0 {
|
|
writeError(w, http.StatusBadRequest, "bad_request",
|
|
"subtitle_ai.transcribe_quota_jobs must be an integer >= 0 (0 = unlimited)")
|
|
return
|
|
}
|
|
case "subtitle_ai.transcribe_quota_period":
|
|
if !subtitleai.ValidQuotaPeriod(req.Value) {
|
|
writeError(w, http.StatusBadRequest, "bad_request",
|
|
"subtitle_ai.transcribe_quota_period must be day, week, or month")
|
|
return
|
|
}
|
|
case notifications.SettingApplePushDeliveryEnabled,
|
|
notifications.SettingAndroidPushDeliveryEnabled:
|
|
enabled, err := strconv.ParseBool(strings.TrimSpace(req.Value))
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", key+" must be true or false")
|
|
return
|
|
}
|
|
req.Value = strconv.FormatBool(enabled)
|
|
case notifications.SettingPushRelayURL,
|
|
notifications.SettingPushRelayDeploymentID,
|
|
notifications.SettingPushRelayAPIKey,
|
|
notifications.SettingPushRelayExpiresAt,
|
|
notifications.SettingPushRelayKeyPrefix,
|
|
notifications.SettingPushRelayReregister:
|
|
// The registration flow persists the relay URL, deployment id, and API
|
|
// key together; a direct write to any of them desyncs the stored URL
|
|
// from the credentials the relay minted for it (and feeds an arbitrary
|
|
// id into the next rotation request).
|
|
writeError(w, http.StatusBadRequest, "bad_request",
|
|
key+" is managed by the push relay registration flow; use POST /admin/notifications/push/relay/register")
|
|
return
|
|
case catalog.SearchSettingProvider:
|
|
switch strings.TrimSpace(strings.ToLower(req.Value)) {
|
|
case catalog.SearchProviderPostgres, catalog.SearchProviderMeilisearch:
|
|
req.Value = strings.TrimSpace(strings.ToLower(req.Value))
|
|
default:
|
|
writeError(w, http.StatusBadRequest, "bad_request", "catalog.search.provider must be postgres or meilisearch")
|
|
return
|
|
}
|
|
case catalog.SearchSettingMeilisearchURL:
|
|
value := strings.TrimSpace(req.Value)
|
|
if value != "" {
|
|
parsed, err := url.Parse(value)
|
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "catalog.search.meilisearch.url must include scheme and host")
|
|
return
|
|
}
|
|
}
|
|
req.Value = value
|
|
case catalog.SearchSettingMeilisearchIndex:
|
|
req.Value = strings.TrimSpace(req.Value)
|
|
if req.Value == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "catalog.search.meilisearch.index is required")
|
|
return
|
|
}
|
|
case catalog.SearchSettingMeilisearchTimeoutMS:
|
|
n, err := strconv.Atoi(strings.TrimSpace(req.Value))
|
|
if err != nil || n <= 0 {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "catalog.search.meilisearch.timeout_ms must be an integer greater than 0")
|
|
return
|
|
}
|
|
req.Value = strconv.Itoa(n)
|
|
case catalog.SearchSettingMeilisearchMatchingStrategy:
|
|
switch strings.TrimSpace(strings.ToLower(req.Value)) {
|
|
case "last", "all":
|
|
req.Value = strings.TrimSpace(strings.ToLower(req.Value))
|
|
default:
|
|
writeError(w, http.StatusBadRequest, "bad_request", "catalog.search.meilisearch.matching_strategy must be last or all")
|
|
return
|
|
}
|
|
case catalog.SearchSettingMeilisearchSyncBatchSize:
|
|
n, err := strconv.Atoi(strings.TrimSpace(req.Value))
|
|
if err != nil || n < 1 || n > catalog.MaxMeilisearchSyncBatchSize {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "catalog.search.meilisearch.sync_batch_size must be an integer between 1 and 10000")
|
|
return
|
|
}
|
|
req.Value = strconv.Itoa(n)
|
|
case catalog.SearchSettingMeilisearchRebuildBatchSize:
|
|
n, err := strconv.Atoi(strings.TrimSpace(req.Value))
|
|
if err != nil || n < 1 || n > catalog.MaxMeilisearchRebuildBatchSize {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "catalog.search.meilisearch.rebuild_batch_size must be an integer between 1 and 25000")
|
|
return
|
|
}
|
|
req.Value = strconv.Itoa(n)
|
|
case catalog.SearchSettingMeilisearchRebuildQueue:
|
|
n, err := strconv.Atoi(strings.TrimSpace(req.Value))
|
|
if err != nil || n < 1 || n > catalog.MaxMeilisearchRebuildQueueDepth {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "catalog.search.meilisearch.rebuild_task_queue_depth must be an integer between 1 and 16")
|
|
return
|
|
}
|
|
req.Value = strconv.Itoa(n)
|
|
case catalog.SearchSettingMeilisearchIndexTypes:
|
|
itemTypes, err := catalog.NormalizeCatalogSearchIndexTypesValue(req.Value)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
req.Value = catalog.FormatCatalogSearchIndexTypesValue(itemTypes)
|
|
case catalog.SearchSettingMeilisearchSemanticEnabled:
|
|
enabled, err := strconv.ParseBool(strings.TrimSpace(req.Value))
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "catalog.search.meilisearch.semantic_enabled must be true or false")
|
|
return
|
|
}
|
|
req.Value = strconv.FormatBool(enabled)
|
|
case catalog.SearchSettingMeilisearchSemanticRatio:
|
|
ratio, err := strconv.ParseFloat(strings.TrimSpace(req.Value), 64)
|
|
if err != nil || math.IsNaN(ratio) || ratio < 0 || ratio > 1 {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "catalog.search.meilisearch.semantic_ratio must be a number between 0 and 1")
|
|
return
|
|
}
|
|
req.Value = strconv.FormatFloat(ratio, 'f', -1, 64)
|
|
case catalog.SearchSettingMeilisearchEmbedder:
|
|
embedder, err := catalog.NormalizeCatalogSearchEmbedderName(req.Value)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
req.Value = embedder
|
|
case catalog.SearchSettingMeilisearchBinaryQuantized:
|
|
enabled, err := strconv.ParseBool(strings.TrimSpace(req.Value))
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "catalog.search.meilisearch.binary_quantized must be true or false")
|
|
return
|
|
}
|
|
req.Value = strconv.FormatBool(enabled)
|
|
}
|
|
|
|
var (
|
|
after map[string]string
|
|
effectiveChanged bool
|
|
validationErr error
|
|
)
|
|
err := updateServerSettingsAtomically(r.Context(), h.SettingsRepo,
|
|
func(stored map[string]string) (map[string]string, error) {
|
|
prospective := maps.Clone(stored)
|
|
prospective[key] = req.Value
|
|
// This legacy route can only change one key, so enforcing every
|
|
// cross-field invariant would make paired settings impossible to
|
|
// establish or clear one write at a time. Per-key validation above
|
|
// remains strict; Redis transport is the one durable prerequisite
|
|
// that may not be broken by a single-key write.
|
|
if key == "redis.url" {
|
|
if err := config.ValidateRedisRateLimitTransport(
|
|
h.activeAdminSettings(prospective),
|
|
h.RedisBootstrapAvailable,
|
|
); err != nil {
|
|
validationErr = err
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
before := h.effectiveAdminSettings(stored)
|
|
after = h.effectiveAdminSettings(prospective)
|
|
effectiveChanged = before[key] != after[key]
|
|
if shouldPersistAdminSetting(stored, key, req.Value, effectiveChanged) {
|
|
return map[string]string{key: req.Value}, nil
|
|
}
|
|
return nil, nil
|
|
})
|
|
if validationErr != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_settings", validationErr.Error())
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update setting")
|
|
return
|
|
}
|
|
if effectiveChanged {
|
|
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, after[key])
|
|
}
|
|
}
|
|
restartRequired := effectiveChanged && config.RestartRequired(key)
|
|
if restartRequired {
|
|
h.markServerRestartRequired("server_settings")
|
|
}
|
|
if sensitiveSettingKeys[key] {
|
|
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, RestartRequired: restartRequired})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: after[key], RestartRequired: restartRequired})
|
|
}
|
|
|
|
func (h *AdminHandler) validateDiagnosticsUploadsEnabled(ctx context.Context) error {
|
|
if h.DiagnosticsStore == nil || strings.TrimSpace(h.DiagnosticsStore.Bucket()) == "" {
|
|
return errors.New("diagnostics uploads require configured private object storage")
|
|
}
|
|
const probeKey = "diagnostics/.probe"
|
|
if err := h.DiagnosticsStore.PutStream(
|
|
ctx,
|
|
h.DiagnosticsStore.Bucket(),
|
|
probeKey,
|
|
strings.NewReader("ok"),
|
|
"application/octet-stream",
|
|
); err != nil {
|
|
return fmt.Errorf("diagnostics storage probe write failed: %w", err)
|
|
}
|
|
if err := h.DiagnosticsStore.DeleteObject(ctx, h.DiagnosticsStore.Bucket(), probeKey); err != nil {
|
|
return fmt.Errorf("diagnostics storage probe delete failed: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *AdminHandler) normalizeDiagnosticsNumericSetting(ctx context.Context, key, raw string) (string, error) {
|
|
value, err := normalizeDiagnosticsNumericSettingValue(key, raw)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
settings := diagnostics.DefaultSettings()
|
|
if h.SettingsRepo != nil {
|
|
loaded, loadErr := diagnostics.LoadSettings(ctx, h.SettingsRepo)
|
|
if loadErr != nil {
|
|
return "", fmt.Errorf("load diagnostics settings: %w", loadErr)
|
|
}
|
|
settings = loaded
|
|
}
|
|
|
|
switch key {
|
|
case diagnostics.KeyMaxBundleBytes:
|
|
if value > settings.MaxUncompressedBytes {
|
|
return "", fmt.Errorf("%s must not exceed %s (%d bytes)", key, diagnostics.KeyMaxUncompressedBytes, settings.MaxUncompressedBytes)
|
|
}
|
|
// A single bundle can never exceed the per-user byte cap, or every upload
|
|
// at this size would fail quota; keep the two bounds consistent.
|
|
if value > settings.MaxBytesPerUser {
|
|
return "", fmt.Errorf("%s must not exceed %s (%d bytes)", key, diagnostics.KeyMaxBytesPerUser, settings.MaxBytesPerUser)
|
|
}
|
|
case diagnostics.KeyMaxUncompressedBytes:
|
|
if value < settings.MaxBundleBytes {
|
|
return "", fmt.Errorf("%s must be at least %s (%d bytes)", key, diagnostics.KeyMaxBundleBytes, settings.MaxBundleBytes)
|
|
}
|
|
case diagnostics.KeyMaxReportsPerUserDay, diagnostics.KeyRetentionDays:
|
|
// These settings have only independent bounds, which were checked above.
|
|
case diagnostics.KeyMaxBytesPerUser:
|
|
// The per-user cap must leave room for at least one max-size bundle, or
|
|
// /diagnostics/status would advertise a bundle size InsertReceiving always
|
|
// rejects as quota_exceeded.
|
|
if value < settings.MaxBundleBytes {
|
|
return "", fmt.Errorf("%s must be at least %s (%d bytes)", key, diagnostics.KeyMaxBundleBytes, settings.MaxBundleBytes)
|
|
}
|
|
default:
|
|
return "", fmt.Errorf("unsupported diagnostics numeric setting %s", key)
|
|
}
|
|
|
|
return strconv.FormatInt(value, 10), nil
|
|
}
|
|
|
|
const (
|
|
diagnosticsMiB = int64(1024 * 1024)
|
|
diagnosticsGiB = 1024 * diagnosticsMiB
|
|
)
|
|
|
|
func normalizeDiagnosticsNumericSettingValue(key, raw string) (int64, error) {
|
|
value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("%s must be an integer", key)
|
|
}
|
|
|
|
switch key {
|
|
case diagnostics.KeyMaxBundleBytes:
|
|
if value < diagnosticsMiB || value > 256*diagnosticsMiB {
|
|
return 0, fmt.Errorf(
|
|
"%s must be between 1 MiB (%d bytes) and 256 MiB (%d bytes)",
|
|
key,
|
|
diagnosticsMiB,
|
|
256*diagnosticsMiB,
|
|
)
|
|
}
|
|
case diagnostics.KeyMaxUncompressedBytes:
|
|
if value < diagnosticsMiB || value > diagnosticsGiB {
|
|
return 0, fmt.Errorf(
|
|
"%s must be between 1 MiB (%d bytes) and 1 GiB (%d bytes)",
|
|
key,
|
|
diagnosticsMiB,
|
|
diagnosticsGiB,
|
|
)
|
|
}
|
|
case diagnostics.KeyMaxReportsPerUserDay:
|
|
if value < 1 || value > 1000 {
|
|
return 0, fmt.Errorf("%s must be between 1 and 1000", key)
|
|
}
|
|
case diagnostics.KeyRetentionDays:
|
|
if value < 1 || value > 365 {
|
|
return 0, fmt.Errorf("%s must be between 1 and 365", key)
|
|
}
|
|
case diagnostics.KeyMaxBytesPerUser:
|
|
if value < 10*diagnosticsMiB || value > 10*diagnosticsGiB {
|
|
return 0, fmt.Errorf(
|
|
"%s must be between 10 MiB (%d bytes) and 10 GiB (%d bytes)",
|
|
key,
|
|
10*diagnosticsMiB,
|
|
10*diagnosticsGiB,
|
|
)
|
|
}
|
|
default:
|
|
return 0, fmt.Errorf("unsupported diagnostics numeric setting %s", key)
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func validateProspectiveDiagnosticsSettings(values map[string]string) error {
|
|
settings := diagnostics.DefaultSettings()
|
|
targets := []struct {
|
|
key string
|
|
assign func(int64)
|
|
}{
|
|
{diagnostics.KeyMaxBundleBytes, func(value int64) { settings.MaxBundleBytes = value }},
|
|
{diagnostics.KeyMaxUncompressedBytes, func(value int64) { settings.MaxUncompressedBytes = value }},
|
|
{diagnostics.KeyMaxReportsPerUserDay, func(value int64) { settings.MaxReportsPerUserDay = int(value) }},
|
|
{diagnostics.KeyRetentionDays, func(value int64) { settings.RetentionDays = int(value) }},
|
|
{diagnostics.KeyMaxBytesPerUser, func(value int64) { settings.MaxBytesPerUser = value }},
|
|
}
|
|
for _, target := range targets {
|
|
raw := values[target.key]
|
|
if raw == "" {
|
|
continue
|
|
}
|
|
value, err := normalizeDiagnosticsNumericSettingValue(target.key, raw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
target.assign(value)
|
|
}
|
|
|
|
if settings.MaxBundleBytes > settings.MaxUncompressedBytes {
|
|
return fmt.Errorf(
|
|
"%s must not exceed %s (%d bytes)",
|
|
diagnostics.KeyMaxBundleBytes,
|
|
diagnostics.KeyMaxUncompressedBytes,
|
|
settings.MaxUncompressedBytes,
|
|
)
|
|
}
|
|
if settings.MaxBundleBytes > settings.MaxBytesPerUser {
|
|
return fmt.Errorf(
|
|
"%s must not exceed %s (%d bytes)",
|
|
diagnostics.KeyMaxBundleBytes,
|
|
diagnostics.KeyMaxBytesPerUser,
|
|
settings.MaxBytesPerUser,
|
|
)
|
|
}
|
|
return nil
|
|
}
|