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

+101 -21
View File
@@ -32,11 +32,11 @@ func (s *SeriesIntegrationTestSuite) TearDownSuite() {
}
func (s *SeriesIntegrationTestSuite) TestGetSeries_RequiresLibraryID() {
// Ground truth: library_id is OPTIONAL on /api/series by design —
// GetDistinctSeries matches (narg IS NULL OR library_id = narg), so a
// missing library_id aggregates series across all libraries. The
// former "library_id required → 400" expectation asserted validation
// the shipped query never had.
// Ground truth (2026-10-13 visibility hardening): library_id stays
// optional, but the unscoped aggregate now covers only the requester's
// VISIBLE libraries — hidden libraries contribute nothing. Series are
// grouped per library (same name in two libraries = two entries), so
// every row carries a library_id.
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series", nil)
req.Header.Set("Authorization", "Bearer "+s.token)
@@ -54,6 +54,13 @@ func (s *SeriesIntegrationTestSuite) TestGetSeries_RequiresLibraryID() {
require.NoError(s.T(), err)
assert.Contains(s.T(), body, "series", "response carries the series array")
assert.Contains(s.T(), body, "total")
if rows, ok := body["series"].([]interface{}); ok {
for _, r := range rows {
row, ok := r.(map[string]interface{})
require.True(s.T(), ok)
assert.NotEmpty(s.T(), row["library_id"], "series rows carry their library scope")
}
}
}
func (s *SeriesIntegrationTestSuite) TestGetSeries_InvalidLibraryID() {
@@ -165,14 +172,13 @@ func (s *SeriesIntegrationTestSuite) TestGetSeries_LimitClampedTo100() {
assert.Equal(s.T(), float64(100), limit, "limit should be clamped to 100")
}
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_NameOnlyContract() {
// Ground truth: /api/series/books requires only `name` — the app
// client sends name-only (BookhoardApi.kt) and the web SSR page too
// (frontend.go). The query is intentionally library-agnostic (it
// matches the series name across libraries; library_id, if sent, is
// ignored — nothing validates it). The former "library_id required /
// bad uuid → 400" expectations asserted validation that never
// shipped.
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_LibraryIDRequired() {
// Ground truth (2026-10-13 visibility hardening): /api/series/books is
// per-library now — library_id is REQUIRED, validated, and must be
// visible to the requester. The former name-only contract let any
// authenticated account pull series books across ALL libraries
// (including hidden ones), and merged same-name series across
// libraries (manga adaptations / LN splits).
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series/books?name=Test+Series", nil)
req.Header.Set("Authorization", "Bearer "+s.token)
@@ -183,14 +189,12 @@ func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_NameOnlyContract() {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
var body map[string]interface{}
var body map[string]string
err = json.NewDecoder(resp.Body).Decode(&body)
require.NoError(s.T(), err)
assert.Equal(s.T(), "Test Series", body["name"])
assert.Contains(s.T(), body, "books", "response carries the books array")
assert.Contains(s.T(), body, "total")
assert.Equal(s.T(), "library_id required", body["error"])
}
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_RequiresName() {
@@ -214,8 +218,7 @@ func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_RequiresName() {
}
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_InvalidLibraryID() {
// Same name-only contract: an unparseable library_id is ignored, not
// validated (nothing in the shipped pipeline ever reads it here).
// library_id is now validated: an unparseable uuid is a 400.
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series/books?library_id=bad-uuid&name=Test", nil)
req.Header.Set("Authorization", "Bearer "+s.token)
@@ -226,7 +229,7 @@ func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_InvalidLibraryID() {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
}
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_NonexistentSeries() {
@@ -313,6 +316,83 @@ func (s *SeriesIntegrationTestSuite) TestGetSeries_ResponseStructure() {
assert.True(s.T(), ok, "'series' should be an array")
}
func (s *SeriesIntegrationTestSuite) setLibraryVisibility(token, libraryID string, visible bool) *http.Response {
payload, _ := json.Marshal(map[string]interface{}{
"library_id": libraryID,
"is_visible": visible,
})
req, _ := http.NewRequest("POST", s.setup.Server.URL+"/api/libraries/visibility", bytes.NewBuffer(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := (&http.Client{}).Do(req)
require.NoError(s.T(), err)
return resp
}
// TestGetSeries_HiddenLibraryScopedOut pins the family-lens contract: a
// library hidden from a user contributes NOTHING to that user's series
// views, and naming it explicitly is a 403, not a silent pass — across
// /api/series, /api/series/books, and /api/media-items.
func (s *SeriesIntegrationTestSuite) TestGetSeries_HiddenLibraryScopedOut() {
regular := s.setup.RegularToken
// Baseline: the regular user can see the suite library.
resp := s.setLibraryVisibility(regular, s.libraryID, true)
defer resp.Body.Close()
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
// Hide it from the regular user.
resp = s.setLibraryVisibility(regular, s.libraryID, false)
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
defer func() {
r := s.setLibraryVisibility(regular, s.libraryID, true)
_ = r.Body.Close()
}()
client := &http.Client{}
// Explicit hidden library → 403.
for _, path := range []string{
"/api/series?library_id=" + s.libraryID,
"/api/series/books?library_id=" + s.libraryID + "&name=Test",
"/api/media-items?library_id=" + s.libraryID,
} {
req, _ := http.NewRequest("GET", s.setup.Server.URL+path, nil)
req.Header.Set("Authorization", "Bearer "+regular)
r, err := client.Do(req)
require.NoError(s.T(), err)
body, _ := io.ReadAll(r.Body)
r.Body.Close()
require.Equalf(s.T(), http.StatusForbidden, r.StatusCode, "%s must be 403 for a hidden library (body: %s)", path, body)
}
// Unscoped aggregate: the hidden library's series never appear.
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series", nil)
req.Header.Set("Authorization", "Bearer "+regular)
r, err := client.Do(req)
require.NoError(s.T(), err)
defer r.Body.Close()
require.Equal(s.T(), http.StatusOK, r.StatusCode)
var body map[string]interface{}
require.NoError(s.T(), json.NewDecoder(r.Body).Decode(&body))
if rows, ok := body["series"].([]interface{}); ok {
for _, row := range rows {
m, ok := row.(map[string]interface{})
require.True(s.T(), ok)
assert.NotEqual(s.T(), s.libraryID, m["library_id"], "hidden library must not appear in the aggregate")
}
}
// The admin still sees the library (default-allow; visibility rows are
// per-user).
req, _ = http.NewRequest("GET", s.setup.Server.URL+"/api/series?library_id="+s.libraryID, nil)
req.Header.Set("Authorization", "Bearer "+s.token)
r, err = client.Do(req)
require.NoError(s.T(), err)
defer r.Body.Close()
assert.Equal(s.T(), http.StatusOK, r.StatusCode)
}
func (s *SeriesIntegrationTestSuite) TestRestoreSystemCollection_ContinueSeries() {
reqBody := map[string]interface{}{
"collection_name": "Continue Series",