Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ab4f9fa90 | ||
|
|
304ea9599f | ||
|
|
cf73eec2e4 | ||
|
|
ab4bddc784 | ||
|
|
de26544d9d | ||
|
|
efbe312f16 | ||
|
|
d49a9e6ca2 | ||
|
|
313268a7ae | ||
|
|
1457dc7289 | ||
|
|
f579696633 | ||
|
|
4d697f5fcf | ||
|
|
bb2cc26e9d | ||
|
|
385a5d9dba | ||
|
|
922b92a8b6 | ||
|
|
8c3d919aa0 | ||
|
|
004d4197a4 | ||
|
|
f4b581872f | ||
|
|
a19d7a4cbf | ||
|
|
cb258b6d20 | ||
|
|
3dcb794032 | ||
|
|
2b122f2481 |
No files matched your search
+9
-1
@@ -6,10 +6,18 @@
|
||||
# Generate with: openssl rand -hex 32
|
||||
JWT_SECRET=your-secure-jwt-secret-key-here
|
||||
|
||||
# PostgreSQL database password
|
||||
# PostgreSQL database password (the bootstrap superuser account; kept
|
||||
# for container initialization — the app itself connects as bookhoard_srv)
|
||||
# Generate with: openssl rand -hex 16
|
||||
DBPASS=your-secure-database-password-here
|
||||
|
||||
# Runtime database role password (bookhoard_srv — the non-superuser
|
||||
# account the app connects as; subject to RLS). Required for the app to
|
||||
# start. Consumed by the one-shot db-init service on every startup
|
||||
# (idempotent — creates/refreshes the role and its privileges).
|
||||
# Generate with: openssl rand -hex 16
|
||||
DBSRVPASS=your-secure-runtime-role-password-here
|
||||
|
||||
# Networking: change a port if it conflicts on your host
|
||||
# Postgres port, host + container (e.g. another local DB already uses 5432)
|
||||
# DB_PORT=15432
|
||||
|
||||
@@ -106,30 +106,50 @@ inside quoted attribute values (gotcha #1 below), so dynamic
|
||||
## Library visibility (family-lens rule) — ALWAYS apply
|
||||
|
||||
Per-user library visibility (`library_visibility` table, default-ALLOW:
|
||||
every user sees every library unless an admin hides one) must be
|
||||
enforced on **every endpoint that returns media metadata or content**.
|
||||
It is NOT automatic — enforcement is per-handler.
|
||||
every user sees every library unless an admin hides one) is enforced by
|
||||
**Postgres RLS** — one source of truth: the labeled policy block in
|
||||
`database/schema/schema.sql` + the runtime role provisioned by the
|
||||
`db-init` compose service.
|
||||
|
||||
- **New endpoints that list/filter media or series** MUST filter by the
|
||||
requesting user's visible-library set, and MUST reject an explicit
|
||||
`library_id` outside that set with 403 (never silently pass).
|
||||
- Helpers (do not hand-roll):
|
||||
- `handlers`: `(*MediaHandler).visibleLibrarySet` /
|
||||
`(*SeriesHandler).visibleLibrarySet` — membership map + id slice.
|
||||
- `router`: `visibleLibraryIDsFor` — same for SSR routes.
|
||||
- `resolveLibrary` (helpers.go) only honors requested library ids
|
||||
inside the visible set.
|
||||
- SQL pattern: `AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR
|
||||
library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))` — see
|
||||
`ListMediaItems`, `GetDistinctSeries`, `SearchMediaItems`.
|
||||
- Content endpoints (file bytes, page images) additionally gate per item
|
||||
like `ReaderHandler.ensureItemVisible` / the download endpoint.
|
||||
- Tests must pin visibility: hidden library → 403 on the endpoint, and
|
||||
absent from unscoped aggregates.
|
||||
- **The engine is the enforcement.** Every `media_items` read is
|
||||
filtered by the policy, which joins `library_visibility` on the
|
||||
per-transaction GUC `app.user_id`. New media-listing queries need
|
||||
NO visibility clause; new endpoints need NO visibility plumbing.
|
||||
- **The app declares identity; it never computes visibility.** All
|
||||
sqlc statements run through `ScopedDB`
|
||||
(`internal/database/scoped.go`), which declares `app.user_id` from
|
||||
the stdlib request context. Three bridge sites put it there:
|
||||
`router.go` JWT, `router.go` cookie/device, and
|
||||
`middleware/device_auth.go` (KOReader/Kobo sync devices). A media
|
||||
read on a detached `context.Background()` RAISES — thread the
|
||||
request context or the acting identity (see conflicts.go and
|
||||
analytics.go for the pattern).
|
||||
- **Background jobs declare the acting identity** before detached
|
||||
work: scan jobs carry the library owner / first-admin fallback
|
||||
(the job Context carries it — `ScanFolders` self-declares the
|
||||
configured admin), the hash backfill runs as first admin. The sync
|
||||
queue processor needs nothing (`sync_queue` is not RLS-governed).
|
||||
- **NEVER**: session-scoped `app.user_id` (pooled-connection identity
|
||||
bleed); a bypass flag; connecting the app as the bootstrap
|
||||
superuser (superusers bypass RLS unconditionally — `DATABASE_USER`
|
||||
stays `bookhoard_srv`; the `db-init` compose service provisions the
|
||||
role on every startup).
|
||||
- **schema.sql re-runs on EVERY boot** (executeSchema) — everything
|
||||
added to it must be idempotent (`DROP .. IF EXISTS` + `CREATE`).
|
||||
The migration tx declares a synthetic identity
|
||||
(`schemaMigrationIdentity`) because the schema's own dedup block
|
||||
reads `media_items`.
|
||||
- **Auditors**: `TestRlsInvariant` (API black-box — add new
|
||||
media-returning surfaces to it; loosening requires a
|
||||
docs/contract_pins.md entry) and the Playwright sweep (`tests/ui`,
|
||||
new pages/controls go into `tests/ui/COVERAGE.md`). Item-scoped
|
||||
gates may answer 404 as well as 403 (row invisible vs Go-level
|
||||
check — contract_pins 2026-10-29).
|
||||
|
||||
History: 2026-10-13 (`431fec0`) the series/media listing surfaces
|
||||
leaked hidden libraries to any authenticated account — series endpoints
|
||||
were name-only across ALL libraries. Fixed; do not regress.
|
||||
leaked hidden libraries; **2026-10-29 enforcement moved to RLS** — the
|
||||
dashboard section queries had silently never carried the clauses,
|
||||
and the invariant test went red → green and stays as the auditor.
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
@@ -21,160 +21,165 @@ A modern self-hosted media library system built with Go, PostgreSQL, HTMX, and T
|
||||
- **Podman** (recommended) or Docker
|
||||
- **5 minutes** of your time
|
||||
|
||||
### Installation
|
||||
### Installation (no source checkout needed)
|
||||
|
||||
Create a directory on the Docker host. You only need the compose file,
|
||||
an `.env`, and an `uploads` folder — everything else rides the published
|
||||
image.
|
||||
|
||||
```bash
|
||||
# 1. Clone the repository
|
||||
git clone https://git.linuxhg.com/Bookhoard/bookhoard.git
|
||||
cd bookhoard
|
||||
|
||||
# 2. Set up environment
|
||||
cp .env.example .env
|
||||
# Generate secure passwords (no special characters):
|
||||
# JWT_SECRET: openssl rand -hex 32
|
||||
# DBPASS: openssl rand -hex 16
|
||||
# Edit .env with your generated values
|
||||
|
||||
# 3. Pull images and start the server
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
# Optionally pin a specific version: set IMAGE_TAG in .env (defaults to "latest")
|
||||
|
||||
# 4. Open your browser
|
||||
open http://localhost:8765
|
||||
mkdir bookhoard && cd bookhoard
|
||||
mkdir uploads
|
||||
```
|
||||
|
||||
The first user to register automatically becomes an admin.
|
||||
**`docker-compose.yml`** — copy this wholesale:
|
||||
|
||||
---
|
||||
```yaml
|
||||
services:
|
||||
# PostgreSQL Database
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
container_name: bookhoard_db
|
||||
environment:
|
||||
POSTGRES_DB: bookhoard
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: ${DBPASS}
|
||||
# PGPORT makes Postgres listen on DB_PORT (kept in sync with the host mapping + app's DATABASE_PORT)
|
||||
PGPORT: ${DB_PORT:-5432}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "127.0.0.1:${DB_PORT:-5432}:${DB_PORT:-5432}"
|
||||
- "[::1]:${DB_PORT:-5432}:${DB_PORT:-5432}"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
## 📖 Key Features
|
||||
# One-shot bootstrap: creates the bookhoard_srv runtime role and
|
||||
# transfers object ownership to it (RLS prerequisite). Idempotent —
|
||||
# safe on every startup; the app waits for its success. Passwords
|
||||
# accept inline values or .env, like the rest of this file.
|
||||
# NOTE: plpgsql $$ tags are written $$$$ — compose eats one dollar
|
||||
# per pair before the container sees the SQL.
|
||||
db-init:
|
||||
image: postgres:15-alpine
|
||||
container_name: bookhoard_db_init
|
||||
environment:
|
||||
PGHOST: db
|
||||
PGPORT: ${DB_PORT:-5432}
|
||||
PGUSER: postgres
|
||||
PGDATABASE: bookhoard
|
||||
PGPASSWORD: ${DBPASS}
|
||||
DBSRVPASS: ${DBSRVPASS}
|
||||
entrypoint: ["bash", "-c"]
|
||||
command:
|
||||
- |
|
||||
set -e
|
||||
: "${DBSRVPASS:?DBSRVPASS required in .env}"
|
||||
psql -v ON_ERROR_STOP=1 -v role_pass="$DBSRVPASS" <<'EOSQL'
|
||||
DO $$$$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'bookhoard_srv') THEN
|
||||
CREATE ROLE bookhoard_srv LOGIN;
|
||||
END IF;
|
||||
END $$$$;
|
||||
ALTER ROLE bookhoard_srv LOGIN PASSWORD :'role_pass';
|
||||
DO $$$$
|
||||
DECLARE r RECORD;
|
||||
BEGIN
|
||||
FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = 'public' LOOP
|
||||
EXECUTE format('ALTER TABLE public.%I OWNER TO bookhoard_srv', r.tablename);
|
||||
END LOOP;
|
||||
FOR r IN SELECT sequencename FROM pg_sequences WHERE schemaname = 'public' LOOP
|
||||
EXECUTE format('ALTER SEQUENCE public.%I OWNER TO bookhoard_srv', r.sequencename);
|
||||
END LOOP;
|
||||
FOR r IN SELECT p.oid::regprocedure AS fn FROM pg_proc p
|
||||
JOIN pg_namespace n ON p.pronamespace = n.oid
|
||||
WHERE n.nspname = 'public' AND p.prokind = 'f' LOOP
|
||||
EXECUTE format('ALTER FUNCTION %s OWNER TO bookhoard_srv', r.fn);
|
||||
END LOOP;
|
||||
END $$$$;
|
||||
ALTER SCHEMA public OWNER TO bookhoard_srv;
|
||||
ALTER DATABASE bookhoard OWNER TO bookhoard_srv;
|
||||
EOSQL
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: "no"
|
||||
|
||||
### Universal Cross-Platform Sync
|
||||
# Bookhoard Application
|
||||
# In production this image is pulled from the Gitea container registry.
|
||||
# Override IMAGE_TAG in .env to pin or rollback a specific version (defaults to "latest").
|
||||
app:
|
||||
image: git.linuxhg.com/bookhoard/bookhoard:${IMAGE_TAG:-latest}
|
||||
container_name: bookhoard
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Database Configuration
|
||||
# Runtime role = bookhoard_srv (non-superuser OWNER, subject to RLS
|
||||
# via FORCE). POSTGRES_USER above is the bootstrap account ONLY —
|
||||
# the app must NEVER connect as it (superusers bypass RLS).
|
||||
DATABASE_HOST: db
|
||||
DATABASE_PORT: ${DB_PORT:-5432}
|
||||
DATABASE_USER: bookhoard_srv
|
||||
DATABASE_PASSWORD: ${DBSRVPASS}
|
||||
DATABASE_NAME: bookhoard
|
||||
|
||||
- **Real-Time Progress**: Turn a page on your e-reader, see it in your browser
|
||||
- **Format-Aware**: EPUB CFI, page numbers, percentages - all handled correctly
|
||||
- **Offline Queue**: Changes sync when you reconnect, priority-processed
|
||||
- **Conflict Resolution**: Smart handling when same book read on multiple devices
|
||||
- **Book Matching**: Automatic matching using SHA-256, ISBN, UUID
|
||||
- **OPDS Catalog**: Wireless book delivery to e-readers over Wi-Fi
|
||||
- **Format Conversion**: On-the-fly EPUB→KEPUB conversion (for upcoming native Kobo support)
|
||||
# Application Configuration
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
SERVER_PORT: ${SERVER_PORT:-8765}
|
||||
# IMPORTANT: Device sync requires full URL with protocol
|
||||
# Local: http://localhost:8765
|
||||
# Local network: http://192.168.1.X:8765
|
||||
# Domain: https://bookhoard.example.com
|
||||
BASE_URL: ${BASE_URL:-http://localhost:8765}
|
||||
# Mark session cookies Secure; set true behind a TLS-terminating reverse proxy (Caddy/nginx/traefik)
|
||||
COOKIE_SECURE: ${COOKIE_SECURE:-false}
|
||||
|
||||
### Media Management
|
||||
# Rate Limiting Configuration
|
||||
TEST_MODE: ${TEST_MODE:-false}
|
||||
RATE_LIMIT_ENABLED: ${RATE_LIMIT_ENABLED:-true}
|
||||
REQUESTS_PER_MINUTE: ${REQUESTS_PER_MINUTE:-10}
|
||||
|
||||
- **Calibre Integration**: Automatic metadata import from Calibre `metadata.opf` sidecar files
|
||||
- **Smart Search**: Partial matching with fuzzy search fallback for typos
|
||||
- **Advanced Filtering**: Filter by author, series, genre, language, year, cover images
|
||||
- **Dynamic Sorting**: By title, author, date added, published date, page count, series
|
||||
- **Rich Metadata**: Title, author, series, publisher, ISBN, language, edition, tags
|
||||
- **5-Star Ratings**: Half-star precision (1-10 scale)
|
||||
- **Notes & Highlights**: Color-coded annotations with linked notes
|
||||
- **Usage Analytics**: Reading statistics, device usage, popular books
|
||||
# Conversion Service Configuration
|
||||
BOOKHOARD_CONVERSION_CACHE_DIR: ${BOOKHOARD_CONVERSION_CACHE_DIR:-/app/cache/kepub}
|
||||
BOOKHOARD_CONVERSION_TOOL: ${BOOKHOARD_CONVERSION_TOOL:-/usr/bin/kepubify}
|
||||
BOOKHOARD_CONVERSION_CACHE_TTL: ${BOOKHOARD_CONVERSION_CACHE_TTL:-24h}
|
||||
|
||||
### Smart Collections
|
||||
# Library Maintenance
|
||||
# Days an archived item (file missing from disk for 2+ scans) is kept,
|
||||
# with its reading history, before library scans purge it for good.
|
||||
# Set 0 to keep archived items until purged manually on the library admin page.
|
||||
ARCHIVE_RETENTION_DAYS: ${ARCHIVE_RETENTION_DAYS:-90}
|
||||
|
||||
- **Auto-Assign Rules**: Automatically add books based on genre, author, series, tags, language, publisher, year
|
||||
- **Device Shelf Mappings**: Map collections to device shelves (used by native Kobo sync, coming soon)
|
||||
- **Test Before Creating**: Preview which books match your rules
|
||||
# System timezone (fallback for server-side time operations)
|
||||
TZ: ${TZ:-UTC}
|
||||
ports:
|
||||
- "${SERVER_PORT:-8765}:${SERVER_PORT:-8765}"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
db-init:
|
||||
condition: service_completed_successfully
|
||||
volumes:
|
||||
- ./uploads:/app/uploads
|
||||
- bookhoard_conversion_cache:/app/cache/kepub
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:${SERVER_PORT:-8765}/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
### Library Organization
|
||||
|
||||
- **Multi-Library Support**: Ebooks, Comics, and Manga with type-specific file formats
|
||||
- **Multiple Folders**: Add multiple scanning folders per library
|
||||
- **Visibility Control**: Admins control which libraries each user can see
|
||||
- **Background Scanning**: Auto-scan with per-user frequency settings
|
||||
- **Watch Mode**: Real-time file system monitoring for instant updates
|
||||
|
||||
### Security
|
||||
|
||||
- **JWT Authentication**: Short-lived access tokens (1 hour) with refresh tokens (7 days)
|
||||
- **Strong Passwords**: Complexity requirements enforced (8+ chars, uppercase, lowercase, number, special)
|
||||
- **Account Lockout**: 5 failed attempts = 15-minute lockout
|
||||
- **Rate Limiting**: 10 requests/minute on auth endpoints
|
||||
- **Input Validation**: Comprehensive validation on all inputs
|
||||
- **No Passwords on Devices**: Web-based device approval with QR codes
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
### For Users & Self-Hosters
|
||||
|
||||
- **[docs/user/calibre-integration.md](docs/user/calibre-integration.md)** - Calibre library integration
|
||||
- **[docs/user/sync-guide.md](docs/user/sync-guide.md)** - Understanding and using universal sync
|
||||
- **[docs/user/devices/koreader-setup.md](docs/user/devices/koreader-setup.md)** - KOReader configuration
|
||||
- **[docs/user/devices/kobo-setup.md](docs/user/devices/kobo-setup.md)** - Kobo e-reader configuration (coming soon)
|
||||
- **[docs/user/user-guide.md](docs/user/user-guide.md)** - General user guide
|
||||
- **[docs/user/admin-guide.md](docs/user/admin-guide.md)** - Admin features and configuration
|
||||
- **[docs/user/settings-guide.md](docs/user/settings-guide.md)** - Settings and preferences
|
||||
|
||||
### For Developers
|
||||
|
||||
- **[docs/developer/api/api-reference.md](docs/developer/api/api-reference.md)** - Complete API documentation
|
||||
- **[docs/developer/android-app.md](docs/developer/android-app.md)** - Android app design & roadmap
|
||||
- **[docs/contributing/development.md](docs/contributing/development.md)** - Development workflow
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Supported Devices
|
||||
|
||||
| Platform | Sync | OPDS | Status |
|
||||
| ---------------- | ---- | ---- | ------------------------------------------------------------- |
|
||||
| **Web Browser** | ✅ | ✅ | Full support |
|
||||
| **KOReader** | ✅ | ✅ | Runs on Kindle, Kobo, PocketBook hardware |
|
||||
| **Kobo Devices** | 🚧 | 🚧 | Native Kobo sync coming soon (use KOReader on Kobo today) |
|
||||
| **Mobile Apps** | 🚧 | 🚧 | Native Android app in design ([docs](docs/developer/android-app.md)); iOS later |
|
||||
|
||||
---
|
||||
|
||||
## 💾 Network Storage (NAS / NFS / SMB)
|
||||
|
||||
Libraries on network mounts are supported for everyday use — serving,
|
||||
reading, progress, and annotation sync all work over the mount — with a
|
||||
few behaviors worth knowing:
|
||||
|
||||
- **Outage hysteresis.** When the mount drops, files miss one scan
|
||||
(marked missing, hidden) then a second (archived, still recoverable).
|
||||
When the mount returns — by path or by identical content elsewhere —
|
||||
everything is restored automatically: annotations, progress, and
|
||||
covers included.
|
||||
- **Retention purge.** Archived items older than `ARCHIVE_RETENTION_DAYS`
|
||||
(default **90**, `0` = never auto-purge) are deleted for good by
|
||||
library scans, annotations included. A NAS offline longer than the
|
||||
retention window loses those books' metadata on return.
|
||||
- **Deleting while the storage is down is refused.** Deleting a book
|
||||
cascades its annotations, so deletes are rejected with a clear error
|
||||
while the item's library storage is unreachable. Archive the item
|
||||
instead, or retry when the mount is back.
|
||||
- **Watch mode does not fire on network mounts** (inotify doesn't work
|
||||
over NFS/SMB). The periodic scan is the change detector.
|
||||
- **Latency** — folder reachability checks are cached ~30s; large
|
||||
libraries on slow mounts will still feel slower than local disk.
|
||||
|
||||
---
|
||||
|
||||
## 🛠 Tech Stack
|
||||
|
||||
- **Backend**: Go 1.25+ with Echo framework
|
||||
- **Database**: PostgreSQL 15+ with pgx v5
|
||||
- **Frontend**: HTMX + Tailwind CSS + Templ
|
||||
- **Auth**: JWT tokens with bcrypt password hashing
|
||||
- **Container**: Podman (Docker compatible)
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
make test-all
|
||||
|
||||
# Run integration tests (with test mode)
|
||||
make test-integration
|
||||
|
||||
# Run Bruno OpenCollection YAML API tests
|
||||
npm install -g @usebruno/cli
|
||||
bruno run
|
||||
# Named Volumes
|
||||
volumes:
|
||||
postgres_data:
|
||||
bookhoard_conversion_cache:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
+10
-2
@@ -41,7 +41,12 @@ func main() {
|
||||
}
|
||||
defer dbPool.Close()
|
||||
|
||||
queries := database.New(dbPool)
|
||||
// The search service's raw DBTX path must ALSO carry the RLS
|
||||
// identity — hand it the ScopedDB itself (a DBTX), never the bare
|
||||
// pool, or search queries bypass the per-user GUC and raise under
|
||||
// the policies.
|
||||
scopedDB := database.NewScopedDB(dbPool)
|
||||
queries := database.New(scopedDB)
|
||||
|
||||
// Initialize database schema
|
||||
log.Println("🔧 Ensuring database schema is initialized...")
|
||||
@@ -53,6 +58,9 @@ func main() {
|
||||
|
||||
// Load tunable settings from the DB into the registry. All values fall back
|
||||
// to compiled defaults if a row is missing, so this never blocks startup.
|
||||
// (Startup has no end-user identity; system_settings is not RLS-
|
||||
// governed, and ScopedDB declares nothing when no identity exists —
|
||||
// the engine only raises on governed tables.)
|
||||
registry := database.NewSettingsRegistry(queries)
|
||||
if err := registry.Load(ctx); err != nil {
|
||||
log.Printf("⚠️ Could not load system settings (using defaults): %v", err)
|
||||
@@ -165,7 +173,7 @@ func main() {
|
||||
dashboardHandler := handlers.NewDashboardHandler(queries)
|
||||
seriesHandler := handlers.NewSeriesHandler(queries)
|
||||
filtersHandler := handlers.NewFiltersHandler(queries)
|
||||
mediaHandler := handlers.NewMediaHandler(queries, dbPool, libraryService, progressService, annotationService)
|
||||
mediaHandler := handlers.NewMediaHandler(queries, scopedDB, libraryService, progressService, annotationService)
|
||||
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
||||
jobsHandler := handlers.NewJobsHandler(queries, worker)
|
||||
|
||||
|
||||
@@ -289,7 +289,12 @@ func TestAudiobookTrackEndpoints(t *testing.T) {
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
_ = resp.Body.Close()
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode, "%s must be gated", path)
|
||||
// PIN WIDENED for RLS (docs/contract_pins.md 2026-10-29): the
|
||||
// handler's own Go-level gate still answers 403, but under
|
||||
// engine-level visibility the hidden row is simply ABSENT —
|
||||
// 404 is equally gated (no existence leak). Both accepted.
|
||||
require.True(t, resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusNotFound,
|
||||
"%s must be gated (403 or 404), got %d", path, resp.StatusCode)
|
||||
}
|
||||
|
||||
// Restore visibility so harness cleanup ordering never sees a
|
||||
|
||||
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -367,8 +366,6 @@ func TestPreviewCollection(t *testing.T) {
|
||||
}
|
||||
|
||||
func createPreviewTestMediaItems(t *testing.T, db *database.Queries, libraryID string, count int) []string {
|
||||
ctx := context.Background()
|
||||
|
||||
libUUID, err := uuid.Parse(libraryID)
|
||||
require.NoError(t, err, "Should parse library ID")
|
||||
|
||||
@@ -379,7 +376,7 @@ func createPreviewTestMediaItems(t *testing.T, db *database.Queries, libraryID s
|
||||
for i := 0; i < count; i++ {
|
||||
genre := genres[i%len(genres)]
|
||||
|
||||
media, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
media, err := db.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||
Title: "Preview Test Book " + string(rune('A'+i)),
|
||||
Authors: []string{"Test Author " + string(rune('A'+i))},
|
||||
|
||||
@@ -108,7 +108,7 @@ func smartCreateLibraryAndBooks(t *testing.T, setup *TestServerSetup, userID uui
|
||||
})
|
||||
|
||||
for title, genre := range titlesByGenre {
|
||||
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
media, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||
Title: title,
|
||||
Authors: []string{"Smart Rule Author"},
|
||||
|
||||
@@ -113,7 +113,7 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
params.AlternateInfo = []byte(v)
|
||||
}
|
||||
|
||||
mediaItem, err := setup.DB.CreateMediaItem(ctx, params)
|
||||
mediaItem, err := setup.DB.CreateMediaItem(seedCtx(), params)
|
||||
require.NoError(t, err, "Should create media item")
|
||||
|
||||
mediaUUID, err := uuid.FromBytes(mediaItem.ID.Bytes[0:16])
|
||||
@@ -596,7 +596,7 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
Isbn: pgtype.Text{String: "9781234567890", Valid: true},
|
||||
}
|
||||
|
||||
mediaItem, err := setup.DB.CreateMediaItem(ctx, params)
|
||||
mediaItem, err := setup.DB.CreateMediaItem(seedCtx(), params)
|
||||
require.NoError(t, err, "Should create ebook")
|
||||
|
||||
mediaUUID, err := uuid.FromBytes(mediaItem.ID.Bytes[0:16])
|
||||
@@ -740,7 +740,7 @@ func TestComicMetadataDisplay_AllFieldsTogether(t *testing.T) {
|
||||
// Create a media item with ALL metadata fields populated
|
||||
alternateInfoStr := `{"alternate_series":"Ultimate X-Men","alternate_number":1,"alternate_count":12}`
|
||||
var mediaItem database.MediaItems
|
||||
mediaItem, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
mediaItem, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||
LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
|
||||
Title: "Complete Metadata Comic",
|
||||
FilePath: "/test/complete.cbz",
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestComicMetadataExtraction(t *testing.T) {
|
||||
require.NoError(t, err, "Should parse library UUID")
|
||||
|
||||
// Insert test media item with full comic metadata
|
||||
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
_, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||
LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
|
||||
Title: "Test Manga",
|
||||
FilePath: "/test/manga.cbz",
|
||||
@@ -64,7 +64,7 @@ func TestComicMetadataExtraction(t *testing.T) {
|
||||
parsedLibraryID, err := uuid.Parse(libraryID)
|
||||
require.NoError(t, err, "Should parse library UUID")
|
||||
|
||||
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
_, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||
LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
|
||||
Title: "Test Comic",
|
||||
FilePath: "/test/comic.cbz",
|
||||
@@ -94,7 +94,7 @@ func TestComicMetadataExtraction(t *testing.T) {
|
||||
parsedLibraryID, err := uuid.Parse(libraryID)
|
||||
require.NoError(t, err, "Should parse library UUID")
|
||||
|
||||
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
_, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||
LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
|
||||
Title: "Minimal Comic",
|
||||
FilePath: "/test/minimal.cbz",
|
||||
@@ -136,7 +136,7 @@ func TestReadingDirectionAPI(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
_, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||
LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
|
||||
Title: tc.title,
|
||||
FilePath: "/test/" + tc.title + ".cbz",
|
||||
@@ -205,7 +205,7 @@ func TestUniversalMetadataFields(t *testing.T) {
|
||||
require.NoError(t, err, "Should parse ebook library UUID")
|
||||
|
||||
t.Run("Comic with universal fields", func(t *testing.T) {
|
||||
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
_, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||
LibraryID: pgtype.UUID{Bytes: parsedComicLibraryID, Valid: true},
|
||||
Title: "Comic with Universal Metadata",
|
||||
FilePath: "/test/comic.cbz",
|
||||
@@ -231,7 +231,7 @@ func TestUniversalMetadataFields(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("Ebook with universal fields", func(t *testing.T) {
|
||||
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
_, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||
LibraryID: pgtype.UUID{Bytes: parsedEbookLibraryID, Valid: true},
|
||||
Title: "Ebook with Universal Metadata",
|
||||
FilePath: "/test/book.epub",
|
||||
@@ -270,7 +270,7 @@ func TestComicSpecificFields(t *testing.T) {
|
||||
t.Run("Alternate series info as JSONB", func(t *testing.T) {
|
||||
alternateInfo := `{"alternate_series":"Ultimate X-Men","alternate_number":1,"alternate_count":12}`
|
||||
|
||||
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
_, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||
LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
|
||||
Title: "X-Men with Alternate Series",
|
||||
FilePath: "/test/xmen.cbz",
|
||||
|
||||
@@ -120,7 +120,7 @@ func createContractFixture(t *testing.T, setup *TestServerSetup) contractFixture
|
||||
|
||||
seriesName := fmt.Sprintf("Test Contract Series %d", time.Now().UnixNano())
|
||||
itemTitle := fmt.Sprintf("Test Contract Item %d", time.Now().UnixNano())
|
||||
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
media, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||
Title: itemTitle,
|
||||
Authors: []string{"Test Contract Author"},
|
||||
|
||||
@@ -89,7 +89,7 @@ func TestFacetRegistryParamsFilter(t *testing.T) {
|
||||
if mutate != nil {
|
||||
mutate(&p)
|
||||
}
|
||||
_, err := setup.DB.CreateMediaItem(ctx, p)
|
||||
_, err := setup.DB.CreateMediaItem(seedCtx(), p)
|
||||
require.NoError(t, err, "create %s", title)
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@ func TestFacetsEndpoint(t *testing.T) {
|
||||
|
||||
// Two items share the genre; one carries the language + narrator.
|
||||
for _, title := range []string{"Facet List One", "Facet List Two"} {
|
||||
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
_, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||
LibraryID: library.ID,
|
||||
Title: title,
|
||||
Authors: []string{"Facet Listing Author"},
|
||||
@@ -206,7 +206,7 @@ func TestFacetsEndpoint(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
_, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||
LibraryID: library.ID,
|
||||
Title: "Facet List Three",
|
||||
Authors: []string{"Facet Listing Author"},
|
||||
@@ -318,7 +318,6 @@ func mergeParams(base, extra url.Values) url.Values {
|
||||
// "Baen Books") cross-match at 0.3+ (session gotcha).
|
||||
func facetFixtureBooks(t *testing.T, setup *TestServerSetup, libraryID pgtype.UUID, prefix string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
books := []struct {
|
||||
title string
|
||||
genre string
|
||||
@@ -329,7 +328,7 @@ func facetFixtureBooks(t *testing.T, setup *TestServerSetup, libraryID pgtype.UU
|
||||
{prefix + " Gamma", "history", "Pinecrest"},
|
||||
}
|
||||
for _, b := range books {
|
||||
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
_, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||
LibraryID: libraryID,
|
||||
Title: b.title,
|
||||
Authors: []string{prefix + " Author"},
|
||||
@@ -426,7 +425,7 @@ func TestFacetCountsUnderFilters(t *testing.T) {
|
||||
facetFixtureBooks(t, setup, library.ID, "Counts")
|
||||
// A book in the HIDDEN library: distinctive values that must never
|
||||
// surface for the regular user.
|
||||
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
_, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||
LibraryID: hiddenLib.ID,
|
||||
Title: "Counts Hidden Book",
|
||||
Authors: []string{"Counts Secret Author"},
|
||||
@@ -559,7 +558,7 @@ func TestFieldValuesAllLibraries(t *testing.T) {
|
||||
id pgtype.UUID
|
||||
name string
|
||||
}{{visibleLib.ID, "Visible"}, {hiddenLib.ID, "Hidden"}} {
|
||||
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
_, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||
LibraryID: lib.id,
|
||||
Title: fmt.Sprintf("Field Values %s Book", lib.name),
|
||||
Authors: []string{"Verdana Scribe"},
|
||||
|
||||
@@ -156,7 +156,7 @@ func builderFixtureLib(t *testing.T, setup *TestServerSetup, userID uuid.UUID, n
|
||||
}
|
||||
books[i].TagsSearch = lowered
|
||||
}
|
||||
_, err := setup.DB.CreateMediaItem(ctx, books[i])
|
||||
_, err := setup.DB.CreateMediaItem(seedCtx(), books[i])
|
||||
require.NoError(t, err, "create %s", books[i].Title)
|
||||
}
|
||||
return libUUID, library.ID
|
||||
@@ -267,11 +267,9 @@ func TestFilterComposedSemantics(t *testing.T) {
|
||||
assert.Len(t, got, 4, "mime_type is")
|
||||
|
||||
// Series full membership: the alternate_info series matches too.
|
||||
ctx := context.Background()
|
||||
_, err := setup.DBPool.Exec(ctx,
|
||||
seedExec(t, setup.DBPool,
|
||||
`UPDATE media_items SET alternate_info = '{"alternate_series":"Quill Chronicles"}'::jsonb
|
||||
WHERE title = $1`, "Sem Fen")
|
||||
require.NoError(t, err)
|
||||
got = titles(qfAll(qfRule("series", "contains", "Quill Chron")))
|
||||
assert.Equal(t, []string{"Sem Fen"}, got, "series matches alternate_info membership")
|
||||
got = titles(qfAll(qfRule("series", "is", "Quill Chronicles")))
|
||||
@@ -327,43 +325,34 @@ func TestFilterUserRelativeFields(t *testing.T) {
|
||||
// Reader A: reading Tide (50%), finished Stone, rated Tide 8,
|
||||
// reviewed Fen, noted on Tide ("the catharsis scene"), highlighted
|
||||
// Stone, time on Wick (2h), collection holding Tide.
|
||||
_, err := setup.DBPool.Exec(ctx,
|
||||
seedExec(t, setup.DBPool,
|
||||
`INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at) VALUES ($1,$2,0.5, now()), ($3,$4,1.0, now())`,
|
||||
tideID, aID, stoneID, aID)
|
||||
require.NoError(t, err)
|
||||
_, err = setup.DBPool.Exec(ctx,
|
||||
seedExec(t, setup.DBPool,
|
||||
`INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at) VALUES ($1,$2,1.0, now())`,
|
||||
tideID, bID)
|
||||
require.NoError(t, err)
|
||||
_, err = setup.DBPool.Exec(ctx,
|
||||
seedExec(t, setup.DBPool,
|
||||
`INSERT INTO media_ratings (media_item_id, user_id, rating) VALUES ($1,$2,8)`, tideID, aID)
|
||||
require.NoError(t, err)
|
||||
_, err = setup.DBPool.Exec(ctx,
|
||||
seedExec(t, setup.DBPool,
|
||||
`INSERT INTO media_ratings (media_item_id, user_id, rating) VALUES ($1,$2,3)`, tideID, bID)
|
||||
require.NoError(t, err)
|
||||
_, err = setup.DBPool.Exec(ctx,
|
||||
seedExec(t, setup.DBPool,
|
||||
`INSERT INTO media_reviews (media_item_id, user_id, review) VALUES ($1,$2,'splendid')`, fenID, aID)
|
||||
require.NoError(t, err)
|
||||
_, err = setup.DBPool.Exec(ctx,
|
||||
seedExec(t, setup.DBPool,
|
||||
`INSERT INTO media_notes (media_item_id, user_id, content) VALUES ($1,$2,'remember the catharsis scene')`, tideID, aID)
|
||||
require.NoError(t, err)
|
||||
_, err = setup.DBPool.Exec(ctx,
|
||||
seedExec(t, setup.DBPool,
|
||||
`INSERT INTO media_highlights (media_item_id, user_id, selection_text) VALUES ($1,$2,'the dark forest road')`, stoneID, aID)
|
||||
require.NoError(t, err)
|
||||
_, err = setup.DBPool.Exec(ctx,
|
||||
seedExec(t, setup.DBPool,
|
||||
`INSERT INTO reading_history (user_id, media_item_id, time_spent_seconds, pages_read) VALUES ($1,$2,7200,120), ($3,$4,600,30)`,
|
||||
aID, wickID, aID, tideID)
|
||||
require.NoError(t, err)
|
||||
|
||||
var collID pgtype.UUID
|
||||
err = setup.DBPool.QueryRow(ctx,
|
||||
err := setup.DBPool.QueryRow(context.Background(),
|
||||
`INSERT INTO collections (user_id, name) VALUES ($1, $2) RETURNING id`,
|
||||
aID, fmt.Sprintf("Test Builder Coll %d", time.Now().UnixNano())).Scan(&collID)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = setup.DB.DeleteCollection(ctx, collID) })
|
||||
_, err = setup.DBPool.Exec(ctx,
|
||||
seedExec(t, setup.DBPool,
|
||||
`INSERT INTO collection_items (collection_id, media_item_id) VALUES ($1,$2)`, collID, tideID)
|
||||
require.NoError(t, err)
|
||||
|
||||
titlesFor := func(token string, qf string) []string {
|
||||
p := url.Values{"library_id": {libUUID.String()}, "qf": {qf}}
|
||||
@@ -445,7 +434,7 @@ func TestFilterSuggestionsUnderRules(t *testing.T) {
|
||||
|
||||
// Add a second distinct author+genre pair (trigram-disjoint).
|
||||
ctx := context.Background()
|
||||
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
_, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||
Title: "Sug Dune",
|
||||
Authors: []string{"Zephyrine Hale"},
|
||||
@@ -572,18 +561,17 @@ func TestFilterSuggestionsUnderRules(t *testing.T) {
|
||||
// offered with the surviving count; zero-match containers never are.
|
||||
userA := getRegularUserID(t, setup.DB)
|
||||
var collID pgtype.UUID
|
||||
err = setup.DBPool.QueryRow(ctx,
|
||||
err = setup.DBPool.QueryRow(context.Background(),
|
||||
`INSERT INTO collections (user_id, name) VALUES ($1, $2) RETURNING id`,
|
||||
pgtype.UUID{Bytes: [16]byte(userA), Valid: true},
|
||||
fmt.Sprintf("Test Builder Sug Coll %d", time.Now().UnixNano())).Scan(&collID)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = setup.DB.DeleteCollection(ctx, collID) })
|
||||
var tideID pgtype.UUID
|
||||
err = setup.DBPool.QueryRow(ctx, `SELECT id FROM media_items WHERE title = $1`, "Sug Tide").Scan(&tideID)
|
||||
err = setup.DBPool.QueryRow(context.Background(), `SELECT id FROM media_items WHERE title = $1`, "Sug Tide").Scan(&tideID)
|
||||
require.NoError(t, err)
|
||||
_, err = setup.DBPool.Exec(ctx,
|
||||
seedExec(t, setup.DBPool,
|
||||
`INSERT INTO collection_items (collection_id, media_item_id) VALUES ($1, $2)`, collID, tideID)
|
||||
require.NoError(t, err)
|
||||
|
||||
status, results = suggest("collection", " ", "")
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
@@ -632,7 +620,7 @@ func TestFilterFamilyLens(t *testing.T) {
|
||||
id pgtype.UUID
|
||||
title string
|
||||
}{{visibleID, "Lens Vis Book"}, {hiddenID, "Lens Hid Book"}} {
|
||||
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
_, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||
LibraryID: lib.id, Title: lib.title,
|
||||
Authors: []string{"Lens Secret Author"},
|
||||
FilePath: fmt.Sprintf("/tmp/builder/%s-%d.epub", lib.title, time.Now().UnixNano()),
|
||||
@@ -641,10 +629,9 @@ func TestFilterFamilyLens(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
_, err = setup.DBPool.Exec(ctx,
|
||||
seedExec(t, setup.DBPool,
|
||||
`INSERT INTO library_visibility (library_id, user_id, is_visible) VALUES ($1, $2, false)`,
|
||||
hiddenID, pgtype.UUID{Bytes: [16]byte(userID), Valid: true})
|
||||
require.NoError(t, err)
|
||||
|
||||
search := func(params url.Values) (int, []map[string]interface{}) {
|
||||
return qfSearch(t, setup, setup.RegularToken, params)
|
||||
|
||||
@@ -85,7 +85,7 @@ func (s *OwnershipAndAdminTestSuite) createItemViaDB(withFolder bool) (itemID, l
|
||||
}
|
||||
}
|
||||
|
||||
item, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
item, err := db.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||
LibraryID: library.ID,
|
||||
Title: "Ownership Suite Item",
|
||||
Authors: []string{"Suite Author"},
|
||||
@@ -269,7 +269,7 @@ func (s *OwnershipAndAdminTestSuite) TestDelete_StorageDownRefused() {
|
||||
})
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
item, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
item, err := db.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||
LibraryID: library.ID,
|
||||
Title: "Offline Storage Item",
|
||||
FilePath: "offline.epub",
|
||||
|
||||
@@ -134,7 +134,7 @@ func TestManuallyEditedFilter(t *testing.T) {
|
||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||
|
||||
createBook := func(path string) pgtype.UUID {
|
||||
created, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
created, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||
LibraryID: libID,
|
||||
Title: path,
|
||||
Authors: []string{},
|
||||
|
||||
@@ -48,7 +48,7 @@ func createTestQueueItem(t *testing.T, db *database.Queries, userID uuid.UUID) (
|
||||
require.NoError(t, err, "Should create library")
|
||||
|
||||
// 4. Create media item
|
||||
mediaItem, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
mediaItem, err := db.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||
LibraryID: library.ID,
|
||||
Title: "Test Book for Queue",
|
||||
Authors: []string{"Test Author"},
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
package main
|
||||
|
||||
// rls_visibility_test.go — the PERMANENT black-box auditor for per-user
|
||||
// library visibility (the "family lens").
|
||||
//
|
||||
// The one sentence it asserts, in the user's language:
|
||||
//
|
||||
// A user with a library hidden sees NOTHING from that library, on
|
||||
// ANY media-returning surface — while an admin still sees it (the
|
||||
// hide is per-user, never global).
|
||||
//
|
||||
// Implementation-agnostic on purpose: it speaks only HTTP + response
|
||||
// bodies, so it stays valid regardless of HOW enforcement is done
|
||||
// (per-query visible_library_ids clauses today, Postgres RLS in the
|
||||
// RLS rollout, anything tomorrow). It is the auditor that makes a
|
||||
// forgotten surface a failing test instead of a silent leak.
|
||||
//
|
||||
// Red/green history: RED at introduction on the dashboard-section and
|
||||
// collection-detail (mobile View All) surfaces, whose queries never
|
||||
// carried the visible_library_ids clause; GREEN once enforcement
|
||||
// covers every surface (plan:
|
||||
// ~/.opencode/plan/server-rls-library-visibility.md).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
)
|
||||
|
||||
// uuidInBody finds candidate collection ids in a dashboard-sections
|
||||
// body so the View-All detail endpoint can be probed without coupling
|
||||
// the test to unrelated ids in the body.
|
||||
|
||||
func TestRlsInvariant(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
ctx := context.Background()
|
||||
|
||||
adminToken := setup.Token
|
||||
regularToken := setup.RegularToken
|
||||
regularID := getRegularUserID(t, setup.DB)
|
||||
|
||||
// Unique markers: one for the hidden library's whole subtree
|
||||
// (library name + both book titles — a single string that must
|
||||
// appear NOWHERE), one for the visible control book.
|
||||
hiddenMarker := fmt.Sprintf("RLSHIDE%s", uuid.New().String()[:8])
|
||||
controlMarker := fmt.Sprintf("RLSCTRL%s", uuid.New().String()[:8])
|
||||
|
||||
libType, err := setup.DB.GetLibraryTypeByName(ctx, "ebooks")
|
||||
require.NoError(t, err, "ebooks library type should exist")
|
||||
|
||||
createLib := func(name string) database.Libraries {
|
||||
lib, cerr := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
|
||||
Name: name,
|
||||
Description: pgtype.Text{String: "rls invariant test library", Valid: true},
|
||||
LibraryTypeID: libType.ID,
|
||||
CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(regularID), Valid: true},
|
||||
})
|
||||
require.NoError(t, cerr, "create library %q", name)
|
||||
// Deleting the library cascades its media_items AND its
|
||||
// library_visibility rows (FK ON DELETE CASCADE) — one cleanup
|
||||
// covers the whole subtree.
|
||||
t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, lib.ID) })
|
||||
return lib
|
||||
}
|
||||
libH := createLib("RLS Test Hidden Library " + hiddenMarker)
|
||||
libV := createLib("RLS Test Visible Library " + controlMarker)
|
||||
|
||||
createBook := func(libraryID pgtype.UUID, title string) {
|
||||
_, cerr := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||
LibraryID: libraryID,
|
||||
Title: title,
|
||||
Authors: []string{"RLS Invariant Author"},
|
||||
FilePath: fmt.Sprintf("/tmp/rls-invariant/%s-%s.epub", title, uuid.New().String()),
|
||||
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
|
||||
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||
})
|
||||
require.NoError(t, cerr, "create book %q", title)
|
||||
}
|
||||
createBook(libH.ID, hiddenMarker+" Hidden Alpha")
|
||||
createBook(libH.ID, hiddenMarker+" Hidden Beta")
|
||||
createBook(libV.ID, controlMarker+" Visible Gamma")
|
||||
|
||||
// Hide libH from the REGULAR user — the same row the admin
|
||||
// visibility UI writes (harness idiom: SetLibraryVisibility).
|
||||
_, err = setup.DB.SetLibraryVisibility(ctx, database.SetLibraryVisibilityParams{
|
||||
UserID: pgtype.UUID{Bytes: [16]byte(regularID), Valid: true},
|
||||
LibraryID: libH.ID,
|
||||
IsVisible: false,
|
||||
})
|
||||
require.NoError(t, err, "hide libH from regular user")
|
||||
|
||||
// OPDS device owned by the REGULAR user (OPDS identity = device owner).
|
||||
opdsToken := "dev_" + uuid.New().String()
|
||||
device, err := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{
|
||||
UserID: pgtype.UUID{Bytes: [16]byte(regularID), Valid: true},
|
||||
DeviceName: "RLS Invariant OPDS Device",
|
||||
DeviceType: "koreader",
|
||||
DeviceIdentifier: "rls-invariant-" + uuid.New().String()[:8],
|
||||
AuthToken: opdsToken,
|
||||
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
|
||||
AutoSync: pgtype.Bool{Bool: true, Valid: true},
|
||||
SyncFrequencyMinutes: pgtype.Int4{Int32: 60, Valid: true},
|
||||
DeviceMetadata: []byte("{}"),
|
||||
})
|
||||
require.NoError(t, err, "create OPDS device for regular user")
|
||||
t.Cleanup(func() { _ = setup.DB.DeleteDevice(ctx, device.ID) })
|
||||
|
||||
rlsGet := func(token, path string) (int, string) {
|
||||
req, _ := http.NewRequest(http.MethodGet, setup.Server.URL+path, nil)
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err, "GET %s", path)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
var sb strings.Builder
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
sb.Write(buf[:n])
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return resp.StatusCode, sb.String()
|
||||
}
|
||||
|
||||
// Surfaces that render media TITLES (books appear by name).
|
||||
titleSurfaces := []struct {
|
||||
name string
|
||||
path string
|
||||
auth string
|
||||
}{
|
||||
{"media-items list", "/api/media-items?limit=500", regularToken},
|
||||
{"dashboard sections", "/api/dashboard/sections?limit=500", regularToken},
|
||||
{"opds catalog", "/opds/devices/" + device.ID.String() + "/catalog", opdsToken},
|
||||
}
|
||||
// Surfaces that return media-adjacent data (counts, series names,
|
||||
// aggregates) — hidden-subtree leakage would show up as marker
|
||||
// series names / inventory rows, not book titles.
|
||||
aggregateSurfaces := []struct {
|
||||
name string
|
||||
path string
|
||||
auth string
|
||||
}{
|
||||
{"collections list", "/api/collections", regularToken},
|
||||
{"series list", "/api/series", regularToken},
|
||||
{"stats summary", "/api/stats/summary", regularToken},
|
||||
{"stats library-usage", "/api/stats/library-usage", regularToken},
|
||||
}
|
||||
|
||||
t.Run("restricted_user_hidden_library_absent_everywhere", func(t *testing.T) {
|
||||
for _, s := range titleSurfaces {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
code, body := rlsGet(s.auth, s.path)
|
||||
require.Equal(t, http.StatusOK, code, "surface %s must be authorized (a 401 here is a harness bug, not a pass)", s.path)
|
||||
// THE INVARIANT: nothing from the hidden subtree —
|
||||
// neither its books nor the library itself.
|
||||
assert.NotContains(t, body, hiddenMarker,
|
||||
"LEAK: hidden library content surfaced on %s", s.path)
|
||||
// Control: the visible library's book IS present —
|
||||
// guards against false greens from empty/anonymous
|
||||
// responses.
|
||||
if s.name != "opds catalog" { // root catalog may nest books behind library subsections
|
||||
assert.Contains(t, body, controlMarker,
|
||||
"control book missing from %s — response is not listing media (false-green guard)", s.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, s := range aggregateSurfaces {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
code, body := rlsGet(s.auth, s.path)
|
||||
require.Equal(t, http.StatusOK, code, "surface %s must be authorized", s.path)
|
||||
assert.NotContains(t, body, hiddenMarker,
|
||||
"LEAK: hidden library content surfaced on %s", s.path)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("restricted_user_dashboard_scoped_to_hidden_library", func(t *testing.T) {
|
||||
code, body := rlsGet(regularToken, "/api/dashboard/sections?library_id="+uuid.UUID(libH.ID.Bytes).String())
|
||||
assert.NotContains(t, body, hiddenMarker,
|
||||
"LEAK: dashboard scoped to the hidden library leaked its content (status %d)", code)
|
||||
})
|
||||
|
||||
t.Run("restricted_user_collection_detail_view_all", func(t *testing.T) {
|
||||
// The mobile View All path: GET /api/collections/{id} for every
|
||||
// collection the dashboard references (system sections included).
|
||||
// Structured decode — probe exactly the collection ids the
|
||||
// sections payload declares (its own contract field), not
|
||||
// whatever UUIDs happen to appear in the body.
|
||||
_, sectionsBody := rlsGet(regularToken, "/api/dashboard/sections?limit=500")
|
||||
var sections struct {
|
||||
Sections []struct {
|
||||
CollectionID string `json:"collection_id"`
|
||||
} `json:"sections"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(sectionsBody), §ions),
|
||||
"dashboard sections should decode; body: %s", sectionsBody)
|
||||
seen := map[string]bool{}
|
||||
for _, sec := range sections.Sections {
|
||||
id := sec.CollectionID
|
||||
if id == "" || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
code, body := rlsGet(regularToken, "/api/collections/"+id)
|
||||
if code != http.StatusOK {
|
||||
continue // not visible to this user — absence trivially holds
|
||||
}
|
||||
assert.NotContains(t, body, hiddenMarker,
|
||||
"LEAK: collection detail %s (View All surface) returned hidden-library content", id)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("restricted_user_explicit_hidden_library_scoped_requests_rejected", func(t *testing.T) {
|
||||
// Pinned contract (AGENTS.md family-lens rule): naming a hidden
|
||||
// library explicitly is a 403, never a silent pass.
|
||||
libID := uuid.UUID(libH.ID.Bytes).String()
|
||||
for _, path := range []string{
|
||||
"/api/media-items?library_id=" + libID,
|
||||
"/api/series?library_id=" + libID,
|
||||
"/api/series/books?library_id=" + libID + "&name=RLS",
|
||||
} {
|
||||
code, body := rlsGet(regularToken, path)
|
||||
assert.Equal(t, http.StatusForbidden, code,
|
||||
"scoped request %s must be 403 for a hidden library (got %d)", path, code)
|
||||
assert.NotContains(t, body, hiddenMarker)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("admin_still_sees_hidden_library_user_scoped_not_global", func(t *testing.T) {
|
||||
// The hide is PER-USER: an admin (no hidden rows) must still see
|
||||
// everything. This catches "fixing" the leak by hiding the
|
||||
// library globally.
|
||||
for _, s := range []struct{ name, path string }{
|
||||
{"media-items list", "/api/media-items?limit=500"},
|
||||
{"dashboard sections", "/api/dashboard/sections?limit=500"},
|
||||
} {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
code, body := rlsGet(adminToken, s.path)
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
assert.Contains(t, body, hiddenMarker,
|
||||
"admin must still see the hidden library on %s (hide is user-scoped)", s.path)
|
||||
})
|
||||
}
|
||||
t.Run("opds catalog", func(t *testing.T) {
|
||||
// OPDS identity = device owner; an ADMIN-owned device sees all.
|
||||
adminDevToken := "dev_" + uuid.New().String()
|
||||
adminID := getTestUserID(t, setup.DB)
|
||||
adminDevice, derr := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{
|
||||
UserID: pgtype.UUID{Bytes: [16]byte(adminID), Valid: true},
|
||||
DeviceName: "RLS Invariant Admin OPDS Device",
|
||||
DeviceType: "koreader",
|
||||
DeviceIdentifier: "rls-invariant-admin-" + uuid.New().String()[:8],
|
||||
AuthToken: adminDevToken,
|
||||
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
|
||||
AutoSync: pgtype.Bool{Bool: true, Valid: true},
|
||||
SyncFrequencyMinutes: pgtype.Int4{Int32: 60, Valid: true},
|
||||
DeviceMetadata: []byte("{}"),
|
||||
})
|
||||
require.NoError(t, derr, "create OPDS device for admin")
|
||||
t.Cleanup(func() { _ = setup.DB.DeleteDevice(ctx, adminDevice.ID) })
|
||||
|
||||
code, body := rlsGet(adminDevToken, "/opds/devices/"+adminDevice.ID.String()+"/catalog")
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
assert.Contains(t, body, hiddenMarker,
|
||||
"admin's OPDS catalog must still expose the hidden library (user-scoped hide)")
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -78,7 +78,7 @@ func TestScanStateBulkLoad(t *testing.T) {
|
||||
require.NoError(t, os.WriteFile(apiFile, []byte("api made book content"), 0644))
|
||||
apiStat, err := os.Stat(apiFile)
|
||||
require.NoError(t, err)
|
||||
created, err := setup.DB.CreateMediaItem(context.Background(), database.CreateMediaItemParams{
|
||||
created, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||
LibraryID: libID,
|
||||
Title: "API Made Book",
|
||||
Authors: []string{"API Author"},
|
||||
|
||||
@@ -253,7 +253,7 @@ func TestReadingStatsSummary(t *testing.T) {
|
||||
media, err = setup.DB.GetMediaItem(ctx, uuidToPGType(uuid.MustParse(reuseMediaID[0])))
|
||||
require.NoError(t, err, "reuse seeded media item")
|
||||
} else {
|
||||
media, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
media, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||
LibraryID: pgtype.UUID{Bytes: [16]byte(libUUID), Valid: true},
|
||||
Title: title,
|
||||
FilePath: fmt.Sprintf("/tmp/stats-%s-%d.epub", title, time.Now().UnixNano()),
|
||||
@@ -448,7 +448,7 @@ func TestReadingStatsWeekLibraryScope(t *testing.T) {
|
||||
libB := uuid.MustParse(createLibraryDirect(t, setup.DB, userID, fmt.Sprintf("Test Scope LibB %d", time.Now().UnixNano()), "ebooks"))
|
||||
|
||||
seed := func(lib uuid.UUID, title string, daysAgo int, minutes int32) {
|
||||
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
media, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||
LibraryID: pgtype.UUID{Bytes: [16]byte(lib), Valid: true},
|
||||
Title: title,
|
||||
FilePath: fmt.Sprintf("/tmp/%s-%d.epub", title, time.Now().UnixNano()),
|
||||
@@ -526,7 +526,6 @@ func TestReadingStatsWeekLibraryScope(t *testing.T) {
|
||||
func TestLibraryUsageStats(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
userID := getRegularUserID(t, setup.DB)
|
||||
ctx := context.Background()
|
||||
|
||||
ebooksID := uuid.MustParse(createLibraryDirect(t, setup.DB, userID,
|
||||
fmt.Sprintf("Test Usage Ebooks %d", time.Now().UnixNano()), "ebooks"))
|
||||
@@ -541,7 +540,7 @@ func TestLibraryUsageStats(t *testing.T) {
|
||||
if durationSeconds > 0 {
|
||||
audioMeta = []byte(fmt.Sprintf(`{"duration_seconds":%d,"chapters":[]}`, durationSeconds))
|
||||
}
|
||||
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
media, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||
LibraryID: pgtype.UUID{Bytes: [16]byte(lib), Valid: true},
|
||||
Title: title,
|
||||
Authors: []string{author},
|
||||
@@ -689,7 +688,6 @@ func TestLibraryUsageStats(t *testing.T) {
|
||||
func TestLibraryUsageStats_Visibility(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
userID := getRegularUserID(t, setup.DB)
|
||||
ctx := context.Background()
|
||||
|
||||
visibleID := uuid.MustParse(createLibraryDirect(t, setup.DB, userID,
|
||||
fmt.Sprintf("Test Usage Visible %d", time.Now().UnixNano()), "ebooks"))
|
||||
@@ -697,7 +695,7 @@ func TestLibraryUsageStats_Visibility(t *testing.T) {
|
||||
fmt.Sprintf("Test Usage Hidden %d", time.Now().UnixNano()), "ebooks"))
|
||||
|
||||
seed := func(lib uuid.UUID, title string, size int64) {
|
||||
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
_, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||
LibraryID: pgtype.UUID{Bytes: [16]byte(lib), Valid: true},
|
||||
Title: title,
|
||||
FilePath: fmt.Sprintf("/tmp/usage-vis-%s-%d.bin", title, time.Now().UnixNano()),
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"bookhoard/internal/config"
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/rls"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -26,11 +28,16 @@ func setupSyncTestDB(t *testing.T) *database.Queries {
|
||||
dbConfig, err := pgxpool.ParseConfig(dbURL)
|
||||
require.NoError(t, err, "Failed to parse database URL")
|
||||
dbConfig.MaxConns = 1
|
||||
// Harness-only session identity (plan §5): see setupTestServer.
|
||||
dbConfig.BeforeAcquire = func(ctx context.Context, conn *pgx.Conn) bool {
|
||||
_, err := conn.Exec(ctx, `SELECT set_config('app.user_id', $1, false)`, rls.SeedIdentity.String())
|
||||
return err == nil
|
||||
}
|
||||
|
||||
dbPool, err := pgxpool.NewWithConfig(ctx, dbConfig)
|
||||
require.NoError(t, err, "Failed to connect to test database")
|
||||
|
||||
db := database.New(dbPool)
|
||||
db := database.New(database.NewSeedDB(dbPool))
|
||||
t.Cleanup(func() {
|
||||
// Scoped wipes ONLY: reading_history now carries real captured
|
||||
// sessions for the shared dev accounts, so a table-wide DELETE
|
||||
@@ -90,9 +97,8 @@ func createSyncTestDevice(t *testing.T, db *database.Queries, userID pgtype.UUID
|
||||
}
|
||||
|
||||
func createSyncTestMedia(t *testing.T, db *database.Queries, libraryID pgtype.UUID) pgtype.UUID {
|
||||
ctx := context.Background()
|
||||
|
||||
media, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
media, err := db.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||
LibraryID: libraryID,
|
||||
Title: "Sync Test Book",
|
||||
Authors: []string{"Test Author"},
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"bookhoard/internal/handlers"
|
||||
"bookhoard/internal/middleware"
|
||||
ratelimit "bookhoard/internal/middleware"
|
||||
"bookhoard/internal/rls"
|
||||
"bookhoard/internal/router"
|
||||
"bookhoard/internal/services"
|
||||
wsync "bookhoard/internal/sync"
|
||||
@@ -317,6 +318,37 @@ func createRegularUserOnce(t *testing.T, db *database.Queries) UserTestData {
|
||||
}
|
||||
}
|
||||
|
||||
// seedCtx is the context harness-level media seeding runs under. It
|
||||
// carries rls.SeedIdentity — a valid UUID with NO library_visibility
|
||||
// rows, so RLS grants it full visibility (needed because sqlc :one
|
||||
// inserts use RETURNING, which requires SELECT visibility of the new
|
||||
// row). setup.DB additionally falls back to this identity for calls
|
||||
// that pass a bare context. Product code never uses this; requests
|
||||
// carry real personas.
|
||||
func seedCtx() context.Context {
|
||||
return rls.WithUser(context.Background(), rls.SeedIdentity)
|
||||
}
|
||||
|
||||
// seedExec runs a raw statement against the pool under the seed
|
||||
// identity. The bare pool bypasses ScopedDB (nothing reads the ctx for
|
||||
// the GUC), so governed tables need the identity declared manually
|
||||
// inside a transaction.
|
||||
func seedExec(t *testing.T, pool *pgxpool.Pool, sql string, args ...any) {
|
||||
t.Helper()
|
||||
ctx := rls.WithUser(context.Background(), rls.SeedIdentity)
|
||||
tx, err := pool.Begin(ctx)
|
||||
require.NoError(t, err)
|
||||
if _, err := tx.Exec(ctx, `SELECT set_config('app.user_id', $1, true)`, rls.SeedIdentity.String()); err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
require.NoError(t, err, "declare seed identity")
|
||||
}
|
||||
if _, err := tx.Exec(ctx, sql, args...); err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
require.NoError(t, err, "seed exec")
|
||||
}
|
||||
require.NoError(t, tx.Commit(ctx))
|
||||
}
|
||||
|
||||
// uuidToPGType converts uuid.UUID to pgtype.UUID
|
||||
func uuidToPGType(u uuid.UUID) pgtype.UUID {
|
||||
return pgtype.UUID{Bytes: [16]byte(u), Valid: true}
|
||||
@@ -491,10 +523,29 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
dbConfig, err := pgxpool.ParseConfig(cfg.DatabaseURL())
|
||||
require.NoError(t, err, "Failed to parse database URL")
|
||||
dbConfig.MaxConns = 1
|
||||
// Harness-only session identity (plan §5): every connection in this
|
||||
// pool runs as rls.SeedIdentity at SESSION scope, so raw-pool
|
||||
// statements in tests (DBPool.Exec/QueryRow against governed tables)
|
||||
// pass the read policy. No bleed risk: this pool never serves a
|
||||
// second identity — A3 applies to the server's request pool.
|
||||
dbConfig.BeforeAcquire = func(ctx context.Context, conn *pgx.Conn) bool {
|
||||
_, err := conn.Exec(ctx, `SELECT set_config('app.user_id', $1, false)`, rls.SeedIdentity.String())
|
||||
return err == nil
|
||||
}
|
||||
dbPool, err := pgxpool.NewWithConfig(context.Background(), dbConfig)
|
||||
require.NoError(t, err, "Failed to connect to test database")
|
||||
|
||||
queries := database.New(dbPool)
|
||||
// ScopedDB: the test server must behave EXACTLY like the production
|
||||
// wiring (main.go) — every statement through Queries declares the
|
||||
// acting user for RLS, including the search service's raw DBTX path.
|
||||
// TestServerSetup.DB below is the SEED handle — also ScopedDB-wired
|
||||
// (required: sqlc :one inserts use RETURNING, which evaluates the
|
||||
// SELECT policy even on INSERT) — but its callers pass seedCtx(), a
|
||||
// synthetic identity with no visibility rows, so seeding sees and
|
||||
// writes everything. Real personas only exist at the HTTP layer.
|
||||
scopedDB := database.NewScopedDB(dbPool)
|
||||
queries := database.New(scopedDB)
|
||||
seedDB := database.New(database.NewSeedDB(dbPool))
|
||||
|
||||
// Wire the tunable settings registry exactly like main.go: Load
|
||||
// populates the cache (missing rows fall back to compiled defaults)
|
||||
@@ -552,7 +603,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
statisticsService := services.NewStatisticsService(queries)
|
||||
statsHandler := handlers.NewStatsHandler(statisticsService)
|
||||
seriesHandler := handlers.NewSeriesHandler(queries)
|
||||
mediaHandler := handlers.NewMediaHandler(queries, dbPool, libraryService, progressService, annotationService, worker)
|
||||
mediaHandler := handlers.NewMediaHandler(queries, scopedDB, libraryService, progressService, annotationService, worker)
|
||||
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
||||
|
||||
// Create conversion service for OPDS
|
||||
@@ -756,7 +807,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
// Create TestServerSetup struct with all resources
|
||||
setup := &TestServerSetup{
|
||||
Server: ts,
|
||||
DB: queries,
|
||||
DB: seedDB, // harness seeding/cleanup handle (see comment above)
|
||||
DBPool: dbPool,
|
||||
Config: cfg,
|
||||
ConnManager: connManager,
|
||||
|
||||
@@ -1877,3 +1877,72 @@ CREATE INDEX IF NOT EXISTS idx_hash_conflicts_status ON hash_conflicts(status);
|
||||
|
||||
-- Widen for databases created before the resolution format settled (no-op otherwise)
|
||||
ALTER TABLE hash_conflicts ALTER COLUMN resolution TYPE VARCHAR(50);
|
||||
|
||||
-- ============================================================
|
||||
-- RLS: PER-USER LIBRARY VISIBILITY (ONE SOURCE OF TRUTH)
|
||||
-- Plan: ~/.opencode/plan/server-rls-library-visibility.md REV 2.
|
||||
-- The policy joins library_visibility DIRECTLY — the same table the
|
||||
-- visibility UI writes. The app declares the acting user per
|
||||
-- transaction via the LOCAL GUC app.user_id (ScopedDB, set_config
|
||||
-- is_local=true). Unset identity FAILS LOUD, never fails open:
|
||||
-- - GUC never set in the session -> current_setting() raises
|
||||
-- ("unrecognized configuration parameter")
|
||||
-- - GUC set-then-transaction-ended -> reverts to '' -> the ::uuid
|
||||
-- cast raises
|
||||
-- - NULL GUC (unreachable via ScopedDB) -> IS NOT NULL guard ->
|
||||
-- zero rows (fail-closed)
|
||||
-- The runtime role bookhoard_srv OWNS these tables and is subject to
|
||||
-- the policies via FORCE (z_rls_role.sh). Never connect the app as
|
||||
-- the bootstrap superuser — superusers bypass RLS unconditionally.
|
||||
-- IDEMPOTENT: schema.sql re-executes on EVERY app boot
|
||||
-- (database.Initialize -> executeSchema); DROP .. IF EXISTS + CREATE
|
||||
-- matches the repo's trigger idiom. DO NOT make this block
|
||||
-- non-idempotent — the second boot would brick the app.
|
||||
-- ============================================================
|
||||
|
||||
DROP POLICY IF EXISTS p_media_items_visible ON media_items;
|
||||
DROP POLICY IF EXISTS p_media_items_insert ON media_items;
|
||||
DROP POLICY IF EXISTS p_media_items_update ON media_items;
|
||||
DROP POLICY IF EXISTS p_media_items_delete ON media_items;
|
||||
|
||||
ALTER TABLE media_items ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE media_items FORCE ROW LEVEL SECURITY;
|
||||
|
||||
-- READ policy: EXACTLY the app's COALESCE(lv.is_visible, true)
|
||||
-- semantics — row absent -> visible; row true -> visible; row false
|
||||
-- -> hidden. No new index needed: library_visibility's
|
||||
-- UNIQUE(user_id, library_id) constraint already indexes the join.
|
||||
CREATE POLICY p_media_items_visible ON media_items
|
||||
FOR SELECT
|
||||
USING (
|
||||
current_setting('app.user_id') IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM library_visibility lv
|
||||
WHERE lv.user_id = current_setting('app.user_id')::uuid
|
||||
AND lv.library_id = media_items.library_id
|
||||
AND lv.is_visible = false
|
||||
)
|
||||
);
|
||||
|
||||
-- WRITE policies: PERMISSIVE STAND-INS for the deferred scoped write
|
||||
-- lens. MANDATORY at ENABLE time — with RLS enabled and no applicable
|
||||
-- policy, INSERT fails (42501) and UPDATE/DELETE silently affect zero
|
||||
-- rows, bricking the media scanner. Per-command split is deliberate:
|
||||
-- a single FOR ALL policy with USING (true) would OR into SELECT and
|
||||
-- disable the read policy above. Writes still need SELECT visibility
|
||||
-- of the target rows (the read policy governs that), which internal
|
||||
-- identities satisfy (they have no hidden rows).
|
||||
-- ON CONFLICT upserts (scanner: (library_id, file_path)) need the
|
||||
-- conflicting row SELECT-visible to the acting identity — holds for
|
||||
-- every internal identity by construction.
|
||||
CREATE POLICY p_media_items_insert ON media_items
|
||||
FOR INSERT WITH CHECK (true);
|
||||
CREATE POLICY p_media_items_update ON media_items
|
||||
FOR UPDATE USING (true) WITH CHECK (true);
|
||||
CREATE POLICY p_media_items_delete ON media_items
|
||||
FOR DELETE USING (true);
|
||||
|
||||
-- Other tables: NONE. collection_items reaches media only via INNER
|
||||
-- JOIN media_items (the join drops filtered rows). collections
|
||||
-- metadata (book_count) is a separate counts question — plan A13.
|
||||
-- ============================================================
|
||||
@@ -22,10 +22,12 @@ services:
|
||||
container_name: bookhoard_tests
|
||||
environment:
|
||||
# Database Configuration
|
||||
# Runtime role = bookhoard_srv (non-superuser OWNER, subject to RLS
|
||||
# via FORCE) — same role the app service uses.
|
||||
DATABASE_HOST: db
|
||||
DATABASE_PORT: ${DB_PORT:-5432}
|
||||
DATABASE_USER: postgres
|
||||
DATABASE_PASSWORD: ${DBPASS}
|
||||
DATABASE_USER: bookhoard_srv
|
||||
DATABASE_PASSWORD: ${DBSRVPASS}
|
||||
DATABASE_NAME: bookhoard
|
||||
COOKIE_SECURE: false
|
||||
|
||||
|
||||
+59
-5
@@ -11,9 +11,6 @@ services:
|
||||
PGPORT: ${DB_PORT:-5432}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./database/schema:/docker-entrypoint-initdb.d
|
||||
# Make other volumes as needed
|
||||
- ./uploads:/app/uploads
|
||||
ports:
|
||||
- "127.0.0.1:${DB_PORT:-5432}:${DB_PORT:-5432}"
|
||||
- "[::1]:${DB_PORT:-5432}:${DB_PORT:-5432}"
|
||||
@@ -26,6 +23,58 @@ services:
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
# One-shot bootstrap: creates the bookhoard_srv runtime role and
|
||||
# transfers object ownership to it (RLS prerequisite). Idempotent —
|
||||
# safe on every startup; the app waits for its success. Passwords
|
||||
# accept inline values or .env, like the rest of this file.
|
||||
# NOTE: plpgsql $$ tags are written $$$$ — compose eats one dollar
|
||||
# per pair before the container sees the SQL.
|
||||
db-init:
|
||||
image: postgres:15-alpine
|
||||
container_name: bookhoard_db_init
|
||||
environment:
|
||||
PGHOST: db
|
||||
PGPORT: ${DB_PORT:-5432}
|
||||
PGUSER: postgres
|
||||
PGDATABASE: bookhoard
|
||||
PGPASSWORD: ${DBPASS}
|
||||
DBSRVPASS: ${DBSRVPASS}
|
||||
entrypoint: ["bash", "-c"]
|
||||
command:
|
||||
- |
|
||||
set -e
|
||||
: "${DBSRVPASS:?DBSRVPASS required in .env}"
|
||||
psql -v ON_ERROR_STOP=1 -v role_pass="$DBSRVPASS" <<'EOSQL'
|
||||
DO $$$$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'bookhoard_srv') THEN
|
||||
CREATE ROLE bookhoard_srv LOGIN;
|
||||
END IF;
|
||||
END $$$$;
|
||||
ALTER ROLE bookhoard_srv LOGIN PASSWORD :'role_pass';
|
||||
DO $$$$
|
||||
DECLARE r RECORD;
|
||||
BEGIN
|
||||
FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = 'public' LOOP
|
||||
EXECUTE format('ALTER TABLE public.%I OWNER TO bookhoard_srv', r.tablename);
|
||||
END LOOP;
|
||||
FOR r IN SELECT sequencename FROM pg_sequences WHERE schemaname = 'public' LOOP
|
||||
EXECUTE format('ALTER SEQUENCE public.%I OWNER TO bookhoard_srv', r.sequencename);
|
||||
END LOOP;
|
||||
FOR r IN SELECT p.oid::regprocedure AS fn FROM pg_proc p
|
||||
JOIN pg_namespace n ON p.pronamespace = n.oid
|
||||
WHERE n.nspname = 'public' AND p.prokind = 'f' LOOP
|
||||
EXECUTE format('ALTER FUNCTION %s OWNER TO bookhoard_srv', r.fn);
|
||||
END LOOP;
|
||||
END $$$$;
|
||||
ALTER SCHEMA public OWNER TO bookhoard_srv;
|
||||
ALTER DATABASE bookhoard OWNER TO bookhoard_srv;
|
||||
EOSQL
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: "no"
|
||||
|
||||
# Bookhoard Application
|
||||
# In production this image is pulled from the Gitea container registry.
|
||||
# Override IMAGE_TAG in .env to pin or rollback a specific version (defaults to "latest").
|
||||
@@ -35,10 +84,13 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Database Configuration
|
||||
# Runtime role = bookhoard_srv (non-superuser OWNER, subject to RLS
|
||||
# via FORCE). POSTGRES_USER above is the bootstrap account ONLY —
|
||||
# the app must NEVER connect as it (superusers bypass RLS).
|
||||
DATABASE_HOST: db
|
||||
DATABASE_PORT: ${DB_PORT:-5432}
|
||||
DATABASE_USER: postgres
|
||||
DATABASE_PASSWORD: ${DBPASS}
|
||||
DATABASE_USER: bookhoard_srv
|
||||
DATABASE_PASSWORD: ${DBSRVPASS}
|
||||
DATABASE_NAME: bookhoard
|
||||
|
||||
# Application Configuration
|
||||
@@ -75,6 +127,8 @@ services:
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
db-init:
|
||||
condition: service_completed_successfully
|
||||
volumes:
|
||||
- ./uploads:/app/uploads
|
||||
- bookhoard_conversion_cache:/app/cache/kepub
|
||||
|
||||
@@ -432,3 +432,14 @@ library_name`.
|
||||
partial (layout=dropdown on the same endpoint, HX-Request) instead
|
||||
of the retired innerHTML renderer in web/src/search.ts — same
|
||||
endpoint, same auth, content server-rendered.
|
||||
|
||||
## 2026-10-29 — RLS visibility: item-scoped gates may answer 404 as well as 403
|
||||
|
||||
With library visibility enforced by Postgres RLS (see
|
||||
database/schema/schema.sql), a hidden library's rows are INVISIBLE to
|
||||
the engine — item-scoped endpoints (chapters, tracks, download, detail)
|
||||
can no longer run their Go-level 403 gate because the row never arrives.
|
||||
Both answers are gated: 403 (Go-level explicit-library checks remain on
|
||||
listing endpoints) and 404 (row-level invisibility — no existence leak).
|
||||
Pins asserting 403 on item-scoped content endpoints widen to accept
|
||||
{403, 404}. Listing/scoped endpoints keep the strict 403 contract.
|
||||
@@ -171,6 +171,12 @@ type Querier interface {
|
||||
DeleteUnlinkedBook(ctx context.Context, id pgtype.UUID) error
|
||||
DeleteUser(ctx context.Context, id pgtype.UUID) error
|
||||
DeleteUserSystemCollection(ctx context.Context, arg DeleteUserSystemCollectionParams) error
|
||||
// Device deletion (deregister on logout): detach the device's reading
|
||||
// history instead of deleting it — these rows are real captured stats
|
||||
// (the Users section reads them); reading_history.device_id is NO ACTION
|
||||
// so a blind device delete 500s once any history anchors to the device.
|
||||
// device_id is nullable: NULL keeps the session, loses the association.
|
||||
DetachReadingHistoryFromDevice(ctx context.Context, deviceID pgtype.UUID) error
|
||||
// Device existence probe for capture: the web/app progress paths
|
||||
// historically stuff the user id into DeviceID (it only fed a broadcast
|
||||
// label before stats); a capture row must only reference a REAL device.
|
||||
|
||||
@@ -2362,6 +2362,20 @@ func (q *Queries) DeleteUserSystemCollection(ctx context.Context, arg DeleteUser
|
||||
return err
|
||||
}
|
||||
|
||||
const DetachReadingHistoryFromDevice = `-- name: DetachReadingHistoryFromDevice :exec
|
||||
UPDATE reading_history SET device_id = NULL WHERE device_id = $1
|
||||
`
|
||||
|
||||
// Device deletion (deregister on logout): detach the device's reading
|
||||
// history instead of deleting it — these rows are real captured stats
|
||||
// (the Users section reads them); reading_history.device_id is NO ACTION
|
||||
// so a blind device delete 500s once any history anchors to the device.
|
||||
// device_id is nullable: NULL keeps the session, loses the association.
|
||||
func (q *Queries) DetachReadingHistoryFromDevice(ctx context.Context, deviceID pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, DetachReadingHistoryFromDevice, deviceID)
|
||||
return err
|
||||
}
|
||||
|
||||
const DeviceExistsForUser = `-- name: DeviceExistsForUser :one
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM devices
|
||||
|
||||
@@ -2809,6 +2809,14 @@ SET
|
||||
updated_at = NOW()
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: DetachReadingHistoryFromDevice :exec
|
||||
-- Device deletion (deregister on logout): detach the device's reading
|
||||
-- history instead of deleting it — these rows are real captured stats
|
||||
-- (the Users section reads them); reading_history.device_id is NO ACTION
|
||||
-- so a blind device delete 500s once any history anchors to the device.
|
||||
-- device_id is nullable: NULL keeps the session, loses the association.
|
||||
UPDATE reading_history SET device_id = NULL WHERE device_id = $1;
|
||||
|
||||
-- name: DeleteDevice :exec
|
||||
DELETE FROM devices WHERE id = $1;
|
||||
|
||||
|
||||
@@ -178,6 +178,16 @@ func verifyFunctions(ctx context.Context, db *pgxpool.Pool, expectedFunctions []
|
||||
return nil
|
||||
}
|
||||
|
||||
// schemaMigrationIdentity is the synthetic identity the schema
|
||||
// migration transaction declares for RLS (plan appendix A11). The
|
||||
// schema's dedup DO block SELECTs/DELETEs media_items, and with the
|
||||
// visibility policies live (FORCE applies to the owner) that SELECT
|
||||
// needs app.user_id set even in this maintenance tx. The identity has
|
||||
// NO library_visibility rows, so the policy grants it full visibility
|
||||
// — appropriate for the server's own schema pass, and unreachable by
|
||||
// requests (this tx only ever runs at startup, on the raw pool).
|
||||
const schemaMigrationIdentity = "00000000-0000-0000-0000-000000000001"
|
||||
|
||||
// executeSchema runs the entire schema.sql in a single transaction
|
||||
func executeSchema(ctx context.Context, db *pgxpool.Pool) error {
|
||||
tx, err := db.Begin(ctx)
|
||||
@@ -186,6 +196,12 @@ func executeSchema(ctx context.Context, db *pgxpool.Pool) error {
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
// Declare the migration identity BEFORE the file executes (A11):
|
||||
// policy'd tables are queried by schema.sql itself on re-runs.
|
||||
if _, err := tx.Exec(ctx, `SELECT set_config('app.user_id', $1, true)`, schemaMigrationIdentity); err != nil {
|
||||
return fmt.Errorf("schema execution failed (declare migration identity): %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, SchemaFile)
|
||||
if err != nil {
|
||||
log.Printf("HINT: Run manually to debug: psql -h localhost -U postgres -d bookhoard -f database/schema/schema.sql")
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
// ScopedDB wraps a pgx pool so every sqlc statement runs inside a
|
||||
// transaction that declares the acting user via the LOCAL
|
||||
// `app.user_id` GUC — the one thing the RLS policies
|
||||
// (database/schema/schema.sql) need to filter media rows per user.
|
||||
//
|
||||
// Fail-loud lives in the ENGINE, not here: a context with no identity
|
||||
// simply declares nothing, and any RLS-governed statement (media_items)
|
||||
// then raises on the unset GUC — while ungoverned tables (auth lookups,
|
||||
// settings, sessions) keep working, as they have no policy. That is the
|
||||
// plan-C property: the database is the one door everything crosses; the
|
||||
// decorator is only the identity declarer.
|
||||
//
|
||||
// SEMANTICS PRESERVED: every sqlc call was already a single
|
||||
// auto-committed statement; one transaction per call keeps per-call
|
||||
// atomicity identical. pgx v5 rows are bound to their transaction, so
|
||||
// the transaction ends when the ROWS end: commit-on-Close for :many
|
||||
// results, commit-on-Scan for :one results, plain commit for Exec.
|
||||
//
|
||||
// Rollout plan: ~/.opencode/plan/server-rls-library-visibility.md.
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"bookhoard/internal/rls"
|
||||
)
|
||||
|
||||
// Compile-pin: ScopedDB is a drop-in DBTX for database.New.
|
||||
var _ DBTX = (*ScopedDB)(nil)
|
||||
|
||||
// ScopedDB implements DBTX over a pool, declaring the acting user for
|
||||
// the RLS policies on every statement. Fallback (test harness ONLY —
|
||||
// see NewSeedDB) supplies an identity when the context carries none.
|
||||
type ScopedDB struct {
|
||||
Pool *pgxpool.Pool
|
||||
Fallback *pgtype.UUID
|
||||
}
|
||||
|
||||
// NewScopedDB is the constructor used at the single Queries
|
||||
// construction site (cmd/server/main.go).
|
||||
func NewScopedDB(pool *pgxpool.Pool) *ScopedDB {
|
||||
return &ScopedDB{Pool: pool}
|
||||
}
|
||||
|
||||
// NewSeedDB returns a ScopedDB for TEST-HARNESS seeding/verification:
|
||||
// when the context carries no identity it falls back to
|
||||
// rls.SeedIdentity, so direct media reads/writes from helpers work
|
||||
// without impersonating a real user. Never use in product wiring.
|
||||
func NewSeedDB(pool *pgxpool.Pool) *ScopedDB {
|
||||
seed := rls.SeedIdentity
|
||||
return &ScopedDB{Pool: pool, Fallback: &seed}
|
||||
}
|
||||
|
||||
const zeroUUID = "00000000-0000-0000-0000-000000000000"
|
||||
|
||||
// begin opens the per-statement transaction. If the context carries a
|
||||
// user identity, the GUC is declared (LOCAL); if not, NOTHING is
|
||||
// declared and the ENGINE decides: RLS-governed tables (media_items)
|
||||
// raise on the unset GUC — fail-loud at the database, the layer plan C
|
||||
// exists to enforce at — while RLS-ungoverned tables (users, settings,
|
||||
// sessions, auth lookups pre-bridge) simply work, as they have no
|
||||
// policy to evaluate. The decorator therefore never refuses: refusing
|
||||
// here would re-create a convention gate in code, one more door future
|
||||
// paths could forget — the engine is the one door everything crosses.
|
||||
func (s *ScopedDB) begin(ctx context.Context) (pgx.Tx, error) {
|
||||
tx, err := s.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uid, ok := rls.FromContext(ctx)
|
||||
if (!ok || !uid.Valid || uid.String() == zeroUUID) && s.Fallback != nil {
|
||||
uid, ok = *s.Fallback, true
|
||||
}
|
||||
if ok && uid.Valid && uid.String() != zeroUUID {
|
||||
if err := rls.DeclareUser(ctx, tx, uid); err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
return nil, fmt.Errorf("rls: declare user: %w", err)
|
||||
}
|
||||
}
|
||||
return tx, nil
|
||||
}
|
||||
|
||||
func (s *ScopedDB) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) {
|
||||
tx, err := s.begin(ctx)
|
||||
if err != nil {
|
||||
return pgconn.CommandTag{}, err
|
||||
}
|
||||
// Rollback after a successful Commit is a no-op (ErrTxClosed ignored):
|
||||
// this defer only acts on the error paths.
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
tag, err := tx.Exec(ctx, sql, args...)
|
||||
if err != nil {
|
||||
return pgconn.CommandTag{}, err
|
||||
}
|
||||
return tag, tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (s *ScopedDB) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) {
|
||||
tx, err := s.begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := tx.Query(ctx, sql, args...)
|
||||
if err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
return nil, err
|
||||
}
|
||||
return &scopedRows{Rows: rows, tx: tx, ctx: ctx}, nil
|
||||
}
|
||||
|
||||
// scopedRows commits the transaction when the rows close — committing
|
||||
// earlier would kill the rows mid-read. sqlc's :many path fully
|
||||
// consumes rows then Close()s. pgx v5 Rows.Close returns nothing, so a
|
||||
// commit error is logged, not propagated (a commit failure on a
|
||||
// read-only transaction is log-worthy only).
|
||||
type scopedRows struct {
|
||||
pgx.Rows
|
||||
tx pgx.Tx
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (r *scopedRows) Close() {
|
||||
r.Rows.Close()
|
||||
if err := r.tx.Commit(r.ctx); err != nil {
|
||||
log.Printf("rls: tx commit after rows: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// scopedRow terminates the transaction on EVERY Scan path for sqlc's
|
||||
// :one results (pgx.Row is just { Scan(dest ...) error }).
|
||||
type scopedRow struct {
|
||||
row pgx.Row
|
||||
tx pgx.Tx
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (r scopedRow) Scan(dest ...any) error {
|
||||
err := r.row.Scan(dest...)
|
||||
if err == nil {
|
||||
return r.tx.Commit(r.ctx)
|
||||
}
|
||||
_ = r.tx.Rollback(r.ctx) // best effort; the tx ends either way
|
||||
return err
|
||||
}
|
||||
|
||||
type errRow struct{ err error }
|
||||
|
||||
func (r errRow) Scan(_ ...any) error { return r.err }
|
||||
|
||||
func (s *ScopedDB) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row {
|
||||
tx, err := s.begin(ctx)
|
||||
if err != nil {
|
||||
return errRow{err}
|
||||
}
|
||||
return scopedRow{row: tx.QueryRow(ctx, sql, args...), tx: tx, ctx: ctx}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package handlers
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/utils"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -102,7 +101,7 @@ func (h *AnalyticsHandler) GetReadingStats(c *echo.Context) error {
|
||||
// "today") never appear.
|
||||
endTime = endTime.AddDate(0, 0, 1).Add(-time.Nanosecond)
|
||||
|
||||
ctx := context.Background()
|
||||
ctx := c.Request().Context()
|
||||
|
||||
history, err := h.db.GetUserReadingHistory(ctx, database.GetUserReadingHistoryParams{
|
||||
UserID: user.ID,
|
||||
@@ -220,7 +219,7 @@ func (h *AnalyticsHandler) calculateMostActiveDay(history []database.GetUserRead
|
||||
func (h *AnalyticsHandler) GetDeviceUsage(c *echo.Context) error {
|
||||
user := c.Get("user").(database.Users)
|
||||
|
||||
ctx := context.Background()
|
||||
ctx := c.Request().Context()
|
||||
|
||||
usage, err := h.db.GetUserDeviceUsage(ctx, user.ID)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
@@ -268,7 +267,7 @@ func (h *AnalyticsHandler) GetPopularBooks(c *echo.Context) error {
|
||||
limitInt = parsedLimit
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ctx := c.Request().Context()
|
||||
|
||||
books, err := h.db.GetPopularBooks(ctx, database.GetPopularBooksParams{
|
||||
UserID: user.ID,
|
||||
|
||||
@@ -80,7 +80,7 @@ func (h *ConflictHandler) GetConflictsData(c *echo.Context) ([]ConflictDetailRes
|
||||
status = "unresolved"
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ctx := c.Request().Context()
|
||||
|
||||
var conflicts interface{}
|
||||
var err error
|
||||
@@ -175,7 +175,7 @@ func (h *ConflictHandler) GetConflict(c *echo.Context) error {
|
||||
}
|
||||
|
||||
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
|
||||
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID)
|
||||
conflict, err := h.db.GetSyncConflict(c.Request().Context(), conflictUUID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "conflict not found")
|
||||
@@ -187,7 +187,7 @@ func (h *ConflictHandler) GetConflict(c *echo.Context) error {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "access denied")
|
||||
}
|
||||
|
||||
mediaItem, err := h.db.GetMediaItem(context.Background(), conflict.MediaItemID)
|
||||
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), conflict.MediaItemID)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get media item")
|
||||
}
|
||||
@@ -223,7 +223,7 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
|
||||
}
|
||||
|
||||
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
|
||||
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID)
|
||||
conflict, err := h.db.GetSyncConflict(c.Request().Context(), conflictUUID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "conflict not found")
|
||||
@@ -263,13 +263,13 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
|
||||
}
|
||||
|
||||
if conflict.ConflictType == "progress" {
|
||||
if err := h.applyProgressResolution(conflict.MediaItemID, conflict.UserID, winnerSource, winnerData); err == nil {
|
||||
if err := h.applyProgressResolution(c.Request().Context(), conflict.MediaItemID, conflict.UserID, winnerSource, winnerData); err == nil {
|
||||
appliedTo["progress"] = true
|
||||
}
|
||||
}
|
||||
|
||||
if conflict.ConflictType == "annotation_highlight" || conflict.ConflictType == "annotation_bookmark" || conflict.ConflictType == "annotation_note" {
|
||||
if err := h.applyAnnotationResolution(conflict.MediaItemID, conflict.UserID, winnerData, conflict.ConflictType); err == nil {
|
||||
if err := h.applyAnnotationResolution(c.Request().Context(), conflict.MediaItemID, conflict.UserID, winnerData, conflict.ConflictType); err == nil {
|
||||
appliedTo["annotations"] = true
|
||||
}
|
||||
}
|
||||
@@ -283,7 +283,7 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
|
||||
|
||||
resolutionDataJSON, _ := json.Marshal(resolutionData)
|
||||
|
||||
_, err = h.db.ResolveSyncConflict(context.Background(), database.ResolveSyncConflictParams{
|
||||
_, err = h.db.ResolveSyncConflict(c.Request().Context(), database.ResolveSyncConflictParams{
|
||||
ID: conflictUUID,
|
||||
ResolutionStatus: pgtype.Text{String: "user_resolved", Valid: true},
|
||||
ResolutionData: resolutionDataJSON,
|
||||
@@ -321,7 +321,7 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
|
||||
// the service's LWW seed instead of being NULLed — that is the fix.
|
||||
// chapter_progress keeps its stored value instead of being overwritten
|
||||
// with the book-level percentage.
|
||||
func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerSource string, data map[string]interface{}) error {
|
||||
func (h *ConflictHandler) applyProgressResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, winnerSource string, data map[string]interface{}) error {
|
||||
req := wsync.SaveProgressRequest{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: userID,
|
||||
@@ -361,12 +361,11 @@ func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userI
|
||||
req.TotalPages = &tp
|
||||
}
|
||||
|
||||
_, err := h.progressSvc.SaveProgress(context.Background(), req)
|
||||
_, err := h.progressSvc.SaveProgress(ctx, req)
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *ConflictHandler) applyAnnotationResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerData map[string]interface{}, conflictType string) error {
|
||||
ctx := context.Background()
|
||||
func (h *ConflictHandler) applyAnnotationResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, winnerData map[string]interface{}, conflictType string) error {
|
||||
|
||||
dedupKey, _ := winnerData["dedup_key"].(string)
|
||||
if dedupKey == "" {
|
||||
@@ -530,7 +529,7 @@ func (h *ConflictHandler) DeleteConflict(c *echo.Context) error {
|
||||
}
|
||||
|
||||
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
|
||||
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID)
|
||||
conflict, err := h.db.GetSyncConflict(c.Request().Context(), conflictUUID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "conflict not found")
|
||||
@@ -542,7 +541,7 @@ func (h *ConflictHandler) DeleteConflict(c *echo.Context) error {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "access denied")
|
||||
}
|
||||
|
||||
if err := h.db.DeleteSyncConflict(context.Background(), conflictUUID); err != nil {
|
||||
if err := h.db.DeleteSyncConflict(c.Request().Context(), conflictUUID); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to delete conflict")
|
||||
}
|
||||
|
||||
@@ -552,7 +551,7 @@ func (h *ConflictHandler) DeleteConflict(c *echo.Context) error {
|
||||
func (h *ConflictHandler) DismissAllResolved(c *echo.Context) error {
|
||||
user := c.Get("user").(database.Users)
|
||||
|
||||
conflicts, err := h.db.ListConflictsByUser(context.Background(), user.ID)
|
||||
conflicts, err := h.db.ListConflictsByUser(c.Request().Context(), user.ID)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to list conflicts")
|
||||
}
|
||||
@@ -560,7 +559,7 @@ func (h *ConflictHandler) DismissAllResolved(c *echo.Context) error {
|
||||
deleted := 0
|
||||
for _, conflict := range conflicts {
|
||||
if conflict.ResolutionStatus.String == "user_resolved" || conflict.ResolutionStatus.String == "bulk_resolved" {
|
||||
if err := h.db.DeleteSyncConflict(context.Background(), conflict.ID); err == nil {
|
||||
if err := h.db.DeleteSyncConflict(c.Request().Context(), conflict.ID); err == nil {
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
@@ -620,7 +619,7 @@ func (h *ConflictHandler) BulkResolveConflicts(c *echo.Context) error {
|
||||
}
|
||||
|
||||
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
|
||||
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID)
|
||||
conflict, err := h.db.GetSyncConflict(c.Request().Context(), conflictUUID)
|
||||
if err != nil {
|
||||
results = append(results, ConflictResult{
|
||||
ConflictID: conflictIDStr,
|
||||
@@ -702,7 +701,7 @@ func (h *ConflictHandler) BulkResolveConflicts(c *echo.Context) error {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := h.applyProgressResolution(conflict.MediaItemID, conflict.UserID, winningSource, winnerData); err != nil {
|
||||
if err := h.applyProgressResolution(c.Request().Context(), conflict.MediaItemID, conflict.UserID, winningSource, winnerData); err != nil {
|
||||
results = append(results, ConflictResult{
|
||||
ConflictID: conflictIDStr,
|
||||
Status: "error",
|
||||
@@ -719,7 +718,7 @@ func (h *ConflictHandler) BulkResolveConflicts(c *echo.Context) error {
|
||||
}
|
||||
resolutionDataJSON, _ := json.Marshal(resolutionData)
|
||||
|
||||
_, err = h.db.ResolveSyncConflict(context.Background(), database.ResolveSyncConflictParams{
|
||||
_, err = h.db.ResolveSyncConflict(c.Request().Context(), database.ResolveSyncConflictParams{
|
||||
ID: conflictUUID,
|
||||
ResolutionStatus: pgtype.Text{String: "bulk_resolved", Valid: true},
|
||||
ResolutionData: resolutionDataJSON,
|
||||
@@ -817,7 +816,7 @@ func (h *ConflictHandler) BulkDismissConflicts(c *echo.Context) error {
|
||||
}
|
||||
|
||||
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
|
||||
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID)
|
||||
conflict, err := h.db.GetSyncConflict(c.Request().Context(), conflictUUID)
|
||||
if err != nil {
|
||||
results = append(results, ConflictResult{
|
||||
ConflictID: conflictIDStr,
|
||||
@@ -838,7 +837,7 @@ func (h *ConflictHandler) BulkDismissConflicts(c *echo.Context) error {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := h.db.DeleteSyncConflict(context.Background(), conflictUUID); err != nil {
|
||||
if err := h.db.DeleteSyncConflict(c.Request().Context(), conflictUUID); err != nil {
|
||||
results = append(results, ConflictResult{
|
||||
ConflictID: conflictIDStr,
|
||||
Status: "error",
|
||||
|
||||
@@ -433,6 +433,12 @@ func (h *DeviceHandler) DeleteDevice(c *echo.Context) error {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "access denied"})
|
||||
}
|
||||
|
||||
// Detach reading history first: the history FK to devices is NO
|
||||
// ACTION on purpose (rows are real captured stats); NULLing the
|
||||
// device association keeps the stats while freeing the device.
|
||||
if err := h.db.DetachReadingHistoryFromDevice(c.Request().Context(), pgDeviceID); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to detach device history"})
|
||||
}
|
||||
if err := h.db.DeleteDevice(c.Request().Context(), pgDeviceID); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to delete device"})
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/rls"
|
||||
"bookhoard/internal/services"
|
||||
)
|
||||
|
||||
@@ -49,12 +52,17 @@ func (h *JobsHandler) CreateJob(c *echo.Context) error {
|
||||
// Add database to params
|
||||
req.Params["db"] = h.db
|
||||
|
||||
// RLS plan §6: the job's Context carries the requesting admin's
|
||||
// identity — governed reads inside the job would raise otherwise.
|
||||
userUUID, _ := uuid.Parse(c.Get("user_id").(string))
|
||||
|
||||
// Create job
|
||||
job := &services.Job{
|
||||
ID: uuid.New().String(),
|
||||
Type: jobType,
|
||||
Params: req.Params,
|
||||
Status: services.JobStatusPending,
|
||||
ID: uuid.New().String(),
|
||||
Type: jobType,
|
||||
Params: req.Params,
|
||||
Status: services.JobStatusPending,
|
||||
Context: rls.WithUser(context.Background(), pgtype.UUID{Bytes: userUUID, Valid: true}),
|
||||
}
|
||||
|
||||
// Enqueue job
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"bookhoard/internal/database"
|
||||
wsync "bookhoard/internal/sync"
|
||||
"bookhoard/internal/utils"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -46,7 +45,7 @@ func (h *Handler) GetProgressHistory(c *echo.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
history, err := h.db.GetReadingHistory(context.Background(), database.GetReadingHistoryParams{
|
||||
history, err := h.db.GetReadingHistory(c.Request().Context(), database.GetReadingHistoryParams{
|
||||
UserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
|
||||
Limit: int32(limit),
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"bookhoard/internal/rls"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
@@ -127,6 +129,11 @@ func (h *Handler) ScanLibrary(c *echo.Context) error {
|
||||
|
||||
jobID := uuid.New().String()
|
||||
|
||||
// RLS plan §6: the scan job's media reads run under the triggering
|
||||
// admin's identity. h.ctx is a handler-lifetime Background context
|
||||
// (outlives the request), so the identity is stamped explicitly —
|
||||
// a bare context makes governed reads raise (placeholder reverting
|
||||
// to '' on reused connections, or 42704 on fresh ones).
|
||||
job := &services.Job{
|
||||
ID: jobID,
|
||||
Type: services.JobTypeScan,
|
||||
@@ -140,7 +147,7 @@ func (h *Handler) ScanLibrary(c *echo.Context) error {
|
||||
"trigger": "admin",
|
||||
},
|
||||
Status: services.JobStatusPending,
|
||||
Context: h.ctx,
|
||||
Context: rls.WithUser(h.ctx, pgtype.UUID{Bytes: userUUID, Valid: true}),
|
||||
}
|
||||
|
||||
if err := h.worker.EnqueueJob(job); err != nil {
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
|
||||
jwt "github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"bookhoard/internal/rls"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/labstack/echo/v5"
|
||||
@@ -201,14 +203,17 @@ func (h *WSHandler) getInitialState(userID string) map[string]interface{} {
|
||||
|
||||
pgUserID := pgtype.UUID{Bytes: userUUID, Valid: true}
|
||||
|
||||
mediaItems, err := h.db.GetUserMediaItemsForSync(context.Background(), pgUserID)
|
||||
// RLS plan §6: this read is the connection's OWN user's media —
|
||||
// declare that identity explicitly (context.Background() carries
|
||||
// nothing through ScopedDB, and media_items is RLS-governed).
|
||||
mediaItems, err := h.db.GetUserMediaItemsForSync(rls.WithUser(context.Background(), pgUserID), pgUserID)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": "failed to fetch initial state"}
|
||||
}
|
||||
|
||||
progressMap := make(map[string]map[string]interface{})
|
||||
for _, item := range mediaItems {
|
||||
progress, err := h.db.GetUniversalProgress(context.Background(), database.GetUniversalProgressParams{
|
||||
progress, err := h.db.GetUniversalProgress(rls.WithUser(context.Background(), pgUserID), database.GetUniversalProgressParams{
|
||||
MediaItemID: item.ID,
|
||||
UserID: pgUserID,
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ package middleware
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/rls"
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -183,6 +184,13 @@ func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerF
|
||||
c.Set("device_ctx", ctx)
|
||||
c.Set("device_id", device.ID.Bytes)
|
||||
|
||||
// RLS bridge (plan §5.1/A9): device-authenticated requests
|
||||
// (KOReader/Kobo sync) read media as the DEVICE OWNER — echo's
|
||||
// c.Set is echo-local, so the stdlib request context needs the
|
||||
// identity explicitly or every governed read raises.
|
||||
c.SetRequest(c.Request().WithContext(context.WithValue(
|
||||
c.Request().Context(), rls.CtxKeyUserID, device.UserID)))
|
||||
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// Package rls carries the authenticated end user's identity through
|
||||
// the stdlib context so the database layer can declare it to Postgres
|
||||
// per transaction (the `app.user_id` GUC the RLS policies read — see
|
||||
// database/schema/schema.sql and
|
||||
// ~/.opencode/plan/server-rls-library-visibility.md).
|
||||
//
|
||||
// WHY A SEPARATE PACKAGE: Echo's c.Set/c.Get is echo-local storage and
|
||||
// never reaches the context.Context handed to database calls; the auth
|
||||
// middleware therefore bridges the user id into the request context
|
||||
// here, and ScopedDB (internal/database) reads it back. A query whose
|
||||
// context carries no identity is REFUSED (fail-loud) — a code path
|
||||
// that forgets the middleware breaks visibly, it never leaks.
|
||||
package rls
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
type ctxKey int
|
||||
|
||||
// CtxKeyUserID is the context key the auth middleware bridge sets and
|
||||
// ScopedDB reads. Typed key — no string-key collisions.
|
||||
const CtxKeyUserID ctxKey = iota
|
||||
|
||||
// FromContext returns the authenticated end user's UUID.
|
||||
// ok=false means the caller forgot the middleware bridge — the
|
||||
// decorator will refuse the query loudly.
|
||||
func FromContext(ctx context.Context) (pgtype.UUID, bool) {
|
||||
uid, ok := ctx.Value(CtxKeyUserID).(pgtype.UUID)
|
||||
if !ok || !uid.Valid {
|
||||
return pgtype.UUID{}, false
|
||||
}
|
||||
return uid, true
|
||||
}
|
||||
|
||||
// WithUser re-attaches an identity to a (possibly detached) context.
|
||||
// Background jobs MUST capture the acting user's UUID from the request
|
||||
// context BEFORE `go func` detaches — context.Background() carries
|
||||
// nothing — and rebuild their job context with this.
|
||||
func WithUser(ctx context.Context, uid pgtype.UUID) context.Context {
|
||||
return context.WithValue(ctx, CtxKeyUserID, uid)
|
||||
}
|
||||
|
||||
// SeedIdentity is the harness seeding identity (plan §5): a valid UUID
|
||||
// with NO library_visibility rows, so RLS grants it full visibility.
|
||||
// It exists so test seeding and direct DB verification can read and
|
||||
// write governed tables without impersonating a real user. Product
|
||||
// code never runs as it.
|
||||
var SeedIdentity = pgtype.UUID{Bytes: [16]byte(uuid.MustParse("00000000-0000-0000-0000-000000000002")), Valid: true}
|
||||
|
||||
// DeclareUser sets the RLS identity GUC inside an ALREADY-OPEN
|
||||
// transaction. The `true` argument is is_local: transaction-scoped,
|
||||
// evaporating at COMMIT/ROLLBACK — NEVER session-scoped on a pooled
|
||||
// connection (cross-request identity bleed). For the manual-tx paths
|
||||
// the ScopedDB decorator cannot wrap.
|
||||
func DeclareUser(ctx context.Context, tx pgx.Tx, uid pgtype.UUID) error {
|
||||
_, err := tx.Exec(ctx, `SELECT set_config('app.user_id', $1, true)`, uid.String())
|
||||
return err
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"bookhoard/internal/handlers"
|
||||
"bookhoard/internal/middleware"
|
||||
ratelimit "bookhoard/internal/middleware"
|
||||
"bookhoard/internal/rls"
|
||||
"bookhoard/internal/services"
|
||||
"bookhoard/internal/sync"
|
||||
"bookhoard/templates"
|
||||
@@ -155,6 +156,12 @@ func createOptionalAuthMiddleware(cfg *Config) echo.MiddlewareFunc {
|
||||
Timezone: liveUser.Timezone,
|
||||
Theme: liveUser.Theme, // SSR body class; see the required middleware
|
||||
})
|
||||
// RLS bridge: echo's c.Set is echo-local and never reaches
|
||||
// the stdlib context.Context the database layer receives —
|
||||
// ScopedDB refuses media queries without this (plan §5.1).
|
||||
c.SetRequest(c.Request().WithContext(context.WithValue(
|
||||
c.Request().Context(), rls.CtxKeyUserID,
|
||||
pgtype.UUID{Bytes: userUUID, Valid: true})))
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
@@ -222,6 +229,12 @@ func createJWTMiddleware(cfg *Config) echo.MiddlewareFunc {
|
||||
// admin/docs theme flash.
|
||||
Theme: liveUser.Theme,
|
||||
})
|
||||
// RLS bridge (same as the JWT path above): echo-local c.Set
|
||||
// never reaches the stdlib context.Context the database
|
||||
// layer receives — ScopedDB refuses media queries without it.
|
||||
c.SetRequest(c.Request().WithContext(context.WithValue(
|
||||
c.Request().Context(), rls.CtxKeyUserID,
|
||||
pgtype.UUID{Bytes: userUUID, Valid: true})))
|
||||
return nil
|
||||
},
|
||||
ErrorHandler: func(c *echo.Context, err error) error {
|
||||
@@ -414,7 +427,15 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
|
||||
// startup scans for disk I/O.
|
||||
go func() {
|
||||
time.Sleep(30 * time.Second)
|
||||
services.NewHashBackfillService(cfg.Queries).Run(context.Background())
|
||||
// RLS plan §6: this self-heal sweeps ALL media — run it as the
|
||||
// first admin (the scanner's documented fallback identity);
|
||||
// a bare Background context makes governed reads raise.
|
||||
adminID, err := cfg.Queries.GetFirstAdmin(context.Background())
|
||||
if err != nil {
|
||||
log.Printf("[HASH-BACKFILL] no admin found, skipping backfill: %v", err)
|
||||
return
|
||||
}
|
||||
services.NewHashBackfillService(cfg.Queries).Run(rls.WithUser(context.Background(), adminID))
|
||||
}()
|
||||
|
||||
// Register progress routes with actual handler
|
||||
|
||||
@@ -43,6 +43,8 @@ import (
|
||||
epub "github.com/ArcadiaLin/go-epub"
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"bookhoard/internal/rls"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/nwaples/rardecode"
|
||||
@@ -621,17 +623,23 @@ func (s *MediaScanner) enqueueLibraryScan(rootFolder, trigger string) {
|
||||
folderPaths[i] = f.FolderPath
|
||||
}
|
||||
|
||||
// The acting admin: the library owner, or the first admin as the
|
||||
// documented fallback (existing pattern). Used BOTH for the job's
|
||||
// UserID and — since RLS (plan §6) — as the identity the job's
|
||||
// media reads run under via its Context.
|
||||
adminIDStr := ""
|
||||
if libRow.CreatedByAdminID.Valid {
|
||||
adminIDStr = uuid.UUID(libRow.CreatedByAdminID.Bytes).String()
|
||||
adminUUID := libRow.CreatedByAdminID
|
||||
if adminUUID.Valid {
|
||||
adminIDStr = uuid.UUID(adminUUID.Bytes).String()
|
||||
}
|
||||
if adminIDStr == "" {
|
||||
if !adminUUID.Valid {
|
||||
fmt.Printf("[MTIME-POLL] Library has no owner, falling back to first admin\n")
|
||||
fallbackAdmin, err := s.db.GetFirstAdmin(context.Background())
|
||||
if err != nil {
|
||||
fmt.Printf("[MTIME-POLL] Warning: no admin found in database, skipping scan\n")
|
||||
return
|
||||
}
|
||||
adminUUID = fallbackAdmin
|
||||
adminIDStr = uuid.UUID(fallbackAdmin.Bytes).String()
|
||||
}
|
||||
|
||||
@@ -650,7 +658,7 @@ func (s *MediaScanner) enqueueLibraryScan(rootFolder, trigger string) {
|
||||
Type: JobTypeScan,
|
||||
Status: JobStatusPending,
|
||||
UserID: adminIDStr,
|
||||
Context: context.Background(),
|
||||
Context: rls.WithUser(context.Background(), adminUUID),
|
||||
Params: map[string]any{
|
||||
"library_id": libraryIDStr,
|
||||
"folders": folderPaths,
|
||||
@@ -667,6 +675,13 @@ func (s *MediaScanner) enqueueLibraryScan(rootFolder, trigger string) {
|
||||
}
|
||||
|
||||
func (s *MediaScanner) ScanFolders(ctx context.Context) error {
|
||||
// RLS plan §6: a scan is server maintenance over library media —
|
||||
// self-declare the scanner's configured admin identity so internal
|
||||
// reads work regardless of the caller's context (worker jobs carry
|
||||
// one; test/direct callers may not). No-op when unconfigured.
|
||||
if s.adminID.Valid {
|
||||
ctx = rls.WithUser(ctx, s.adminID)
|
||||
}
|
||||
// Cancellation is checked at every stage boundary and inside the
|
||||
// walk callback: the in-flight file finishes, then the scan aborts
|
||||
// and reports context.Canceled (processJob classifies that as a
|
||||
@@ -4771,7 +4786,7 @@ func (s *MediaScanner) performInitialScan(ctx context.Context) {
|
||||
Type: JobTypeScan,
|
||||
Status: JobStatusPending,
|
||||
UserID: adminIDStr,
|
||||
Context: context.Background(),
|
||||
Context: rls.WithUser(ctx, s.adminID), // RLS plan §6: startup scans run as the configured admin
|
||||
Params: map[string]any{
|
||||
"library_id": libraryIDStr,
|
||||
"folders": folderPaths,
|
||||
|
||||
@@ -303,7 +303,7 @@ templ BookShelf(
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<div id="books-grid" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
<div id="books-grid" class="grid grid-cols-3 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 gap-4">
|
||||
@BooksGrid(books, limit, offset, count, currentLibraryID)
|
||||
if errorMessage != "" {
|
||||
<div class="col-span-full mt-2 p-4 rounded-xl border" style="background-color: color-mix(in srgb, var(--status-danger) 10%, transparent); border-color: var(--status-danger);">
|
||||
|
||||
@@ -406,7 +406,7 @@ func BookShelf(
|
||||
}
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</select></div><!-- The rule builder (faceted plan Phase 9): the drawer's\n\t\t\t\t\t\t\t\t\tONLY filter UI. Templ owns the look, Alpine owns the\n\t\t\t\t\t\t\t\t\trule-tree state (the sanctioned island — same pattern\n\t\t\t\t\t\t\t\t\tas the chips bar; server JSON rendered by Alpine, no\n\t\t\t\t\t\t\t\t\tinnerHTML templating). Rows are a flat projection of\n\t\t\t\t\t\t\t\t\tthe tree (depth = indent); groups nest via + group. --><div x-data=\"filterBuilderExtra\" x-cloak x-effect=\"filtersOpen === true && ensureReadyRule()\"><template x-for=\"row in builderRows\" :key=\"row.key\"><div><template x-if=\"row.type === 'groupOpen'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-1\" :style=\"`margin-left:${row.depth * 14}px`\"><span class=\"text-[11px] font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\" x-text=\"row.depth === 0 ? 'Match' : 'Group'\"></span> <span class=\"flex-1\"></span> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addRule(row.path)\">+ rule</button> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addGroup(row.path)\">+ group</button> <button type=\"button\" class=\"fb-mini-btn fb-danger\" @click=\"removeNode(row.path)\" aria-label=\"Remove group\">×</button></div></template><template x-if=\"row.type === 'rule'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-0.5\" :style=\"`margin-left:${(row.depth + 1) * 14}px`\"><button type=\"button\" class=\"fb-connective fb-connective-btn\" :class=\"row.chipClickable ? '' : 'fb-chip-static'\" x-show=\"!row.isFirst\" x-text=\"row.chip\" @click=\"if (row.chipClickable) toggleRuleConnective(row.path)\" :title=\"row.chipClickable ? 'Switch this rule between AND / OR' : 'This rule joins with ' + row.chip\"></button><!-- field picker: grouped menu --><div class=\"relative\"><button type=\"button\" class=\"fb-picker\" :class=\"{'fb-picker-active': row.rule.field !== ''}\" @click=\"toggleMenu(row.key + ':field')\" x-text=\"fieldLabelOf(row.rule.field)\"></button><div class=\"fb-menu fb-menu-fields\" x-show=\"openMenu === row.key + ':field'\" @click.outside=\"openMenu = ''\"><div class=\"fb-menu-filter-wrap\"><input type=\"text\" class=\"fb-menu-filter\" placeholder=\"Filter fields…\" x-model=\"fieldFilter\" @keydown.enter.prevent @click.stop></div><div class=\"fb-menu-scroll\"><template x-for=\"grp in visibleFieldGroups\" :key=\"grp.group\"><div><div class=\"fb-menu-group\" x-text=\"grp.group\"></div><template x-for=\"f in grp.fields\" :key=\"f.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"row.rule.field === f.key ? 'fb-menu-item-active' : ''\" @click=\"setRuleField(row.path, f.key)\" x-text=\"f.label\"></button></template></div></template><div class=\"fb-menu-empty\" x-show=\"visibleFieldGroups.length === 0\">No matching fields</div></div></div></div><!-- operator picker (negation lives here) --><div class=\"relative\"><button type=\"button\" class=\"fb-picker fb-picker-op\" :class=\"{'fb-negated-text': isNegated(row.rule)}\" @click=\"toggleMenu(row.key + ':op')\" x-text=\"opLabelOf(row.rule)\"></button><div class=\"fb-menu\" x-show=\"openMenu === row.key + ':op'\" @click.outside=\"openMenu = ''\"><template x-for=\"o in opsFor(row.rule.field)\" :key=\"o.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"[row.rule.op === o.key ? 'fb-menu-item-active' : '', o.negated ? 'fb-negated-text' : '']\" @click=\"setRuleOp(row.path, o.key)\" x-text=\"o.label\"></button></template></div></div><!-- value cell: shape follows the operator + field kind --><template x-if=\"hasValueCell(row.rule) && valueArity(row.rule) === 'two'\"><span class=\"flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[0] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" @click.outside=\"suggestionsOpen = ''\" placeholder=\"From\"> <span class=\"text-xs\" style=\"color: var(--text-secondary)\">and</span> <input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[1] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 1, $el.value)\" placeholder=\"To\"></span></template><template x-if=\"hasValueCell(row.rule) && (valueArity(row.rule) === 'one' || valueArity(row.rule) === 'many')\"><span class=\"fb-value-cell relative flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values.join(',')\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" :placeholder=\"fieldMeta(row.rule.field)?.valueKind === 'days' ? 'days' : 'value'\"><template x-if=\"fieldMeta(row.rule.field)?.valueKind === 'days'\"><span class=\"text-xs whitespace-nowrap\" style=\"color: var(--text-secondary)\" x-text=\"'days'\"></span></template><!-- field-aware valueLine truncated
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</select></div><!-- The rule builder (faceted plan Phase 9): the drawer's\n\t\t\t\t\t\t\t\t\tONLY filter UI. Templ owns the look, Alpine owns the\n\t\t\t\t\t\t\t\t\trule-tree state (the sanctioned island — same pattern\n\t\t\t\t\t\t\t\t\tas the chips bar; server JSON rendered by Alpine, no\n\t\t\t\t\t\t\t\t\tinnerHTML templating). Rows are a flat projection of\n\t\t\t\t\t\t\t\t\tthe tree (depth = indent); groups nest via + group. --><div x-data=\"filterBuilderExtra\" x-cloak x-effect=\"filtersOpen === true && ensureReadyRule()\"><template x-for=\"row in builderRows\" :key=\"row.key\"><div><template x-if=\"row.type === 'groupOpen'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-1\" :style=\"`margin-left:${row.depth * 14}px`\"><span class=\"text-[11px] font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\" x-text=\"row.depth === 0 ? 'Match' : 'Group'\"></span> <span class=\"flex-1\"></span> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addRule(row.path)\">+ rule</button> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addGroup(row.path)\">+ group</button> <button type=\"button\" class=\"fb-mini-btn fb-danger\" @click=\"removeNode(row.path)\" aria-label=\"Remove group\">×</button></div></template><template x-if=\"row.type === 'rule'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-0.5\" :style=\"`margin-left:${(row.depth + 1) * 14}px`\"><button type=\"button\" class=\"fb-connective fb-connective-btn\" :class=\"row.chipClickable ? '' : 'fb-chip-static'\" x-show=\"!row.isFirst\" x-text=\"row.chip\" @click=\"if (row.chipClickable) toggleRuleConnective(row.path)\" :title=\"row.chipClickable ? 'Switch this rule between AND / OR' : 'This rule joins with ' + row.chip\"></button><!-- field picker: grouped menu --><div class=\"relative\"><button type=\"button\" class=\"fb-picker\" :class=\"{'fb-picker-active': row.rule.field !== ''}\" @click=\"toggleMenu(row.key + ':field')\" x-text=\"fieldLabelOf(row.rule.field)\"></button><div class=\"fb-menu fb-menu-fields\" x-show=\"openMenu === row.key + ':field'\" @click.outside=\"openMenu = ''\"><div class=\"fb-menu-filter-wrap\"><input type=\"text\" class=\"fb-menu-filter\" placeholder=\"Filter fields…\" x-model=\"fieldFilter\" @keydown.enter.prevent @click.stop></div><div class=\"fb-menu-scroll\"><template x-for=\"grp in visibleFieldGroups\" :key=\"grp.group\"><div><div class=\"fb-menu-group\" x-text=\"grp.group\"></div><template x-for=\"f in grp.fields\" :key=\"f.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"row.rule.field === f.key ? 'fb-menu-item-active' : ''\" @click=\"setRuleField(row.path, f.key)\" x-text=\"f.label\"></button></template></div></template><div class=\"fb-menu-empty\" x-show=\"visibleFieldGroups.length === 0\">No matching fields</div></div></div></div><!-- operator picker (negation lives here) --><div class=\"relative\"><button type=\"button\" class=\"fb-picker fb-picker-op\" :class=\"{'fb-negated-text': isNegated(row.rule)}\" @click=\"toggleMenu(row.key + ':op')\" x-text=\"opLabelOf(row.rule)\"></button><div class=\"fb-menu\" x-show=\"openMenu === row.key + ':op'\" @click.outside=\"openMenu = ''\"><template x-for=\"o in opsFor(row.rule.field)\" :key=\"o.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"[row.rule.op === o.key ? 'fb-menu-item-active' : '', o.negated ? 'fb-negated-text' : '']\" @click=\"setRuleOp(row.path, o.key)\" x-text=\"o.label\"></button></template></div></div><!-- value cell: shape follows the operator + field kind --><template x-if=\"hasValueCell(row.rule) && valueArity(row.rule) === 'two'\"><span class=\"flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[0] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" @click.outside=\"suggestionsOpen = ''\" placeholder=\"From\"> <span class=\"text-xs\" style=\"color: var(--text-secondary)\">and</span> <input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[1] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 1, $el.value)\" placeholder=\"To\"></span></template><template x-if=\"hasValueCell(row.rule) && (valueArity(row.rule) === 'one' || valueArity(row.rule) === 'many')\"><span class=\"fb-value-cell relative flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values.join(',')\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" :placeholder=\"fieldMeta(row.rule.field)?.valueKind === 'days' ? 'days' : 'value'\"><template x-if=\"fieldMeta(row.rule.field)?.valueKind === 'days'\"><span class=\"text-xs whitespace-nowrap\" style=\"color: var(--text-secondary)\" x-text=\"'days'\"></span></template><!-- field-aware valueLine truncated
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, curr
|
||||
</div>
|
||||
}
|
||||
}
|
||||
<div id="series-grid" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-6">
|
||||
<div id="series-grid" class="grid grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-6">
|
||||
for _, series := range seriesList {
|
||||
@SeriesCard(series)
|
||||
}
|
||||
|
||||
@@ -344,7 +344,7 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
|
||||
}
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div id=\"series-grid\" class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-6\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div id=\"series-grid\" class=\"grid grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-6\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
package-lock.json
|
||||
test-results/
|
||||
playwright-report/
|
||||
@@ -0,0 +1,24 @@
|
||||
# UI sweep coverage (plan §7.5)
|
||||
|
||||
Gate: every line below executes for BOTH personas unless marked
|
||||
"admin-only". Status: ✅ swept+green · 🔲 pending (future sweep runs).
|
||||
|
||||
| # | Page / control | admin | restricted |
|
||||
|---|---|---|---|
|
||||
| 1 | Login: bad password rejected; good login lands authed; logout | ✅ | ✅ |
|
||||
| 2 | Dashboard: sections render (Recently Added / Not Started) | ✅ | ✅ (hidden lib absent) |
|
||||
| 3 | Dashboard: hidden library visibility (Audiobooks) | ✅ visible | ✅ absent |
|
||||
| 4 | Search: results scoped (`/api/media-items/search`) | ✅ | ✅ no leaks |
|
||||
| 5 | Media list: no hidden items in payloads | ✅ | ✅ |
|
||||
| 6 | Library browse grid + pagination | 🔲 | 🔲 |
|
||||
| 7 | Library create / edit / delete (admin) | 🔲 | n/a |
|
||||
| 8 | Admin visibility toggle roundtrip (hide → restricted loses → unhide) | 🔲 | 🔲 |
|
||||
| 9 | Media detail: edit metadata / archive / delete | 🔲 | n/a |
|
||||
| 10 | Reader: open, page turn, TOC, bookmark, highlight, note, settings | 🔲 | 🔲 |
|
||||
| 11 | Collections: CRUD, add/remove items, smart-rule editor | 🔲 | 🔲 |
|
||||
| 12 | Series: list/detail | 🔲 | 🔲 |
|
||||
| 13 | Stats screens + range filters | 🔲 | 🔲 |
|
||||
| 14 | Admin: users CRUD, visibility matrix, settings toggles, processing issues | 🔲 | n/a |
|
||||
| 15 | Upload: EPUB generated at runtime, upload → appears → delete | 🔲 | n/a |
|
||||
| 16 | Auth edges: restricted → admin URL; anonymous → media URL | 🔲 | ✅ (detail/404) |
|
||||
| 17 | OPDS / KOReader | API-covered (invariant test) | API-covered |
|
||||
@@ -0,0 +1,18 @@
|
||||
# tests/ui — Playwright sweep (plan §7.5)
|
||||
|
||||
Real-browser sweep of the SSR/HTMX frontend. Dev server must be up on
|
||||
localhost:8765. Run inside the official image (no host browsers needed):
|
||||
|
||||
```bash
|
||||
cd tests/ui
|
||||
docker run --rm --network host \
|
||||
-v "$PWD":/work -w /work \
|
||||
mcr.microsoft.com/playwright:v1.49.0 \
|
||||
bash -c "npm install --no-audit --no-fund && npx playwright test"
|
||||
```
|
||||
|
||||
Personas (see rls_sweep.spec.ts): `testuser` (admin) and `rls-phone`
|
||||
(restricted — Audiobooks hidden from them).
|
||||
|
||||
Coverage: `COVERAGE.md` enumerates every swept page/control per the
|
||||
plan; new pages/controls get a line when added.
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "bookhoard-ui-sweep",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"test": "playwright test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.49.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
// Full-sweep config (plan §7.5). The dev server must be up on
|
||||
// localhost:8765 (same precondition as the Go integration tests).
|
||||
// Run via the official Playwright image — see tests/ui/README.md.
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
timeout: 30_000,
|
||||
retries: 0,
|
||||
reporter: [["list"]],
|
||||
use: {
|
||||
baseURL: "http://localhost:8765",
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
actionTimeout: 10_000,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { expect, test, type Browser, type Page } from "@playwright/test";
|
||||
|
||||
// Persona helpers: the two dev personas used across the sweep.
|
||||
// testuser = admin, sees everything.
|
||||
// rls-phone = restricted user; the Audiobooks library is HIDDEN from
|
||||
// them (seeded by the rollout; see the RLS plan §7.5).
|
||||
//
|
||||
// Sessions are established ONCE per persona and reused via
|
||||
// storageState — the login endpoint rate-limits rapid attempts, and a
|
||||
// sweep should not look like a brute-force run.
|
||||
const PERSONAS = {
|
||||
admin: { login: "testuser", password: "Test@Pass123!" },
|
||||
restricted: { login: "rls-phone", password: "Test@Pass123!" },
|
||||
} as const;
|
||||
|
||||
type Persona = keyof typeof PERSONAS;
|
||||
const stateCache: Partial<Record<Persona, string>> = {};
|
||||
|
||||
async function authedPage(browser: Browser, persona: Persona): Promise<Page> {
|
||||
if (!stateCache[persona]) {
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
await page.goto("/login");
|
||||
await page.fill('input[name="login"]', PERSONAS[persona].login);
|
||||
await page.fill('input[name="password"]', PERSONAS[persona].password);
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForLoadState("networkidle");
|
||||
stateCache[persona] = await ctx.storageState();
|
||||
await ctx.close();
|
||||
}
|
||||
const ctx = await browser.newContext({ storageState: stateCache[persona] });
|
||||
return ctx.newPage();
|
||||
}
|
||||
|
||||
// The restricted persona must never see Audiobooks content anywhere.
|
||||
const HIDDEN = "Audiobooks";
|
||||
|
||||
test.describe("authentication", () => {
|
||||
test("wrong password is rejected without a session", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.fill('input[name="login"]', PERSONAS.admin.login);
|
||||
await page.fill('input[name="password"]', "definitely-wrong");
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForTimeout(1_500);
|
||||
const cookies = await page.context().cookies();
|
||||
expect(cookies.find((c) => c.name === "token" && c.value.length > 10)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("admin persona sees everything", () => {
|
||||
let page: Page;
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await authedPage(browser, "admin");
|
||||
});
|
||||
|
||||
test("dashboard sections render with library content", async () => {
|
||||
await page.goto("/dashboard");
|
||||
const body = (await page.textContent("body")) ?? "";
|
||||
expect(body).toContain("Recently Added");
|
||||
expect(body).toContain("Not Started");
|
||||
expect(body).toContain(HIDDEN); // admin sees the hidden-from-others library
|
||||
});
|
||||
|
||||
test("search returns hidden-library titles for admin", async () => {
|
||||
const resp = await page.goto("/api/media-items/search?query=Witcher");
|
||||
expect(resp?.status()).toBe(200);
|
||||
const body = (await page.textContent("body")) ?? "";
|
||||
expect(body).toContain("Witcher");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("restricted persona: hidden means hidden", () => {
|
||||
let page: Page;
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await authedPage(browser, "restricted");
|
||||
});
|
||||
|
||||
test("dashboard never shows the hidden library or its items", async () => {
|
||||
await page.goto("/dashboard");
|
||||
expect((await page.textContent("body")) ?? "").toContain("Recently Added"); // real dashboard, not the landing page
|
||||
const body = (await page.textContent("body")) ?? "";
|
||||
expect(body).not.toContain(HIDDEN);
|
||||
});
|
||||
|
||||
test("search results exclude hidden-library content", async () => {
|
||||
const resp = await page.goto("/api/media-items/search?query=Witcher");
|
||||
expect(resp?.status()).toBe(200);
|
||||
const body = (await page.textContent("body")) ?? "";
|
||||
// The Witcher audiobooks live in the hidden library — none may leak.
|
||||
expect(body).not.toContain("Witcher");
|
||||
});
|
||||
|
||||
test("media payloads exclude hidden-library items", async () => {
|
||||
const resp = await page.goto("/api/media-items?limit=200");
|
||||
expect(resp?.status()).toBe(200);
|
||||
const body = (await page.textContent("body")) ?? "";
|
||||
expect(body).not.toContain(HIDDEN);
|
||||
});
|
||||
});
|
||||
@@ -1,2 +1,2 @@
|
||||
*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }
|
||||
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated
|
||||
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated
|
||||
Reference in new issue
Block a user