diff --git a/internal/api/handlers/admin.go b/internal/api/handlers/admin.go index de9fdfed..e22d1970 100644 --- a/internal/api/handlers/admin.go +++ b/internal/api/handlers/admin.go @@ -112,19 +112,20 @@ func NewAdminHandler( // 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"` - 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"` + 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 { @@ -149,20 +150,43 @@ func (f updateLibraryIDsField) Ptr() *[]int { 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"` - 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"` + 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. @@ -171,6 +195,7 @@ type adminUserResponse struct { 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"` @@ -234,6 +259,7 @@ func toAdminUserResponse(u *models.User) adminUserResponse { 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), @@ -361,6 +387,11 @@ func (h *AdminHandler) HandleCreateUser(w http.ResponseWriter, r *http.Request) 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{ @@ -368,6 +399,7 @@ func (h *AdminHandler) HandleCreateUser(w http.ResponseWriter, r *http.Request) Email: req.Email, Password: req.Password, Role: req.Role, + Permissions: permissions, LibraryIDs: req.LibraryIDs, MaxPlaybackQuality: maxPlaybackQuality, MaxStreams: req.MaxStreams, @@ -418,12 +450,22 @@ func (h *AdminHandler) HandleUpdateUser(w http.ResponseWriter, r *http.Request) 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, @@ -710,6 +752,7 @@ func updateRequiresSessionRevocation(req updateUserRequest) bool { req.Role != nil || req.Enabled != nil || req.LibraryIDs.Set || + req.Permissions.Set || req.MaxPlaybackQuality != nil } @@ -901,7 +944,7 @@ func (h *AdminHandler) HandleRefreshItemMetadata(w http.ResponseWriter, r *http. publishEventJob(r.Context(), h.RealtimeHub.EventsHub(), "job.created", job) } - writeJSON(w, http.StatusAccepted, adminJobToResponse(r, job, nil)) + writeJSON(w, http.StatusAccepted, adminJobToResponseForClaims(r, job, nil, apimw.GetClaims(r.Context()))) } // UpdateItemMetadataRequest contains the fields that can be updated via diff --git a/internal/api/handlers/admin_jobs.go b/internal/api/handlers/admin_jobs.go index 4e4d000e..20ae2ea6 100644 --- a/internal/api/handlers/admin_jobs.go +++ b/internal/api/handlers/admin_jobs.go @@ -12,6 +12,7 @@ import ( "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/models" ) @@ -110,7 +111,13 @@ func (h *AdminJobsHandler) HandleGet(w http.ResponseWriter, r *http.Request) { return } - writeJSON(w, http.StatusOK, adminJobToResponse(r, job, h.store)) + claims := apimw.GetClaims(r.Context()) + if !canReadAdminJob(claims, job) { + writeError(w, http.StatusForbidden, "forbidden", "Admin access required") + return + } + + writeJSON(w, http.StatusOK, adminJobToResponseForClaims(r, job, h.store, claims)) } func adminJobToResponse(r *http.Request, job *models.AdminJob, store AdminJobArtifactStore) adminJobResponse { @@ -147,6 +154,96 @@ func adminJobToResponse(r *http.Request, job *models.AdminJob, store AdminJobArt return resp } +func adminJobToResponseForClaims( + r *http.Request, + job *models.AdminJob, + store AdminJobArtifactStore, + claims *auth.Claims, +) adminJobResponse { + response := adminJobToResponse(r, job, store) + sanitizeAdminJobResponseForClaims(&response, claims) + return response +} + +func sanitizeAdminJobResponseForClaims(response *adminJobResponse, claims *auth.Claims) { + if response == nil || (claims != nil && claims.Role == "admin") { + return + } + response.RequestPayload = json.RawMessage(`{}`) + response.ResultPayload = sanitizeNonAdminAdminJobResultPayload(response.JobType, response.ResultPayload) + response.ErrorMessage = "" + response.PublicURL = "" + response.DownloadURL = "" + response.DownloadExpiresAt = nil +} + +func sanitizeNonAdminAdminJobResultPayload(jobType string, payload json.RawMessage) json.RawMessage { + if jobType != adminjob.JobTypeItemRefresh { + return json.RawMessage(`{}`) + } + + var raw map[string]json.RawMessage + if len(payload) == 0 || json.Unmarshal(payload, &raw) != nil { + return json.RawMessage(`{}`) + } + + safe := make(map[string]json.RawMessage) + copyJSONFields(safe, raw, + "requested_content_id", + "refresh_content_id", + "detail_content_id", + "matched_files", + ) + if scanPayload, ok := raw["scan_result"]; ok { + if scanSummary := sanitizeScanResultPayload(scanPayload); len(scanSummary) > 0 { + safe["scan_result"] = scanSummary + } + } + if len(safe) == 0 { + return json.RawMessage(`{}`) + } + data, err := json.Marshal(safe) + if err != nil { + return json.RawMessage(`{}`) + } + return data +} + +func sanitizeScanResultPayload(payload json.RawMessage) json.RawMessage { + var raw map[string]json.RawMessage + if len(payload) == 0 || json.Unmarshal(payload, &raw) != nil { + return nil + } + safe := make(map[string]json.RawMessage) + copyJSONFields(safe, raw, + "New", + "Updated", + "Unchanged", + "Missing", + "FilesDeleted", + "MembershipsRemoved", + "ItemsDeleted", + "Errors", + "EmptyRootGuarded", + ) + if len(safe) == 0 { + return nil + } + data, err := json.Marshal(safe) + if err != nil { + return nil + } + return data +} + +func copyJSONFields(dst, src map[string]json.RawMessage, keys ...string) { + for _, key := range keys { + if value, ok := src[key]; ok && len(value) > 0 { + dst[key] = value + } + } +} + func writeAdminJobConflict(w http.ResponseWriter, message string, job *models.AdminJob, handler *AdminJobsHandler, r *http.Request) { resp := adminJobConflictResponse{ Error: "conflict", @@ -176,3 +273,13 @@ func currentAdminUserID(r *http.Request) int { } return claims.UserID } + +func canReadAdminJob(claims *auth.Claims, job *models.AdminJob) bool { + if claims == nil || job == nil { + return false + } + if claims.Role == "admin" { + return true + } + return job.JobType == adminjob.JobTypeItemRefresh && job.CreatedByUserID == claims.UserID +} diff --git a/internal/api/handlers/admin_jobs_test.go b/internal/api/handlers/admin_jobs_test.go new file mode 100644 index 00000000..d7dd5ec1 --- /dev/null +++ b/internal/api/handlers/admin_jobs_test.go @@ -0,0 +1,81 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/Silo-Server/silo-server/internal/adminjob" + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/models" +) + +func TestCanReadAdminJob_AdminCanReadAnyJob(t *testing.T) { + claims := &auth.Claims{UserID: 1, Role: "admin"} + job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeCatalogExport} + if !canReadAdminJob(claims, job) { + t.Fatal("admin should be allowed to read any job") + } +} + +func TestCanReadAdminJob_CreatorCanReadOwnItemRefreshJob(t *testing.T) { + claims := &auth.Claims{UserID: 2, Role: "user"} + job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeItemRefresh} + if !canReadAdminJob(claims, job) { + t.Fatal("creator should be allowed to read own item refresh job") + } +} + +func TestCanReadAdminJob_CreatorCannotReadOwnNonItemRefreshJob(t *testing.T) { + claims := &auth.Claims{UserID: 2, Role: "user"} + job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeCatalogExport} + if canReadAdminJob(claims, job) { + t.Fatal("non-admin should not read non-item-refresh jobs") + } +} + +func TestCanReadAdminJob_OtherUserCannotReadItemRefreshJob(t *testing.T) { + claims := &auth.Claims{UserID: 3, Role: "user"} + job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeItemRefresh} + if canReadAdminJob(claims, job) { + t.Fatal("non-admin should not read another user's item refresh job") + } +} + +func TestAdminJobToResponseForClaims_NonAdminSanitizesItemRefreshPayloads(t *testing.T) { + claims := &auth.Claims{UserID: 2, Role: "user"} + job := &models.AdminJob{ + JobType: adminjob.JobTypeItemRefresh, + CreatedByUserID: 2, + RequestPayload: json.RawMessage( + `{"requested_content_id":"item-1","scan_path":"/srv/media/private/movie"}`, + ), + ResultPayload: json.RawMessage( + `{"requested_content_id":"item-1","detail_content_id":"item-2","scan_path":"/srv/media/private/movie","scan_result":{"New":1,"RootObservations":[{"RootPath":"/srv/media/private","SampleFilePath":"/srv/media/private/movie.mkv"}]}}`, + ), + ErrorMessage: "scan scope: stat /srv/media/private/movie: permission denied", + PublicURL: "https://example.test/public", + } + + resp := adminJobToResponseForClaims(nil, job, nil, claims) + + if string(resp.RequestPayload) != `{}` { + t.Fatalf("RequestPayload = %s, want sanitized empty object", resp.RequestPayload) + } + if resp.PublicURL != "" || resp.DownloadURL != "" || resp.DownloadExpiresAt != nil { + t.Fatalf("expected non-admin URLs to be stripped, got public=%q download=%q", resp.PublicURL, resp.DownloadURL) + } + if bytes.Contains(resp.ResultPayload, []byte("/srv/media")) || + bytes.Contains(resp.ResultPayload, []byte("scan_path")) || + bytes.Contains(resp.ResultPayload, []byte("RootObservations")) || + bytes.Contains(resp.ResultPayload, []byte("SampleFilePath")) { + t.Fatalf("ResultPayload leaked sensitive data: %s", resp.ResultPayload) + } + if !bytes.Contains(resp.ResultPayload, []byte("requested_content_id")) || + !bytes.Contains(resp.ResultPayload, []byte("detail_content_id")) { + t.Fatalf("ResultPayload = %s, want safe item refresh summary fields", resp.ResultPayload) + } + if resp.ErrorMessage != "" { + t.Fatalf("ErrorMessage = %q, want stripped for non-admin", resp.ErrorMessage) + } +} diff --git a/internal/api/handlers/admin_test.go b/internal/api/handlers/admin_test.go new file mode 100644 index 00000000..d434a029 --- /dev/null +++ b/internal/api/handlers/admin_test.go @@ -0,0 +1,73 @@ +package handlers + +import "testing" + +func TestUpdateRequiresSessionRevocation(t *testing.T) { + role := "admin" + enabled := true + libraryIDs := []int{1, 2} + maxPlaybackQuality := "1080p" + password := "new-password" + username := "renamed" + maxStreams := 4 + + tests := []struct { + name string + req updateUserRequest + want bool + }{ + { + name: "permissions set", + req: updateUserRequest{Permissions: updateStringSliceField{Set: true, Value: []string{"metadata_curation"}}}, + want: true, + }, + { + name: "permissions unset", + req: updateUserRequest{Permissions: updateStringSliceField{Set: false, Value: []string{"metadata_curation"}}}, + want: false, + }, + { + name: "role", + req: updateUserRequest{Role: &role}, + want: true, + }, + { + name: "enabled", + req: updateUserRequest{Enabled: &enabled}, + want: true, + }, + { + name: "library ids", + req: updateUserRequest{LibraryIDs: updateLibraryIDsField{Set: true, Value: libraryIDs}}, + want: true, + }, + { + name: "max playback quality", + req: updateUserRequest{MaxPlaybackQuality: &maxPlaybackQuality}, + want: true, + }, + { + name: "password", + req: updateUserRequest{Password: &password}, + want: true, + }, + { + name: "non access fields", + req: updateUserRequest{Username: &username, MaxStreams: &maxStreams}, + want: false, + }, + { + name: "empty update", + req: updateUserRequest{}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := updateRequiresSessionRevocation(tt.req); got != tt.want { + t.Fatalf("updateRequiresSessionRevocation() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/api/handlers/auth.go b/internal/api/handlers/auth.go index 21f31404..42804740 100644 --- a/internal/api/handlers/auth.go +++ b/internal/api/handlers/auth.go @@ -106,6 +106,7 @@ type userResponse struct { Username string `json:"username"` Email string `json:"email"` Role string `json:"role"` + Permissions []string `json:"permissions"` DownloadAllowed bool `json:"download_allowed"` Impersonation *impersonationResponse `json:"impersonation,omitempty"` } @@ -510,6 +511,7 @@ func buildUserResponse(user *models.User, impersonatorUserID *int, impersonator Username: user.Username, Email: user.Email, Role: user.Role, + Permissions: auth.EffectivePermissions(user), DownloadAllowed: user.DownloadAllowed, } if impersonatorUserID != nil { diff --git a/internal/api/handlers/catalog_resources.go b/internal/api/handlers/catalog_resources.go index 404e9d42..61d3ea58 100644 --- a/internal/api/handlers/catalog_resources.go +++ b/internal/api/handlers/catalog_resources.go @@ -78,7 +78,7 @@ func (h *CatalogResourceHandler) HandleGetItemVersions(w http.ResponseWriter, r return } - if !requestIsAdmin(r) { + if !h.items.requestCanViewFilePaths(r) { for i := range detail.Versions { detail.Versions[i].FilePath = "" } @@ -502,7 +502,7 @@ func (h *CatalogResourceHandler) enrichItemDetail(r *http.Request, detail *catal applyEffectiveEditionPreference(detail.SeasonUserData, &detail.EffectiveVersionEditionKey) } - if !requestIsAdmin(r) { + if !h.items.requestCanViewFilePaths(r) { for i := range detail.Versions { detail.Versions[i].FilePath = "" } diff --git a/internal/api/handlers/items.go b/internal/api/handlers/items.go index 0b8114cb..ffa4ffe5 100644 --- a/internal/api/handlers/items.go +++ b/internal/api/handlers/items.go @@ -1258,7 +1258,21 @@ func isNotFound(err error) bool { errors.Is(err, catalog.ErrSeasonNotFound) } -func requestIsAdmin(r *http.Request) bool { +func (h *ItemsHandler) requestCanViewFilePaths(r *http.Request) bool { claims := apimw.GetClaims(r.Context()) - return claims != nil && claims.Role == "admin" + if claims == nil { + return false + } + if claims.Role == "admin" { + return true + } + if h == nil || h.UserRepo == nil { + return false + } + user, err := h.UserRepo.GetByID(r.Context(), claims.UserID) + if err != nil { + slog.WarnContext(r.Context(), "checking file path visibility permissions", "user_id", claims.UserID, "error", err) + return false + } + return auth.HasEffectivePermission(user, auth.PermissionMetadataCuration) } diff --git a/internal/api/middleware/permissions.go b/internal/api/middleware/permissions.go new file mode 100644 index 00000000..aa7059d6 --- /dev/null +++ b/internal/api/middleware/permissions.go @@ -0,0 +1,155 @@ +package middleware + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/models" +) + +type PermissionUserLoader interface { + GetByID(ctx context.Context, id int) (*models.User, error) +} + +type MetadataTargetLibraryResolver interface { + ResolveMetadataTargetLibraryIDs(ctx context.Context, contentID string) ([]int, error) +} + +type PermissionMiddleware struct { + users PermissionUserLoader + libraries MetadataTargetLibraryResolver +} + +func NewPermissionMiddleware(users PermissionUserLoader, libraries MetadataTargetLibraryResolver) *PermissionMiddleware { + return &PermissionMiddleware{users: users, libraries: libraries} +} + +// RequireMetadataCurationForItem allows admins or users with metadata_curation +// permission when every library containing the target item is within the user's +// assigned libraries. A nil user library list means unrestricted library access. +func (m *PermissionMiddleware) RequireMetadataCurationForItem(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims := GetClaims(r.Context()) + if claims == nil { + writeUnauthorized(w, "Authentication required") + return + } + if claims.Role == "admin" { + next.ServeHTTP(w, r) + return + } + if m == nil || m.users == nil || m.libraries == nil { + writeForbidden(w, "Metadata curation permission required") + return + } + + contentID := chi.URLParam(r, "id") + if contentID == "" { + writePermissionError(w, http.StatusBadRequest, "bad_request", "Item ID is required") + return + } + + user, err := m.users.GetByID(r.Context(), claims.UserID) + if err != nil || user == nil || !user.Enabled { + writeForbidden(w, "Metadata curation permission required") + return + } + if !auth.HasEffectivePermission(user, auth.PermissionMetadataCuration) { + writeForbidden(w, "Metadata curation permission required") + return + } + + targetLibraries, err := m.libraries.ResolveMetadataTargetLibraryIDs(r.Context(), contentID) + if err != nil { + writePermissionError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve item libraries") + return + } + if len(targetLibraries) == 0 { + writePermissionError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + if !metadataTargetWithinUserLibraries(user.LibraryIDs, targetLibraries) { + writeForbidden(w, "Item is outside your assigned libraries") + return + } + + next.ServeHTTP(w, r) + }) +} + +func metadataTargetWithinUserLibraries(allowed []int, target []int) bool { + if allowed == nil { + return true + } + if len(target) == 0 { + return false + } + allowedSet := make(map[int]struct{}, len(allowed)) + for _, id := range allowed { + allowedSet[id] = struct{}{} + } + for _, id := range target { + if _, ok := allowedSet[id]; !ok { + return false + } + } + return true +} + +type PGMetadataTargetLibraryResolver struct { + Pool *pgxpool.Pool +} + +func NewPGMetadataTargetLibraryResolver(pool *pgxpool.Pool) *PGMetadataTargetLibraryResolver { + return &PGMetadataTargetLibraryResolver{Pool: pool} +} + +func (r *PGMetadataTargetLibraryResolver) ResolveMetadataTargetLibraryIDs(ctx context.Context, contentID string) ([]int, error) { + if r == nil || r.Pool == nil { + return nil, fmt.Errorf("database not configured") + } + rows, err := r.Pool.Query(ctx, ` + WITH target_root AS ( + SELECT mi.content_id + FROM media_items mi + WHERE mi.content_id = $1 + UNION + SELECT s.series_id + FROM seasons s + WHERE s.content_id = $1 + UNION + SELECT e.series_id + FROM episodes e + WHERE e.content_id = $1 + ) + SELECT DISTINCT mil.media_folder_id + FROM target_root tr + JOIN media_item_libraries mil ON mil.content_id = tr.content_id + ORDER BY mil.media_folder_id`, contentID) + if err != nil { + return nil, err + } + defer rows.Close() + + var ids []int + for rows.Next() { + var id int + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +func writePermissionError(w http.ResponseWriter, status int, code, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(errorResponse{Error: code, Message: message}) +} diff --git a/internal/api/middleware/permissions_test.go b/internal/api/middleware/permissions_test.go new file mode 100644 index 00000000..7b4699d2 --- /dev/null +++ b/internal/api/middleware/permissions_test.go @@ -0,0 +1,100 @@ +package middleware + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/models" +) + +type fakePermissionUserLoader struct { + user *models.User + err error +} + +func (f fakePermissionUserLoader) GetByID(context.Context, int) (*models.User, error) { + return f.user, f.err +} + +type fakeTargetLibraryResolver struct { + ids []int + err error +} + +func (f fakeTargetLibraryResolver) ResolveMetadataTargetLibraryIDs(context.Context, string) ([]int, error) { + return f.ids, f.err +} + +func requestWithItemID(role string) *http.Request { + req := httptest.NewRequest(http.MethodPost, "/admin/items/item-1/refresh-metadata", nil) + ctx := SetClaims(req.Context(), &auth.Claims{UserID: 7, Role: role, TokenType: auth.TokenTypeAccess}) + routeCtx := chi.NewRouteContext() + routeCtx.URLParams.Add("id", "item-1") + ctx = context.WithValue(ctx, chi.RouteCtxKey, routeCtx) + return req.WithContext(ctx) +} + +func runMetadataCurationMiddleware(user *models.User, libraryIDs []int, role string) int { + mw := NewPermissionMiddleware( + fakePermissionUserLoader{user: user}, + fakeTargetLibraryResolver{ids: libraryIDs}, + ) + next := mw.RequireMetadataCurationForItem(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + rec := httptest.NewRecorder() + next.ServeHTTP(rec, requestWithItemID(role)) + return rec.Code +} + +func TestRequireMetadataCurationForItem_AllowsAdmin(t *testing.T) { + code := runMetadataCurationMiddleware(nil, nil, "admin") + if code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", code, http.StatusNoContent) + } +} + +func TestRequireMetadataCurationForItem_RejectsUserWithoutPermission(t *testing.T) { + user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: []int{1}, Permissions: nil} + code := runMetadataCurationMiddleware(user, []int{1}, "user") + if code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", code, http.StatusForbidden) + } +} + +func TestRequireMetadataCurationForItem_AllowsUnrestrictedCurator(t *testing.T) { + user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: nil, Permissions: []string{"metadata_curation"}} + code := runMetadataCurationMiddleware(user, []int{1, 2}, "user") + if code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", code, http.StatusNoContent) + } +} + +func TestRequireMetadataCurationForItem_AllowsWhenAllTargetLibrariesAreAllowed(t *testing.T) { + user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: []int{1, 2, 3}, Permissions: []string{"metadata_curation"}} + code := runMetadataCurationMiddleware(user, []int{1, 3}, "user") + if code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", code, http.StatusNoContent) + } +} + +func TestRequireMetadataCurationForItem_RejectsWhenAnyTargetLibraryIsOutsideAccess(t *testing.T) { + user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: []int{1}, Permissions: []string{"metadata_curation"}} + code := runMetadataCurationMiddleware(user, []int{1, 2}, "user") + if code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", code, http.StatusForbidden) + } +} + +func TestRequireMetadataCurationForItem_NotFoundWhenTargetHasNoLibraries(t *testing.T) { + user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: nil, Permissions: []string{"metadata_curation"}} + code := runMetadataCurationMiddleware(user, nil, "user") + if code != http.StatusNotFound { + t.Fatalf("status = %d, want %d", code, http.StatusNotFound) + } +} diff --git a/internal/api/router.go b/internal/api/router.go index 563617a2..75e38dfa 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -209,6 +209,7 @@ func NewRouter(deps Dependencies) chi.Router { var authHandler *handlers.AuthHandler var authMiddleware *apimw.AuthMiddleware var viewerAccessMiddleware *apimw.ViewerAccessMiddleware + var permissionMiddleware *apimw.PermissionMiddleware var viewerResolver *access.Resolver var profileTokenService *access.ProfileTokenService var jwtService *auth.JWTService @@ -245,6 +246,12 @@ func NewRouter(deps Dependencies) chi.Router { viewerResolver = access.NewResolver(userRepo, deps.UserStoreProvider, profileTokenService) viewerAccessMiddleware = apimw.NewViewerAccessMiddleware(viewerResolver) } + if deps.DB != nil { + permissionMiddleware = apimw.NewPermissionMiddleware( + userRepo, + apimw.NewPGMetadataTargetLibraryResolver(deps.DB), + ) + } } if deps.SessionMgr != nil && userRepo != nil { deps.SessionMgr.SetLimitProvider(func(ctx context.Context, userID int) (playback.SessionLimits, error) { @@ -1652,332 +1659,347 @@ func NewRouter(deps Dependencies) chi.Router { }) } - // Admin routes (admin-only). + // Admin routes. if adminHandler != nil { r.Route("/admin", func(r chi.Router) { - r.Use(apimw.RequireAdmin) - - r.Get("/users", adminHandler.HandleListUsers) - r.Post("/users", adminHandler.HandleCreateUser) - r.Get("/users/{id}", adminHandler.HandleGetUser) - r.Put("/users/{id}", adminHandler.HandleUpdateUser) - r.Delete("/users/{id}", adminHandler.HandleDeleteUser) - r.Post("/users/{id}/impersonate", adminHandler.HandleImpersonateUser) - r.Get("/users/{id}/profiles", adminHandler.HandleListUserProfiles) - r.Get("/users/{id}/settings", adminHandler.HandleListUserSettings) - r.Get("/users/{id}/settings/{key}", adminHandler.HandleGetUserSetting) - r.Put("/users/{id}/settings/{key}", adminHandler.HandleUpdateUserSetting) - r.Delete("/users/{id}/settings/{key}", adminHandler.HandleDeleteUserSetting) - r.Get("/users/{id}/device-settings", adminHandler.HandleListUserDeviceSettings) - r.Get("/users/{id}/device-settings/{key}", adminHandler.HandleListUserDeviceSettingsByKey) - r.Put("/users/{id}/profiles/{profile_id}/device-settings/{key}/{device_id}", adminHandler.HandleUpdateUserDeviceSetting) - r.Delete("/users/{id}/device-settings/{key}", adminHandler.HandleDeleteUserDeviceSettingsByKey) - r.Delete("/users/{id}/profiles/{profile_id}/device-settings/{key}/{device_id}", adminHandler.HandleDeleteUserDeviceSetting) - r.Delete("/users/{id}/profiles/{profile_id}/devices/{device_id}/settings", adminHandler.HandleDeleteAllUserDeviceSettings) - r.Get("/devices", adminHandler.HandleListDevices) - r.Get("/devices/{user_id}/{device_id}", adminHandler.HandleGetDevice) - - r.Get("/sessions", adminHandler.HandleListSessions) - r.Get("/playback-history", adminHandler.HandleListPlaybackHistory) - r.Get("/unmatched", adminHandler.HandleListUnmatched) - r.Get("/stats", adminHandler.HandleGetStats) - r.Get("/settings/sensitive-status", adminHandler.HandleGetSensitiveStatus) - r.Post("/settings/check/{kind}", adminHandler.HandleCheckSettingsConnection) - if sectionSettingsHandler != nil { - r.Get("/settings/sections", sectionSettingsHandler.HandleGet) - r.Put("/settings/sections", sectionSettingsHandler.HandlePut) - } - r.Get("/settings/{key}", adminHandler.HandleGetSetting) - r.Get("/settings", adminHandler.HandleGetSettings) - r.Put("/settings/{key}", adminHandler.HandleUpdateSetting) - r.Post("/items/{id}/refresh-metadata", adminHandler.HandleRefreshItemMetadata) - r.Patch("/items/{id}/metadata", adminHandler.HandleUpdateItemMetadata) - if adminIntroHandler != nil { - r.Post("/items/{id}/refresh-markers", adminIntroHandler.HandleRefreshEpisodeMarkers) - r.Post("/items/{id}/redetect-intro", adminIntroHandler.HandleRedetectEpisodeIntro) - } - if peopleHandler != nil { - r.Post("/people/{id}/refresh", peopleHandler.HandleAdminRefreshPerson) - r.Patch("/people/{id}", peopleHandler.HandleAdminUpdatePerson) + metadataItemAccess := apimw.RequireAdmin + if permissionMiddleware != nil { + metadataItemAccess = permissionMiddleware.RequireMetadataCurationForItem } - if adminMatchHandler != nil { - r.Post("/items/{id}/match/search", adminMatchHandler.HandleSearchItemMatchCandidates) - r.Post("/items/{id}/match/apply", adminMatchHandler.HandleApplyItemMatch) - } - - if adminImageHandler != nil { - r.Get("/items/{id}/images", adminImageHandler.HandleGetItemImages) - r.Post("/items/{id}/images/apply", adminImageHandler.HandleApplyItemImage) - } - - filesystemHandler := handlers.NewFilesystemHandler() - r.Get("/filesystem/browse", filesystemHandler.HandleBrowse) - - if catalogSeedHandler != nil { - r.Route("/catalog", func(r chi.Router) { - r.Post("/export", catalogSeedHandler.HandleExport) - r.Post("/export-jobs", catalogSeedHandler.HandleCreateExportJob) - r.Post("/export-jobs/{id}/publish", catalogSeedHandler.HandlePublishExportJob) - r.Post("/import-jobs", catalogSeedHandler.HandleCreateImportJob) - r.Get("/import-sources", catalogSeedHandler.HandleListImportSources) - r.Get("/local-import-sources", catalogSeedHandler.HandleListLocalImportSources) - r.Post("/import", catalogSeedHandler.HandleImport) - }) - } + r.Group(func(r chi.Router) { + r.Use(metadataItemAccess) + r.Post("/items/{id}/refresh-metadata", adminHandler.HandleRefreshItemMetadata) + r.Patch("/items/{id}/metadata", adminHandler.HandleUpdateItemMetadata) + if adminMatchHandler != nil { + r.Post("/items/{id}/match/search", adminMatchHandler.HandleSearchItemMatchCandidates) + r.Post("/items/{id}/match/apply", adminMatchHandler.HandleApplyItemMatch) + } + }) if adminJobsHandler != nil { - r.Route("/jobs", func(r chi.Router) { - r.Get("/", adminJobsHandler.HandleList) - r.Get("/{id}", adminJobsHandler.HandleGet) - }) + // Curators must poll their own item-refresh jobs, so this stays outside + // the admin-only group. HandleGet enforces per-job authorization. + r.Get("/jobs/{id}", adminJobsHandler.HandleGet) } - if deps.PluginService != nil && deps.PluginUserConfig != nil { - pluginHandler := handlers.NewPluginHandler( - plugins.NewRepositoryStore(deps.DB), - plugins.NewInstallationStore(deps.DB), - plugins.NewRuntimeConfigStore(deps.DB), - deps.PluginService, - deps.PluginUserConfig, - deps.PluginHTTPProxy, - metadata.NewChainRepository(deps.DB), - deps.PluginImageResolver, - ) - r.Route("/plugins", func(r chi.Router) { - r.Get("/repositories", pluginHandler.HandleListRepositories) - r.Post("/repositories", pluginHandler.HandleCreateRepository) - r.Put("/repositories/{id}", pluginHandler.HandleUpdateRepository) - r.Delete("/repositories/{id}", pluginHandler.HandleDeleteRepository) - r.Get("/catalog", pluginHandler.HandleCatalog) - r.Get("/installations", pluginHandler.HandleListInstallations) - r.Post("/installations", pluginHandler.HandleCreateInstallation) - r.Post("/uploads", pluginHandler.HandleUploadInstallation) - r.Put("/installations/{id}", pluginHandler.HandleUpdateInstallation) - r.Post("/installations/{id}/update", pluginHandler.HandleApplyUpdate) - r.Post("/installations/{id}/config/test", pluginHandler.HandleTestInstallationConfig) - r.Put("/installations/{id}/config", pluginHandler.HandlePutInstallationConfig) - r.Put("/installations/{id}/auth-binding", pluginHandler.HandlePutAuthBinding) - r.Put("/installations/{id}/task-bindings/{capability_id}", pluginHandler.HandlePutTaskBinding) - r.Delete("/installations/{id}", pluginHandler.HandleDeleteInstallation) - }) - } + r.Group(func(r chi.Router) { + r.Use(apimw.RequireAdmin) - if historyImportHandler != nil { - r.Route("/history-import-sources", func(r chi.Router) { - r.Get("/", historyImportHandler.HandleAdminListSources) - r.Post("/", historyImportHandler.HandleAdminCreateSource) - r.Put("/{id}", historyImportHandler.HandleAdminUpdateSource) - r.Delete("/{id}", historyImportHandler.HandleAdminDeleteSource) - }) + r.Get("/users", adminHandler.HandleListUsers) + r.Post("/users", adminHandler.HandleCreateUser) + r.Get("/users/{id}", adminHandler.HandleGetUser) + r.Put("/users/{id}", adminHandler.HandleUpdateUser) + r.Delete("/users/{id}", adminHandler.HandleDeleteUser) + r.Post("/users/{id}/impersonate", adminHandler.HandleImpersonateUser) + r.Get("/users/{id}/profiles", adminHandler.HandleListUserProfiles) + r.Get("/users/{id}/settings", adminHandler.HandleListUserSettings) + r.Get("/users/{id}/settings/{key}", adminHandler.HandleGetUserSetting) + r.Put("/users/{id}/settings/{key}", adminHandler.HandleUpdateUserSetting) + r.Delete("/users/{id}/settings/{key}", adminHandler.HandleDeleteUserSetting) + r.Get("/users/{id}/device-settings", adminHandler.HandleListUserDeviceSettings) + r.Get("/users/{id}/device-settings/{key}", adminHandler.HandleListUserDeviceSettingsByKey) + r.Put("/users/{id}/profiles/{profile_id}/device-settings/{key}/{device_id}", adminHandler.HandleUpdateUserDeviceSetting) + r.Delete("/users/{id}/device-settings/{key}", adminHandler.HandleDeleteUserDeviceSettingsByKey) + r.Delete("/users/{id}/profiles/{profile_id}/device-settings/{key}/{device_id}", adminHandler.HandleDeleteUserDeviceSetting) + r.Delete("/users/{id}/profiles/{profile_id}/devices/{device_id}/settings", adminHandler.HandleDeleteAllUserDeviceSettings) + r.Get("/devices", adminHandler.HandleListDevices) + r.Get("/devices/{user_id}/{device_id}", adminHandler.HandleGetDevice) - r.Route("/history-imports", func(r chi.Router) { - r.Post("/plex/login", historyImportHandler.HandleAdminPlexLogin) - r.Put("/sources/{id}/token", historyImportHandler.HandleAdminSetSourceToken) - r.Delete("/sources/{id}/token", historyImportHandler.HandleAdminClearSourceToken) - r.Get("/sources/{id}/users", historyImportHandler.HandleAdminDiscoverUsers) - r.Post("/sources/{id}/bulk-run", historyImportHandler.HandleAdminBulkRun) - r.Get("/mappings", historyImportHandler.HandleAdminListMappings) - r.Post("/mappings", historyImportHandler.HandleAdminCreateMapping) - r.Put("/mappings/{id}", historyImportHandler.HandleAdminUpdateMapping) - r.Delete("/mappings/{id}", historyImportHandler.HandleAdminDeleteMapping) - r.Post("/mappings/{id}/run", historyImportHandler.HandleAdminCreateRun) - r.Get("/runs", historyImportHandler.HandleAdminListRuns) - r.Get("/runs/{id}", historyImportHandler.HandleAdminGetRun) - r.Post("/runs/{id}/cancel", historyImportHandler.HandleAdminCancelRun) - }) - } + r.Get("/sessions", adminHandler.HandleListSessions) + r.Get("/playback-history", adminHandler.HandleListPlaybackHistory) + r.Get("/unmatched", adminHandler.HandleListUnmatched) + r.Get("/stats", adminHandler.HandleGetStats) + r.Get("/settings/sensitive-status", adminHandler.HandleGetSensitiveStatus) + r.Post("/settings/check/{kind}", adminHandler.HandleCheckSettingsConnection) + if sectionSettingsHandler != nil { + r.Get("/settings/sections", sectionSettingsHandler.HandleGet) + r.Put("/settings/sections", sectionSettingsHandler.HandlePut) + } + r.Get("/settings/{key}", adminHandler.HandleGetSetting) + r.Get("/settings", adminHandler.HandleGetSettings) + r.Put("/settings/{key}", adminHandler.HandleUpdateSetting) + if adminIntroHandler != nil { + r.Post("/items/{id}/refresh-markers", adminIntroHandler.HandleRefreshEpisodeMarkers) + r.Post("/items/{id}/redetect-intro", adminIntroHandler.HandleRedetectEpisodeIntro) + } + if peopleHandler != nil { + r.Post("/people/{id}/refresh", peopleHandler.HandleAdminRefreshPerson) + r.Patch("/people/{id}", peopleHandler.HandleAdminUpdatePerson) + } - if sectionHandler != nil { - r.Route("/sections", func(r chi.Router) { - r.Get("/", sectionHandler.HandleListSections) - r.Post("/", sectionHandler.HandleCreateSection) - r.Post("/preview", sectionHandler.HandlePreview) - r.Put("/reorder", sectionHandler.HandleReorderSections) - r.Post("/restore-defaults", sectionHandler.HandleRestoreDefaults) - r.Put("/{id}", sectionHandler.HandleUpdateSection) - r.Delete("/{id}", sectionHandler.HandleDeleteSection) - if sectionBulkHandler != nil { - r.Post("/bulk-create", sectionBulkHandler.HandleBulkCreate) + if adminImageHandler != nil { + r.Get("/items/{id}/images", adminImageHandler.HandleGetItemImages) + r.Post("/items/{id}/images/apply", adminImageHandler.HandleApplyItemImage) + } + + filesystemHandler := handlers.NewFilesystemHandler() + r.Get("/filesystem/browse", filesystemHandler.HandleBrowse) + + if catalogSeedHandler != nil { + r.Route("/catalog", func(r chi.Router) { + r.Post("/export", catalogSeedHandler.HandleExport) + r.Post("/export-jobs", catalogSeedHandler.HandleCreateExportJob) + r.Post("/export-jobs/{id}/publish", catalogSeedHandler.HandlePublishExportJob) + r.Post("/import-jobs", catalogSeedHandler.HandleCreateImportJob) + r.Get("/import-sources", catalogSeedHandler.HandleListImportSources) + r.Get("/local-import-sources", catalogSeedHandler.HandleListLocalImportSources) + r.Post("/import", catalogSeedHandler.HandleImport) + }) + } + + if adminJobsHandler != nil { + r.Route("/jobs", func(r chi.Router) { + r.Get("/", adminJobsHandler.HandleList) + }) + } + + if deps.PluginService != nil && deps.PluginUserConfig != nil { + pluginHandler := handlers.NewPluginHandler( + plugins.NewRepositoryStore(deps.DB), + plugins.NewInstallationStore(deps.DB), + plugins.NewRuntimeConfigStore(deps.DB), + deps.PluginService, + deps.PluginUserConfig, + deps.PluginHTTPProxy, + metadata.NewChainRepository(deps.DB), + deps.PluginImageResolver, + ) + r.Route("/plugins", func(r chi.Router) { + r.Get("/repositories", pluginHandler.HandleListRepositories) + r.Post("/repositories", pluginHandler.HandleCreateRepository) + r.Put("/repositories/{id}", pluginHandler.HandleUpdateRepository) + r.Delete("/repositories/{id}", pluginHandler.HandleDeleteRepository) + r.Get("/catalog", pluginHandler.HandleCatalog) + r.Get("/installations", pluginHandler.HandleListInstallations) + r.Post("/installations", pluginHandler.HandleCreateInstallation) + r.Post("/uploads", pluginHandler.HandleUploadInstallation) + r.Put("/installations/{id}", pluginHandler.HandleUpdateInstallation) + r.Post("/installations/{id}/update", pluginHandler.HandleApplyUpdate) + r.Post("/installations/{id}/config/test", pluginHandler.HandleTestInstallationConfig) + r.Put("/installations/{id}/config", pluginHandler.HandlePutInstallationConfig) + r.Put("/installations/{id}/auth-binding", pluginHandler.HandlePutAuthBinding) + r.Put("/installations/{id}/task-bindings/{capability_id}", pluginHandler.HandlePutTaskBinding) + r.Delete("/installations/{id}", pluginHandler.HandleDeleteInstallation) + }) + } + + if historyImportHandler != nil { + r.Route("/history-import-sources", func(r chi.Router) { + r.Get("/", historyImportHandler.HandleAdminListSources) + r.Post("/", historyImportHandler.HandleAdminCreateSource) + r.Put("/{id}", historyImportHandler.HandleAdminUpdateSource) + r.Delete("/{id}", historyImportHandler.HandleAdminDeleteSource) + }) + + r.Route("/history-imports", func(r chi.Router) { + r.Post("/plex/login", historyImportHandler.HandleAdminPlexLogin) + r.Put("/sources/{id}/token", historyImportHandler.HandleAdminSetSourceToken) + r.Delete("/sources/{id}/token", historyImportHandler.HandleAdminClearSourceToken) + r.Get("/sources/{id}/users", historyImportHandler.HandleAdminDiscoverUsers) + r.Post("/sources/{id}/bulk-run", historyImportHandler.HandleAdminBulkRun) + r.Get("/mappings", historyImportHandler.HandleAdminListMappings) + r.Post("/mappings", historyImportHandler.HandleAdminCreateMapping) + r.Put("/mappings/{id}", historyImportHandler.HandleAdminUpdateMapping) + r.Delete("/mappings/{id}", historyImportHandler.HandleAdminDeleteMapping) + r.Post("/mappings/{id}/run", historyImportHandler.HandleAdminCreateRun) + r.Get("/runs", historyImportHandler.HandleAdminListRuns) + r.Get("/runs/{id}", historyImportHandler.HandleAdminGetRun) + r.Post("/runs/{id}/cancel", historyImportHandler.HandleAdminCancelRun) + }) + } + + if sectionHandler != nil { + r.Route("/sections", func(r chi.Router) { + r.Get("/", sectionHandler.HandleListSections) + r.Post("/", sectionHandler.HandleCreateSection) + r.Post("/preview", sectionHandler.HandlePreview) + r.Put("/reorder", sectionHandler.HandleReorderSections) + r.Post("/restore-defaults", sectionHandler.HandleRestoreDefaults) + r.Put("/{id}", sectionHandler.HandleUpdateSection) + r.Delete("/{id}", sectionHandler.HandleDeleteSection) + if sectionBulkHandler != nil { + r.Post("/bulk-create", sectionBulkHandler.HandleBulkCreate) + } + }) + } + + if libraryCollectionHandler != nil { + collectionTemplateHandler := handlers.NewCollectionTemplateHandler(nil) + r.Route("/collections", func(r chi.Router) { + r.Get("/", libraryCollectionHandler.HandleListAdminCollections) + r.Get("/templates", collectionTemplateHandler.HandleListTemplates) + r.Get("/template-bundles", libraryCollectionHandler.HandleListTemplateBundles) + r.Post("/template-bundles/{bundleID}/apply", libraryCollectionHandler.HandleApplyTemplateBundle) + r.Post("/template-bundles/{bundleID}/apply-job", libraryCollectionHandler.HandleApplyTemplateBundleJob) + r.Post("/", libraryCollectionHandler.HandleCreateAdminCollection) + r.Post("/preview", libraryCollectionHandler.HandlePreviewAdminCollection) + r.Put("/order", libraryCollectionHandler.HandleReorderAdminCollections) + r.Put("/{id}", libraryCollectionHandler.HandleUpdateAdminCollection) + r.Delete("/{id}", libraryCollectionHandler.HandleDeleteAdminCollection) + r.Post("/{id}/sync", libraryCollectionHandler.HandleSyncAdminCollection) + r.Delete("/{id}/image", libraryCollectionHandler.HandleDeleteCollectionImage) + r.Put("/{id}/items/order", libraryCollectionHandler.HandleReorderAdminCollectionItems) + r.Put("/{id}/items/{item_id}", libraryCollectionHandler.HandleAddAdminCollectionItem) + r.Delete("/{id}/items/{item_id}", libraryCollectionHandler.HandleRemoveAdminCollectionItem) + r.Post("/import/mdblist", libraryCollectionHandler.HandleImportMDBList) + r.Post("/import/tmdb", libraryCollectionHandler.HandleImportTMDBCollection) + r.Post("/import/trakt", libraryCollectionHandler.HandleImportTraktCollection) + }) + } + if libraryCollectionGroupHandler != nil { + r.Route("/libraries/{libraryID}/collection-groups", func(r chi.Router) { + r.Get("/", libraryCollectionGroupHandler.HandleListGroups) + r.Post("/", libraryCollectionGroupHandler.HandleCreateGroup) + r.Put("/reorder", libraryCollectionGroupHandler.HandleReorderGroups) + }) + r.Route("/collection-groups", func(r chi.Router) { + r.Put("/{id}", libraryCollectionGroupHandler.HandleUpdateGroup) + r.Delete("/{id}", libraryCollectionGroupHandler.HandleDeleteGroup) + r.Put("/{groupID}/collections/reorder", libraryCollectionGroupHandler.HandleReorderCollectionsInGroup) + }) + } + + if deps.NodeRepo != nil { + jwtSecret := "" + if deps.Config != nil { + jwtSecret = deps.Config.Auth.JWTSecret } - }) - } - - if libraryCollectionHandler != nil { - collectionTemplateHandler := handlers.NewCollectionTemplateHandler(nil) - r.Route("/collections", func(r chi.Router) { - r.Get("/", libraryCollectionHandler.HandleListAdminCollections) - r.Get("/templates", collectionTemplateHandler.HandleListTemplates) - r.Get("/template-bundles", libraryCollectionHandler.HandleListTemplateBundles) - r.Post("/template-bundles/{bundleID}/apply", libraryCollectionHandler.HandleApplyTemplateBundle) - r.Post("/template-bundles/{bundleID}/apply-job", libraryCollectionHandler.HandleApplyTemplateBundleJob) - r.Post("/", libraryCollectionHandler.HandleCreateAdminCollection) - r.Post("/preview", libraryCollectionHandler.HandlePreviewAdminCollection) - r.Put("/order", libraryCollectionHandler.HandleReorderAdminCollections) - r.Put("/{id}", libraryCollectionHandler.HandleUpdateAdminCollection) - r.Delete("/{id}", libraryCollectionHandler.HandleDeleteAdminCollection) - r.Post("/{id}/sync", libraryCollectionHandler.HandleSyncAdminCollection) - r.Delete("/{id}/image", libraryCollectionHandler.HandleDeleteCollectionImage) - r.Put("/{id}/items/order", libraryCollectionHandler.HandleReorderAdminCollectionItems) - r.Put("/{id}/items/{item_id}", libraryCollectionHandler.HandleAddAdminCollectionItem) - r.Delete("/{id}/items/{item_id}", libraryCollectionHandler.HandleRemoveAdminCollectionItem) - r.Post("/import/mdblist", libraryCollectionHandler.HandleImportMDBList) - r.Post("/import/tmdb", libraryCollectionHandler.HandleImportTMDBCollection) - r.Post("/import/trakt", libraryCollectionHandler.HandleImportTraktCollection) - }) - } - if libraryCollectionGroupHandler != nil { - r.Route("/libraries/{libraryID}/collection-groups", func(r chi.Router) { - r.Get("/", libraryCollectionGroupHandler.HandleListGroups) - r.Post("/", libraryCollectionGroupHandler.HandleCreateGroup) - r.Put("/reorder", libraryCollectionGroupHandler.HandleReorderGroups) - }) - r.Route("/collection-groups", func(r chi.Router) { - r.Put("/{id}", libraryCollectionGroupHandler.HandleUpdateGroup) - r.Delete("/{id}", libraryCollectionGroupHandler.HandleDeleteGroup) - r.Put("/{groupID}/collections/reorder", libraryCollectionGroupHandler.HandleReorderCollectionsInGroup) - }) - } - - if deps.NodeRepo != nil { - jwtSecret := "" - if deps.Config != nil { - jwtSecret = deps.Config.Auth.JWTSecret - } - nodeHandler := handlers.NewNodeHandler(deps.NodeRepo, deps.ProxyPool, deps.TranscodePool, deps.NodeRepo, deps.EventBus, deps.RedisClient, jwtSecret) - r.Route("/nodes", func(r chi.Router) { - r.Get("/", nodeHandler.HandleListNodes) - r.Post("/", nodeHandler.HandleCreateNode) - r.Put("/{id}", nodeHandler.HandleUpdateNode) - r.Delete("/{id}", nodeHandler.HandleDeleteNode) - r.Post("/{id}/check", nodeHandler.HandleCheckNode) - r.Post("/force-reload", nodeHandler.HandleForceReloadNodes) - r.Post("/{id}/force-reload", nodeHandler.HandleForceReloadNode) - }) - // Live node sessions (reads from Redis) - // Note: /admin/sessions is already used for playback sessions from PostgreSQL. - r.Get("/node-sessions", nodeHandler.HandleListSessions) - } - - // System inspection. - { - sysJWTSecret := "" - if deps.Config != nil { - sysJWTSecret = deps.Config.Auth.JWTSecret - } - systemHandler := handlers.NewSystemHandler(deps.TranscodePool, sysJWTSecret) - r.Route("/system", func(r chi.Router) { - r.Get("/build", systemHandler.HandleBuildInfo) - r.Get("/hw-accel", systemHandler.HandleHWAccel) - }) - } - - if deps.RecWorker != nil { - adminRecsHandler := handlers.NewAdminRecommendationsHandler(deps.RecWorker) - r.Route("/recommendations", func(r chi.Router) { - r.Get("/status", adminRecsHandler.HandleStatus) - r.Post("/trigger/embeddings", adminRecsHandler.HandleTriggerEmbeddings) - r.Post("/trigger/taste-profiles", adminRecsHandler.HandleTriggerTasteProfiles) - r.Post("/trigger/cowatch", adminRecsHandler.HandleTriggerCowatch) - r.Post("/trigger/recommendations", adminRecsHandler.HandleTriggerRecommendations) - }) - } - - if inviteCodeRepo != nil { - inviteCodeHandler := handlers.NewInviteCodeHandler(inviteCodeRepo) - r.Route("/invite-codes", func(r chi.Router) { - r.Get("/", inviteCodeHandler.HandleListInviteCodes) - r.Post("/", inviteCodeHandler.HandleCreateInviteCode) - r.Put("/{id}", inviteCodeHandler.HandleUpdateInviteCode) - r.Post("/{id}/top-up", inviteCodeHandler.HandleTopUpInviteCode) - r.Delete("/{id}", inviteCodeHandler.HandleDeleteInviteCode) - }) - } - - if adminSubtitleHandler != nil { - r.Route("/subtitle-providers", func(r chi.Router) { - r.Get("/", adminSubtitleHandler.HandleListProviders) - r.Route("/{provider}", func(r chi.Router) { - r.Put("/", adminSubtitleHandler.HandleUpdateProvider) - r.Post("/test", adminSubtitleHandler.HandleTestProvider) + nodeHandler := handlers.NewNodeHandler(deps.NodeRepo, deps.ProxyPool, deps.TranscodePool, deps.NodeRepo, deps.EventBus, deps.RedisClient, jwtSecret) + r.Route("/nodes", func(r chi.Router) { + r.Get("/", nodeHandler.HandleListNodes) + r.Post("/", nodeHandler.HandleCreateNode) + r.Put("/{id}", nodeHandler.HandleUpdateNode) + r.Delete("/{id}", nodeHandler.HandleDeleteNode) + r.Post("/{id}/check", nodeHandler.HandleCheckNode) + r.Post("/force-reload", nodeHandler.HandleForceReloadNodes) + r.Post("/{id}/force-reload", nodeHandler.HandleForceReloadNode) }) - }) - r.Route("/subtitles", func(r chi.Router) { - r.Get("/", adminSubtitleHandler.HandleListDownloadedSubtitles) - r.Route("/{id}", func(r chi.Router) { - r.Patch("/", adminSubtitleHandler.HandlePatchDownloadedSubtitle) - r.Get("/download", adminSubtitleHandler.HandleDownloadDownloadedSubtitle) - r.Delete("/", adminSubtitleHandler.HandleDeleteDownloadedSubtitle) + // Live node sessions (reads from Redis) + // Note: /admin/sessions is already used for playback sessions from PostgreSQL. + r.Get("/node-sessions", nodeHandler.HandleListSessions) + } + + // System inspection. + { + sysJWTSecret := "" + if deps.Config != nil { + sysJWTSecret = deps.Config.Auth.JWTSecret + } + systemHandler := handlers.NewSystemHandler(deps.TranscodePool, sysJWTSecret) + r.Route("/system", func(r chi.Router) { + r.Get("/build", systemHandler.HandleBuildInfo) + r.Get("/hw-accel", systemHandler.HandleHWAccel) }) - }) - } + } - // Rate limit admin routes - if deps.RateLimitMW != nil && settingsRepo != nil { - rateLimitHandler := handlers.NewRateLimitHandler(settingsRepo, deps.RateLimitMW, deps.EventBus) - r.Route("/rate-limits", func(r chi.Router) { - r.Get("/config", rateLimitHandler.HandleGetConfig) - r.Put("/config", rateLimitHandler.HandleUpdateConfig) - }) - } + if deps.RecWorker != nil { + adminRecsHandler := handlers.NewAdminRecommendationsHandler(deps.RecWorker) + r.Route("/recommendations", func(r chi.Router) { + r.Get("/status", adminRecsHandler.HandleStatus) + r.Post("/trigger/embeddings", adminRecsHandler.HandleTriggerEmbeddings) + r.Post("/trigger/taste-profiles", adminRecsHandler.HandleTriggerTasteProfiles) + r.Post("/trigger/cowatch", adminRecsHandler.HandleTriggerCowatch) + r.Post("/trigger/recommendations", adminRecsHandler.HandleTriggerRecommendations) + }) + } - if apiKeyRepo != nil { - apiKeyHandler := handlers.NewAPIKeyHandler(apiKeyRepo) - r.Get("/users/{userId}/api-keys", apiKeyHandler.HandleAdminListUserAPIKeys) - r.Get("/api-keys", apiKeyHandler.HandleAdminListAllAPIKeys) - r.Post("/api-keys", apiKeyHandler.HandleAdminCreateAPIKey) - r.Delete("/api-keys/{id}", apiKeyHandler.HandleAdminDeleteAPIKey) - r.Put("/api-keys/{id}/tier", apiKeyHandler.HandleAdminUpdateTier) - } + if inviteCodeRepo != nil { + inviteCodeHandler := handlers.NewInviteCodeHandler(inviteCodeRepo) + r.Route("/invite-codes", func(r chi.Router) { + r.Get("/", inviteCodeHandler.HandleListInviteCodes) + r.Post("/", inviteCodeHandler.HandleCreateInviteCode) + r.Put("/{id}", inviteCodeHandler.HandleUpdateInviteCode) + r.Post("/{id}/top-up", inviteCodeHandler.HandleTopUpInviteCode) + r.Delete("/{id}", inviteCodeHandler.HandleDeleteInviteCode) + }) + } - if requestHandler != nil { - r.Get("/requests", requestHandler.HandleAdminList) - r.Post("/requests/{id}/approve", requestHandler.HandleApprove) - r.Post("/requests/{id}/decline", requestHandler.HandleDecline) - r.Post("/requests/{id}/cancel", requestHandler.HandleCancel) - r.Post("/requests/{id}/retry", requestHandler.HandleRetry) - r.Get("/request-settings", requestHandler.HandleGetSettings) - r.Put("/request-settings", requestHandler.HandleUpdateSettings) - r.Get("/request-users/{user_id}/limit", requestHandler.HandleGetUserLimit) - r.Put("/request-users/{user_id}/limit", requestHandler.HandleUpdateUserLimit) - r.Get("/request-integrations", requestHandler.HandleListIntegrations) - r.Put("/request-integrations", requestHandler.HandleUpdateIntegrations) - r.Post("/request-integrations/{kind}/options", requestHandler.HandleLoadIntegrationOptions) - } + if adminSubtitleHandler != nil { + r.Route("/subtitle-providers", func(r chi.Router) { + r.Get("/", adminSubtitleHandler.HandleListProviders) + r.Route("/{provider}", func(r chi.Router) { + r.Put("/", adminSubtitleHandler.HandleUpdateProvider) + r.Post("/test", adminSubtitleHandler.HandleTestProvider) + }) + }) + r.Route("/subtitles", func(r chi.Router) { + r.Get("/", adminSubtitleHandler.HandleListDownloadedSubtitles) + r.Route("/{id}", func(r chi.Router) { + r.Patch("/", adminSubtitleHandler.HandlePatchDownloadedSubtitle) + r.Get("/download", adminSubtitleHandler.HandleDownloadDownloadedSubtitle) + r.Delete("/", adminSubtitleHandler.HandleDeleteDownloadedSubtitle) + }) + }) + } - if deps.ActivityLogRepo != nil { - adminIPHandler := handlers.NewAdminIPHandler(deps.ActivityLogRepo) - r.Get("/users/{id}/ips", adminIPHandler.HandleGetUserIPs) - r.Get("/ips", adminIPHandler.HandleGetIPUsers) - } - if deps.OpsLogRepo != nil && deps.ActivityLogRepo != nil { - adminLogsHandler := handlers.NewAdminLogsHandler(deps.OpsLogRepo, deps.ActivityLogRepo, deps.LogStreamHub) - r.Get("/logs/app", adminLogsHandler.HandleListOperationalLogs) - r.Get("/logs/audit", adminLogsHandler.HandleListAuditLogs) - r.Get("/logs/ws", adminLogsHandler.HandleLogStreamWebSocket) - } - if adminPlaybackControlHandler != nil { - r.Post("/sessions/{session_id}/pause", adminPlaybackControlHandler.HandlePauseSession) - r.Post("/sessions/{session_id}/resume", adminPlaybackControlHandler.HandleResumeSession) - r.Post("/sessions/{session_id}/stop", adminPlaybackControlHandler.HandleStopSession) - r.Post("/sessions/{session_id}/terminate", adminPlaybackControlHandler.HandleTerminateSession) - r.Post("/sessions/{session_id}/message", adminPlaybackControlHandler.HandleMessageSession) - } + // Rate limit admin routes + if deps.RateLimitMW != nil && settingsRepo != nil { + rateLimitHandler := handlers.NewRateLimitHandler(settingsRepo, deps.RateLimitMW, deps.EventBus) + r.Route("/rate-limits", func(r chi.Router) { + r.Get("/config", rateLimitHandler.HandleGetConfig) + r.Put("/config", rateLimitHandler.HandleUpdateConfig) + }) + } - if deps.TaskManager != nil { - taskHistoryRepo := repository.NewPgExecutionRepository(deps.DB) - taskMetrics := handlers.NewTaskMetricsService(metadata.NewRefreshDebtRepository(deps.DB)) - taskHandler := handlers.NewTaskHandler(deps.TaskManager, taskHistoryRepo, taskMetrics) - r.Route("/tasks", func(r chi.Router) { - r.Get("/", taskHandler.HandleListTasks) - r.Get("/{key}", taskHandler.HandleGetTask) - r.Get("/{key}/metrics", taskHandler.HandleGetMetrics) - r.Post("/{key}/run", taskHandler.HandleRunTask) - r.Post("/{key}/cancel", taskHandler.HandleCancelTask) - r.Put("/{key}/triggers", taskHandler.HandleUpdateTriggers) - r.Get("/{key}/history", taskHandler.HandleGetHistory) - }) - } + if apiKeyRepo != nil { + apiKeyHandler := handlers.NewAPIKeyHandler(apiKeyRepo) + r.Get("/users/{userId}/api-keys", apiKeyHandler.HandleAdminListUserAPIKeys) + r.Get("/api-keys", apiKeyHandler.HandleAdminListAllAPIKeys) + r.Post("/api-keys", apiKeyHandler.HandleAdminCreateAPIKey) + r.Delete("/api-keys/{id}", apiKeyHandler.HandleAdminDeleteAPIKey) + r.Put("/api-keys/{id}/tier", apiKeyHandler.HandleAdminUpdateTier) + } + + if requestHandler != nil { + r.Get("/requests", requestHandler.HandleAdminList) + r.Post("/requests/{id}/approve", requestHandler.HandleApprove) + r.Post("/requests/{id}/decline", requestHandler.HandleDecline) + r.Post("/requests/{id}/cancel", requestHandler.HandleCancel) + r.Post("/requests/{id}/retry", requestHandler.HandleRetry) + r.Get("/request-settings", requestHandler.HandleGetSettings) + r.Put("/request-settings", requestHandler.HandleUpdateSettings) + r.Get("/request-users/{user_id}/limit", requestHandler.HandleGetUserLimit) + r.Put("/request-users/{user_id}/limit", requestHandler.HandleUpdateUserLimit) + r.Get("/request-integrations", requestHandler.HandleListIntegrations) + r.Put("/request-integrations", requestHandler.HandleUpdateIntegrations) + r.Post("/request-integrations/{kind}/options", requestHandler.HandleLoadIntegrationOptions) + } + + if deps.ActivityLogRepo != nil { + adminIPHandler := handlers.NewAdminIPHandler(deps.ActivityLogRepo) + r.Get("/users/{id}/ips", adminIPHandler.HandleGetUserIPs) + r.Get("/ips", adminIPHandler.HandleGetIPUsers) + } + if deps.OpsLogRepo != nil && deps.ActivityLogRepo != nil { + adminLogsHandler := handlers.NewAdminLogsHandler(deps.OpsLogRepo, deps.ActivityLogRepo, deps.LogStreamHub) + r.Get("/logs/app", adminLogsHandler.HandleListOperationalLogs) + r.Get("/logs/audit", adminLogsHandler.HandleListAuditLogs) + r.Get("/logs/ws", adminLogsHandler.HandleLogStreamWebSocket) + } + if adminPlaybackControlHandler != nil { + r.Post("/sessions/{session_id}/pause", adminPlaybackControlHandler.HandlePauseSession) + r.Post("/sessions/{session_id}/resume", adminPlaybackControlHandler.HandleResumeSession) + r.Post("/sessions/{session_id}/stop", adminPlaybackControlHandler.HandleStopSession) + r.Post("/sessions/{session_id}/terminate", adminPlaybackControlHandler.HandleTerminateSession) + r.Post("/sessions/{session_id}/message", adminPlaybackControlHandler.HandleMessageSession) + } + + if deps.TaskManager != nil { + taskHistoryRepo := repository.NewPgExecutionRepository(deps.DB) + taskMetrics := handlers.NewTaskMetricsService(metadata.NewRefreshDebtRepository(deps.DB)) + taskHandler := handlers.NewTaskHandler(deps.TaskManager, taskHistoryRepo, taskMetrics) + r.Route("/tasks", func(r chi.Router) { + r.Get("/", taskHandler.HandleListTasks) + r.Get("/{key}", taskHandler.HandleGetTask) + r.Get("/{key}/metrics", taskHandler.HandleGetMetrics) + r.Post("/{key}/run", taskHandler.HandleRunTask) + r.Post("/{key}/cancel", taskHandler.HandleCancelTask) + r.Put("/{key}/triggers", taskHandler.HandleUpdateTriggers) + r.Get("/{key}/history", taskHandler.HandleGetHistory) + }) + } + }) }) } }) diff --git a/internal/auth/permissions.go b/internal/auth/permissions.go new file mode 100644 index 00000000..2227e2ea --- /dev/null +++ b/internal/auth/permissions.go @@ -0,0 +1,93 @@ +package auth + +import ( + "fmt" + "sort" + "strings" + + "github.com/Silo-Server/silo-server/internal/models" +) + +type Permission string + +const PermissionMetadataCuration Permission = "metadata_curation" + +var assignablePermissions = map[Permission]struct{}{ + PermissionMetadataCuration: {}, +} + +func assignablePermissionList() []string { + out := make([]string, 0, len(assignablePermissions)) + for permission := range assignablePermissions { + out = append(out, string(permission)) + } + sort.Strings(out) + return out +} + +func isAssignablePermission(permission Permission) bool { + _, ok := assignablePermissions[permission] + return ok +} + +func NormalizePermissions(values []string) ([]string, error) { + if len(values) == 0 { + return []string{}, nil + } + + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + for _, raw := range values { + key := strings.TrimSpace(raw) + if key == "" { + continue + } + permission := Permission(key) + if !isAssignablePermission(permission) { + return nil, fmt.Errorf("unknown permission %q", key) + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, key) + } + sort.Strings(out) + return out, nil +} + +func HasAssignedPermission(user *models.User, permission Permission) bool { + if user == nil { + return false + } + for _, value := range user.Permissions { + if value == string(permission) { + return true + } + } + return false +} + +func HasEffectivePermission(user *models.User, permission Permission) bool { + if user == nil || !user.Enabled { + return false + } + if user.Role == "admin" { + return isAssignablePermission(permission) + } + return HasAssignedPermission(user, permission) +} + +func EffectivePermissions(user *models.User) []string { + if user == nil || !user.Enabled { + return []string{} + } + if user.Role == "admin" { + return assignablePermissionList() + } + permissions, err := NormalizePermissions(user.Permissions) + if err != nil { + return []string{} + } + return permissions +} diff --git a/internal/auth/permissions_test.go b/internal/auth/permissions_test.go new file mode 100644 index 00000000..43e65b2d --- /dev/null +++ b/internal/auth/permissions_test.go @@ -0,0 +1,47 @@ +package auth + +import ( + "reflect" + "testing" + + "github.com/Silo-Server/silo-server/internal/models" +) + +func TestNormalizePermissions_DeduplicatesAndSorts(t *testing.T) { + got, err := NormalizePermissions([]string{ + " metadata_curation ", + "metadata_curation", + "", + }) + if err != nil { + t.Fatalf("NormalizePermissions returned error: %v", err) + } + want := []string{"metadata_curation"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("permissions = %#v, want %#v", got, want) + } +} + +func TestNormalizePermissions_RejectsUnknownPermission(t *testing.T) { + if _, err := NormalizePermissions([]string{"server_owner"}); err == nil { + t.Fatal("expected unknown permission error") + } +} + +func TestHasEffectivePermission_AdminImpliesMetadataCuration(t *testing.T) { + user := &models.User{Role: "admin", Enabled: true} + if !HasEffectivePermission(user, PermissionMetadataCuration) { + t.Fatal("admin should have metadata curation") + } +} + +func TestHasEffectivePermission_UserRequiresAssignedPermission(t *testing.T) { + user := &models.User{Role: "user", Enabled: true} + if HasEffectivePermission(user, PermissionMetadataCuration) { + t.Fatal("plain user should not have metadata curation") + } + user.Permissions = []string{"metadata_curation"} + if !HasEffectivePermission(user, PermissionMetadataCuration) { + t.Fatal("assigned user should have metadata curation") + } +} diff --git a/internal/auth/repository.go b/internal/auth/repository.go index 76df5933..f4a7f8e1 100644 --- a/internal/auth/repository.go +++ b/internal/auth/repository.go @@ -49,7 +49,7 @@ func NewUserRepository(pool *pgxpool.Pool) *UserRepository { // allColumns is the list of columns returned by all SELECT queries. // Kept in one place so scanUser stays in sync. -const allColumns = `id, email, username, password_hash, local_password_login_enabled, role, enabled, +const allColumns = `id, email, username, password_hash, local_password_login_enabled, role, permissions, enabled, library_ids, max_playback_quality, access_policy_revision, max_streams, max_transcodes, max_profiles, download_allowed, download_transcode_allowed, created_at, updated_at` @@ -64,6 +64,7 @@ func scanUser(row pgx.Row) (*models.User, error) { &u.PasswordHash, &u.LocalPasswordLoginEnabled, &u.Role, + &u.Permissions, &u.Enabled, &u.LibraryIDs, &u.MaxPlaybackQuality, @@ -97,6 +98,7 @@ func scanUsers(rows pgx.Rows) ([]*models.User, error) { &u.PasswordHash, &u.LocalPasswordLoginEnabled, &u.Role, + &u.Permissions, &u.Enabled, &u.LibraryIDs, &u.MaxPlaybackQuality, @@ -133,13 +135,19 @@ func (r *UserRepository) Create(ctx context.Context, input models.CreateUserInpu localPasswordLoginEnabled = *input.LocalPasswordLoginEnabled } - cols := []string{"email", "username", "password_hash", "local_password_login_enabled", "role", "library_ids", "max_playback_quality"} + permissions, err := NormalizePermissions(input.Permissions) + if err != nil { + return nil, err + } + + cols := []string{"email", "username", "password_hash", "local_password_login_enabled", "role", "permissions", "library_ids", "max_playback_quality"} args := []any{ input.Email, input.Username, string(hash), localPasswordLoginEnabled, input.Role, + permissions, input.LibraryIDs, input.MaxPlaybackQuality, } @@ -245,6 +253,15 @@ func (r *UserRepository) Update(ctx context.Context, id int, input models.Update args = append(args, *input.Role) argIndex++ } + if input.Permissions != nil { + permissions, err := NormalizePermissions(*input.Permissions) + if err != nil { + return err + } + setClauses = append(setClauses, fmt.Sprintf("permissions = $%d", argIndex)) + args = append(args, permissions) + argIndex++ + } if input.Enabled != nil { setClauses = append(setClauses, fmt.Sprintf("enabled = $%d", argIndex)) args = append(args, *input.Enabled) @@ -292,6 +309,14 @@ func (r *UserRepository) Update(ctx context.Context, id int, input models.Update return err } + if input.Role != nil || + input.Enabled != nil || + input.LibraryIDs != nil || + input.MaxPlaybackQuality != nil || + input.Permissions != nil { + setClauses = append(setClauses, "access_policy_revision = access_policy_revision + 1") + } + // Always bump updated_at. setClauses = append(setClauses, "updated_at = NOW()") diff --git a/internal/catalog/access_filter.go b/internal/catalog/access_filter.go index f58ace5b..0f1aa09b 100644 --- a/internal/catalog/access_filter.go +++ b/internal/catalog/access_filter.go @@ -51,9 +51,24 @@ func FileAllowedByAccess(file *models.MediaFile, filter AccessFilter) bool { if file == nil { return false } + if filter.AllowedLibraryIDs != nil && !intInSlice(file.MediaFolderID, filter.AllowedLibraryIDs) { + return false + } + if len(filter.DisabledLibraryIDs) > 0 && intInSlice(file.MediaFolderID, filter.DisabledLibraryIDs) { + return false + } return access.QualityAllowed(file.Resolution, filter.MaxPlaybackQuality) } +func intInSlice(value int, values []int) bool { + for _, candidate := range values { + if candidate == value { + return true + } + } + return false +} + // FilterMediaFilesByAccess drops file versions that exceed the viewer's // effective quality ceiling. func FilterMediaFilesByAccess(files []*models.MediaFile, filter AccessFilter) []*models.MediaFile { diff --git a/internal/database/testdata/migrations/001_create_users.up.sql b/internal/database/testdata/migrations/001_create_users.up.sql index 193ad2b4..f02facd7 100644 --- a/internal/database/testdata/migrations/001_create_users.up.sql +++ b/internal/database/testdata/migrations/001_create_users.up.sql @@ -1,5 +1,7 @@ CREATE TABLE users ( id BIGSERIAL PRIMARY KEY, username TEXT NOT NULL UNIQUE, + role TEXT, + permissions TEXT[] DEFAULT '{}'::TEXT[] NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); diff --git a/internal/models/user.go b/internal/models/user.go index f16b835f..9da9ab88 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -10,6 +10,7 @@ type User struct { PasswordHash string LocalPasswordLoginEnabled bool Role string + Permissions []string Enabled bool LibraryIDs []int // nullable in PG (nil = all libraries) MaxPlaybackQuality string @@ -30,6 +31,7 @@ type CreateUserInput struct { Password string // plaintext, will be bcrypt-hashed LocalPasswordLoginEnabled *bool Role string // e.g. "admin", "user" + Permissions []string LibraryIDs []int MaxPlaybackQuality string MaxStreams *int // nil = use DB default (6) @@ -47,6 +49,7 @@ type UpdateUserInput struct { Password *string // plaintext, will be bcrypt-hashed if provided LocalPasswordLoginEnabled *bool Role *string + Permissions *[]string Enabled *bool LibraryIDs *[]int MaxPlaybackQuality *string diff --git a/migrations/001_schema.up.sql b/migrations/001_schema.up.sql index f05d74cd..2bdf889e 100644 --- a/migrations/001_schema.up.sql +++ b/migrations/001_schema.up.sql @@ -1058,6 +1058,7 @@ CREATE TABLE public.users ( username text, password_hash text, role text, + permissions text[] DEFAULT '{}'::text[] NOT NULL, enabled boolean DEFAULT true, library_ids integer[], max_streams integer DEFAULT 6, diff --git a/migrations/140_user_permissions.down.sql b/migrations/140_user_permissions.down.sql new file mode 100644 index 00000000..12011ed4 --- /dev/null +++ b/migrations/140_user_permissions.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.users + DROP COLUMN IF EXISTS permissions; diff --git a/migrations/140_user_permissions.up.sql b/migrations/140_user_permissions.up.sql new file mode 100644 index 00000000..0921ec97 --- /dev/null +++ b/migrations/140_user_permissions.up.sql @@ -0,0 +1,6 @@ +ALTER TABLE public.users + ADD COLUMN IF NOT EXISTS permissions text[] NOT NULL DEFAULT '{}'::text[]; + +UPDATE public.users +SET permissions = '{}'::text[] +WHERE permissions IS NULL; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index a6f5fcc9..55c3a06e 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -97,6 +97,7 @@ export interface User { username: string; email: string; role: string; + permissions: string[]; download_allowed: boolean; impersonation?: ImpersonationInfo | null; } @@ -1631,6 +1632,7 @@ export interface AdminUser { username: string; email: string; role: string; + permissions: string[]; enabled: boolean; library_ids: number[] | null; max_playback_quality: string; @@ -1649,6 +1651,7 @@ export interface CreateUserRequest { email: string; password: string; role: string; + permissions?: string[]; create_default_profile?: boolean; default_profile_name?: string; library_ids?: number[] | null; @@ -1665,6 +1668,7 @@ export interface UpdateUserRequest { email?: string; password?: string; role?: string; + permissions?: string[]; enabled?: boolean; library_ids?: number[] | null; max_playback_quality?: string; diff --git a/web/src/components/EditMetadataDialog.tsx b/web/src/components/EditMetadataDialog.tsx index 4e3c6749..1574470c 100644 --- a/web/src/components/EditMetadataDialog.tsx +++ b/web/src/components/EditMetadataDialog.tsx @@ -13,6 +13,7 @@ import { useRefreshItemMetadata, type UpdateItemMetadataRequest, } from "@/hooks/queries/items"; +import { useAuth } from "@/hooks/useAuth"; import { cn } from "@/lib/utils"; // MetadataField enum values matching internal/metadata/types.go @@ -92,6 +93,7 @@ function initFormState(item: ItemDetail) { } export default function EditMetadataDialog({ item, open, onOpenChange }: EditMetadataDialogProps) { + const { user } = useAuth(); const [activeSection, setActiveSection] = useState
("general"); const [form, setForm] = useState(() => initFormState(item)); const [lockedFields, setLockedFields] = useState>( @@ -103,7 +105,13 @@ export default function EditMetadataDialog({ item, open, onOpenChange }: EditMet const refreshMutation = useRefreshItemMetadata(); const isLockable = item.type === "movie" || item.type === "series"; - const visibleSections = SECTIONS.filter((s) => s.types.includes(item.type)); + const canEditImages = user?.role === "admin"; + const visibleSections = SECTIONS.filter( + (s) => s.types.includes(item.type) && (s.key !== "images" || canEditImages), + ); + const effectiveActiveSection = visibleSections.some((section) => section.key === activeSection) + ? activeSection + : "general"; const originalForm = useMemo(() => initFormState(item), [item]); @@ -261,7 +269,7 @@ export default function EditMetadataDialog({ item, open, onOpenChange }: EditMet onClick={() => setActiveSection(section.key)} className={cn( "px-4 py-2 text-left text-[13px] font-medium whitespace-nowrap transition-colors", - activeSection === section.key + effectiveActiveSection === section.key ? "border-primary bg-primary/8 text-primary max-sm:border-b-2 sm:border-r-2" : "text-muted-foreground hover:text-foreground", )} @@ -273,7 +281,7 @@ export default function EditMetadataDialog({ item, open, onOpenChange }: EditMet {/* Content */}
- {activeSection === "general" && ( + {effectiveActiveSection === "general" && (
setField("title", e.target.value)} /> @@ -389,7 +397,7 @@ export default function EditMetadataDialog({ item, open, onOpenChange }: EditMet
)} - {activeSection === "dates" && ( + {effectiveActiveSection === "dates" && (
{(item.type === "movie" || item.type === "series") && (
@@ -528,7 +536,7 @@ export default function EditMetadataDialog({ item, open, onOpenChange }: EditMet
)} - {activeSection === "tags" && ( + {effectiveActiveSection === "tags" && (
)} - {activeSection === "ids" && ( + {effectiveActiveSection === "ids" && (
)} -
- -
+ {canEditImages && ( +
+ +
+ )}
diff --git a/web/src/components/admin/deviceOverrides.tsx b/web/src/components/admin/deviceOverrides.tsx index 122b05ee..89916244 100644 --- a/web/src/components/admin/deviceOverrides.tsx +++ b/web/src/components/admin/deviceOverrides.tsx @@ -535,17 +535,18 @@ export function DeviceProfileTabs({ }: DeviceProfileTabsProps) { const [activeId, setActiveId] = useState(initialProfileId ?? null); - if (profiles.length === 0) return null; - - const active: DeviceProfileTabEntry = - profiles.find((p) => p.profileId === activeId) ?? profiles[0]!; - const accent = profileAccent(active.profileId); - const rows = buildRenderedRows(active, showAllSettings, device); - const overrideCount = active.settings.length; + const active = useMemo( + () => profiles.find((p) => p.profileId === activeId) ?? profiles[0] ?? null, + [activeId, profiles], + ); + const rows = useMemo( + () => (active ? buildRenderedRows(active, showAllSettings, device) : []), + [active, device, showAllSettings], + ); // Conflicts depend on the active profile's settings as a whole. Memoize // by the active profile's reference + a content hash via the rendered // row keys/values — recomputes on profile switch and on save. - const conflictMap = useMemo(() => detectSettingConflicts(active.settings), [active.settings]); + const conflictMap = useMemo(() => detectSettingConflicts(active?.settings ?? []), [active]); const anomaliesByKey = useMemo(() => { const out = new Map(); for (const { setting, isOverride } of rows) { @@ -557,6 +558,11 @@ export function DeviceProfileTabs({ } return out; }, [rows, deviceStaleDays, conflictMap]); + + if (!active) return null; + + const accent = profileAccent(active.profileId); + const overrideCount = active.settings.length; const anomalyCountInProfile = anomaliesByKey.size; return ( diff --git a/web/src/hooks/useAuth.test.ts b/web/src/hooks/useAuth.test.ts index 7607c208..b0a04934 100644 --- a/web/src/hooks/useAuth.test.ts +++ b/web/src/hooks/useAuth.test.ts @@ -40,6 +40,7 @@ describe("initializeAuthSession", () => { username: "admin", email: "admin@example.com", role: "admin", + permissions: [], download_allowed: true, impersonation: null, }); diff --git a/web/src/lib/permissions.ts b/web/src/lib/permissions.ts new file mode 100644 index 00000000..81d28771 --- /dev/null +++ b/web/src/lib/permissions.ts @@ -0,0 +1,30 @@ +import type { User } from "@/api/types"; + +export const PERMISSION_METADATA_CURATION = "metadata_curation"; + +export function hasPermission( + user: Pick | null | undefined, + permission: string, +) { + if (!user) return false; + if (user.role === "admin") return true; + return Array.isArray(user.permissions) && user.permissions.includes(permission); +} + +export function canCurateMetadata(user: Pick | null | undefined) { + return hasPermission(user, PERMISSION_METADATA_CURATION); +} + +export function hasAssignedPermission(permissions: string[] | undefined, permission: string) { + return Array.isArray(permissions) && permissions.includes(permission); +} + +export function setAssignedPermission(permissions: string[], permission: string, enabled: boolean) { + const next = new Set(permissions); + if (enabled) { + next.add(permission); + } else { + next.delete(permission); + } + return Array.from(next).sort(); +} diff --git a/web/src/pages/AdminUserDetail.tsx b/web/src/pages/AdminUserDetail.tsx index a2a47faa..2f9fe769 100644 --- a/web/src/pages/AdminUserDetail.tsx +++ b/web/src/pages/AdminUserDetail.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useId, useMemo, useState } from "react"; import type { FormEvent } from "react"; import { useParams, Link } from "react-router"; import { @@ -66,6 +66,11 @@ import { playbackQualityValueFromPreset, type PlaybackQualityPreset, } from "@/lib/playback-quality"; +import { + PERMISSION_METADATA_CURATION, + hasAssignedPermission, + setAssignedPermission, +} from "@/lib/permissions"; import { RegistrySettingControl } from "@/components/settings/RegistrySettingControl"; import { formatSettingValue, getSettingDefinition } from "@/lib/settingsManifest"; import { @@ -275,6 +280,14 @@ function OverviewTab({ user }: { user: AdminUser }) {
+ void const [password, setPassword] = useState(""); const [role, setRole] = useState(user.role); const [enabled, setEnabled] = useState(user.enabled); + const [permissions, setPermissions] = useState(user.permissions ?? []); const [libraryIDs, setLibraryIDs] = useState(user.library_ids); const [maxStreams, setMaxStreams] = useState(user.max_streams); const [maxTranscodes, setMaxTranscodes] = useState(user.max_transcodes); @@ -889,6 +903,7 @@ function EditUserForm({ user, onClose }: { user: AdminUser; onClose: () => void const [downloadTranscodeAllowed, setDownloadTranscodeAllowed] = useState( user.download_transcode_allowed, ); + const metadataCurationId = useId(); const updateMutation = useUpdateUser(); function handleSubmit(e: FormEvent) { @@ -897,6 +912,7 @@ function EditUserForm({ user, onClose }: { user: AdminUser; onClose: () => void username, email, role, + permissions, enabled, library_ids: libraryIDs, max_streams: maxStreams, @@ -982,6 +998,23 @@ function EditUserForm({ user, onClose }: { user: AdminUser; onClose: () => void value={libraryIDs} onChange={setLibraryIDs} /> +
+
+ +

+ Edit, refresh, and rematch metadata within assigned libraries. +

+
+ + setPermissions((current) => + setAssignedPermission(current, PERMISSION_METADATA_CURATION, checked), + ) + } + /> +
diff --git a/web/src/pages/AdminUsers.tsx b/web/src/pages/AdminUsers.tsx index 5c277324..eedf6d78 100644 --- a/web/src/pages/AdminUsers.tsx +++ b/web/src/pages/AdminUsers.tsx @@ -59,6 +59,11 @@ import { playbackQualityValueFromPreset, type PlaybackQualityPreset, } from "@/lib/playback-quality"; +import { + PERMISSION_METADATA_CURATION, + hasAssignedPermission, + setAssignedPermission, +} from "@/lib/permissions"; const PAGE_SIZE_OPTIONS = ["25", "50", "100"] as const; type UserSortField = "username" | "email" | "role" | "enabled" | "created_at" | "last_active_at"; @@ -522,6 +527,7 @@ function UserForm({ user, onClose }: { user: AdminUser | null; onClose: () => vo const [password, setPassword] = useState(""); const [role, setRole] = useState(user?.role ?? "user"); const [enabled, setEnabled] = useState(user?.enabled ?? true); + const [permissions, setPermissions] = useState(user?.permissions ?? []); const [libraryIDs, setLibraryIDs] = useState(user?.library_ids ?? null); const [maxStreams, setMaxStreams] = useState( user?.max_streams ?? Number(settings?.["defaults.max_streams"] ?? "6"), @@ -549,6 +555,7 @@ function UserForm({ user, onClose }: { user: AdminUser | null; onClose: () => vo const passwordId = useId(); const roleId = useId(); const enabledId = useId(); + const metadataCurationId = useId(); const downloadAllowedId = useId(); const downloadTranscodeAllowedId = useId(); const maxStreamsId = useId(); @@ -566,6 +573,7 @@ function UserForm({ user, onClose }: { user: AdminUser | null; onClose: () => vo username, email, role, + permissions, enabled, library_ids: libraryIDs, max_streams: maxStreams, @@ -583,6 +591,7 @@ function UserForm({ user, onClose }: { user: AdminUser | null; onClose: () => vo email, password, role, + permissions, create_default_profile: true, max_streams: maxStreams, max_transcodes: maxTranscodes, @@ -682,6 +691,23 @@ function UserForm({ user, onClose }: { user: AdminUser | null; onClose: () => vo value={libraryIDs} onChange={setLibraryIDs} /> +
+
+ +

+ Edit, refresh, and rematch metadata within assigned libraries. +

+
+ + setPermissions((current) => + setAssignedPermission(current, PERMISSION_METADATA_CURATION, checked), + ) + } + /> +
diff --git a/web/src/pages/ItemDetail/EpisodeContent.tsx b/web/src/pages/ItemDetail/EpisodeContent.tsx index aaa380f1..a408bb64 100644 --- a/web/src/pages/ItemDetail/EpisodeContent.tsx +++ b/web/src/pages/ItemDetail/EpisodeContent.tsx @@ -35,6 +35,7 @@ import { type EpisodeNavigationState, } from "./itemDetailLayout"; import { getWatchedActionLabel } from "./watchedState"; +import { canCurateMetadata as canCurateMetadataForUser } from "@/lib/permissions"; function formatDuration(minutes: number): string { if (minutes <= 0) return ""; @@ -49,6 +50,7 @@ export default function EpisodeContent({ item }: { item: ItemDetail & { type: "e useAmbientColor(item.backdrop_thumbhash); const { user } = useAuth(); const isAdmin = user?.role === "admin"; + const canCurateMetadata = canCurateMetadataForUser(user); const { profile: currentProfile } = useCurrentProfile(); const [editOpen, setEditOpen] = useState(false); const [downloadOpen, setDownloadOpen] = useState(false); @@ -294,12 +296,15 @@ export default function EpisodeContent({ item }: { item: ItemDetail & { type: "e watchedLabel={getWatchedActionLabel(item)} onToggleWatched={() => watchedMutation.mutate(!(item.user_data?.played ?? false))} isUpdatingWatched={watchedMutation.isPending} - onRefresh={(mode) => - refreshMetadataMutation.mutate({ - item, - mode, - onReplaced: (contentID) => navigate(`/item/${contentID}`, { replace: true }), - }) + onRefresh={ + canCurateMetadata + ? (mode) => + refreshMetadataMutation.mutate({ + item, + mode, + onReplaced: (contentID) => navigate(`/item/${contentID}`, { replace: true }), + }) + : undefined } isRefreshing={refreshMetadataMutation.isPending} onRedetectIntro={ @@ -307,7 +312,8 @@ export default function EpisodeContent({ item }: { item: ItemDetail & { type: "e } isRedetectingIntro={redetectIntroMutation.isPending} isAdmin={isAdmin} - onEditMetadata={isAdmin ? () => setEditOpen(true) : undefined} + canCurateMetadata={canCurateMetadata} + onEditMetadata={canCurateMetadata ? () => setEditOpen(true) : undefined} versions={item.versions ?? []} playbackVariants={item.playback_variants} selectedVersion={selectedVersion} @@ -340,7 +346,7 @@ export default function EpisodeContent({ item }: { item: ItemDetail & { type: "e />
- {isAdmin && } + {canCurateMetadata && } {/* More Episodes carousel — most useful, so show first */} {siblingsLoading ? ( @@ -367,7 +373,9 @@ export default function EpisodeContent({ item }: { item: ItemDetail & { type: "e {item.crew && item.crew.length > 0 && }
- {isAdmin && } + {canCurateMetadata && ( + + )} toggleWatchlistMutation.mutate(inWatchlist)} inWatchlist={inWatchlist} - onRefresh={(mode) => - refreshMetadataMutation.mutate({ - item, - mode, - onReplaced: (contentID) => navigate(`/item/${contentID}`, { replace: true }), - }) + onRefresh={ + canCurateMetadata + ? (mode) => + refreshMetadataMutation.mutate({ + item, + mode, + onReplaced: (contentID) => navigate(`/item/${contentID}`, { replace: true }), + }) + : undefined } isRefreshing={refreshMetadataMutation.isPending} isAdmin={isAdmin} - onEditMetadata={isAdmin ? () => setEditOpen(true) : undefined} - onMatchItem={isAdmin ? () => setMatchOpen(true) : undefined} + canCurateMetadata={canCurateMetadata} + onEditMetadata={canCurateMetadata ? () => setEditOpen(true) : undefined} + onMatchItem={canCurateMetadata ? () => setMatchOpen(true) : undefined} versions={item.versions} playbackVariants={item.playback_variants} selectedVersion={selectedVersion} @@ -283,7 +289,7 @@ export default function MovieContent({ item }: { item: ItemDetail & { type: "mov />
- {isAdmin && } + {canCurateMetadata && } {item.cast && item.cast.length > 0 && (
@@ -307,8 +313,10 @@ export default function MovieContent({ item }: { item: ItemDetail & { type: "mov ) )}
- {isAdmin && } - {isAdmin && ( + {canCurateMetadata && ( + + )} + {canCurateMetadata && ( watchedMutation.mutate(!(item.user_data?.played ?? false))} isUpdatingWatched={watchedMutation.isPending} - onRefresh={(mode) => - refreshMetadataMutation.mutate({ - item, - mode, - onReplaced: (contentID) => navigate(`/item/${contentID}`, { replace: true }), - }) + onRefresh={ + canCurateMetadata + ? (mode) => + refreshMetadataMutation.mutate({ + item, + mode, + onReplaced: (contentID) => navigate(`/item/${contentID}`, { replace: true }), + }) + : undefined } isRefreshing={refreshMetadataMutation.isPending} isAdmin={isAdmin} - onEditMetadata={isAdmin ? () => setEditOpen(true) : undefined} + canCurateMetadata={canCurateMetadata} + onEditMetadata={canCurateMetadata ? () => setEditOpen(true) : undefined} /> } /> @@ -144,7 +150,9 @@ export default function SeasonContent({ item }: { item: ItemDetail & { type: "se
)}
- {isAdmin && } + {canCurateMetadata && ( + + )}
); } diff --git a/web/src/pages/ItemDetail/SeriesContent.tsx b/web/src/pages/ItemDetail/SeriesContent.tsx index de46882a..638acc45 100644 --- a/web/src/pages/ItemDetail/SeriesContent.tsx +++ b/web/src/pages/ItemDetail/SeriesContent.tsx @@ -25,12 +25,14 @@ import ActionBar from "./components/ActionBar"; import { SeasonCarouselSkeleton, RecommendationGridSkeleton } from "./components/SectionSkeletons"; import { getSeasonDisplayTitle, resolveSeriesPrimaryAction } from "./itemDetailLayout"; import { getWatchedActionLabel } from "./watchedState"; +import { canCurateMetadata as canCurateMetadataForUser } from "@/lib/permissions"; export default function SeriesContent({ item }: { item: ItemDetail & { type: "series" } }) { const navigate = useNavigate(); useAmbientColor(item.backdrop_thumbhash); const { user } = useAuth(); const isAdmin = user?.role === "admin"; + const canCurateMetadata = canCurateMetadataForUser(user); const isFavorite = item.user_state?.is_favorite ?? false; const inWatchlist = item.user_state?.in_watchlist ?? false; @@ -156,17 +158,21 @@ export default function SeriesContent({ item }: { item: ItemDetail & { type: "se isFavorite={isFavorite} onToggleWatchlist={() => toggleWatchlistMutation.mutate(inWatchlist)} inWatchlist={inWatchlist} - onRefresh={(mode) => - refreshMetadataMutation.mutate({ - item, - mode, - onReplaced: (contentID) => navigate(`/item/${contentID}`, { replace: true }), - }) + onRefresh={ + canCurateMetadata + ? (mode) => + refreshMetadataMutation.mutate({ + item, + mode, + onReplaced: (contentID) => navigate(`/item/${contentID}`, { replace: true }), + }) + : undefined } isRefreshing={refreshMetadataMutation.isPending} isAdmin={isAdmin} - onEditMetadata={isAdmin ? () => setEditOpen(true) : undefined} - onMatchItem={isAdmin ? () => setMatchOpen(true) : undefined} + canCurateMetadata={canCurateMetadata} + onEditMetadata={canCurateMetadata ? () => setEditOpen(true) : undefined} + onMatchItem={canCurateMetadata ? () => setMatchOpen(true) : undefined} rating={item.user_rating ?? null} onRatingChange={handleRatingChange} /> @@ -213,8 +219,10 @@ export default function SeriesContent({ item }: { item: ItemDetail & { type: "se ) )}
- {isAdmin && } - {isAdmin && ( + {canCurateMetadata && ( + + )} + {canCurateMetadata && ( void; onMatchItem?: () => void; isAdmin?: boolean; + canCurateMetadata?: boolean; versions?: FileVersion[]; playbackVariants?: PlaybackVariant[]; selectedVersion?: FileVersion | null; @@ -119,6 +120,7 @@ export default function ActionBar({ onEditMetadata, onMatchItem, isAdmin = false, + canCurateMetadata = false, versions, playbackVariants, selectedVersion, @@ -223,6 +225,10 @@ export default function ActionBar({ const hasOverflowActions = Boolean( restartHref || onToggleWatchlist || onDownload || onSearchSubtitles, ); + const hasAdminActions = Boolean(isAdmin && (contentId || onRedetectIntro)); + const hasMetadataActions = Boolean( + canCurateMetadata && (onRefresh || onEditMetadata || onMatchItem), + ); const formattedResumeTime = formatPlaybackTime(resumePositionSeconds ?? 0); const percentComplete = @@ -370,10 +376,10 @@ export default function ActionBar({ Search Subtitles )} - {isAdmin && ( + {(hasAdminActions || hasMetadataActions) && ( <> {hasOverflowActions && } - {contentId && ( + {isAdmin && contentId && ( navigate(`/admin/history?media_item_id=${encodeURIComponent(contentId)}`) @@ -382,7 +388,7 @@ export default function ActionBar({ View Play History )} - {onRefresh && ( + {canCurateMetadata && onRefresh && ( { @@ -393,19 +399,19 @@ export default function ActionBar({ Refresh Metadata )} - {onRedetectIntro && ( + {isAdmin && onRedetectIntro && ( Re-detect Intro Markers )} - {onEditMetadata && ( + {canCurateMetadata && onEditMetadata && ( Edit Metadata )} - {onMatchItem && ( + {canCurateMetadata && onMatchItem && ( Match Item