Login identifiers were compared case-sensitively, so "John" and "john" were distinct accounts and a user could not log in unless they matched the exact casing used at registration. Convert users.username and users.email to the citext type (migration 165). citext compares case-insensitively while preserving the originally stored casing for display, so the existing unique constraints become case-insensitive and `WHERE username = $1` / `email = $1` lookups match regardless of case with no change to the query code itself. Also add auth.NormalizeUsername/NormalizeEmail (trim-only; case preserved), applied at the repository chokepoints (Create, Update, GetByUsername, GetByEmail) and before validation in the create paths, so surrounding whitespace no longer defeats matching or creates lookalike accounts. Verified non-destructively against the dev DB: mixed-case lookups resolve to the same row, case-variant inserts are rejected by the unique constraint, and the down migration cleanly reverts to text. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1964 lines
64 KiB
Go
1964 lines
64 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"golang.org/x/sync/errgroup"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/access"
|
|
"github.com/Silo-Server/silo-server/internal/adminjob"
|
|
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
|
"github.com/Silo-Server/silo-server/internal/auth"
|
|
"github.com/Silo-Server/silo-server/internal/cache"
|
|
"github.com/Silo-Server/silo-server/internal/catalog"
|
|
"github.com/Silo-Server/silo-server/internal/clientip"
|
|
"github.com/Silo-Server/silo-server/internal/markers"
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
"github.com/Silo-Server/silo-server/internal/notifications"
|
|
"github.com/Silo-Server/silo-server/internal/userstore"
|
|
)
|
|
|
|
// AdminMetadataRefresher can refresh metadata for individual items.
|
|
type AdminMetadataRefresher interface {
|
|
RefreshItem(ctx context.Context, contentID string) error
|
|
}
|
|
|
|
// UserRepository defines the operations the AdminHandler needs on users.
|
|
type UserRepository interface {
|
|
List(ctx context.Context) ([]*models.User, error)
|
|
Create(ctx context.Context, input models.CreateUserInput) (*models.User, error)
|
|
Update(ctx context.Context, id int, input models.UpdateUserInput) error
|
|
Delete(ctx context.Context, id int) error
|
|
GetByID(ctx context.Context, id int) (*models.User, error)
|
|
}
|
|
|
|
// ServerSettingsStore provides access to server-wide admin settings.
|
|
type ServerSettingsStore interface {
|
|
Get(ctx context.Context, key string) (string, error)
|
|
Set(ctx context.Context, key, value string) error
|
|
GetAll(ctx context.Context) (map[string]string, error)
|
|
}
|
|
|
|
type AdminJobCreator interface {
|
|
Create(ctx context.Context, input adminjob.CreateJobInput) (*models.AdminJob, error)
|
|
CreateLibraryRefresh(
|
|
ctx context.Context,
|
|
createdByUserID int,
|
|
req adminjob.LibraryRefreshRequest,
|
|
message string,
|
|
) (*models.AdminJob, error)
|
|
}
|
|
|
|
type ItemRefreshScopeResolver interface {
|
|
Resolve(ctx context.Context, contentID string) (*adminjob.ItemRefreshRequest, error)
|
|
ResolveWithMode(ctx context.Context, contentID string, mode adminjob.ItemRefreshMode) (*adminjob.ItemRefreshRequest, error)
|
|
}
|
|
|
|
type ImpersonationService interface {
|
|
StartImpersonation(ctx context.Context, adminUserID, targetUserID int, deviceName, ip string) (*auth.TokenPair, *models.User, *models.User, error)
|
|
}
|
|
|
|
// AdminHandler handles admin-only HTTP endpoints for user management,
|
|
// session listing, unmatched files, and system stats.
|
|
type AdminHandler struct {
|
|
userRepo UserRepository
|
|
pool *pgxpool.Pool
|
|
SessionsLoader *PlaybackSessionsLoader
|
|
storeProv userstore.UserStoreProvider
|
|
accountProvisioner *auth.AccountProvisioner
|
|
DetailSvc *catalog.DetailService
|
|
StatsSource AdminStatsSource
|
|
EventBus cache.EventBus
|
|
SettingsRepo ServerSettingsStore
|
|
JobRepo AdminJobCreator
|
|
ItemRefreshResolver ItemRefreshScopeResolver
|
|
ImpersonationService ImpersonationService
|
|
RealtimeHub *notifications.Hub
|
|
BootstrapSensitiveConfigured map[string]bool
|
|
BootstrapSensitiveValues map[string]string
|
|
OnUserSessionsRevoked func(ctx context.Context, userID int)
|
|
OnServerSettingUpdated func(ctx context.Context, key, value string)
|
|
}
|
|
|
|
// NewAdminHandler creates a new AdminHandler backed by the given
|
|
// user repository and database pool.
|
|
func NewAdminHandler(
|
|
userRepo UserRepository,
|
|
pool *pgxpool.Pool,
|
|
storeProv userstore.UserStoreProvider,
|
|
) *AdminHandler {
|
|
return &AdminHandler{
|
|
userRepo: userRepo,
|
|
pool: pool,
|
|
storeProv: storeProv,
|
|
accountProvisioner: auth.NewAccountProvisioner(userRepo, storeProv),
|
|
}
|
|
}
|
|
|
|
// --- Request/Response types ---
|
|
|
|
// createUserRequest represents the JSON body for POST /admin/users.
|
|
type createUserRequest struct {
|
|
Username string `json:"username"`
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
Role string `json:"role"`
|
|
Permissions []string `json:"permissions"`
|
|
CreateDefaultProfile bool `json:"create_default_profile"`
|
|
DefaultProfileName string `json:"default_profile_name,omitempty"`
|
|
LibraryIDs []int `json:"library_ids"`
|
|
MaxPlaybackQuality string `json:"max_playback_quality"`
|
|
MaxStreams *int `json:"max_streams,omitempty"`
|
|
MaxTranscodes *int `json:"max_transcodes,omitempty"`
|
|
MaxProfiles *int `json:"max_profiles,omitempty"`
|
|
DownloadAllowed *bool `json:"download_allowed,omitempty"`
|
|
DownloadTranscodeAllowed *bool `json:"download_transcode_allowed,omitempty"`
|
|
}
|
|
|
|
type updateLibraryIDsField struct {
|
|
Set bool
|
|
Value []int
|
|
}
|
|
|
|
func (f *updateLibraryIDsField) UnmarshalJSON(data []byte) error {
|
|
f.Set = true
|
|
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
|
|
f.Value = nil
|
|
return nil
|
|
}
|
|
return json.Unmarshal(data, &f.Value)
|
|
}
|
|
|
|
func (f updateLibraryIDsField) Ptr() *[]int {
|
|
if !f.Set {
|
|
return nil
|
|
}
|
|
value := append([]int(nil), f.Value...)
|
|
return &value
|
|
}
|
|
|
|
type updateStringSliceField struct {
|
|
Set bool
|
|
Value []string
|
|
}
|
|
|
|
func (f *updateStringSliceField) UnmarshalJSON(data []byte) error {
|
|
f.Set = true
|
|
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
|
|
f.Value = []string{}
|
|
return nil
|
|
}
|
|
return json.Unmarshal(data, &f.Value)
|
|
}
|
|
|
|
func (f updateStringSliceField) Ptr() *[]string {
|
|
if !f.Set {
|
|
return nil
|
|
}
|
|
value := append([]string(nil), f.Value...)
|
|
return &value
|
|
}
|
|
|
|
// updateUserRequest represents the JSON body for PUT /admin/users/{id}.
|
|
type updateUserRequest struct {
|
|
Username *string `json:"username,omitempty"`
|
|
Email *string `json:"email,omitempty"`
|
|
Password *string `json:"password,omitempty"`
|
|
Role *string `json:"role,omitempty"`
|
|
Permissions updateStringSliceField `json:"permissions,omitempty"`
|
|
Enabled *bool `json:"enabled,omitempty"`
|
|
LibraryIDs updateLibraryIDsField `json:"library_ids,omitempty"`
|
|
MaxPlaybackQuality *string `json:"max_playback_quality,omitempty"`
|
|
MaxStreams *int `json:"max_streams,omitempty"`
|
|
MaxTranscodes *int `json:"max_transcodes,omitempty"`
|
|
MaxProfiles *int `json:"max_profiles,omitempty"`
|
|
DownloadAllowed *bool `json:"download_allowed,omitempty"`
|
|
DownloadTranscodeAllowed *bool `json:"download_transcode_allowed,omitempty"`
|
|
}
|
|
|
|
// adminUserResponse represents a user in admin JSON responses.
|
|
type adminUserResponse struct {
|
|
ID int `json:"id"`
|
|
Username string `json:"username"`
|
|
Email string `json:"email"`
|
|
Role string `json:"role"`
|
|
Permissions []string `json:"permissions"`
|
|
Enabled bool `json:"enabled"`
|
|
LibraryIDs []int `json:"library_ids"`
|
|
MaxPlaybackQuality string `json:"max_playback_quality"`
|
|
MaxStreams int `json:"max_streams"`
|
|
MaxTranscodes int `json:"max_transcodes"`
|
|
MaxProfiles int `json:"max_profiles"`
|
|
DownloadAllowed bool `json:"download_allowed"`
|
|
DownloadTranscodeAllowed bool `json:"download_transcode_allowed"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
LastActiveAt *time.Time `json:"last_active_at,omitempty"`
|
|
}
|
|
|
|
type adminPlaybackHistoryRow struct {
|
|
SessionID string `json:"session_id"`
|
|
UserID int `json:"user_id"`
|
|
Username string `json:"username"`
|
|
ProfileID string `json:"profile_id"`
|
|
ProfileName string `json:"profile_name"`
|
|
MediaItemID string `json:"media_item_id"`
|
|
MediaFileID int `json:"media_file_id"`
|
|
MediaTitle string `json:"media_title"`
|
|
MediaType string `json:"media_type"`
|
|
PlayMethod string `json:"play_method"`
|
|
StartedAt time.Time `json:"started_at"`
|
|
EndedAt time.Time `json:"ended_at"`
|
|
WatchedSeconds float64 `json:"watched_seconds"`
|
|
DurationSeconds *float64 `json:"duration_seconds"`
|
|
Completed bool `json:"completed"`
|
|
}
|
|
|
|
type adminUserProfileRow struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// unmatchedFileRow represents a media file with no content_id.
|
|
type unmatchedFileRow struct {
|
|
ID int `json:"id"`
|
|
MediaFolderID int `json:"media_folder_id"`
|
|
FilePath string `json:"file_path"`
|
|
FileSize int64 `json:"file_size"`
|
|
Container string `json:"container"`
|
|
}
|
|
|
|
// --- Helper ---
|
|
|
|
// presignPosterURL generates a presigned poster URL for admin sessions.
|
|
// Returns empty string if no detail service is configured or the path is empty.
|
|
func (h *AdminHandler) presignPosterURL(r *http.Request, path string) string {
|
|
if h.DetailSvc != nil {
|
|
return h.DetailSvc.PresignURL(r.Context(), cardThumbnailPath(path), "card")
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// toAdminUserResponse converts a User model to an admin API response.
|
|
func toAdminUserResponse(u *models.User) adminUserResponse {
|
|
return adminUserResponse{
|
|
ID: u.ID,
|
|
Username: u.Username,
|
|
Email: u.Email,
|
|
Role: u.Role,
|
|
Permissions: append([]string{}, u.Permissions...),
|
|
Enabled: u.Enabled,
|
|
LibraryIDs: append([]int(nil), u.LibraryIDs...),
|
|
MaxPlaybackQuality: access.NormalizePlaybackQuality(u.MaxPlaybackQuality),
|
|
MaxStreams: u.MaxStreams,
|
|
MaxTranscodes: u.MaxTranscodes,
|
|
MaxProfiles: u.MaxProfiles,
|
|
DownloadAllowed: u.DownloadAllowed,
|
|
DownloadTranscodeAllowed: u.DownloadTranscodeAllowed,
|
|
CreatedAt: u.CreatedAt,
|
|
UpdatedAt: u.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
func (h *AdminHandler) loadUserLastActiveAt(ctx context.Context, userIDs []int) (map[int]time.Time, error) {
|
|
lastActive := make(map[int]time.Time)
|
|
if h == nil || h.pool == nil || len(userIDs) == 0 {
|
|
return lastActive, nil
|
|
}
|
|
|
|
rows, err := h.pool.Query(ctx, `
|
|
SELECT user_id, MAX("timestamp") AS last_active_at
|
|
FROM activity_log
|
|
WHERE user_id = ANY($1::int[])
|
|
GROUP BY user_id`, userIDs)
|
|
if err != nil {
|
|
return lastActive, fmt.Errorf("loading user last activity: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var userID int
|
|
var timestamp time.Time
|
|
if err := rows.Scan(&userID, ×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,
|
|
"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})
|
|
}
|