Merge pull request #11 from Silo-Server/t3code/eb3dedf1
feat(auth): add metadata curation permission
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = ""
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+329
-307
@@ -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)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()")
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE public.users
|
||||
DROP COLUMN IF EXISTS permissions;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Section>("general");
|
||||
const [form, setForm] = useState(() => initFormState(item));
|
||||
const [lockedFields, setLockedFields] = useState<Set<number>>(
|
||||
@@ -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 */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4 sm:px-6 sm:py-5">
|
||||
{activeSection === "general" && (
|
||||
{effectiveActiveSection === "general" && (
|
||||
<div className="space-y-4">
|
||||
<FieldRow label="Title" lockIcon={renderLockIcon("title")}>
|
||||
<Input value={form.title} onChange={(e) => setField("title", e.target.value)} />
|
||||
@@ -389,7 +397,7 @@ export default function EditMetadataDialog({ item, open, onOpenChange }: EditMet
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === "dates" && (
|
||||
{effectiveActiveSection === "dates" && (
|
||||
<div className="space-y-4">
|
||||
{(item.type === "movie" || item.type === "series") && (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
@@ -528,7 +536,7 @@ export default function EditMetadataDialog({ item, open, onOpenChange }: EditMet
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === "tags" && (
|
||||
{effectiveActiveSection === "tags" && (
|
||||
<div className="space-y-4">
|
||||
<FieldRow label="Genres" lockIcon={renderLockIcon("genres")}>
|
||||
<TagInput
|
||||
@@ -566,7 +574,7 @@ export default function EditMetadataDialog({ item, open, onOpenChange }: EditMet
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === "ids" && (
|
||||
{effectiveActiveSection === "ids" && (
|
||||
<div className="space-y-4">
|
||||
<FieldRow label="IMDb ID">
|
||||
<Input
|
||||
@@ -590,9 +598,15 @@ export default function EditMetadataDialog({ item, open, onOpenChange }: EditMet
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={activeSection === "images" ? "flex h-full flex-col" : "hidden"}>
|
||||
<ImageSelectorTab item={item} enabled={activeSection === "images"} />
|
||||
</div>
|
||||
{canEditImages && (
|
||||
<div
|
||||
className={
|
||||
effectiveActiveSection === "images" ? "flex h-full flex-col" : "hidden"
|
||||
}
|
||||
>
|
||||
<ImageSelectorTab item={item} enabled={effectiveActiveSection === "images"} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -535,17 +535,18 @@ export function DeviceProfileTabs({
|
||||
}: DeviceProfileTabsProps) {
|
||||
const [activeId, setActiveId] = useState<string | null>(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<string, SettingAnomaly>();
|
||||
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 (
|
||||
|
||||
@@ -40,6 +40,7 @@ describe("initializeAuthSession", () => {
|
||||
username: "admin",
|
||||
email: "admin@example.com",
|
||||
role: "admin",
|
||||
permissions: [],
|
||||
download_allowed: true,
|
||||
impersonation: null,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { User } from "@/api/types";
|
||||
|
||||
export const PERMISSION_METADATA_CURATION = "metadata_curation";
|
||||
|
||||
export function hasPermission(
|
||||
user: Pick<User, "role" | "permissions"> | 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<User, "role" | "permissions"> | 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();
|
||||
}
|
||||
@@ -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 }) {
|
||||
</div>
|
||||
<div className="divide-border divide-y">
|
||||
<DetailRow label="Library Access" value={libraryNames} />
|
||||
<DetailRow
|
||||
label="Metadata Curation"
|
||||
value={
|
||||
hasAssignedPermission(user.permissions, PERMISSION_METADATA_CURATION)
|
||||
? "Allowed"
|
||||
: "Not allowed"
|
||||
}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Max Playback Quality"
|
||||
value={formatPlaybackQualityPreset(user.max_playback_quality)}
|
||||
@@ -878,6 +891,7 @@ function EditUserForm({ user, onClose }: { user: AdminUser; onClose: () => void
|
||||
const [password, setPassword] = useState("");
|
||||
const [role, setRole] = useState(user.role);
|
||||
const [enabled, setEnabled] = useState(user.enabled);
|
||||
const [permissions, setPermissions] = useState<string[]>(user.permissions ?? []);
|
||||
const [libraryIDs, setLibraryIDs] = useState<number[] | null>(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}
|
||||
/>
|
||||
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<div>
|
||||
<Label htmlFor={metadataCurationId}>Metadata Curation</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Edit, refresh, and rematch metadata within assigned libraries.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id={metadataCurationId}
|
||||
checked={hasAssignedPermission(permissions, PERMISSION_METADATA_CURATION)}
|
||||
onCheckedChange={(checked) =>
|
||||
setPermissions((current) =>
|
||||
setAssignedPermission(current, PERMISSION_METADATA_CURATION, checked),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<Label>Downloads Allowed</Label>
|
||||
|
||||
@@ -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<string[]>(user?.permissions ?? []);
|
||||
const [libraryIDs, setLibraryIDs] = useState<number[] | null>(user?.library_ids ?? null);
|
||||
const [maxStreams, setMaxStreams] = useState<number>(
|
||||
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}
|
||||
/>
|
||||
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<div>
|
||||
<Label htmlFor={metadataCurationId}>Metadata Curation</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Edit, refresh, and rematch metadata within assigned libraries.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id={metadataCurationId}
|
||||
checked={hasAssignedPermission(permissions, PERMISSION_METADATA_CURATION)}
|
||||
onCheckedChange={(checked) =>
|
||||
setPermissions((current) =>
|
||||
setAssignedPermission(current, PERMISSION_METADATA_CURATION, checked),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<Label htmlFor={downloadAllowedId}>Downloads Allowed</Label>
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
<div className="page-shell space-y-12 py-10 sm:space-y-14">
|
||||
{isAdmin && <MediaLocations title="Media locations" versions={item.versions} />}
|
||||
{canCurateMetadata && <MediaLocations title="Media locations" versions={item.versions} />}
|
||||
|
||||
{/* 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 && <CrewList crew={item.crew} />}
|
||||
</div>
|
||||
{isAdmin && <EditMetadataDialog item={item} open={editOpen} onOpenChange={setEditOpen} />}
|
||||
{canCurateMetadata && (
|
||||
<EditMetadataDialog item={item} open={editOpen} onOpenChange={setEditOpen} />
|
||||
)}
|
||||
<DownloadVersionPicker
|
||||
open={downloadOpen}
|
||||
onOpenChange={setDownloadOpen}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { selectDefaultPlaybackVariantVersion } from "./components/versionRanking
|
||||
import { RecommendationGridSkeleton } from "./components/SectionSkeletons";
|
||||
import { resolveLeafPrimaryAction } from "./itemDetailLayout";
|
||||
import { getWatchedActionLabel } from "./watchedState";
|
||||
import { canCurateMetadata as canCurateMetadataForUser } from "@/lib/permissions";
|
||||
|
||||
function formatDuration(minutes: number): string {
|
||||
if (minutes <= 0) return "";
|
||||
@@ -42,6 +43,7 @@ export default function MovieContent({ item }: { item: ItemDetail & { type: "mov
|
||||
useAmbientColor(item.backdrop_thumbhash);
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.role === "admin";
|
||||
const canCurateMetadata = canCurateMetadataForUser(user);
|
||||
const { profile: currentProfile } = useCurrentProfile();
|
||||
|
||||
const isFavorite = item.user_state?.is_favorite ?? false;
|
||||
@@ -238,17 +240,21 @@ export default function MovieContent({ item }: { item: ItemDetail & { type: "mov
|
||||
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}
|
||||
versions={item.versions}
|
||||
playbackVariants={item.playback_variants}
|
||||
selectedVersion={selectedVersion}
|
||||
@@ -283,7 +289,7 @@ export default function MovieContent({ item }: { item: ItemDetail & { type: "mov
|
||||
/>
|
||||
|
||||
<div className="page-shell space-y-12 py-10 sm:space-y-14">
|
||||
{isAdmin && <MediaLocations title="Media locations" versions={item.versions} />}
|
||||
{canCurateMetadata && <MediaLocations title="Media locations" versions={item.versions} />}
|
||||
|
||||
{item.cast && item.cast.length > 0 && (
|
||||
<div>
|
||||
@@ -307,8 +313,10 @@ export default function MovieContent({ item }: { item: ItemDetail & { type: "mov
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && <EditMetadataDialog item={item} open={editOpen} onOpenChange={setEditOpen} />}
|
||||
{isAdmin && (
|
||||
{canCurateMetadata && (
|
||||
<EditMetadataDialog item={item} open={editOpen} onOpenChange={setEditOpen} />
|
||||
)}
|
||||
{canCurateMetadata && (
|
||||
<MatchItemDialog
|
||||
key={item.content_id}
|
||||
item={item}
|
||||
|
||||
@@ -15,6 +15,7 @@ import DetailBreadcrumb from "./components/DetailBreadcrumb";
|
||||
import SeasonEpisodeGrid from "./components/SeasonEpisodeGrid";
|
||||
import type { EpisodeNavigationState } from "./itemDetailLayout";
|
||||
import { getWatchedActionLabel } from "./watchedState";
|
||||
import { canCurateMetadata as canCurateMetadataForUser } from "@/lib/permissions";
|
||||
|
||||
function seasonLabel(seasonNumber: number, title?: string) {
|
||||
if (title) return title;
|
||||
@@ -27,6 +28,7 @@ export default function SeasonContent({ item }: { item: ItemDetail & { type: "se
|
||||
useAmbientColor(item.backdrop_thumbhash);
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.role === "admin";
|
||||
const canCurateMetadata = canCurateMetadataForUser(user);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const watchedMutation = useWatchedStateMutation(item);
|
||||
const refreshMetadataMutation = useRefreshItemMetadata();
|
||||
@@ -103,16 +105,20 @@ export default function SeasonContent({ item }: { item: ItemDetail & { type: "se
|
||||
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}
|
||||
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
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && <EditMetadataDialog item={item} open={editOpen} onOpenChange={setEditOpen} />}
|
||||
{canCurateMetadata && (
|
||||
<EditMetadataDialog item={item} open={editOpen} onOpenChange={setEditOpen} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && <EditMetadataDialog item={item} open={editOpen} onOpenChange={setEditOpen} />}
|
||||
{isAdmin && (
|
||||
{canCurateMetadata && (
|
||||
<EditMetadataDialog item={item} open={editOpen} onOpenChange={setEditOpen} />
|
||||
)}
|
||||
{canCurateMetadata && (
|
||||
<MatchItemDialog
|
||||
key={item.content_id}
|
||||
item={item}
|
||||
|
||||
@@ -71,6 +71,7 @@ interface ActionBarProps {
|
||||
onEditMetadata?: () => 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
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
{(hasAdminActions || hasMetadataActions) && (
|
||||
<>
|
||||
{hasOverflowActions && <DropdownMenuSeparator />}
|
||||
{contentId && (
|
||||
{isAdmin && contentId && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
navigate(`/admin/history?media_item_id=${encodeURIComponent(contentId)}`)
|
||||
@@ -382,7 +388,7 @@ export default function ActionBar({
|
||||
View Play History
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onRefresh && (
|
||||
{canCurateMetadata && onRefresh && (
|
||||
<DropdownMenuItem
|
||||
disabled={isRefreshing}
|
||||
onSelect={() => {
|
||||
@@ -393,19 +399,19 @@ export default function ActionBar({
|
||||
Refresh Metadata
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onRedetectIntro && (
|
||||
{isAdmin && onRedetectIntro && (
|
||||
<DropdownMenuItem disabled={isRedetectingIntro} onSelect={onRedetectIntro}>
|
||||
<RefreshCw className={`size-4 ${isRedetectingIntro ? "animate-spin" : ""}`} />
|
||||
Re-detect Intro Markers
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onEditMetadata && (
|
||||
{canCurateMetadata && onEditMetadata && (
|
||||
<DropdownMenuItem onSelect={onEditMetadata}>
|
||||
<Pencil className="size-4" />
|
||||
Edit Metadata
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onMatchItem && (
|
||||
{canCurateMetadata && onMatchItem && (
|
||||
<DropdownMenuItem onSelect={onMatchItem}>
|
||||
<Search className="size-4" />
|
||||
Match Item
|
||||
|
||||
Reference in New Issue
Block a user