From fa5b0c59c9db632f735e516ba2de51ce3cf9a360 Mon Sep 17 00:00:00 2001 From: RXWatcher <14085001+RXWatcher@users.noreply.github.com> Date: Tue, 26 May 2026 22:00:54 +0200 Subject: [PATCH] =?UTF-8?q?feat(audiobooks):=20RSS=20feeds=20=E2=80=94=20a?= =?UTF-8?q?uth=20handlers=20+=20pgx=20store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/feeds/item/{itemId}/open (auto-gen or custom slug), GET /api/feeds (owner+profile scope, only open feeds), POST /api/feeds/{id}/close (idempotent, owner-gated). Slug validation via ^[a-z0-9-]{4,64}$. 409 on collision via pgx unique-violation substring detection. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/audiobooks/abs/handler.go | 3 + internal/audiobooks/abs/rss_feeds.go | 42 ++++ internal/audiobooks/abs/rss_feeds_handler.go | 140 +++++++++++++ .../audiobooks/abs/rss_feeds_handler_test.go | 187 ++++++++++++++++++ internal/audiobooks/abs_rss_feed_store.go | 108 ++++++++++ 5 files changed, 480 insertions(+) create mode 100644 internal/audiobooks/abs/rss_feeds.go create mode 100644 internal/audiobooks/abs/rss_feeds_handler.go create mode 100644 internal/audiobooks/abs/rss_feeds_handler_test.go create mode 100644 internal/audiobooks/abs_rss_feed_store.go diff --git a/internal/audiobooks/abs/handler.go b/internal/audiobooks/abs/handler.go index 662937db..02939113 100644 --- a/internal/audiobooks/abs/handler.go +++ b/internal/audiobooks/abs/handler.go @@ -215,6 +215,9 @@ type Dependencies struct { // SmartCollectionStore persists abs_smart_collections rows // (migration 153). May be nil; handlers respond 503 when unset. SmartCollectionStore SmartCollectionStore + // RSSFeedStore persists abs_rss_feeds rows (migration 155). + // May be nil; handlers respond 503 when unset. + RSSFeedStore RSSFeedStore // SocketIO is the Socket.io server mounted at /abs/socket.io/. May be nil; // the route is only registered when a non-nil value is supplied. SocketIO SocketIOServer diff --git a/internal/audiobooks/abs/rss_feeds.go b/internal/audiobooks/abs/rss_feeds.go new file mode 100644 index 00000000..e67b3e0c --- /dev/null +++ b/internal/audiobooks/abs/rss_feeds.go @@ -0,0 +1,42 @@ +package abs + +import ( + "context" + "time" +) + +// RSSFeedStore is the storage contract for the abs_rss_feeds table. +type RSSFeedStore interface { + ListUserFeeds(ctx context.Context, userID, profileID string) ([]RSSFeed, error) + GetFeed(ctx context.Context, id string) (RSSFeed, error) + GetFeedBySlug(ctx context.Context, slug string) (RSSFeed, error) + CreateFeed(ctx context.Context, f RSSFeed) error + CloseFeed(ctx context.Context, id string) error +} + +// RSSFeed mirrors an abs_rss_feeds row. +type RSSFeed struct { + ID string + UserID string + ProfileID string + LibraryItemID string + Slug string + Minified bool + CreatedAt time.Time + ClosedAt *time.Time +} + +// rssFeedToABS shapes a feed in the ABS wire format. `url` is built +// from the supplied base URL + slug. +func rssFeedToABS(f RSSFeed, baseURL string) map[string]any { + url := baseURL + "/feed/" + f.Slug + ".xml" + return map[string]any{ + "id": f.ID, + "userId": f.UserID, + "libraryItemId": f.LibraryItemID, + "slug": f.Slug, + "minified": f.Minified, + "createdAt": f.CreatedAt.UnixMilli(), + "url": url, + } +} diff --git a/internal/audiobooks/abs/rss_feeds_handler.go b/internal/audiobooks/abs/rss_feeds_handler.go new file mode 100644 index 00000000..326fa995 --- /dev/null +++ b/internal/audiobooks/abs/rss_feeds_handler.go @@ -0,0 +1,140 @@ +package abs + +import ( + "crypto/rand" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "regexp" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/oklog/ulid/v2" +) + +var slugRe = regexp.MustCompile(`^[a-z0-9-]{4,64}$`) + +type feedOpenBody struct { + Slug string `json:"slug"` + Minified bool `json:"minified"` +} + +func (h *Handler) handleListRSSFeeds(w http.ResponseWriter, r *http.Request) { + a, ok := absAuthFrom(r) + if !ok || a.UserID == "" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if h.deps.RSSFeedStore == nil { + writeJSON(w, http.StatusOK, map[string]any{"feeds": []any{}}) + return + } + rows, err := h.deps.RSSFeedStore.ListUserFeeds(r.Context(), a.UserID, a.ProfileID) + if err != nil { + slog.Error("abs feed list failed", "err", err, "user", a.UserID) + http.Error(w, "feed list failed", http.StatusInternalServerError) + return + } + base := h.absBaseURL(r) + out := make([]map[string]any, 0, len(rows)) + for _, f := range rows { + out = append(out, rssFeedToABS(f, base)) + } + writeJSON(w, http.StatusOK, map[string]any{"feeds": out}) +} + +func (h *Handler) handleOpenItemFeed(w http.ResponseWriter, r *http.Request) { + a, ok := absAuthFrom(r) + if !ok || a.UserID == "" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if h.deps.RSSFeedStore == nil { + http.Error(w, "feed store unavailable", http.StatusServiceUnavailable) + return + } + itemID := chi.URLParam(r, "itemId") + item, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), itemID) + if err != nil || item == nil { + http.Error(w, "item not found", http.StatusNotFound) + return + } + + var body feedOpenBody + _ = json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body) + + slug := strings.ToLower(strings.TrimSpace(body.Slug)) + if slug == "" { + slug = randomSlug() + } else if !slugRe.MatchString(slug) { + http.Error(w, "invalid slug", http.StatusBadRequest) + return + } + + f := RSSFeed{ + ID: ulid.Make().String(), + UserID: a.UserID, + ProfileID: a.ProfileID, + LibraryItemID: itemID, + Slug: slug, + Minified: body.Minified, + } + if err := h.deps.RSSFeedStore.CreateFeed(r.Context(), f); err != nil { + if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "unique") { + http.Error(w, "slug taken", http.StatusConflict) + return + } + slog.Error("abs feed create failed", "err", err, "user", a.UserID) + http.Error(w, "feed persist failed", http.StatusInternalServerError) + return + } + + persisted, err := h.deps.RSSFeedStore.GetFeed(r.Context(), f.ID) + if errors.Is(err, ErrNotFound) || err != nil { + f.CreatedAt = time.Now() + persisted = f + } + writeJSON(w, http.StatusOK, rssFeedToABS(persisted, h.absBaseURL(r))) +} + +func (h *Handler) handleCloseFeed(w http.ResponseWriter, r *http.Request) { + a, ok := absAuthFrom(r) + if !ok || a.UserID == "" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if h.deps.RSSFeedStore == nil { + http.Error(w, "feed not found", http.StatusNotFound) + return + } + id := chi.URLParam(r, "id") + f, err := h.deps.RSSFeedStore.GetFeed(r.Context(), id) + if errors.Is(err, ErrNotFound) || (err == nil && f.UserID != a.UserID) { + http.Error(w, "feed not found", http.StatusNotFound) + return + } + if err != nil { + slog.Error("abs feed get-for-close failed", "err", err, "id", id) + http.Error(w, "feed get failed", http.StatusInternalServerError) + return + } + if err := h.deps.RSSFeedStore.CloseFeed(r.Context(), id); err != nil { + slog.Error("abs feed close failed", "err", err, "id", id) + http.Error(w, "feed close failed", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func randomSlug() string { + const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789" + buf := make([]byte, 16) + _, _ = rand.Read(buf) + for i, b := range buf { + buf[i] = alphabet[int(b)%len(alphabet)] + } + return string(buf) +} diff --git a/internal/audiobooks/abs/rss_feeds_handler_test.go b/internal/audiobooks/abs/rss_feeds_handler_test.go new file mode 100644 index 00000000..ff01a1d3 --- /dev/null +++ b/internal/audiobooks/abs/rss_feeds_handler_test.go @@ -0,0 +1,187 @@ +package abs + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "sort" + "sync" + "testing" + "time" + + "github.com/Silo-Server/silo-server/internal/models" +) + +type memRSSFeedStore struct { + mu sync.Mutex + rows map[string]RSSFeed +} + +func newMemRSSFeedStore() *memRSSFeedStore { return &memRSSFeedStore{rows: map[string]RSSFeed{}} } + +func (m *memRSSFeedStore) ListUserFeeds(_ context.Context, userID, profileID string) ([]RSSFeed, error) { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]RSSFeed, 0) + for _, f := range m.rows { + if f.UserID == userID && f.ProfileID == profileID && f.ClosedAt == nil { + out = append(out, f) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) }) + return out, nil +} + +func (m *memRSSFeedStore) GetFeed(_ context.Context, id string) (RSSFeed, error) { + m.mu.Lock() + defer m.mu.Unlock() + f, ok := m.rows[id] + if !ok { + return RSSFeed{}, ErrNotFound + } + return f, nil +} + +func (m *memRSSFeedStore) GetFeedBySlug(_ context.Context, slug string) (RSSFeed, error) { + m.mu.Lock() + defer m.mu.Unlock() + for _, f := range m.rows { + if f.Slug == slug && f.ClosedAt == nil { + return f, nil + } + } + return RSSFeed{}, ErrNotFound +} + +func (m *memRSSFeedStore) CreateFeed(_ context.Context, f RSSFeed) error { + m.mu.Lock() + defer m.mu.Unlock() + for _, existing := range m.rows { + if existing.Slug == f.Slug && existing.ClosedAt == nil { + return errors.New("unique violation duplicate key") + } + } + f.CreatedAt = time.Now() + m.rows[f.ID] = f + return nil +} + +func (m *memRSSFeedStore) CloseFeed(_ context.Context, id string) error { + m.mu.Lock() + defer m.mu.Unlock() + f, ok := m.rows[id] + if !ok { + return nil + } + now := time.Now() + f.ClosedAt = &now + m.rows[id] = f + return nil +} + +func newFeedsHarness(t *testing.T, knownItems ...string) (*Handler, *memRSSFeedStore) { + t.Helper() + known := map[string]*models.MediaItem{} + for _, id := range knownItems { + known[id] = nil + } + store := newMemRSSFeedStore() + h := New(Dependencies{MediaStore: &stubMediaStore{known: known}, RSSFeedStore: store}) + return h, store +} + +func TestFeed_Open_GeneratesSlug(t *testing.T) { + h, _ := newFeedsHarness(t, "book-1") + rec := dispatchABSWithParams(http.MethodPost, "/api/feeds/item/book-1/open", + map[string]string{"itemId": "book-1"}, []byte(`{}`), "1", "", h.handleOpenItemFeed) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; body=%s", rec.Code, rec.Body.String()) + } + var got map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &got) + slug, _ := got["slug"].(string) + if len(slug) != 16 { + t.Errorf("slug = %q (len %d), want 16-char auto-generated", slug, len(slug)) + } +} + +func TestFeed_Open_CustomSlug(t *testing.T) { + h, _ := newFeedsHarness(t, "book-1") + rec := dispatchABSWithParams(http.MethodPost, "/api/feeds/item/book-1/open", + map[string]string{"itemId": "book-1"}, []byte(`{"slug":"my-cool-feed"}`), "1", "", h.handleOpenItemFeed) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; body=%s", rec.Code, rec.Body.String()) + } + var got map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &got) + if got["slug"] != "my-cool-feed" { + t.Errorf("slug = %v, want my-cool-feed", got["slug"]) + } +} + +func TestFeed_Open_InvalidSlug_400(t *testing.T) { + h, _ := newFeedsHarness(t, "book-1") + rec := dispatchABSWithParams(http.MethodPost, "/api/feeds/item/book-1/open", + map[string]string{"itemId": "book-1"}, []byte(`{"slug":"BAD!"}`), "1", "", h.handleOpenItemFeed) + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", rec.Code) + } +} + +func TestFeed_Open_Collision_409(t *testing.T) { + h, _ := newFeedsHarness(t, "book-1") + body := []byte(`{"slug":"taken-slug"}`) + _ = dispatchABSWithParams(http.MethodPost, "/api/feeds/item/book-1/open", map[string]string{"itemId": "book-1"}, body, "1", "", h.handleOpenItemFeed) + rec := dispatchABSWithParams(http.MethodPost, "/api/feeds/item/book-1/open", map[string]string{"itemId": "book-1"}, body, "1", "", h.handleOpenItemFeed) + if rec.Code != http.StatusConflict { + t.Errorf("status = %d, want 409", rec.Code) + } +} + +func TestFeed_Open_UnknownItem_404(t *testing.T) { + h, _ := newFeedsHarness(t) + rec := dispatchABSWithParams(http.MethodPost, "/api/feeds/item/ghost/open", + map[string]string{"itemId": "ghost"}, []byte(`{}`), "1", "", h.handleOpenItemFeed) + if rec.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", rec.Code) + } +} + +func TestFeed_List_OwnerOnly(t *testing.T) { + h, _ := newFeedsHarness(t, "book-1") + _ = dispatchABSWithParams(http.MethodPost, "/api/feeds/item/book-1/open", map[string]string{"itemId": "book-1"}, []byte(`{}`), "1", "", h.handleOpenItemFeed) + rec := dispatchABSWithParams(http.MethodGet, "/api/feeds", nil, nil, "2", "", h.handleListRSSFeeds) + var env map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &env) + feeds, _ := env["feeds"].([]any) + if len(feeds) != 0 { + t.Errorf("user 2 sees %d feeds, want 0", len(feeds)) + } +} + +func TestFeed_Close_Owner(t *testing.T) { + h, _ := newFeedsHarness(t, "book-1") + openRec := dispatchABSWithParams(http.MethodPost, "/api/feeds/item/book-1/open", map[string]string{"itemId": "book-1"}, []byte(`{}`), "1", "", h.handleOpenItemFeed) + var open map[string]any + _ = json.Unmarshal(openRec.Body.Bytes(), &open) + id, _ := open["id"].(string) + + rec := dispatchABSWithParams(http.MethodPost, "/api/feeds/"+id+"/close", map[string]string{"id": id}, nil, "1", "", h.handleCloseFeed) + if rec.Code != http.StatusNoContent { + t.Errorf("status = %d, want 204", rec.Code) + } +} + +func TestFeed_Close_NonOwner_404(t *testing.T) { + h, _ := newFeedsHarness(t, "book-1") + openRec := dispatchABSWithParams(http.MethodPost, "/api/feeds/item/book-1/open", map[string]string{"itemId": "book-1"}, []byte(`{}`), "1", "", h.handleOpenItemFeed) + var open map[string]any + _ = json.Unmarshal(openRec.Body.Bytes(), &open) + id, _ := open["id"].(string) + + rec := dispatchABSWithParams(http.MethodPost, "/api/feeds/"+id+"/close", map[string]string{"id": id}, nil, "2", "", h.handleCloseFeed) + if rec.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", rec.Code) + } +} diff --git a/internal/audiobooks/abs_rss_feed_store.go b/internal/audiobooks/abs_rss_feed_store.go new file mode 100644 index 00000000..bc27119e --- /dev/null +++ b/internal/audiobooks/abs_rss_feed_store.go @@ -0,0 +1,108 @@ +package audiobooks + +import ( + "context" + "errors" + "fmt" + "strconv" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/audiobooks/abs" +) + +type ABSRSSFeedStore struct { + Pool *pgxpool.Pool +} + +var _ abs.RSSFeedStore = (*ABSRSSFeedStore)(nil) + +func (s *ABSRSSFeedStore) ListUserFeeds(ctx context.Context, userID, profileID string) ([]abs.RSSFeed, error) { + uid, err := strconv.Atoi(userID) + if err != nil { + return nil, fmt.Errorf("abs_rss_feed_store: invalid user id %q: %w", userID, err) + } + rows, err := s.Pool.Query(ctx, ` + SELECT id, user_id, profile_id, library_item_id, slug, minified, created_at, closed_at + FROM abs_rss_feeds + WHERE user_id = $1 + AND COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid) + = COALESCE($2::uuid, '00000000-0000-0000-0000-000000000000'::uuid) + AND closed_at IS NULL + ORDER BY created_at DESC`, + uid, profileArg(profileID), + ) + if err != nil { + return nil, fmt.Errorf("abs_rss_feed_store: list: %w", err) + } + defer rows.Close() + out := make([]abs.RSSFeed, 0) + for rows.Next() { + var f abs.RSSFeed + var uidScan int + var profileScan *string + if err := rows.Scan(&f.ID, &uidScan, &profileScan, &f.LibraryItemID, &f.Slug, &f.Minified, &f.CreatedAt, &f.ClosedAt); err != nil { + return nil, fmt.Errorf("abs_rss_feed_store: list scan: %w", err) + } + f.UserID = strconv.Itoa(uidScan) + if profileScan != nil { + f.ProfileID = *profileScan + } + out = append(out, f) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("abs_rss_feed_store: list rows: %w", err) + } + return out, nil +} + +func (s *ABSRSSFeedStore) GetFeed(ctx context.Context, id string) (abs.RSSFeed, error) { + return s.getFeedRow(ctx, "id = $1", id) +} + +func (s *ABSRSSFeedStore) GetFeedBySlug(ctx context.Context, slug string) (abs.RSSFeed, error) { + return s.getFeedRow(ctx, "slug = $1 AND closed_at IS NULL", slug) +} + +func (s *ABSRSSFeedStore) getFeedRow(ctx context.Context, where string, arg string) (abs.RSSFeed, error) { + var f abs.RSSFeed + var uidScan int + var profileScan *string + row := s.Pool.QueryRow(ctx, ` + SELECT id, user_id, profile_id, library_item_id, slug, minified, created_at, closed_at + FROM abs_rss_feeds WHERE `+where, arg) + if err := row.Scan(&f.ID, &uidScan, &profileScan, &f.LibraryItemID, &f.Slug, &f.Minified, &f.CreatedAt, &f.ClosedAt); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return abs.RSSFeed{}, abs.ErrNotFound + } + return abs.RSSFeed{}, fmt.Errorf("abs_rss_feed_store: get: %w", err) + } + f.UserID = strconv.Itoa(uidScan) + if profileScan != nil { + f.ProfileID = *profileScan + } + return f, nil +} + +func (s *ABSRSSFeedStore) CreateFeed(ctx context.Context, f abs.RSSFeed) error { + uid, err := strconv.Atoi(f.UserID) + if err != nil { + return fmt.Errorf("abs_rss_feed_store: invalid user id %q: %w", f.UserID, err) + } + if _, err := s.Pool.Exec(ctx, ` + INSERT INTO abs_rss_feeds (id, user_id, profile_id, library_item_id, slug, minified) + VALUES ($1, $2, $3::uuid, $4, $5, $6)`, + f.ID, uid, profileArg(f.ProfileID), f.LibraryItemID, f.Slug, f.Minified, + ); err != nil { + return fmt.Errorf("abs_rss_feed_store: create: %w", err) + } + return nil +} + +func (s *ABSRSSFeedStore) CloseFeed(ctx context.Context, id string) error { + if _, err := s.Pool.Exec(ctx, `UPDATE abs_rss_feeds SET closed_at = now() WHERE id = $1 AND closed_at IS NULL`, id); err != nil { + return fmt.Errorf("abs_rss_feed_store: close: %w", err) + } + return nil +}