feat(audiobooks): RSS feeds — auth handlers + pgx store

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) <noreply@anthropic.com>
This commit is contained in:
RXWatcher
2026-05-26 22:00:54 +02:00
co-authored by Claude Opus 4.7
parent ebe8ad3bd7
commit fa5b0c59c9
5 changed files with 480 additions and 0 deletions
+3
View File
@@ -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
+42
View File
@@ -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,
}
}
@@ -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)
}
@@ -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)
}
}
+108
View File
@@ -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
}