Files
silo-server/internal/api/handlers/collections.go
T

1040 lines
35 KiB
Go

package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"golang.org/x/sync/errgroup"
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/collectionutil"
"github.com/Silo-Server/silo-server/internal/s3client"
"github.com/Silo-Server/silo-server/internal/usercollections"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// CollectionHandler handles personal collection CRUD endpoints.
type CollectionHandler struct {
storeProvider userstore.UserStoreProvider
Executor *catalog.QueryExecutor
S3GP *s3client.Client
HTTPClient *http.Client
PresignTTL time.Duration
}
// NewCollectionHandler creates a new CollectionHandler.
func NewCollectionHandler(provider userstore.UserStoreProvider) *CollectionHandler {
return &CollectionHandler{storeProvider: provider}
}
// --- Request/Response types ---
type createCollectionRequest struct {
Name string `json:"name"`
CollectionType string `json:"collection_type"`
IsShared bool `json:"is_shared"`
AllowedProfileIDs []string `json:"allowed_profile_ids"`
QueryDefinition json.RawMessage `json:"query_definition"`
SortConfig json.RawMessage `json:"sort_config"`
IncludeInServerCollections bool `json:"include_in_server_collections"`
PosterSourceURL string `json:"poster_source_url"`
}
type updateCollectionRequest struct {
Name *string `json:"name"`
Description *string `json:"description"`
IsShared *bool `json:"is_shared"`
AllowedProfileIDs *[]string `json:"allowed_profile_ids"`
QueryDefinition json.RawMessage `json:"query_definition"`
SortConfig json.RawMessage `json:"sort_config"`
SourceURL *string `json:"source_url"`
MaxItems *int `json:"max_items"`
IncludeInServerCollections *bool `json:"include_in_server_collections"`
PosterSourceURL *string `json:"poster_source_url"`
GroupID optionalNullableString `json:"group_id"`
}
type collectionItemRequest struct {
Position int `json:"position"`
}
type collectionResponse struct {
ID string `json:"id"`
ProfileID string `json:"profile_id"`
CreatorProfileID string `json:"creator_profile_id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
CollectionType string `json:"collection_type"`
IsShared bool `json:"is_shared"`
AllowedProfileIDs []string `json:"allowed_profile_ids"`
QueryDefinition json.RawMessage `json:"query_definition"`
SortConfig json.RawMessage `json:"sort_config"`
SortOrder int `json:"sort_order"`
GroupID *string `json:"group_id"`
SourceURL string `json:"source_url,omitempty"`
SourceConfig json.RawMessage `json:"source_config,omitempty"`
SyncSchedule string `json:"sync_schedule,omitempty"`
NextSyncAt string `json:"next_sync_at,omitempty"`
LastSyncAt string `json:"last_sync_at,omitempty"`
LastSyncStatus string `json:"last_sync_status,omitempty"`
LastSyncMessage string `json:"last_sync_message,omitempty"`
ItemCount int `json:"item_count"`
IncludeInServerCollections bool `json:"include_in_server_collections"`
PosterURL string `json:"poster_url,omitempty"`
PosterThumbhash string `json:"poster_thumbhash,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type collectionListResponse struct {
Collections []collectionResponse `json:"collections"`
Groups []collectionGroupResponse `json:"groups"`
}
type collectionGroupResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
DefaultSortMode string `json:"default_sort_mode"`
SortOrder int `json:"sort_order"`
}
type createCollectionGroupRequest struct {
Name string `json:"name"`
Slug string `json:"slug"`
DefaultSortMode string `json:"default_sort_mode"`
}
type updateCollectionGroupRequest struct {
Name *string `json:"name"`
Slug *string `json:"slug"`
DefaultSortMode *string `json:"default_sort_mode"`
}
type reorderCollectionGroupsRequest struct {
OrderedIDs []string `json:"ordered_ids"`
}
type collectionItemResponse struct {
CollectionID string `json:"collection_id"`
MediaItemID string `json:"media_item_id"`
Position int `json:"position"`
AddedAt string `json:"added_at"`
}
type collectionItemsListResponse struct {
Items []collectionItemResponse `json:"items"`
}
type previewCollectionRequest struct {
QueryDefinition json.RawMessage `json:"query_definition"`
Limit int `json:"limit"`
}
type previewCollectionResponse struct {
Items []previewCollectionItemResponse `json:"items"`
Total int `json:"total"`
}
type previewCollectionItemResponse struct {
ContentID string `json:"content_id"`
Title string `json:"title"`
Type string `json:"type"`
}
// --- Handler methods ---
// HandleListCollections handles GET /collections.
func (h *CollectionHandler) HandleListCollections(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
collectionsCh := make(chan []userstore.Collection, 1)
groupsCh := make(chan []userstore.CollectionGroup, 1)
eg, egCtx := errgroup.WithContext(r.Context())
eg.Go(func() error {
collections, err := store.ListCollections(egCtx, profileID)
if err != nil {
return err
}
collectionsCh <- collections
return nil
})
eg.Go(func() error {
groups, err := store.ListCollectionGroups(egCtx)
if err != nil {
return err
}
groupsCh <- groups
return nil
})
if err := eg.Wait(); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list collections")
return
}
collections := <-collectionsCh
groups := <-groupsCh
resp := collectionListResponse{
Collections: make([]collectionResponse, 0, len(collections)),
Groups: make([]collectionGroupResponse, 0, len(groups)),
}
for _, c := range collections {
resp.Collections = append(resp.Collections, h.toCollectionResponse(r, c))
}
for _, g := range groups {
resp.Groups = append(resp.Groups, collectionGroupResponse{
ID: g.ID,
Name: g.Name,
Slug: g.Slug,
DefaultSortMode: string(g.DefaultSortMode),
SortOrder: g.SortOrder,
})
}
writeJSON(w, http.StatusOK, resp)
}
// HandleCreateCollection handles POST /collections.
func (h *CollectionHandler) HandleCreateCollection(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
var req createCollectionRequest
if err := decodeJSONOrMultipart(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Collection name is required")
return
}
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
queryDefinitionJSON := defaultJSON(req.QueryDefinition)
collectionType := firstNonEmptyCollection(req.CollectionType, "manual")
if collectionType == "smart" {
queryDefinitionJSON, err = normalizeSmartCollectionQueryDefinitionJSON(queryDefinitionJSON, true, true)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid query_definition")
return
}
} else if len(req.QueryDefinition) > 0 {
queryDefinitionJSON, err = normalizeQueryDefinitionJSON(queryDefinitionJSON, true, true)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid query_definition")
return
}
}
queryDefinition := string(queryDefinitionJSON)
sortConfig := string(defaultJSON(req.SortConfig))
collection, err := store.CreateCollection(r.Context(), userstore.CreateCollectionInput{
CreatorProfileID: profileID,
Name: req.Name,
CollectionType: collectionType,
IsShared: req.IsShared,
AllowedProfileIDs: req.AllowedProfileIDs,
QueryDefinition: queryDefinition,
SortConfig: sortConfig,
IncludeInServerCollections: req.IncludeInServerCollections,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create collection")
return
}
if err := h.processCollectionPoster(r, store, collection.ID, profileID, req.PosterSourceURL); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
}
if h.posterInputProvided(r, req.PosterSourceURL) {
if refreshed, err := store.GetCollection(r.Context(), collection.ID); err == nil {
collection = refreshed
}
}
writeJSON(w, http.StatusCreated, h.toCollectionResponse(r, *collection))
}
// HandleUpdateCollection handles PUT /collections/{id}.
func (h *CollectionHandler) HandleUpdateCollection(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
collectionID := chi.URLParam(r, "id")
if collectionID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Collection ID is required")
return
}
var req updateCollectionRequest
if err := decodeJSONOrMultipart(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
profileID := apimw.GetProfileID(r.Context())
input := userstore.UpdateCollectionInput{
ID: collectionID,
RequestProfileID: profileID,
Name: req.Name,
Description: req.Description,
AllowedProfileIDs: req.AllowedProfileIDs,
IncludeInServerCollections: req.IncludeInServerCollections,
}
if len(req.QueryDefinition) > 0 {
normalized, err := normalizeSmartCollectionQueryDefinitionJSON(req.QueryDefinition, true, true)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid query_definition")
return
}
value := string(normalized)
input.QueryDefinition = &value
}
if len(req.SortConfig) > 0 {
value := string(req.SortConfig)
input.SortConfig = &value
}
input.IsShared = req.IsShared
if req.GroupID.Set() {
groupID := req.GroupID.Value()
if groupID != nil && strings.TrimSpace(*groupID) == "" {
groupID = nil
}
if groupID != nil {
if err := store.EnsureCollectionGroup(r.Context(), *groupID); err != nil {
if errors.Is(err, userstore.ErrCollectionGroupNotFound) {
writeError(w, http.StatusBadRequest, "bad_request", "Collection group not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to validate collection group")
return
}
}
input.GroupID = &groupID
}
// Source URL and max items both live inside source_config (Limit / URL).
// Load the existing collection and re-marshal so the unaffected fields
// (preset, media_type, etc.) survive untouched.
if req.SourceURL != nil || req.MaxItems != nil {
existing, err := store.GetCollection(r.Context(), collectionID)
if err != nil {
if strings.Contains(err.Error(), "not found") {
writeError(w, http.StatusNotFound, "not_found", "Collection not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load collection")
return
}
cfg, err := usercollections.ParseSourceConfig(existing.SourceConfig)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to parse source config")
return
}
if req.MaxItems != nil {
if *req.MaxItems < 0 {
writeError(w, http.StatusBadRequest, "bad_request", "max_items must be zero or positive")
return
}
if *req.MaxItems == 0 {
cfg.Limit = nil
} else {
value := *req.MaxItems
cfg.Limit = &value
}
}
if req.SourceURL != nil {
if existing.CollectionType != "mdblist" {
writeError(w, http.StatusBadRequest, "bad_request", "source_url can only be edited for MDBList collections")
return
}
normalized := usercollections.NormalizeMDBListURL(*req.SourceURL)
if normalized == "" {
writeError(w, http.StatusBadRequest, "bad_request", "source_url is required")
return
}
cfg.URL = normalized
topURL := normalized
input.SourceURL = &topURL
}
raw, err := usercollections.MarshalSourceConfig(cfg)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to encode source config")
return
}
input.SourceConfig = &raw
}
if err := store.UpdateCollection(r.Context(), input); err != nil {
if strings.Contains(err.Error(), "creator") {
writeError(w, http.StatusForbidden, "forbidden", "Only the creator can edit this collection")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update collection")
return
}
posterSource := pointerStringValue(req.PosterSourceURL)
if err := h.processCollectionPoster(r, store, collectionID, profileID, posterSource); err != nil {
if errors.Is(err, errCollectionForbidden) {
writeError(w, http.StatusForbidden, "forbidden", "Only the creator can edit this collection")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
}
// Re-read the collection to return updated state.
collection, err := store.GetCollection(r.Context(), collectionID)
if err != nil {
if strings.Contains(err.Error(), "not found") {
writeError(w, http.StatusNotFound, "not_found", "Collection not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retrieve updated collection")
return
}
writeJSON(w, http.StatusOK, h.toCollectionResponse(r, *collection))
}
func (h *CollectionHandler) HandlePreviewCollection(w http.ResponseWriter, r *http.Request) {
if h.Executor == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Preview is not configured")
return
}
var req previewCollectionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
var def catalog.QueryDefinition
if len(req.QueryDefinition) > 0 {
normalized, err := normalizeQueryDefinitionJSON(req.QueryDefinition, true, true)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid query_definition")
return
}
if err := json.Unmarshal(normalized, &def); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid query_definition")
return
}
}
items, total, err := h.Executor.Preview(r.Context(), def, requestAccessFilter(r), req.Limit)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
resp := previewCollectionResponse{Items: make([]previewCollectionItemResponse, 0, len(items)), Total: total}
for _, item := range items {
resp.Items = append(resp.Items, previewCollectionItemResponse{
ContentID: item.ContentID,
Title: item.Title,
Type: item.Type,
})
}
writeJSON(w, http.StatusOK, resp)
}
// HandleDeleteCollection handles DELETE /collections/{id}.
func (h *CollectionHandler) HandleDeleteCollection(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
collectionID := chi.URLParam(r, "id")
if collectionID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Collection ID is required")
return
}
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
if err := store.DeleteCollection(r.Context(), collectionID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete collection")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleListCollectionItems handles GET /collections/{id}/items.
func (h *CollectionHandler) HandleListCollectionItems(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
collectionID := chi.URLParam(r, "id")
if collectionID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Collection ID is required")
return
}
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
items, err := store.ListCollectionItems(r.Context(), collectionID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list collection items")
return
}
resp := collectionItemsListResponse{
Items: make([]collectionItemResponse, 0, len(items)),
}
for _, ci := range items {
resp.Items = append(resp.Items, collectionItemResponse{
CollectionID: ci.CollectionID,
MediaItemID: ci.MediaItemID,
Position: ci.Position,
AddedAt: ci.AddedAt,
})
}
writeJSON(w, http.StatusOK, resp)
}
// HandleAddCollectionItem handles PUT /collections/{id}/items/{item_id}.
func (h *CollectionHandler) HandleAddCollectionItem(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
collectionID := chi.URLParam(r, "id")
itemID := chi.URLParam(r, "item_id")
if collectionID == "" || itemID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Collection ID and item ID are required")
return
}
var req collectionItemRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
// Default position to 0 if body is empty or invalid.
req.Position = 0
}
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
if err := store.AddCollectionItem(r.Context(), collectionID, itemID, req.Position); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to add item to collection")
return
}
w.WriteHeader(http.StatusNoContent)
}
type reorderRequest struct {
OrderedIDs []string `json:"ordered_ids"`
// GroupID scopes the reorder to one section. nil/absent targets Ungrouped.
GroupID *string `json:"group_id,omitempty"`
}
// HandleReorderCollections handles PUT /collections/order.
// The body must contain every collection in scope; concurrent edits that
// would silently drop one are rejected.
func (h *CollectionHandler) HandleReorderCollections(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
var req reorderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
if err := store.ReorderCollections(r.Context(), profileID, req.GroupID, req.OrderedIDs); err != nil {
if errors.Is(err, collectionutil.ErrOrderedIDsMismatch) {
writeError(w, http.StatusBadRequest, "bad_request", "ordered_ids must include every visible collection in the group exactly once")
return
}
if strings.Contains(err.Error(), "ordered_ids contains duplicates") {
writeError(w, http.StatusBadRequest, "bad_request", "ordered_ids contains duplicates")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to reorder collections")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleCreateCollectionGroup handles POST /collections/groups.
func (h *CollectionHandler) HandleCreateCollectionGroup(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
var req createCollectionGroupRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
req.Name = strings.TrimSpace(req.Name)
req.Slug = strings.TrimSpace(req.Slug)
if req.Name == "" {
writeError(w, http.StatusBadRequest, "bad_request", "name is required")
return
}
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
sortMode := userstore.GroupSortMode(req.DefaultSortMode)
group, err := store.CreateCollectionGroup(r.Context(), req.Name, req.Slug, sortMode)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
writeJSON(w, http.StatusCreated, collectionGroupResponse{
ID: group.ID,
Name: group.Name,
Slug: group.Slug,
DefaultSortMode: string(group.DefaultSortMode),
SortOrder: group.SortOrder,
})
}
// HandleUpdateCollectionGroup handles PUT /collections/groups/{id}.
func (h *CollectionHandler) HandleUpdateCollectionGroup(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
id := chi.URLParam(r, "id")
if id == "" {
writeError(w, http.StatusBadRequest, "bad_request", "id is required")
return
}
var req updateCollectionGroupRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if req.Name != nil {
name := strings.TrimSpace(*req.Name)
req.Name = &name
if name == "" {
writeError(w, http.StatusBadRequest, "bad_request", "name cannot be empty")
return
}
}
if req.Slug != nil {
slug := strings.TrimSpace(*req.Slug)
req.Slug = &slug
}
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
var sortMode *userstore.GroupSortMode
if req.DefaultSortMode != nil {
mode := userstore.GroupSortMode(*req.DefaultSortMode)
sortMode = &mode
}
group, err := store.UpdateCollectionGroup(r.Context(), id, req.Name, req.Slug, sortMode)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
writeJSON(w, http.StatusOK, collectionGroupResponse{
ID: group.ID,
Name: group.Name,
Slug: group.Slug,
DefaultSortMode: string(group.DefaultSortMode),
SortOrder: group.SortOrder,
})
}
// HandleDeleteCollectionGroup handles DELETE /collections/groups/{id}.
func (h *CollectionHandler) HandleDeleteCollectionGroup(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
id := chi.URLParam(r, "id")
if id == "" {
writeError(w, http.StatusBadRequest, "bad_request", "id is required")
return
}
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
if err := store.DeleteCollectionGroup(r.Context(), id); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleReorderCollectionGroups handles PUT /collections/groups/order.
func (h *CollectionHandler) HandleReorderCollectionGroups(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
var req reorderCollectionGroupsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
if err := store.ReorderCollectionGroups(r.Context(), req.OrderedIDs); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleReorderCollectionItems handles PUT /collections/{id}/items/order.
// The body must contain every item currently in the collection.
func (h *CollectionHandler) HandleReorderCollectionItems(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
collectionID := chi.URLParam(r, "id")
if collectionID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Collection ID is required")
return
}
var req reorderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
if err := store.ReorderCollectionItems(r.Context(), collectionID, req.OrderedIDs); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleRemoveCollectionItem handles DELETE /collections/{id}/items/{item_id}.
func (h *CollectionHandler) HandleRemoveCollectionItem(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
collectionID := chi.URLParam(r, "id")
itemID := chi.URLParam(r, "item_id")
if collectionID == "" || itemID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Collection ID and item ID are required")
return
}
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
if err := store.RemoveCollectionItem(r.Context(), collectionID, itemID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to remove item from collection")
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- Helpers ---
func toCollectionResponse(c userstore.Collection) collectionResponse {
queryDefinition := defaultJSON([]byte(c.QueryDefinition))
sortConfig := defaultJSON([]byte(c.SortConfig))
resp := collectionResponse{
ID: c.ID,
ProfileID: c.ProfileID,
CreatorProfileID: c.CreatorProfileID,
Name: c.Name,
Description: c.Description,
CollectionType: c.CollectionType,
IsShared: c.IsShared,
AllowedProfileIDs: append([]string(nil), c.AllowedProfileIDs...),
QueryDefinition: queryDefinition,
SortConfig: sortConfig,
SortOrder: c.SortOrder,
GroupID: c.GroupID,
SourceURL: c.SourceURL,
LastSyncStatus: c.LastSyncStatus,
LastSyncMessage: c.LastSyncMessage,
ItemCount: c.ItemCount,
IncludeInServerCollections: c.IncludeInServerCollections,
PosterURL: c.PosterURL,
PosterThumbhash: c.PosterThumbhash,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
}
if c.SourceConfig != "" && c.SourceConfig != "{}" {
resp.SourceConfig = json.RawMessage(c.SourceConfig)
}
if c.SyncSchedule != nil {
resp.SyncSchedule = *c.SyncSchedule
}
if c.NextSyncAt != nil {
resp.NextSyncAt = c.NextSyncAt.UTC().Format(time.RFC3339)
}
if c.LastSyncAt != nil {
resp.LastSyncAt = c.LastSyncAt.UTC().Format(time.RFC3339)
}
return resp
}
func defaultJSON(raw []byte) json.RawMessage {
if len(raw) == 0 {
return json.RawMessage(`{}`)
}
return json.RawMessage(raw)
}
func normalizeQueryDefinitionJSON(raw []byte, allowPersonalizedSorts, allowPersonalizedFields bool) (json.RawMessage, error) {
var def catalog.QueryDefinition
if len(raw) > 0 {
if err := json.Unmarshal(raw, &def); err != nil {
return nil, err
}
}
def = def.Normalize()
if err := def.ValidateWithOptions(allowPersonalizedSorts, allowPersonalizedFields); err != nil {
return nil, err
}
normalized, err := json.Marshal(def)
if err != nil {
return nil, err
}
return normalized, nil
}
func normalizeSmartCollectionQueryDefinitionJSON(raw []byte, allowPersonalizedSorts, allowPersonalizedFields bool) (json.RawMessage, error) {
normalized, err := normalizeQueryDefinitionJSON(raw, allowPersonalizedSorts, allowPersonalizedFields)
if err != nil {
return nil, err
}
var def catalog.QueryDefinition
if err := json.Unmarshal(normalized, &def); err != nil {
return nil, err
}
def = catalog.ApplySmartCollectionItemLimit(def)
out, err := json.Marshal(def)
if err != nil {
return nil, err
}
return out, nil
}
func firstNonEmptyCollection(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
// errCollectionForbidden is returned by the artwork pipeline when the caller
// is not the collection's creator. It surfaces as a 403 to the client.
var errCollectionForbidden = errors.New("only the creator can edit this collection")
// HandleDeleteCollectionImage clears the poster on a personal collection.
// The query parameter "type" is required and currently only "poster" is
// supported.
func (h *CollectionHandler) HandleDeleteCollectionImage(w http.ResponseWriter, r *http.Request) {
collectionID := chi.URLParam(r, "id")
if collectionID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Collection ID is required")
return
}
imageType := r.URL.Query().Get("type")
if imageType != "poster" {
writeError(w, http.StatusBadRequest, "bad_request", `type must be "poster"`)
return
}
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
collection, err := store.GetCollection(r.Context(), collectionID)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "Collection not found")
return
}
if collection.CreatorProfileID != profileID {
writeError(w, http.StatusForbidden, "forbidden", "Only the creator can edit this collection")
return
}
if err := removeCollectionImageVariants(r.Context(), h.S3GP, userCollectionImagePrefix, collectionID, imageType); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete images")
return
}
empty := ""
if err := store.UpdateCollection(r.Context(), userstore.UpdateCollectionInput{
ID: collectionID,
RequestProfileID: profileID,
PosterURL: &empty,
PosterThumbhash: &empty,
}); err != nil {
if strings.Contains(err.Error(), "creator") {
writeError(w, http.StatusForbidden, "forbidden", "Only the creator can edit this collection")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to clear poster")
return
}
w.WriteHeader(http.StatusNoContent)
}
// posterInputProvided reports whether the request body or form contained
// poster artwork inputs that the upload pipeline would act on.
func (h *CollectionHandler) posterInputProvided(r *http.Request, sourceURL string) bool {
if strings.TrimSpace(sourceURL) != "" {
return true
}
if r.MultipartForm == nil {
return false
}
_, ok := r.MultipartForm.File["poster"]
return ok
}
// processCollectionPoster persists a uploaded or sourced poster image on the
// given user collection. It is a no-op when no poster input was provided.
// The caller is responsible for ensuring the request profile owns the
// collection.
func (h *CollectionHandler) processCollectionPoster(
r *http.Request,
store userstore.UserStore,
collectionID, requestProfileID, sourceURL string,
) error {
source := strings.TrimSpace(sourceURL)
isMultipart := strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/")
var fileData []byte
if isMultipart {
data, err := readCollectionImageMultipart(r, "poster")
switch {
case err == nil:
fileData = data
case err == http.ErrMissingFile:
// fall through to source URL handling
default:
return fmt.Errorf("poster: %w", err)
}
}
if fileData == nil {
if source == "" {
return nil
}
downloaded, err := downloadCollectionImageURL(r.Context(), h.HTTPClient, source)
if err != nil {
return fmt.Errorf("poster source: %w", err)
}
fileData = downloaded
}
if h.S3GP == nil {
return fmt.Errorf("poster upload requires configured object storage")
}
if err := removeCollectionImageVariants(r.Context(), h.S3GP, userCollectionImagePrefix, collectionID, "poster"); err != nil {
return fmt.Errorf("clearing previous poster: %w", err)
}
s3Path, thumbhash, err := uploadCollectionImageVariants(r.Context(), h.S3GP, userCollectionImagePrefix, collectionID, "poster", fileData)
if err != nil {
return fmt.Errorf("poster: %w", err)
}
if err := store.UpdateCollection(r.Context(), userstore.UpdateCollectionInput{
ID: collectionID,
RequestProfileID: requestProfileID,
PosterURL: &s3Path,
PosterThumbhash: &thumbhash,
}); err != nil {
if strings.Contains(err.Error(), "creator") {
return errCollectionForbidden
}
return fmt.Errorf("persisting poster: %w", err)
}
return nil
}
// presignUserCollectionPoster returns a presigned URL for the card-sized
// variant of the stored poster, mirroring the admin pipeline. Empty paths
// return "".
func (h *CollectionHandler) presignUserCollectionPoster(ctx context.Context, path string) string {
if path == "" {
return ""
}
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
return path
}
if strings.HasPrefix(path, "/") {
return path
}
if h.S3GP == nil {
return ""
}
ttl := h.PresignTTL
if ttl <= 0 {
ttl = 4 * time.Hour
}
url, err := h.S3GP.PresignGetURL(ctx, h.S3GP.Bucket(), cardThumbnailPath(path), ttl)
if err != nil {
return ""
}
return url
}
// toCollectionResponse mirrors the package-level helper but presigns artwork
// URLs using the handler's S3 client. Use this method when serving HTTP
// responses; the package-level function is reserved for callers without a
// presign capability.
func (h *CollectionHandler) toCollectionResponse(r *http.Request, c userstore.Collection) collectionResponse {
resp := toCollectionResponse(c)
resp.PosterURL = h.presignUserCollectionPoster(r.Context(), c.PosterURL)
return resp
}