diff --git a/internal/api/handlers/admin_jobs.go b/internal/api/handlers/admin_jobs.go index 4e4d000e..888d732a 100644 --- a/internal/api/handlers/admin_jobs.go +++ b/internal/api/handlers/admin_jobs.go @@ -12,6 +12,7 @@ import ( "github.com/Silo-Server/silo-server/internal/adminjob" apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/auth" "github.com/Silo-Server/silo-server/internal/models" ) @@ -110,7 +111,20 @@ 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 + } + + response := adminJobToResponse(r, job, h.store) + if claims == nil || claims.Role != "admin" { + response.RequestPayload = json.RawMessage(`{}`) + response.PublicURL = "" + response.DownloadURL = "" + response.DownloadExpiresAt = nil + } + writeJSON(w, http.StatusOK, response) } func adminJobToResponse(r *http.Request, job *models.AdminJob, store AdminJobArtifactStore) adminJobResponse { @@ -176,3 +190,13 @@ func currentAdminUserID(r *http.Request) int { } return claims.UserID } + +func canReadAdminJob(claims *auth.Claims, job *models.AdminJob) bool { + if claims == nil || job == nil { + return false + } + if claims.Role == "admin" { + return true + } + return job.JobType == adminjob.JobTypeItemRefresh && job.CreatedByUserID == claims.UserID +} diff --git a/internal/api/handlers/admin_jobs_test.go b/internal/api/handlers/admin_jobs_test.go new file mode 100644 index 00000000..e8e83d86 --- /dev/null +++ b/internal/api/handlers/admin_jobs_test.go @@ -0,0 +1,41 @@ +package handlers + +import ( + "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") + } +} diff --git a/internal/api/router.go b/internal/api/router.go index 563617a2..fb36f2f2 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -209,6 +209,7 @@ func NewRouter(deps Dependencies) chi.Router { var authHandler *handlers.AuthHandler var authMiddleware *apimw.AuthMiddleware var viewerAccessMiddleware *apimw.ViewerAccessMiddleware + var permissionMiddleware *apimw.PermissionMiddleware var viewerResolver *access.Resolver var profileTokenService *access.ProfileTokenService var jwtService *auth.JWTService @@ -245,6 +246,12 @@ func NewRouter(deps Dependencies) chi.Router { viewerResolver = access.NewResolver(userRepo, deps.UserStoreProvider, profileTokenService) viewerAccessMiddleware = apimw.NewViewerAccessMiddleware(viewerResolver) } + if deps.DB != nil { + permissionMiddleware = apimw.NewPermissionMiddleware( + userRepo, + apimw.NewPGMetadataTargetLibraryResolver(deps.DB), + ) + } } if deps.SessionMgr != nil && userRepo != nil { deps.SessionMgr.SetLimitProvider(func(ctx context.Context, userID int) (playback.SessionLimits, error) { @@ -1652,332 +1659,345 @@ 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) - }) + 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) + }) + } + }) }) } })