Merge remote-tracking branch 'origin/main' into feat/audiobooks
# Conflicts: # go.sum
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
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles"
|
||||
)
|
||||
|
||||
// AdminDownloadedSubtitle is the admin-facing view of a stored subtitle record.
|
||||
type AdminDownloadedSubtitle struct {
|
||||
ID int `json:"id"`
|
||||
MediaFileID int `json:"media_file_id"`
|
||||
MediaContentID string `json:"media_content_id,omitempty"`
|
||||
Provider string `json:"provider"`
|
||||
Language string `json:"language"`
|
||||
Format string `json:"format"`
|
||||
ReleaseName string `json:"release_name"`
|
||||
Score float64 `json:"score"`
|
||||
HearingImpaired bool `json:"hearing_impaired"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
DownloadedBy *int `json:"downloaded_by,omitempty"`
|
||||
UploaderUsername string `json:"uploader_username"`
|
||||
MediaTitle string `json:"media_title"`
|
||||
MediaType string `json:"media_type"`
|
||||
FilePath string `json:"file_path"`
|
||||
}
|
||||
|
||||
type adminDownloadedSubtitlesResponse struct {
|
||||
Subtitles []AdminDownloadedSubtitle `json:"subtitles"`
|
||||
Total int `json:"total"`
|
||||
Uploads int `json:"uploads"`
|
||||
Provider int `json:"provider_downloads"`
|
||||
}
|
||||
|
||||
type patchDownloadedSubtitleRequest struct {
|
||||
Language *string `json:"language"`
|
||||
ReleaseName *string `json:"release_name"`
|
||||
HearingImpaired *bool `json:"hearing_impaired"`
|
||||
}
|
||||
|
||||
// SetDownloadedSubtitleDeps wires optional dependencies for downloaded subtitle admin routes.
|
||||
func (h *AdminSubtitleHandler) SetDownloadedSubtitleDeps(pool *pgxpool.Pool, manager *subtitles.Manager) {
|
||||
h.pool = pool
|
||||
h.manager = manager
|
||||
}
|
||||
|
||||
// HandleListDownloadedSubtitles handles GET /api/v1/admin/subtitles.
|
||||
func (h *AdminSubtitleHandler) HandleListDownloadedSubtitles(w http.ResponseWriter, r *http.Request) {
|
||||
if h.pool == nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Database not configured")
|
||||
return
|
||||
}
|
||||
|
||||
limit, offset := parsePagination(r)
|
||||
q := r.URL.Query()
|
||||
|
||||
var (
|
||||
args []any
|
||||
conditions []string
|
||||
argIndex = 1
|
||||
)
|
||||
|
||||
if provider := strings.TrimSpace(q.Get("provider")); provider != "" {
|
||||
conditions = append(conditions, "ds.provider = $"+strconv.Itoa(argIndex))
|
||||
args = append(args, provider)
|
||||
argIndex++
|
||||
}
|
||||
|
||||
if language := strings.TrimSpace(q.Get("language")); language != "" {
|
||||
conditions = append(conditions, "ds.language = $"+strconv.Itoa(argIndex))
|
||||
args = append(args, language)
|
||||
argIndex++
|
||||
}
|
||||
|
||||
if userIDStr := strings.TrimSpace(q.Get("user_id")); userIDStr != "" {
|
||||
userID, err := strconv.Atoi(userIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid user_id")
|
||||
return
|
||||
}
|
||||
conditions = append(conditions, "ds.downloaded_by = $"+strconv.Itoa(argIndex))
|
||||
args = append(args, userID)
|
||||
argIndex++
|
||||
}
|
||||
|
||||
if mediaFileIDStr := strings.TrimSpace(q.Get("media_file_id")); mediaFileIDStr != "" {
|
||||
mediaFileID, err := strconv.Atoi(mediaFileIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid media_file_id")
|
||||
return
|
||||
}
|
||||
conditions = append(conditions, "ds.media_file_id = $"+strconv.Itoa(argIndex))
|
||||
args = append(args, mediaFileID)
|
||||
argIndex++
|
||||
}
|
||||
|
||||
if search := strings.TrimSpace(q.Get("q")); search != "" {
|
||||
conditions = append(conditions, "ds.release_name ILIKE $"+strconv.Itoa(argIndex))
|
||||
args = append(args, "%"+search+"%")
|
||||
argIndex++
|
||||
}
|
||||
|
||||
whereClause := ""
|
||||
if len(conditions) > 0 {
|
||||
whereClause = " WHERE " + strings.Join(conditions, " AND ")
|
||||
}
|
||||
|
||||
countQuery := `SELECT COUNT(*) FROM downloaded_subtitles ds` + whereClause
|
||||
var total int
|
||||
if err := h.pool.QueryRow(r.Context(), countQuery, args...).Scan(&total); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count subtitles")
|
||||
return
|
||||
}
|
||||
|
||||
statsQuery := `
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE ds.provider = 'upload'),
|
||||
COUNT(*) FILTER (WHERE ds.provider <> 'upload')
|
||||
FROM downloaded_subtitles ds` + whereClause
|
||||
var uploads, providerDownloads int
|
||||
if err := h.pool.QueryRow(r.Context(), statsQuery, args...).Scan(&uploads, &providerDownloads); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count subtitle stats")
|
||||
return
|
||||
}
|
||||
|
||||
listQuery := `
|
||||
SELECT
|
||||
ds.id,
|
||||
ds.media_file_id,
|
||||
COALESCE(mf.content_id, ''),
|
||||
ds.provider,
|
||||
ds.language,
|
||||
ds.format,
|
||||
ds.release_name,
|
||||
ds.score,
|
||||
ds.hearing_impaired,
|
||||
ds.created_at,
|
||||
ds.downloaded_by,
|
||||
COALESCE(u.username, ''),
|
||||
COALESCE(ep.title, mi.title, ''),
|
||||
COALESCE(CASE WHEN ep.content_id IS NOT NULL THEN 'episode' ELSE mi.type END, ''),
|
||||
COALESCE(mf.file_path, '')
|
||||
FROM downloaded_subtitles ds
|
||||
LEFT JOIN users u ON u.id = ds.downloaded_by
|
||||
LEFT JOIN media_files mf ON mf.id = ds.media_file_id
|
||||
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
|
||||
LEFT JOIN episodes ep ON ep.content_id = mf.content_id` + whereClause + `
|
||||
ORDER BY ds.created_at DESC
|
||||
LIMIT $` + strconv.Itoa(argIndex) + ` OFFSET $` + strconv.Itoa(argIndex+1)
|
||||
|
||||
listArgs := append(append([]any{}, args...), limit, offset)
|
||||
rows, err := h.pool.Query(r.Context(), listQuery, listArgs...)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list subtitles")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
subtitlesList := make([]AdminDownloadedSubtitle, 0)
|
||||
for rows.Next() {
|
||||
var row AdminDownloadedSubtitle
|
||||
if err := rows.Scan(
|
||||
&row.ID,
|
||||
&row.MediaFileID,
|
||||
&row.MediaContentID,
|
||||
&row.Provider,
|
||||
&row.Language,
|
||||
&row.Format,
|
||||
&row.ReleaseName,
|
||||
&row.Score,
|
||||
&row.HearingImpaired,
|
||||
&row.CreatedAt,
|
||||
&row.DownloadedBy,
|
||||
&row.UploaderUsername,
|
||||
&row.MediaTitle,
|
||||
&row.MediaType,
|
||||
&row.FilePath,
|
||||
); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to scan subtitle row")
|
||||
return
|
||||
}
|
||||
subtitlesList = append(subtitlesList, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to iterate subtitles")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, adminDownloadedSubtitlesResponse{
|
||||
Subtitles: subtitlesList,
|
||||
Total: total,
|
||||
Uploads: uploads,
|
||||
Provider: providerDownloads,
|
||||
})
|
||||
}
|
||||
|
||||
// HandlePatchDownloadedSubtitle handles PATCH /api/v1/admin/subtitles/{id}.
|
||||
func (h *AdminSubtitleHandler) HandlePatchDownloadedSubtitle(w http.ResponseWriter, r *http.Request) {
|
||||
if h.manager == nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Subtitle manager not configured")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.Atoi(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_id", "Invalid subtitle ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req patchDownloadedSubtitleRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Language == nil && req.ReleaseName == nil && req.HearingImpaired == nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "No fields to update")
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := h.manager.UpdateDownloadedSubtitle(r.Context(), id, subtitles.SubtitleMetadataPatch{
|
||||
Language: req.Language,
|
||||
ReleaseName: req.ReleaseName,
|
||||
HearingImpaired: req.HearingImpaired,
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, subtitles.ErrSubtitleNotFound):
|
||||
writeError(w, http.StatusNotFound, "not_found", "Subtitle not found")
|
||||
case errors.Is(err, subtitles.ErrSubtitleLanguageConflict):
|
||||
writeError(w, http.StatusConflict, "conflict", "Subtitle with this language already exists for this file")
|
||||
default:
|
||||
if strings.Contains(err.Error(), "invalid subtitle language") {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "update_error", "Failed to update subtitle")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"subtitle": updated})
|
||||
}
|
||||
|
||||
// HandleDownloadDownloadedSubtitle handles GET /api/v1/admin/subtitles/{id}/download.
|
||||
func (h *AdminSubtitleHandler) HandleDownloadDownloadedSubtitle(w http.ResponseWriter, r *http.Request) {
|
||||
if h.manager == nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Subtitle manager not configured")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.Atoi(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_id", "Invalid subtitle ID")
|
||||
return
|
||||
}
|
||||
|
||||
sub, data, err := h.manager.GetSubtitleContent(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, subtitles.ErrSubtitleNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Subtitle not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "download_error", "Failed to download subtitle")
|
||||
return
|
||||
}
|
||||
|
||||
filename := subtitleDownloadFilename(sub)
|
||||
w.Header().Set("Content-Type", subtitles.SubtitleContentType(sub.Format))
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// HandleDeleteDownloadedSubtitle handles DELETE /api/v1/admin/subtitles/{id}.
|
||||
func (h *AdminSubtitleHandler) HandleDeleteDownloadedSubtitle(w http.ResponseWriter, r *http.Request) {
|
||||
if h.manager == nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Subtitle manager not configured")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.Atoi(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_id", "Invalid subtitle ID")
|
||||
return
|
||||
}
|
||||
|
||||
sub, err := h.repo.GetDownloadedSubtitle(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "lookup_error", "Failed to look up subtitle")
|
||||
return
|
||||
}
|
||||
if sub == nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Subtitle not found")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.manager.DeleteSubtitle(r.Context(), id); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "delete_error", "Failed to delete subtitle")
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func subtitleDownloadFilename(sub *subtitles.DownloadedSubtitle) string {
|
||||
base := strings.TrimSpace(sub.ReleaseName)
|
||||
if base == "" {
|
||||
base = fmt.Sprintf("subtitle-%d", sub.ID)
|
||||
}
|
||||
base = path.Base(base)
|
||||
base = strings.TrimSuffix(base, path.Ext(base))
|
||||
if base == "" || base == "." {
|
||||
base = fmt.Sprintf("subtitle-%d", sub.ID)
|
||||
}
|
||||
return fmt.Sprintf("%s.%s", base, sub.Format)
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
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/subtitles"
|
||||
)
|
||||
|
||||
func newAdminSubtitleRequest(method, path string, body []byte) *http.Request {
|
||||
var reader *bytes.Reader
|
||||
if body != nil {
|
||||
reader = bytes.NewReader(body)
|
||||
} else {
|
||||
reader = bytes.NewReader(nil)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, reader)
|
||||
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
|
||||
UserID: 1,
|
||||
Role: "admin",
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
})
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
func withSubtitleRouteParam(req *http.Request, key, value string) *http.Request {
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add(key, value)
|
||||
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
|
||||
}
|
||||
|
||||
func TestHandlePatchDownloadedSubtitleUpdatesMetadata(t *testing.T) {
|
||||
repo := newMockSubtitleRepoForHandler()
|
||||
repo.subtitles[7] = &subtitles.DownloadedSubtitle{
|
||||
ID: 7,
|
||||
MediaFileID: 42,
|
||||
Provider: subtitles.ProviderUpload,
|
||||
Language: "en",
|
||||
Format: subtitles.FormatSRT,
|
||||
ReleaseName: "movie.en.srt",
|
||||
S3Key: "subtitles/42/en_upload_abcd1234.srt",
|
||||
}
|
||||
repo.byKey["subtitles/42/en_upload_abcd1234.srt"] = repo.subtitles[7]
|
||||
|
||||
s3 := &trackingHandlerS3Client{objects: map[string][]byte{
|
||||
"subtitles/42/en_upload_abcd1234.srt": []byte("1\n00:00:01,000 --> 00:00:02,000\nHello\n"),
|
||||
}}
|
||||
manager := subtitles.NewManager(repo, s3, "test-bucket")
|
||||
handler := NewAdminSubtitleHandler(repo)
|
||||
handler.SetDownloadedSubtitleDeps(nil, manager)
|
||||
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"language": "es",
|
||||
"release_name": "movie.es.srt",
|
||||
"hearing_impaired": true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal body: %v", err)
|
||||
}
|
||||
|
||||
req := newAdminSubtitleRequest(http.MethodPatch, "/admin/subtitles/7", body)
|
||||
req = withSubtitleRouteParam(req, "id", "7")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandlePatchDownloadedSubtitle(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
updated := repo.subtitles[7]
|
||||
if updated.Language != "es" {
|
||||
t.Fatalf("language = %q, want es", updated.Language)
|
||||
}
|
||||
if updated.ReleaseName != "movie.es.srt" {
|
||||
t.Fatalf("release_name = %q, want movie.es.srt", updated.ReleaseName)
|
||||
}
|
||||
if !updated.HearingImpaired {
|
||||
t.Fatal("expected hearing_impaired=true")
|
||||
}
|
||||
if updated.S3Key == "subtitles/42/en_upload_abcd1234.srt" {
|
||||
t.Fatalf("expected migrated s3 key, got %q", updated.S3Key)
|
||||
}
|
||||
if len(s3.putKeys) != 1 {
|
||||
t.Fatalf("putKeys = %d, want 1", len(s3.putKeys))
|
||||
}
|
||||
if len(s3.deletedKeys) != 1 {
|
||||
t.Fatalf("deletedKeys = %d, want 1", len(s3.deletedKeys))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePatchDownloadedSubtitleNotFound(t *testing.T) {
|
||||
repo := newMockSubtitleRepoForHandler()
|
||||
manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test-bucket")
|
||||
handler := NewAdminSubtitleHandler(repo)
|
||||
handler.SetDownloadedSubtitleDeps(nil, manager)
|
||||
|
||||
body := []byte(`{"language":"fr"}`)
|
||||
req := newAdminSubtitleRequest(http.MethodPatch, "/admin/subtitles/404", body)
|
||||
req = withSubtitleRouteParam(req, "id", "404")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandlePatchDownloadedSubtitle(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDownloadDownloadedSubtitle(t *testing.T) {
|
||||
repo := newMockSubtitleRepoForHandler()
|
||||
content := []byte("WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nHello\n")
|
||||
repo.subtitles[3] = &subtitles.DownloadedSubtitle{
|
||||
ID: 3,
|
||||
MediaFileID: 10,
|
||||
Provider: subtitles.ProviderUpload,
|
||||
Language: "en",
|
||||
Format: subtitles.FormatVTT,
|
||||
ReleaseName: "sample.vtt",
|
||||
S3Key: "subtitles/10/en_upload_deadbeef.vtt",
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
s3 := &trackingHandlerS3Client{objects: map[string][]byte{
|
||||
"subtitles/10/en_upload_deadbeef.vtt": content,
|
||||
}}
|
||||
manager := subtitles.NewManager(repo, s3, "test-bucket")
|
||||
handler := NewAdminSubtitleHandler(repo)
|
||||
handler.SetDownloadedSubtitleDeps(nil, manager)
|
||||
|
||||
req := newAdminSubtitleRequest(http.MethodGet, "/admin/subtitles/3/download", nil)
|
||||
req = withSubtitleRouteParam(req, "id", "3")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleDownloadDownloadedSubtitle(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
if got := rr.Header().Get("Content-Type"); got != "text/vtt; charset=utf-8" {
|
||||
t.Fatalf("content-type = %q", got)
|
||||
}
|
||||
if !bytes.Contains([]byte(rr.Header().Get("Content-Disposition")), []byte("sample.vtt")) {
|
||||
t.Fatalf("content-disposition = %q", rr.Header().Get("Content-Disposition"))
|
||||
}
|
||||
if !bytes.Equal(rr.Body.Bytes(), content) {
|
||||
t.Fatalf("body mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDeleteDownloadedSubtitle(t *testing.T) {
|
||||
repo := newMockSubtitleRepoForHandler()
|
||||
repo.subtitles[5] = &subtitles.DownloadedSubtitle{
|
||||
ID: 5,
|
||||
MediaFileID: 11,
|
||||
Provider: "opensubtitles",
|
||||
Language: "en",
|
||||
Format: subtitles.FormatSRT,
|
||||
S3Key: "subtitles/11/en_opensubtitles_abcd1234.srt",
|
||||
}
|
||||
repo.byKey[repo.subtitles[5].S3Key] = repo.subtitles[5]
|
||||
s3 := &trackingHandlerS3Client{objects: map[string][]byte{
|
||||
repo.subtitles[5].S3Key: []byte("subtitle"),
|
||||
}}
|
||||
manager := subtitles.NewManager(repo, s3, "test-bucket")
|
||||
handler := NewAdminSubtitleHandler(repo)
|
||||
handler.SetDownloadedSubtitleDeps(nil, manager)
|
||||
|
||||
req := newAdminSubtitleRequest(http.MethodDelete, "/admin/subtitles/5", nil)
|
||||
req = withSubtitleRouteParam(req, "id", "5")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleDeleteDownloadedSubtitle(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204", rr.Code)
|
||||
}
|
||||
if _, ok := repo.subtitles[5]; ok {
|
||||
t.Fatal("expected subtitle record deleted")
|
||||
}
|
||||
if len(s3.deletedKeys) != 1 {
|
||||
t.Fatalf("deletedKeys = %d, want 1", len(s3.deletedKeys))
|
||||
}
|
||||
}
|
||||
|
||||
type trackingHandlerS3Client struct {
|
||||
objects map[string][]byte
|
||||
putKeys []string
|
||||
deletedKeys []string
|
||||
}
|
||||
|
||||
func (c *trackingHandlerS3Client) PutObject(_ context.Context, _, key string, data []byte) error {
|
||||
if c.objects == nil {
|
||||
c.objects = make(map[string][]byte)
|
||||
}
|
||||
c.objects[key] = append([]byte(nil), data...)
|
||||
c.putKeys = append(c.putKeys, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *trackingHandlerS3Client) GetObject(_ context.Context, _, key string) ([]byte, error) {
|
||||
if data, ok := c.objects[key]; ok {
|
||||
return append([]byte(nil), data...), nil
|
||||
}
|
||||
return nil, context.Canceled
|
||||
}
|
||||
|
||||
func (c *trackingHandlerS3Client) DeleteObject(_ context.Context, _, key string) error {
|
||||
delete(c.objects, key)
|
||||
c.deletedKeys = append(c.deletedKeys, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestSubtitleDownloadFilename(t *testing.T) {
|
||||
sub := &subtitles.DownloadedSubtitle{
|
||||
ID: 9,
|
||||
ReleaseName: "../unsafe/path/movie.en.srt",
|
||||
Format: subtitles.FormatSRT,
|
||||
}
|
||||
got := subtitleDownloadFilename(sub)
|
||||
if got != "movie.en.srt" {
|
||||
t.Fatalf("filename = %q, want movie.en.srt", got)
|
||||
}
|
||||
if strconv.Itoa(sub.ID) == "" {
|
||||
t.Fatal("unexpected")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles/subdl"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles/subsource"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// SubtitleProviderFactory creates a Provider from a config. Allows testing without real providers.
|
||||
@@ -21,6 +22,8 @@ type SubtitleProviderFactory func(cfg *subtitles.ProviderConfig) (subtitles.Prov
|
||||
// AdminSubtitleHandler handles admin operations for subtitle provider management.
|
||||
type AdminSubtitleHandler struct {
|
||||
repo subtitles.Repository
|
||||
manager *subtitles.Manager
|
||||
pool *pgxpool.Pool
|
||||
providerFactory SubtitleProviderFactory
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/plugins"
|
||||
"github.com/Silo-Server/silo-server/internal/scanner"
|
||||
"github.com/Silo-Server/silo-server/internal/scantrigger"
|
||||
"github.com/Silo-Server/silo-server/internal/sections"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
@@ -570,10 +571,10 @@ func (h *LibraryHandler) HandleCreateLibrary(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
} else {
|
||||
initialScanID := ulid.Make().String()
|
||||
h.recordAcceptedScan(initialScanID, &resolvedScanTarget{
|
||||
folder: folder,
|
||||
mode: scanModeLibrary,
|
||||
trigger: "library_created",
|
||||
h.recordAcceptedScan(initialScanID, &scantrigger.Target{
|
||||
Folder: folder,
|
||||
Mode: scantrigger.ModeLibrary,
|
||||
Trigger: "library_created",
|
||||
})
|
||||
h.runFolderScanAsync(initialScanID, folder, "library_created")
|
||||
}
|
||||
@@ -660,10 +661,10 @@ func (h *LibraryHandler) HandleUpdateLibrary(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
} else {
|
||||
updateScanID := ulid.Make().String()
|
||||
h.recordAcceptedScan(updateScanID, &resolvedScanTarget{
|
||||
folder: folder,
|
||||
mode: scanModeLibrary,
|
||||
trigger: "library_paths_changed",
|
||||
h.recordAcceptedScan(updateScanID, &scantrigger.Target{
|
||||
Folder: folder,
|
||||
Mode: scantrigger.ModeLibrary,
|
||||
Trigger: "library_paths_changed",
|
||||
})
|
||||
h.runFolderScanAsync(updateScanID, folder, "library_paths_changed")
|
||||
}
|
||||
@@ -791,29 +792,6 @@ func (h *LibraryHandler) HandleCheckLibraryMount(w http.ResponseWriter, r *http.
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
type scanMode string
|
||||
|
||||
const (
|
||||
scanModeLibrary scanMode = "library"
|
||||
scanModeSubtree scanMode = "subtree"
|
||||
scanModeFile scanMode = "file"
|
||||
)
|
||||
|
||||
type resolvedScanTarget struct {
|
||||
folder *models.MediaFolder
|
||||
mode scanMode
|
||||
path string
|
||||
trigger string
|
||||
}
|
||||
|
||||
type scanRequestError struct {
|
||||
status int
|
||||
code string
|
||||
message string
|
||||
}
|
||||
|
||||
func (e *scanRequestError) Error() string { return e.message }
|
||||
|
||||
// HandleScan handles POST /scan. It accepts either a library_id, a path, or both
|
||||
// and dispatches to full-library, subtree, or single-file scanning.
|
||||
func (h *LibraryHandler) HandleScan(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -823,11 +801,14 @@ func (h *LibraryHandler) HandleScan(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
target, err := h.resolveScanTarget(r.Context(), req)
|
||||
target, err := scantrigger.NewResolver(h.folderRepo).Resolve(r.Context(), scantrigger.Request{
|
||||
LibraryID: req.LibraryID,
|
||||
Path: req.Path,
|
||||
})
|
||||
if err != nil {
|
||||
var reqErr *scanRequestError
|
||||
var reqErr *scantrigger.RequestError
|
||||
if errors.As(err, &reqErr) {
|
||||
writeError(w, reqErr.status, reqErr.code, reqErr.message)
|
||||
writeError(w, reqErr.Status, reqErr.Code, reqErr.Message)
|
||||
return
|
||||
}
|
||||
slog.Error("resolving scan target", "error", err)
|
||||
@@ -836,21 +817,21 @@ func (h *LibraryHandler) HandleScan(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if h.ScanQueue != nil {
|
||||
if _, err := h.ScanQueue.EnqueueScan(r.Context(), target.folder.ID, string(target.mode), target.path, target.trigger); err != nil {
|
||||
slog.Error("queueing library scan", "library_id", target.folder.ID, "error", err)
|
||||
if _, err := h.ScanQueue.EnqueueScan(r.Context(), target.Folder.ID, target.Mode, target.Path, target.Trigger); err != nil {
|
||||
slog.Error("queueing library scan", "library_id", target.Folder.ID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to queue scan")
|
||||
return
|
||||
}
|
||||
} else if h.ingester != nil {
|
||||
scanID := ulid.Make().String()
|
||||
h.recordAcceptedScan(scanID, target)
|
||||
switch target.mode {
|
||||
case scanModeFile:
|
||||
h.runFileScanAsync(scanID, target.folder, target.path, target.trigger)
|
||||
case scanModeSubtree:
|
||||
h.runSubtreeScanAsync(scanID, target.folder, target.path, target.trigger)
|
||||
switch target.Mode {
|
||||
case scantrigger.ModeFile:
|
||||
h.runFileScanAsync(scanID, target.Folder, target.Path, target.Trigger)
|
||||
case scantrigger.ModeSubtree:
|
||||
h.runSubtreeScanAsync(scanID, target.Folder, target.Path, target.Trigger)
|
||||
default:
|
||||
h.runFolderScanAsync(scanID, target.folder, target.trigger)
|
||||
h.runFolderScanAsync(scanID, target.Folder, target.Trigger)
|
||||
}
|
||||
} else {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Scanner not available")
|
||||
@@ -859,8 +840,8 @@ func (h *LibraryHandler) HandleScan(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
writeJSON(w, http.StatusAccepted, scanResponse{
|
||||
Status: "accepted",
|
||||
Mode: string(target.mode),
|
||||
LibraryID: target.folder.ID,
|
||||
Mode: target.Mode,
|
||||
LibraryID: target.Folder.ID,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -908,225 +889,6 @@ func (h *LibraryHandler) HandleScanCancel(w http.ResponseWriter, r *http.Request
|
||||
})
|
||||
}
|
||||
|
||||
func (h *LibraryHandler) resolveScanTarget(ctx context.Context, req scanRequest) (*resolvedScanTarget, error) {
|
||||
if req.LibraryID == nil && strings.TrimSpace(req.Path) == "" {
|
||||
return nil, &scanRequestError{
|
||||
status: http.StatusBadRequest,
|
||||
code: "bad_request",
|
||||
message: "Either library_id or path is required",
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
folder *models.MediaFolder
|
||||
err error
|
||||
)
|
||||
if req.LibraryID != nil {
|
||||
folder, err = h.folderRepo.GetByID(ctx, *req.LibraryID)
|
||||
if err != nil {
|
||||
if errors.Is(err, catalog.ErrFolderNotFound) {
|
||||
return nil, &scanRequestError{
|
||||
status: http.StatusNotFound,
|
||||
code: "not_found",
|
||||
message: "Library not found",
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("fetching library for scan: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.Path) == "" {
|
||||
if folder != nil && !folder.Enabled {
|
||||
return nil, &scanRequestError{
|
||||
status: http.StatusConflict,
|
||||
code: "conflict",
|
||||
message: "Library is disabled",
|
||||
}
|
||||
}
|
||||
return &resolvedScanTarget{
|
||||
folder: folder,
|
||||
mode: scanModeLibrary,
|
||||
trigger: "manual",
|
||||
}, nil
|
||||
}
|
||||
|
||||
cleanPath := filepath.Clean(req.Path)
|
||||
var matchedRoot string
|
||||
if folder != nil {
|
||||
matchedRoot, err = longestMatchingRoot(cleanPath, folder.Paths)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if matchedRoot == "" {
|
||||
return nil, &scanRequestError{
|
||||
status: http.StatusBadRequest,
|
||||
code: "bad_request",
|
||||
message: "Path does not belong to the specified library",
|
||||
}
|
||||
}
|
||||
} else {
|
||||
folders, err := h.folderRepo.List(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing libraries for scan: %w", err)
|
||||
}
|
||||
folder, matchedRoot, err = matchFolderForPath(cleanPath, folders)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if folder != nil && !folder.Enabled {
|
||||
return nil, &scanRequestError{
|
||||
status: http.StatusConflict,
|
||||
code: "conflict",
|
||||
message: "Library is disabled",
|
||||
}
|
||||
}
|
||||
|
||||
mode, err := classifyScanPath(cleanPath, matchedRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
trigger := "path"
|
||||
if req.LibraryID != nil {
|
||||
trigger = "library_id_path"
|
||||
}
|
||||
|
||||
return &resolvedScanTarget{
|
||||
folder: folder,
|
||||
mode: mode,
|
||||
path: cleanPath,
|
||||
trigger: trigger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func longestMatchingRoot(targetPath string, roots []string) (string, error) {
|
||||
bestRoot := ""
|
||||
bestLen := -1
|
||||
for _, root := range roots {
|
||||
if !pathWithinRoot(targetPath, root) {
|
||||
continue
|
||||
}
|
||||
cleanRoot := filepath.Clean(root)
|
||||
rootLen := len(cleanRoot)
|
||||
if rootLen > bestLen {
|
||||
bestRoot = cleanRoot
|
||||
bestLen = rootLen
|
||||
}
|
||||
}
|
||||
return bestRoot, nil
|
||||
}
|
||||
|
||||
func matchFolderForPath(targetPath string, folders []*models.MediaFolder) (*models.MediaFolder, string, error) {
|
||||
var (
|
||||
bestFolder *models.MediaFolder
|
||||
bestRoot string
|
||||
bestLen = -1
|
||||
ambiguous bool
|
||||
)
|
||||
|
||||
for _, folder := range folders {
|
||||
if folder == nil {
|
||||
continue
|
||||
}
|
||||
root, err := longestMatchingRoot(targetPath, folder.Paths)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if root == "" {
|
||||
continue
|
||||
}
|
||||
rootLen := len(root)
|
||||
if rootLen > bestLen {
|
||||
bestFolder = folder
|
||||
bestRoot = root
|
||||
bestLen = rootLen
|
||||
ambiguous = false
|
||||
continue
|
||||
}
|
||||
if rootLen == bestLen && bestFolder != nil && folder.ID != bestFolder.ID {
|
||||
ambiguous = true
|
||||
}
|
||||
}
|
||||
|
||||
if ambiguous {
|
||||
return nil, "", &scanRequestError{
|
||||
status: http.StatusBadRequest,
|
||||
code: "bad_request",
|
||||
message: "Path matches multiple libraries",
|
||||
}
|
||||
}
|
||||
if bestFolder == nil {
|
||||
return nil, "", &scanRequestError{
|
||||
status: http.StatusBadRequest,
|
||||
code: "bad_request",
|
||||
message: "No library matches the given path",
|
||||
}
|
||||
}
|
||||
return bestFolder, bestRoot, nil
|
||||
}
|
||||
|
||||
func classifyScanPath(targetPath, matchedRoot string) (scanMode, error) {
|
||||
if filepath.Clean(targetPath) == filepath.Clean(matchedRoot) {
|
||||
return scanModeLibrary, nil
|
||||
}
|
||||
|
||||
info, err := os.Stat(targetPath)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, os.ErrNotExist):
|
||||
return "", &scanRequestError{
|
||||
status: http.StatusBadRequest,
|
||||
code: "bad_request",
|
||||
message: "Path does not exist",
|
||||
}
|
||||
case errors.Is(err, os.ErrPermission):
|
||||
return "", &scanRequestError{
|
||||
status: http.StatusBadRequest,
|
||||
code: "bad_request",
|
||||
message: "Permission denied for path",
|
||||
}
|
||||
default:
|
||||
return "", &scanRequestError{
|
||||
status: http.StatusBadRequest,
|
||||
code: "bad_request",
|
||||
message: "Path could not be inspected",
|
||||
}
|
||||
}
|
||||
}
|
||||
if info.IsDir() {
|
||||
return scanModeSubtree, nil
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return "", &scanRequestError{
|
||||
status: http.StatusBadRequest,
|
||||
code: "bad_request",
|
||||
message: "Path must be a file or directory",
|
||||
}
|
||||
}
|
||||
if !scanner.SupportsVideoFile(targetPath) {
|
||||
return "", &scanRequestError{
|
||||
status: http.StatusBadRequest,
|
||||
code: "bad_request",
|
||||
message: "Unsupported media file extension",
|
||||
}
|
||||
}
|
||||
return scanModeFile, nil
|
||||
}
|
||||
|
||||
func pathWithinRoot(targetPath, rootPath string) bool {
|
||||
cleanTarget := filepath.Clean(targetPath)
|
||||
cleanRoot := filepath.Clean(rootPath)
|
||||
rel, err := filepath.Rel(cleanRoot, cleanTarget)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if rel == "." || rel == "" {
|
||||
return true
|
||||
}
|
||||
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
||||
}
|
||||
|
||||
func (h *LibraryHandler) runFolderScanAsync(scanID string, folder *models.MediaFolder, trigger string) {
|
||||
go func() {
|
||||
h.markScanRunning(scanID)
|
||||
@@ -1287,16 +1049,16 @@ func (h *LibraryHandler) runFileScanAsync(scanID string, folder *models.MediaFol
|
||||
}()
|
||||
}
|
||||
|
||||
func (h *LibraryHandler) recordAcceptedScan(scanID string, target *resolvedScanTarget) {
|
||||
if h == nil || h.ScanRegistry == nil || target == nil || target.folder == nil {
|
||||
func (h *LibraryHandler) recordAcceptedScan(scanID string, target *scantrigger.Target) {
|
||||
if h == nil || h.ScanRegistry == nil || target == nil || target.Folder == nil {
|
||||
return
|
||||
}
|
||||
h.ScanRegistry.Upsert(evt.ScanRun{
|
||||
ID: scanID,
|
||||
LibraryID: target.folder.ID,
|
||||
Mode: string(target.mode),
|
||||
Path: target.path,
|
||||
Trigger: target.trigger,
|
||||
LibraryID: target.Folder.ID,
|
||||
Mode: target.Mode,
|
||||
Path: target.Path,
|
||||
Trigger: target.Trigger,
|
||||
Status: "accepted",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/scanner"
|
||||
)
|
||||
|
||||
// MediaFileAuthorizer validates that the authenticated user can access a media file.
|
||||
type MediaFileAuthorizer struct {
|
||||
FileResolver FilePathResolver
|
||||
ItemAccess PlaybackItemAccessChecker
|
||||
EpisodeLookup PlaybackEpisodeLookup
|
||||
}
|
||||
|
||||
// Authorize returns the media file when the caller may access it, or catalog.ErrItemNotFound.
|
||||
func (a *MediaFileAuthorizer) Authorize(r *http.Request, fileID int) (*models.MediaFile, error) {
|
||||
if a == nil || a.FileResolver == nil || a.ItemAccess == nil {
|
||||
return nil, fmt.Errorf("media file authorization dependencies not configured")
|
||||
}
|
||||
|
||||
file, err := a.FileResolver.GetByID(r.Context(), fileID)
|
||||
if err != nil {
|
||||
return nil, mapMediaFileLookupError(err)
|
||||
}
|
||||
if file == nil || file.MissingSince != nil {
|
||||
return nil, catalog.ErrItemNotFound
|
||||
}
|
||||
|
||||
filter := requestAccessFilter(r)
|
||||
switch {
|
||||
case file.EpisodeID != "":
|
||||
if a.EpisodeLookup == nil {
|
||||
return nil, fmt.Errorf("episode lookup not configured")
|
||||
}
|
||||
episode, err := a.EpisodeLookup.GetByID(r.Context(), file.EpisodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if episode == nil {
|
||||
return nil, catalog.ErrEpisodeNotFound
|
||||
}
|
||||
if err := a.ItemAccess.EnsureAccessible(r.Context(), episode.SeriesID, filter); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case file.ContentID != "":
|
||||
if err := a.ItemAccess.EnsureAccessible(r.Context(), file.ContentID, filter); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
return nil, catalog.ErrItemNotFound
|
||||
}
|
||||
|
||||
if !catalog.FileAllowedByAccess(file, filter) {
|
||||
return nil, catalog.ErrItemNotFound
|
||||
}
|
||||
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func mapMediaFileLookupError(err error) error {
|
||||
if errors.Is(err, scanner.ErrFileNotFound) {
|
||||
return catalog.ErrItemNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
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/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/scanner"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles"
|
||||
)
|
||||
|
||||
func TestMediaFileAuthorizerMapsMissingFileToNotFound(t *testing.T) {
|
||||
authorizer := &MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{err: scanner.ErrFileNotFound},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{
|
||||
UserID: 1,
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
}))
|
||||
|
||||
_, err := authorizer.Authorize(req, 99)
|
||||
if !errors.Is(err, catalog.ErrItemNotFound) {
|
||||
t.Fatalf("Authorize() error = %v, want ErrItemNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUploadMissingMediaFileReturns404(t *testing.T) {
|
||||
repo := newMockSubtitleRepoForHandler()
|
||||
manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test-bucket")
|
||||
handler := NewSubtitleSearchHandler(manager, repo, stubSubtitleMediaResolver{})
|
||||
handler.FileAuthorizer = &MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{err: scanner.ErrFileNotFound},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
}
|
||||
|
||||
req := newSubtitleUploadRequest(t, 99, "en", "custom.srt", []byte("1\n00:00:01,000 --> 00:00:02,000\nHi\n"))
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleUpload(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleListMissingMediaFileReturns404(t *testing.T) {
|
||||
repo := newMockSubtitleRepoForHandler()
|
||||
manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test-bucket")
|
||||
handler := NewSubtitleSearchHandler(manager, repo, stubSubtitleMediaResolver{})
|
||||
handler.FileAuthorizer = &MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{err: scanner.ErrFileNotFound},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
}
|
||||
|
||||
req := newSubtitleAuthRequest(http.MethodGet, "/subtitles/99", nil)
|
||||
req = withProfileRouteParam(req, "media_file_id", "99")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleList(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaFileAuthorizerAllowsAccessibleFile(t *testing.T) {
|
||||
authorizer := &MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{
|
||||
file: &models.MediaFile{ID: 42, ContentID: "movie-1"},
|
||||
},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{
|
||||
UserID: 1,
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
}))
|
||||
|
||||
file, err := authorizer.Authorize(req, 42)
|
||||
if err != nil {
|
||||
t.Fatalf("Authorize() error = %v", err)
|
||||
}
|
||||
if file == nil || file.ID != 42 {
|
||||
t.Fatalf("file = %#v, want id 42", file)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapMediaFileLookupError(t *testing.T) {
|
||||
if !errors.Is(mapMediaFileLookupError(scanner.ErrFileNotFound), catalog.ErrItemNotFound) {
|
||||
t.Fatal("expected scanner.ErrFileNotFound to map to catalog.ErrItemNotFound")
|
||||
}
|
||||
if mapMediaFileLookupError(errors.New("db down")) == nil {
|
||||
t.Fatal("expected unrelated error to pass through")
|
||||
}
|
||||
}
|
||||
@@ -1780,7 +1780,7 @@ func (h *PlaybackHandler) loadAuthorizedFile(r *http.Request, fileID int) (*mode
|
||||
}
|
||||
file, err := h.fileResolver.GetByID(r.Context(), fileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, mapMediaFileLookupError(err)
|
||||
}
|
||||
if file == nil || file.MissingSince != nil {
|
||||
return nil, catalog.ErrItemNotFound
|
||||
|
||||
@@ -3,16 +3,40 @@ package handlers
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
subtitleUploadMaxSize = subtitles.MaxUploadSize
|
||||
// Allow multipart framing and small form fields above the file size cap.
|
||||
subtitleUploadMaxBodySize = subtitleUploadMaxSize + (256 << 10)
|
||||
)
|
||||
|
||||
func parseSubtitleMultipartForm(w http.ResponseWriter, r *http.Request) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, subtitleUploadMaxBodySize)
|
||||
if err := r.ParseMultipartForm(subtitleUploadMaxSize); err != nil {
|
||||
var maxBytesErr *http.MaxBytesError
|
||||
if errors.As(err, &maxBytesErr) {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "Subtitle file must be under 5 MB")
|
||||
} else {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid multipart form")
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// SubtitleMediaResolver looks up media metadata for subtitle search.
|
||||
type SubtitleMediaResolver interface {
|
||||
GetMediaFileWithMetadata(ctx context.Context, fileID int) (*MediaFileMetadata, error)
|
||||
@@ -36,9 +60,10 @@ type MediaFileMetadata struct {
|
||||
|
||||
// SubtitleSearchHandler handles user-facing subtitle search operations.
|
||||
type SubtitleSearchHandler struct {
|
||||
manager *subtitles.Manager
|
||||
repo subtitles.Repository
|
||||
mediaResolver SubtitleMediaResolver
|
||||
manager *subtitles.Manager
|
||||
repo subtitles.Repository
|
||||
mediaResolver SubtitleMediaResolver
|
||||
FileAuthorizer *MediaFileAuthorizer
|
||||
}
|
||||
|
||||
// NewSubtitleSearchHandler creates a new SubtitleSearchHandler.
|
||||
@@ -70,6 +95,23 @@ type downloadSubtitleRequest struct {
|
||||
HearingImpaired bool `json:"hearing_impaired"`
|
||||
}
|
||||
|
||||
func (h *SubtitleSearchHandler) authorizeMediaFile(w http.ResponseWriter, r *http.Request, fileID int) bool {
|
||||
if h.FileAuthorizer == nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Media file authorization is not configured")
|
||||
return false
|
||||
}
|
||||
if _, err := h.FileAuthorizer.Authorize(r, fileID); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, catalog.ErrItemNotFound), errors.Is(err, catalog.ErrEpisodeNotFound):
|
||||
writeError(w, http.StatusNotFound, "not_found", "Media file not found")
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to authorize media file")
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// HandleSearch handles POST /api/v1/subtitles/search
|
||||
func (h *SubtitleSearchHandler) HandleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
var req searchSubtitlesRequest
|
||||
@@ -78,6 +120,10 @@ func (h *SubtitleSearchHandler) HandleSearch(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
if !h.authorizeMediaFile(w, r, req.MediaFileID) {
|
||||
return
|
||||
}
|
||||
|
||||
meta, err := h.mediaResolver.GetMediaFileWithMetadata(r.Context(), req.MediaFileID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "metadata_error", "Failed to look up media metadata")
|
||||
@@ -124,6 +170,10 @@ func (h *SubtitleSearchHandler) HandleDownload(w http.ResponseWriter, r *http.Re
|
||||
return
|
||||
}
|
||||
|
||||
if !h.authorizeMediaFile(w, r, req.MediaFileID) {
|
||||
return
|
||||
}
|
||||
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
|
||||
sub, err := h.manager.Download(r.Context(), subtitles.DownloadRequest{
|
||||
@@ -145,6 +195,115 @@ func (h *SubtitleSearchHandler) HandleDownload(w http.ResponseWriter, r *http.Re
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"subtitle": sub})
|
||||
}
|
||||
|
||||
// HandleUpload handles POST /api/v1/subtitles/upload
|
||||
func (h *SubtitleSearchHandler) HandleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if !parseSubtitleMultipartForm(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
mediaFileID, err := strconv.Atoi(strings.TrimSpace(r.FormValue("media_file_id")))
|
||||
if err != nil || mediaFileID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid media_file_id")
|
||||
return
|
||||
}
|
||||
|
||||
if !h.authorizeMediaFile(w, r, mediaFileID) {
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Missing subtitle file")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(file, subtitleUploadMaxSize+1))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to read upload")
|
||||
return
|
||||
}
|
||||
if len(data) > subtitleUploadMaxSize {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "Subtitle file must be under 5 MB")
|
||||
return
|
||||
}
|
||||
|
||||
releaseName := strings.TrimSpace(r.FormValue("release_name"))
|
||||
hearingImpaired := parseBoolFormValue(r.FormValue("hearing_impaired"))
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
|
||||
userLanguage := strings.TrimSpace(r.FormValue("language"))
|
||||
preferUserLanguage := parseBoolFormValue(r.FormValue("language_override"))
|
||||
|
||||
sub, err := h.manager.Upload(r.Context(), subtitles.UploadRequest{
|
||||
MediaFileID: mediaFileID,
|
||||
UserID: &userID,
|
||||
Language: userLanguage,
|
||||
PreferUserLanguage: preferUserLanguage,
|
||||
Filename: header.Filename,
|
||||
ReleaseName: releaseName,
|
||||
HearingImpaired: hearingImpaired,
|
||||
Data: data,
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case strings.Contains(err.Error(), "unsupported subtitle format"),
|
||||
strings.Contains(err.Error(), "missing file extension"),
|
||||
strings.Contains(err.Error(), "empty subtitle file"),
|
||||
strings.Contains(err.Error(), "could not detect subtitle language"),
|
||||
strings.Contains(err.Error(), "invalid subtitle language"):
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
case strings.Contains(err.Error(), "exceeds maximum size"):
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "Subtitle file must be under 5 MB")
|
||||
default:
|
||||
slog.Error("subtitle upload failed", "media_file_id", mediaFileID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "upload_error", "Failed to upload subtitle")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"subtitle": sub})
|
||||
}
|
||||
|
||||
// HandleDetectLanguage handles POST /api/v1/subtitles/detect-language
|
||||
func (h *SubtitleSearchHandler) HandleDetectLanguage(w http.ResponseWriter, r *http.Request) {
|
||||
if !parseSubtitleMultipartForm(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Missing subtitle file")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(file, subtitleUploadMaxSize+1))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to read upload")
|
||||
return
|
||||
}
|
||||
if len(data) > subtitleUploadMaxSize {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "Subtitle file must be under 5 MB")
|
||||
return
|
||||
}
|
||||
|
||||
format, err := subtitles.FormatFromFilename(header.Filename)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
userLanguage := strings.TrimSpace(r.FormValue("language"))
|
||||
detected, err := subtitles.ResolveUploadLanguage(header.Filename, format, data, userLanguage, false)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, detected)
|
||||
}
|
||||
|
||||
// HandleList handles GET /api/v1/subtitles/{media_file_id}
|
||||
func (h *SubtitleSearchHandler) HandleList(w http.ResponseWriter, r *http.Request) {
|
||||
mediaFileID, err := strconv.Atoi(chi.URLParam(r, "media_file_id"))
|
||||
@@ -153,6 +312,10 @@ func (h *SubtitleSearchHandler) HandleList(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
if !h.authorizeMediaFile(w, r, mediaFileID) {
|
||||
return
|
||||
}
|
||||
|
||||
subs, err := h.repo.ListDownloadedSubtitles(r.Context(), mediaFileID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "list_error", "Failed to list subtitles")
|
||||
@@ -180,6 +343,10 @@ func (h *SubtitleSearchHandler) HandleDelete(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
if !h.authorizeMediaFile(w, r, sub.MediaFileID) {
|
||||
return
|
||||
}
|
||||
|
||||
claims := apimw.GetClaims(r.Context())
|
||||
isAdmin := claims != nil && claims.Role == "admin"
|
||||
isOwner := sub.DownloadedBy != nil && claims != nil && *sub.DownloadedBy == claims.UserID
|
||||
@@ -204,3 +371,12 @@ func firstNonEmpty(values ...string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseBoolFormValue(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
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/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/scanner"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles"
|
||||
)
|
||||
|
||||
type stubSubtitleMediaResolver struct {
|
||||
meta *MediaFileMetadata
|
||||
}
|
||||
|
||||
func (s stubSubtitleMediaResolver) GetMediaFileWithMetadata(context.Context, int) (*MediaFileMetadata, error) {
|
||||
return s.meta, nil
|
||||
}
|
||||
|
||||
type stubMediaFileResolver struct {
|
||||
file *models.MediaFile
|
||||
err error
|
||||
}
|
||||
|
||||
func (s stubMediaFileResolver) GetByID(context.Context, int) (*models.MediaFile, error) {
|
||||
return s.file, s.err
|
||||
}
|
||||
|
||||
type stubItemAccessChecker struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (s stubItemAccessChecker) EnsureAccessible(context.Context, string, catalog.AccessFilter) error {
|
||||
return s.err
|
||||
}
|
||||
|
||||
type stubEpisodeLookup struct {
|
||||
episode *models.Episode
|
||||
}
|
||||
|
||||
func (s stubEpisodeLookup) GetByID(context.Context, string) (*models.Episode, error) {
|
||||
return s.episode, nil
|
||||
}
|
||||
|
||||
func newSubtitleAuthRequest(method, path string, body io.Reader) *http.Request {
|
||||
req := httptest.NewRequest(method, path, body)
|
||||
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
|
||||
UserID: 1,
|
||||
Role: "user",
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
})
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
func newSubtitleUploadRequest(t *testing.T, mediaFileID int, language, filename string, content []byte) *http.Request {
|
||||
t.Helper()
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
if err := writer.WriteField("media_file_id", strconv.Itoa(mediaFileID)); err != nil {
|
||||
t.Fatalf("write media_file_id: %v", err)
|
||||
}
|
||||
if err := writer.WriteField("language", language); err != nil {
|
||||
t.Fatalf("write language: %v", err)
|
||||
}
|
||||
part, err := writer.CreateFormFile("file", filename)
|
||||
if err != nil {
|
||||
t.Fatalf("create form file: %v", err)
|
||||
}
|
||||
if _, err := part.Write(content); err != nil {
|
||||
t.Fatalf("write file content: %v", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
req := newSubtitleAuthRequest(http.MethodPost, "/subtitles/upload", &body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
return req
|
||||
}
|
||||
|
||||
func TestHandleUploadSuccess(t *testing.T) {
|
||||
repo := newMockSubtitleRepoForHandler()
|
||||
manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test-bucket")
|
||||
handler := NewSubtitleSearchHandler(manager, repo, stubSubtitleMediaResolver{})
|
||||
handler.FileAuthorizer = &MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{
|
||||
file: &models.MediaFile{ID: 42, ContentID: "movie-1"},
|
||||
},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
}
|
||||
|
||||
req := newSubtitleUploadRequest(t, 42, "en", "custom.srt", []byte("1\n00:00:01,000 --> 00:00:02,000\nHi\n"))
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleUpload(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Subtitle subtitles.DownloadedSubtitle `json:"subtitle"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.Subtitle.Provider != subtitles.ProviderUpload {
|
||||
t.Fatalf("provider = %q, want upload", resp.Subtitle.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUploadUnauthorizedMediaFile(t *testing.T) {
|
||||
repo := newMockSubtitleRepoForHandler()
|
||||
manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test-bucket")
|
||||
handler := NewSubtitleSearchHandler(manager, repo, stubSubtitleMediaResolver{})
|
||||
handler.FileAuthorizer = &MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{err: scanner.ErrFileNotFound},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
}
|
||||
|
||||
req := newSubtitleUploadRequest(t, 99, "en", "custom.srt", []byte("hello"))
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleUpload(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUploadRejectsBadExtension(t *testing.T) {
|
||||
repo := newMockSubtitleRepoForHandler()
|
||||
manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test-bucket")
|
||||
handler := NewSubtitleSearchHandler(manager, repo, stubSubtitleMediaResolver{})
|
||||
handler.FileAuthorizer = &MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{
|
||||
file: &models.MediaFile{ID: 42, ContentID: "movie-1"},
|
||||
},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
}
|
||||
|
||||
req := newSubtitleUploadRequest(t, 42, "en", "notes.txt", []byte("hello"))
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleUpload(rr, req)
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUploadRejectsOversizedBody(t *testing.T) {
|
||||
repo := newMockSubtitleRepoForHandler()
|
||||
manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test-bucket")
|
||||
handler := NewSubtitleSearchHandler(manager, repo, stubSubtitleMediaResolver{})
|
||||
handler.FileAuthorizer = &MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{
|
||||
file: &models.MediaFile{ID: 42, ContentID: "movie-1"},
|
||||
},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
}
|
||||
|
||||
req := newSubtitleUploadRequest(
|
||||
t,
|
||||
42,
|
||||
"en",
|
||||
"huge.srt",
|
||||
make([]byte, subtitleUploadMaxBodySize+1),
|
||||
)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleUpload(rr, req)
|
||||
|
||||
if rr.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("status = %d, want 413", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDetectLanguageRejectsOversizedBody(t *testing.T) {
|
||||
repo := newMockSubtitleRepoForHandler()
|
||||
manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test-bucket")
|
||||
handler := NewSubtitleSearchHandler(manager, repo, stubSubtitleMediaResolver{})
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
part, err := writer.CreateFormFile("file", "huge.srt")
|
||||
if err != nil {
|
||||
t.Fatalf("create form file: %v", err)
|
||||
}
|
||||
if _, err := part.Write(make([]byte, subtitleUploadMaxBodySize+1)); err != nil {
|
||||
t.Fatalf("write file content: %v", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
req := newSubtitleAuthRequest(http.MethodPost, "/subtitles/detect-language", &body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleDetectLanguage(rr, req)
|
||||
|
||||
if rr.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("status = %d, want 413", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDeleteRequiresAccessToMediaFile(t *testing.T) {
|
||||
repo := newMockSubtitleRepoForHandler()
|
||||
repo.subtitles[1] = &subtitles.DownloadedSubtitle{
|
||||
ID: 1,
|
||||
MediaFileID: 42,
|
||||
Provider: subtitles.ProviderUpload,
|
||||
}
|
||||
manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test-bucket")
|
||||
handler := NewSubtitleSearchHandler(manager, repo, stubSubtitleMediaResolver{})
|
||||
handler.FileAuthorizer = &MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{err: scanner.ErrFileNotFound},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
}
|
||||
|
||||
req := newSubtitleAuthRequest(http.MethodDelete, "/subtitles/1", nil)
|
||||
req = withProfileRouteParam(req, "id", "1")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleDelete(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
type handlerMockSubtitleRepo struct {
|
||||
subtitles map[int]*subtitles.DownloadedSubtitle
|
||||
nextID int
|
||||
byKey map[string]*subtitles.DownloadedSubtitle
|
||||
}
|
||||
|
||||
func newMockSubtitleRepoForHandler() *handlerMockSubtitleRepo {
|
||||
return &handlerMockSubtitleRepo{
|
||||
subtitles: make(map[int]*subtitles.DownloadedSubtitle),
|
||||
byKey: make(map[string]*subtitles.DownloadedSubtitle),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *handlerMockSubtitleRepo) InsertDownloadedSubtitle(_ context.Context, sub *subtitles.DownloadedSubtitle) error {
|
||||
m.nextID++
|
||||
sub.ID = m.nextID
|
||||
m.subtitles[sub.ID] = sub
|
||||
m.byKey[sub.S3Key] = sub
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *handlerMockSubtitleRepo) GetDownloadedSubtitle(_ context.Context, id int) (*subtitles.DownloadedSubtitle, error) {
|
||||
if sub, ok := m.subtitles[id]; ok {
|
||||
copy := *sub
|
||||
return ©, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *handlerMockSubtitleRepo) ListDownloadedSubtitles(context.Context, int) ([]subtitles.DownloadedSubtitle, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *handlerMockSubtitleRepo) DeleteDownloadedSubtitle(_ context.Context, id int) (*subtitles.DownloadedSubtitle, error) {
|
||||
sub := m.subtitles[id]
|
||||
delete(m.subtitles, id)
|
||||
return sub, nil
|
||||
}
|
||||
|
||||
func (m *handlerMockSubtitleRepo) GetDownloadedSubtitleByS3Key(_ context.Context, s3Key string) (*subtitles.DownloadedSubtitle, error) {
|
||||
if sub, ok := m.byKey[s3Key]; ok {
|
||||
copy := *sub
|
||||
return ©, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *handlerMockSubtitleRepo) UpdateDownloadedSubtitle(_ context.Context, id int, update subtitles.SubtitleMetadataUpdate) (*subtitles.DownloadedSubtitle, error) {
|
||||
sub, ok := m.subtitles[id]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
sub.Language = update.Language
|
||||
sub.ReleaseName = update.ReleaseName
|
||||
sub.HearingImpaired = update.HearingImpaired
|
||||
if sub.S3Key != update.S3Key {
|
||||
delete(m.byKey, sub.S3Key)
|
||||
sub.S3Key = update.S3Key
|
||||
m.byKey[sub.S3Key] = sub
|
||||
}
|
||||
copy := *sub
|
||||
return ©, nil
|
||||
}
|
||||
|
||||
func (m *handlerMockSubtitleRepo) ListProviderConfigs(context.Context) ([]subtitles.ProviderConfig, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *handlerMockSubtitleRepo) GetProviderConfig(context.Context, string) (*subtitles.ProviderConfig, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *handlerMockSubtitleRepo) UpsertProviderConfig(context.Context, *subtitles.ProviderConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type handlerMockS3Client struct{}
|
||||
|
||||
func newMockS3ClientForHandler() *handlerMockS3Client {
|
||||
return &handlerMockS3Client{}
|
||||
}
|
||||
|
||||
func (handlerMockS3Client) PutObject(context.Context, string, string, []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (handlerMockS3Client) GetObject(context.Context, string, string) ([]byte, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (handlerMockS3Client) DeleteObject(context.Context, string, string) error {
|
||||
return nil
|
||||
}
|
||||
@@ -39,6 +39,7 @@ var demoBlockedRoutes = []blockedRoute{
|
||||
{methods: []string{"POST", "DELETE"}, prefix: "/api/v1/downloads"},
|
||||
{methods: []string{"POST"}, prefix: "/api/v1/history-imports"},
|
||||
{methods: []string{"POST"}, prefix: "/api/v1/subtitles/download"},
|
||||
{methods: []string{"POST"}, prefix: "/api/v1/subtitles/upload"},
|
||||
{methods: []string{"DELETE"}, prefix: "/api/v1/subtitles/"},
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+342
-298
@@ -222,6 +222,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
|
||||
@@ -258,6 +259,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) {
|
||||
@@ -727,6 +734,7 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
|
||||
// Admin subtitle config handler only needs the DB repo — no S3 required.
|
||||
var adminSubtitleHandler *handlers.AdminSubtitleHandler
|
||||
var subtitleManager *subtitles.Manager
|
||||
if subtitleRepo != nil {
|
||||
adminSubtitleHandler = handlers.NewAdminSubtitleHandler(subtitleRepo)
|
||||
}
|
||||
@@ -734,7 +742,7 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
// Build subtitle search handler if we have DB and S3.
|
||||
var subtitleSearchHandler *handlers.SubtitleSearchHandler
|
||||
if deps.DB != nil && deps.S3Public != nil && subtitleRepo != nil {
|
||||
subtitleManager := subtitles.NewManager(subtitleRepo, deps.S3Public, deps.S3Public.Bucket())
|
||||
subtitleManager = subtitles.NewManager(subtitleRepo, deps.S3Public, deps.S3Public.Bucket())
|
||||
|
||||
// Load provider configs from DB and register enabled providers.
|
||||
providerConfigs, _ := subtitleRepo.ListProviderConfigs(deps.AppContext)
|
||||
@@ -768,6 +776,10 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
subtitleSearchHandler = handlers.NewSubtitleSearchHandler(subtitleManager, subtitleRepo, mediaResolver)
|
||||
}
|
||||
|
||||
if adminSubtitleHandler != nil && deps.DB != nil && subtitleManager != nil {
|
||||
adminSubtitleHandler.SetDownloadedSubtitleDeps(deps.DB, subtitleManager)
|
||||
}
|
||||
|
||||
// Build section handler if DB is available.
|
||||
var sectionHandler *handlers.SectionHandler
|
||||
var sectionSettingsHandler *handlers.SectionSettingsHandler
|
||||
@@ -1530,9 +1542,18 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
|
||||
// Subtitle search routes.
|
||||
if subtitleSearchHandler != nil {
|
||||
if deps.FileRepo != nil && itemRepo != nil {
|
||||
subtitleSearchHandler.FileAuthorizer = &handlers.MediaFileAuthorizer{
|
||||
FileResolver: deps.FileRepo,
|
||||
ItemAccess: itemRepo,
|
||||
EpisodeLookup: episodeRepo,
|
||||
}
|
||||
}
|
||||
r.Route("/subtitles", func(r chi.Router) {
|
||||
r.Post("/search", subtitleSearchHandler.HandleSearch)
|
||||
r.Post("/download", subtitleSearchHandler.HandleDownload)
|
||||
r.Post("/upload", subtitleSearchHandler.HandleUpload)
|
||||
r.Post("/detect-language", subtitleSearchHandler.HandleDetectLanguage)
|
||||
r.Get("/{media_file_id}", subtitleSearchHandler.HandleList)
|
||||
r.Delete("/{id}", subtitleSearchHandler.HandleDelete)
|
||||
})
|
||||
@@ -1670,324 +1691,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)
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
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)
|
||||
}
|
||||
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
|
||||
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)
|
||||
}
|
||||
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 adminImageHandler != nil {
|
||||
r.Get("/items/{id}/images", adminImageHandler.HandleGetItemImages)
|
||||
r.Post("/items/{id}/images/apply", adminImageHandler.HandleApplyItemImage)
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
filesystemHandler := handlers.NewFilesystemHandler()
|
||||
r.Get("/filesystem/browse", filesystemHandler.HandleBrowse)
|
||||
|
||||
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)
|
||||
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)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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 adminJobsHandler != nil {
|
||||
r.Route("/jobs", func(r chi.Router) {
|
||||
r.Get("/", adminJobsHandler.HandleList)
|
||||
})
|
||||
}
|
||||
|
||||
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 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 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 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)
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
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 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 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
|
||||
}
|
||||
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)
|
||||
})
|
||||
})
|
||||
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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 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 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)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user