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