feat(security): library visibility enforced at the source; per-library series; annotation ownership; admin user creation

The family-lens hardening: library_visibility was only enforced where
file bytes flowed (download/reader/OPDS) — metadata surfaces leaked
hidden libraries to any authenticated account, and registration was
open by default with no admin-side user creation.

- Series are grouped PER LIBRARY now (same name in two libraries =
  two entries: manga adaptations / LN splits no longer merge);
  /api/series aggregates only the requester's VISIBLE libraries and
  /api/series/books REQUIRES a visible library_id (breaking contract;
  web + app clients updated in the same change).
- /api/media-items, item detail, and the reader content endpoints
  (chapters/panels/outline/THUMBNAILS — page images leaked unguarded
  next to a gated GetPage) all enforce the visible set; resolveLibrary
  no longer honors a hand-typed library_id outside the visible set.
- Annotation/note/bookmark mutations (update/delete/tombstone, sync
  LWW paths incl. conflict resolution) carry ownership predicates:
  another account's bare row id is a 404, not a silent delete.
- Admin user creation: POST /api/auth/users (AdminMiddleware) + a
  Create User card in the admin Users UI — accounts stay creatable
  with self-service registration OFF, which is now the schema default
  (fresh installs; the first-admin bootstrap exception is unchanged).
- Tests: series contract re-pinned (per-library, visibility 403s),
  new ownership + admin-create integration suites, OPDS all=1 test
  now passes an explicit page size (shared-DB growth made the fresh
  books fall off page 1), device-cap helper switched to the admin
  endpoint.
This commit is contained in:
John O'Keefe committed 2026-10-13 12:03:01 -04:00
1 parent 97fbdf19fb
commit 431fec05ad
60 files changed
+1573 -691

No files matched your search

+49
View File
@@ -3,6 +3,7 @@ package router
import (
"context"
"log"
"net/http"
"net/url"
"os"
"time"
@@ -118,6 +119,15 @@ func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolu
log.Printf("GetUserVisibleLibraries failed: %v", err)
libraries = []database.GetUserVisibleLibrariesRow{}
}
visibleSet := make(map[string]bool, len(libraries))
for _, lib := range libraries {
if !lib.ID.Valid {
continue
}
if id, err := uuid.FromBytes(lib.ID.Bytes[0:16]); err == nil {
visibleSet[id.String()] = true
}
}
counts, countErr := cfg.Queries.GetVisibleLibraryMediaCounts(c.Request().Context(), uuidToPGType(userU))
if countErr != nil {
@@ -189,8 +199,47 @@ func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolu
return res
}
// A requested library outside the requester's visible set is not
// honored — fall back to the first visible library instead of leaking
// hidden-library contents through a hand-typed query param or cookie.
if !visibleSet[libraryID] {
res.LibraryID = res.FirstID
if res.LibraryID != "" {
parsed, _ := uuid.Parse(res.LibraryID)
res.LibUUID = pgtype.UUID{Bytes: parsed, Valid: true}
}
return res
}
res.LibraryID = libraryID
parsed, _ := uuid.Parse(libraryID)
res.LibUUID = pgtype.UUID{Bytes: parsed, Valid: true}
return res
}
// visibleLibraryIDsFor returns the requester's visible-library id set (for
// membership checks) plus the ids themselves (for SQL ANY() filters).
func visibleLibraryIDsFor(c *echo.Context, cfg *Config) (map[string]bool, []pgtype.UUID, error) {
userUUIDStr, ok := c.Get("user_id").(string)
if !ok {
return nil, nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
}
userU, err := uuid.Parse(userUUIDStr)
if err != nil {
return nil, nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
}
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userU))
if err != nil {
return nil, nil, err
}
set := make(map[string]bool, len(libraries))
ids := make([]pgtype.UUID, 0, len(libraries))
for _, lib := range libraries {
if !lib.ID.Valid {
continue
}
set[lib.ID.String()] = true
ids = append(ids, lib.ID)
}
return set, ids, nil
}