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 |
No files matched your search
+9
-1
@@ -6,10 +6,18 @@
|
|||||||
# Generate with: openssl rand -hex 32
|
# Generate with: openssl rand -hex 32
|
||||||
JWT_SECRET=your-secure-jwt-secret-key-here
|
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
|
# Generate with: openssl rand -hex 16
|
||||||
DBPASS=your-secure-database-password-here
|
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
|
# Networking: change a port if it conflicts on your host
|
||||||
# Postgres port, host + container (e.g. another local DB already uses 5432)
|
# Postgres port, host + container (e.g. another local DB already uses 5432)
|
||||||
# DB_PORT=15432
|
# DB_PORT=15432
|
||||||
|
|||||||
@@ -106,30 +106,50 @@ inside quoted attribute values (gotcha #1 below), so dynamic
|
|||||||
## Library visibility (family-lens rule) — ALWAYS apply
|
## Library visibility (family-lens rule) — ALWAYS apply
|
||||||
|
|
||||||
Per-user library visibility (`library_visibility` table, default-ALLOW:
|
Per-user library visibility (`library_visibility` table, default-ALLOW:
|
||||||
every user sees every library unless an admin hides one) must be
|
every user sees every library unless an admin hides one) is enforced by
|
||||||
enforced on **every endpoint that returns media metadata or content**.
|
**Postgres RLS** — one source of truth: the labeled policy block in
|
||||||
It is NOT automatic — enforcement is per-handler.
|
`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
|
- **The engine is the enforcement.** Every `media_items` read is
|
||||||
requesting user's visible-library set, and MUST reject an explicit
|
filtered by the policy, which joins `library_visibility` on the
|
||||||
`library_id` outside that set with 403 (never silently pass).
|
per-transaction GUC `app.user_id`. New media-listing queries need
|
||||||
- Helpers (do not hand-roll):
|
NO visibility clause; new endpoints need NO visibility plumbing.
|
||||||
- `handlers`: `(*MediaHandler).visibleLibrarySet` /
|
- **The app declares identity; it never computes visibility.** All
|
||||||
`(*SeriesHandler).visibleLibrarySet` — membership map + id slice.
|
sqlc statements run through `ScopedDB`
|
||||||
- `router`: `visibleLibraryIDsFor` — same for SSR routes.
|
(`internal/database/scoped.go`), which declares `app.user_id` from
|
||||||
- `resolveLibrary` (helpers.go) only honors requested library ids
|
the stdlib request context. Three bridge sites put it there:
|
||||||
inside the visible set.
|
`router.go` JWT, `router.go` cookie/device, and
|
||||||
- SQL pattern: `AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR
|
`middleware/device_auth.go` (KOReader/Kobo sync devices). A media
|
||||||
library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))` — see
|
read on a detached `context.Background()` RAISES — thread the
|
||||||
`ListMediaItems`, `GetDistinctSeries`, `SearchMediaItems`.
|
request context or the acting identity (see conflicts.go and
|
||||||
- Content endpoints (file bytes, page images) additionally gate per item
|
analytics.go for the pattern).
|
||||||
like `ReaderHandler.ensureItemVisible` / the download endpoint.
|
- **Background jobs declare the acting identity** before detached
|
||||||
- Tests must pin visibility: hidden library → 403 on the endpoint, and
|
work: scan jobs carry the library owner / first-admin fallback
|
||||||
absent from unscoped aggregates.
|
(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
|
History: 2026-10-13 (`431fec0`) the series/media listing surfaces
|
||||||
leaked hidden libraries to any authenticated account — series endpoints
|
leaked hidden libraries; **2026-10-29 enforcement moved to RLS** — the
|
||||||
were name-only across ALL libraries. Fixed; do not regress.
|
dashboard section queries had silently never carried the clauses,
|
||||||
|
and the invariant test went red → green and stays as the auditor.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|||||||
@@ -21,160 +21,165 @@ A modern self-hosted media library system built with Go, PostgreSQL, HTMX, and T
|
|||||||
- **Podman** (recommended) or Docker
|
- **Podman** (recommended) or Docker
|
||||||
- **5 minutes** of your time
|
- **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
|
```bash
|
||||||
# 1. Clone the repository
|
mkdir bookhoard && cd bookhoard
|
||||||
git clone https://git.linuxhg.com/Bookhoard/bookhoard.git
|
mkdir uploads
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
# Application Configuration
|
||||||
- **Format-Aware**: EPUB CFI, page numbers, percentages - all handled correctly
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
- **Offline Queue**: Changes sync when you reconnect, priority-processed
|
SERVER_PORT: ${SERVER_PORT:-8765}
|
||||||
- **Conflict Resolution**: Smart handling when same book read on multiple devices
|
# IMPORTANT: Device sync requires full URL with protocol
|
||||||
- **Book Matching**: Automatic matching using SHA-256, ISBN, UUID
|
# Local: http://localhost:8765
|
||||||
- **OPDS Catalog**: Wireless book delivery to e-readers over Wi-Fi
|
# Local network: http://192.168.1.X:8765
|
||||||
- **Format Conversion**: On-the-fly EPUB→KEPUB conversion (for upcoming native Kobo support)
|
# 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
|
# Conversion Service Configuration
|
||||||
- **Smart Search**: Partial matching with fuzzy search fallback for typos
|
BOOKHOARD_CONVERSION_CACHE_DIR: ${BOOKHOARD_CONVERSION_CACHE_DIR:-/app/cache/kepub}
|
||||||
- **Advanced Filtering**: Filter by author, series, genre, language, year, cover images
|
BOOKHOARD_CONVERSION_TOOL: ${BOOKHOARD_CONVERSION_TOOL:-/usr/bin/kepubify}
|
||||||
- **Dynamic Sorting**: By title, author, date added, published date, page count, series
|
BOOKHOARD_CONVERSION_CACHE_TTL: ${BOOKHOARD_CONVERSION_CACHE_TTL:-24h}
|
||||||
- **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
|
|
||||||
|
|
||||||
### 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
|
# System timezone (fallback for server-side time operations)
|
||||||
- **Device Shelf Mappings**: Map collections to device shelves (used by native Kobo sync, coming soon)
|
TZ: ${TZ:-UTC}
|
||||||
- **Test Before Creating**: Preview which books match your rules
|
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
|
# Named Volumes
|
||||||
|
volumes:
|
||||||
- **Multi-Library Support**: Ebooks, Comics, and Manga with type-specific file formats
|
postgres_data:
|
||||||
- **Multiple Folders**: Add multiple scanning folders per library
|
bookhoard_conversion_cache:
|
||||||
- **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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+10
-2
@@ -41,7 +41,12 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer dbPool.Close()
|
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
|
// Initialize database schema
|
||||||
log.Println("🔧 Ensuring database schema is initialized...")
|
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
|
// 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.
|
// 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)
|
registry := database.NewSettingsRegistry(queries)
|
||||||
if err := registry.Load(ctx); err != nil {
|
if err := registry.Load(ctx); err != nil {
|
||||||
log.Printf("⚠️ Could not load system settings (using defaults): %v", err)
|
log.Printf("⚠️ Could not load system settings (using defaults): %v", err)
|
||||||
@@ -165,7 +173,7 @@ func main() {
|
|||||||
dashboardHandler := handlers.NewDashboardHandler(queries)
|
dashboardHandler := handlers.NewDashboardHandler(queries)
|
||||||
seriesHandler := handlers.NewSeriesHandler(queries)
|
seriesHandler := handlers.NewSeriesHandler(queries)
|
||||||
filtersHandler := handlers.NewFiltersHandler(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)
|
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
||||||
jobsHandler := handlers.NewJobsHandler(queries, worker)
|
jobsHandler := handlers.NewJobsHandler(queries, worker)
|
||||||
|
|
||||||
|
|||||||
@@ -289,7 +289,12 @@ func TestAudiobookTrackEndpoints(t *testing.T) {
|
|||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
_ = resp.Body.Close()
|
_ = 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
|
// Restore visibility so harness cleanup ordering never sees a
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"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 {
|
func createPreviewTestMediaItems(t *testing.T, db *database.Queries, libraryID string, count int) []string {
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
libUUID, err := uuid.Parse(libraryID)
|
libUUID, err := uuid.Parse(libraryID)
|
||||||
require.NoError(t, err, "Should parse library ID")
|
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++ {
|
for i := 0; i < count; i++ {
|
||||||
genre := genres[i%len(genres)]
|
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},
|
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||||
Title: "Preview Test Book " + string(rune('A'+i)),
|
Title: "Preview Test Book " + string(rune('A'+i)),
|
||||||
Authors: []string{"Test Author " + 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 {
|
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},
|
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||||
Title: title,
|
Title: title,
|
||||||
Authors: []string{"Smart Rule Author"},
|
Authors: []string{"Smart Rule Author"},
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
params.AlternateInfo = []byte(v)
|
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")
|
require.NoError(t, err, "Should create media item")
|
||||||
|
|
||||||
mediaUUID, err := uuid.FromBytes(mediaItem.ID.Bytes[0:16])
|
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},
|
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")
|
require.NoError(t, err, "Should create ebook")
|
||||||
|
|
||||||
mediaUUID, err := uuid.FromBytes(mediaItem.ID.Bytes[0:16])
|
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
|
// Create a media item with ALL metadata fields populated
|
||||||
alternateInfoStr := `{"alternate_series":"Ultimate X-Men","alternate_number":1,"alternate_count":12}`
|
alternateInfoStr := `{"alternate_series":"Ultimate X-Men","alternate_number":1,"alternate_count":12}`
|
||||||
var mediaItem database.MediaItems
|
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},
|
LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
|
||||||
Title: "Complete Metadata Comic",
|
Title: "Complete Metadata Comic",
|
||||||
FilePath: "/test/complete.cbz",
|
FilePath: "/test/complete.cbz",
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func TestComicMetadataExtraction(t *testing.T) {
|
|||||||
require.NoError(t, err, "Should parse library UUID")
|
require.NoError(t, err, "Should parse library UUID")
|
||||||
|
|
||||||
// Insert test media item with full comic metadata
|
// 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},
|
LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
|
||||||
Title: "Test Manga",
|
Title: "Test Manga",
|
||||||
FilePath: "/test/manga.cbz",
|
FilePath: "/test/manga.cbz",
|
||||||
@@ -64,7 +64,7 @@ func TestComicMetadataExtraction(t *testing.T) {
|
|||||||
parsedLibraryID, err := uuid.Parse(libraryID)
|
parsedLibraryID, err := uuid.Parse(libraryID)
|
||||||
require.NoError(t, err, "Should parse library UUID")
|
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},
|
LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
|
||||||
Title: "Test Comic",
|
Title: "Test Comic",
|
||||||
FilePath: "/test/comic.cbz",
|
FilePath: "/test/comic.cbz",
|
||||||
@@ -94,7 +94,7 @@ func TestComicMetadataExtraction(t *testing.T) {
|
|||||||
parsedLibraryID, err := uuid.Parse(libraryID)
|
parsedLibraryID, err := uuid.Parse(libraryID)
|
||||||
require.NoError(t, err, "Should parse library UUID")
|
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},
|
LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
|
||||||
Title: "Minimal Comic",
|
Title: "Minimal Comic",
|
||||||
FilePath: "/test/minimal.cbz",
|
FilePath: "/test/minimal.cbz",
|
||||||
@@ -136,7 +136,7 @@ func TestReadingDirectionAPI(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range testCases {
|
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},
|
LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
|
||||||
Title: tc.title,
|
Title: tc.title,
|
||||||
FilePath: "/test/" + tc.title + ".cbz",
|
FilePath: "/test/" + tc.title + ".cbz",
|
||||||
@@ -205,7 +205,7 @@ func TestUniversalMetadataFields(t *testing.T) {
|
|||||||
require.NoError(t, err, "Should parse ebook library UUID")
|
require.NoError(t, err, "Should parse ebook library UUID")
|
||||||
|
|
||||||
t.Run("Comic with universal fields", func(t *testing.T) {
|
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},
|
LibraryID: pgtype.UUID{Bytes: parsedComicLibraryID, Valid: true},
|
||||||
Title: "Comic with Universal Metadata",
|
Title: "Comic with Universal Metadata",
|
||||||
FilePath: "/test/comic.cbz",
|
FilePath: "/test/comic.cbz",
|
||||||
@@ -231,7 +231,7 @@ func TestUniversalMetadataFields(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Ebook with universal fields", func(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},
|
LibraryID: pgtype.UUID{Bytes: parsedEbookLibraryID, Valid: true},
|
||||||
Title: "Ebook with Universal Metadata",
|
Title: "Ebook with Universal Metadata",
|
||||||
FilePath: "/test/book.epub",
|
FilePath: "/test/book.epub",
|
||||||
@@ -270,7 +270,7 @@ func TestComicSpecificFields(t *testing.T) {
|
|||||||
t.Run("Alternate series info as JSONB", func(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}`
|
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},
|
LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
|
||||||
Title: "X-Men with Alternate Series",
|
Title: "X-Men with Alternate Series",
|
||||||
FilePath: "/test/xmen.cbz",
|
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())
|
seriesName := fmt.Sprintf("Test Contract Series %d", time.Now().UnixNano())
|
||||||
itemTitle := fmt.Sprintf("Test Contract Item %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},
|
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||||
Title: itemTitle,
|
Title: itemTitle,
|
||||||
Authors: []string{"Test Contract Author"},
|
Authors: []string{"Test Contract Author"},
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ func TestFacetRegistryParamsFilter(t *testing.T) {
|
|||||||
if mutate != nil {
|
if mutate != nil {
|
||||||
mutate(&p)
|
mutate(&p)
|
||||||
}
|
}
|
||||||
_, err := setup.DB.CreateMediaItem(ctx, p)
|
_, err := setup.DB.CreateMediaItem(seedCtx(), p)
|
||||||
require.NoError(t, err, "create %s", title)
|
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.
|
// Two items share the genre; one carries the language + narrator.
|
||||||
for _, title := range []string{"Facet List One", "Facet List Two"} {
|
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,
|
LibraryID: library.ID,
|
||||||
Title: title,
|
Title: title,
|
||||||
Authors: []string{"Facet Listing Author"},
|
Authors: []string{"Facet Listing Author"},
|
||||||
@@ -206,7 +206,7 @@ func TestFacetsEndpoint(t *testing.T) {
|
|||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
_, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
LibraryID: library.ID,
|
LibraryID: library.ID,
|
||||||
Title: "Facet List Three",
|
Title: "Facet List Three",
|
||||||
Authors: []string{"Facet Listing Author"},
|
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).
|
// "Baen Books") cross-match at 0.3+ (session gotcha).
|
||||||
func facetFixtureBooks(t *testing.T, setup *TestServerSetup, libraryID pgtype.UUID, prefix string) {
|
func facetFixtureBooks(t *testing.T, setup *TestServerSetup, libraryID pgtype.UUID, prefix string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
ctx := context.Background()
|
|
||||||
books := []struct {
|
books := []struct {
|
||||||
title string
|
title string
|
||||||
genre string
|
genre string
|
||||||
@@ -329,7 +328,7 @@ func facetFixtureBooks(t *testing.T, setup *TestServerSetup, libraryID pgtype.UU
|
|||||||
{prefix + " Gamma", "history", "Pinecrest"},
|
{prefix + " Gamma", "history", "Pinecrest"},
|
||||||
}
|
}
|
||||||
for _, b := range books {
|
for _, b := range books {
|
||||||
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
_, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
LibraryID: libraryID,
|
LibraryID: libraryID,
|
||||||
Title: b.title,
|
Title: b.title,
|
||||||
Authors: []string{prefix + " Author"},
|
Authors: []string{prefix + " Author"},
|
||||||
@@ -426,7 +425,7 @@ func TestFacetCountsUnderFilters(t *testing.T) {
|
|||||||
facetFixtureBooks(t, setup, library.ID, "Counts")
|
facetFixtureBooks(t, setup, library.ID, "Counts")
|
||||||
// A book in the HIDDEN library: distinctive values that must never
|
// A book in the HIDDEN library: distinctive values that must never
|
||||||
// surface for the regular user.
|
// surface for the regular user.
|
||||||
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
_, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
LibraryID: hiddenLib.ID,
|
LibraryID: hiddenLib.ID,
|
||||||
Title: "Counts Hidden Book",
|
Title: "Counts Hidden Book",
|
||||||
Authors: []string{"Counts Secret Author"},
|
Authors: []string{"Counts Secret Author"},
|
||||||
@@ -559,7 +558,7 @@ func TestFieldValuesAllLibraries(t *testing.T) {
|
|||||||
id pgtype.UUID
|
id pgtype.UUID
|
||||||
name string
|
name string
|
||||||
}{{visibleLib.ID, "Visible"}, {hiddenLib.ID, "Hidden"}} {
|
}{{visibleLib.ID, "Visible"}, {hiddenLib.ID, "Hidden"}} {
|
||||||
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
_, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
LibraryID: lib.id,
|
LibraryID: lib.id,
|
||||||
Title: fmt.Sprintf("Field Values %s Book", lib.name),
|
Title: fmt.Sprintf("Field Values %s Book", lib.name),
|
||||||
Authors: []string{"Verdana Scribe"},
|
Authors: []string{"Verdana Scribe"},
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ func builderFixtureLib(t *testing.T, setup *TestServerSetup, userID uuid.UUID, n
|
|||||||
}
|
}
|
||||||
books[i].TagsSearch = lowered
|
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)
|
require.NoError(t, err, "create %s", books[i].Title)
|
||||||
}
|
}
|
||||||
return libUUID, library.ID
|
return libUUID, library.ID
|
||||||
@@ -267,11 +267,9 @@ func TestFilterComposedSemantics(t *testing.T) {
|
|||||||
assert.Len(t, got, 4, "mime_type is")
|
assert.Len(t, got, 4, "mime_type is")
|
||||||
|
|
||||||
// Series full membership: the alternate_info series matches too.
|
// Series full membership: the alternate_info series matches too.
|
||||||
ctx := context.Background()
|
seedExec(t, setup.DBPool,
|
||||||
_, err := setup.DBPool.Exec(ctx,
|
|
||||||
`UPDATE media_items SET alternate_info = '{"alternate_series":"Quill Chronicles"}'::jsonb
|
`UPDATE media_items SET alternate_info = '{"alternate_series":"Quill Chronicles"}'::jsonb
|
||||||
WHERE title = $1`, "Sem Fen")
|
WHERE title = $1`, "Sem Fen")
|
||||||
require.NoError(t, err)
|
|
||||||
got = titles(qfAll(qfRule("series", "contains", "Quill Chron")))
|
got = titles(qfAll(qfRule("series", "contains", "Quill Chron")))
|
||||||
assert.Equal(t, []string{"Sem Fen"}, got, "series matches alternate_info membership")
|
assert.Equal(t, []string{"Sem Fen"}, got, "series matches alternate_info membership")
|
||||||
got = titles(qfAll(qfRule("series", "is", "Quill Chronicles")))
|
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,
|
// Reader A: reading Tide (50%), finished Stone, rated Tide 8,
|
||||||
// reviewed Fen, noted on Tide ("the catharsis scene"), highlighted
|
// reviewed Fen, noted on Tide ("the catharsis scene"), highlighted
|
||||||
// Stone, time on Wick (2h), collection holding Tide.
|
// 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())`,
|
`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)
|
tideID, aID, stoneID, aID)
|
||||||
require.NoError(t, err)
|
seedExec(t, setup.DBPool,
|
||||||
_, err = setup.DBPool.Exec(ctx,
|
|
||||||
`INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at) VALUES ($1,$2,1.0, now())`,
|
`INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at) VALUES ($1,$2,1.0, now())`,
|
||||||
tideID, bID)
|
tideID, bID)
|
||||||
require.NoError(t, err)
|
seedExec(t, setup.DBPool,
|
||||||
_, err = setup.DBPool.Exec(ctx,
|
|
||||||
`INSERT INTO media_ratings (media_item_id, user_id, rating) VALUES ($1,$2,8)`, tideID, aID)
|
`INSERT INTO media_ratings (media_item_id, user_id, rating) VALUES ($1,$2,8)`, tideID, aID)
|
||||||
require.NoError(t, err)
|
seedExec(t, setup.DBPool,
|
||||||
_, err = setup.DBPool.Exec(ctx,
|
|
||||||
`INSERT INTO media_ratings (media_item_id, user_id, rating) VALUES ($1,$2,3)`, tideID, bID)
|
`INSERT INTO media_ratings (media_item_id, user_id, rating) VALUES ($1,$2,3)`, tideID, bID)
|
||||||
require.NoError(t, err)
|
seedExec(t, setup.DBPool,
|
||||||
_, err = setup.DBPool.Exec(ctx,
|
|
||||||
`INSERT INTO media_reviews (media_item_id, user_id, review) VALUES ($1,$2,'splendid')`, fenID, aID)
|
`INSERT INTO media_reviews (media_item_id, user_id, review) VALUES ($1,$2,'splendid')`, fenID, aID)
|
||||||
require.NoError(t, err)
|
seedExec(t, setup.DBPool,
|
||||||
_, err = setup.DBPool.Exec(ctx,
|
|
||||||
`INSERT INTO media_notes (media_item_id, user_id, content) VALUES ($1,$2,'remember the catharsis scene')`, tideID, aID)
|
`INSERT INTO media_notes (media_item_id, user_id, content) VALUES ($1,$2,'remember the catharsis scene')`, tideID, aID)
|
||||||
require.NoError(t, err)
|
seedExec(t, setup.DBPool,
|
||||||
_, err = setup.DBPool.Exec(ctx,
|
|
||||||
`INSERT INTO media_highlights (media_item_id, user_id, selection_text) VALUES ($1,$2,'the dark forest road')`, stoneID, aID)
|
`INSERT INTO media_highlights (media_item_id, user_id, selection_text) VALUES ($1,$2,'the dark forest road')`, stoneID, aID)
|
||||||
require.NoError(t, err)
|
seedExec(t, setup.DBPool,
|
||||||
_, err = setup.DBPool.Exec(ctx,
|
|
||||||
`INSERT INTO reading_history (user_id, media_item_id, time_spent_seconds, pages_read) VALUES ($1,$2,7200,120), ($3,$4,600,30)`,
|
`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)
|
aID, wickID, aID, tideID)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
var collID pgtype.UUID
|
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`,
|
`INSERT INTO collections (user_id, name) VALUES ($1, $2) RETURNING id`,
|
||||||
aID, fmt.Sprintf("Test Builder Coll %d", time.Now().UnixNano())).Scan(&collID)
|
aID, fmt.Sprintf("Test Builder Coll %d", time.Now().UnixNano())).Scan(&collID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
t.Cleanup(func() { _ = setup.DB.DeleteCollection(ctx, collID) })
|
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)
|
`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 {
|
titlesFor := func(token string, qf string) []string {
|
||||||
p := url.Values{"library_id": {libUUID.String()}, "qf": {qf}}
|
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).
|
// Add a second distinct author+genre pair (trigram-disjoint).
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
_, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||||
Title: "Sug Dune",
|
Title: "Sug Dune",
|
||||||
Authors: []string{"Zephyrine Hale"},
|
Authors: []string{"Zephyrine Hale"},
|
||||||
@@ -572,18 +561,17 @@ func TestFilterSuggestionsUnderRules(t *testing.T) {
|
|||||||
// offered with the surviving count; zero-match containers never are.
|
// offered with the surviving count; zero-match containers never are.
|
||||||
userA := getRegularUserID(t, setup.DB)
|
userA := getRegularUserID(t, setup.DB)
|
||||||
var collID pgtype.UUID
|
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`,
|
`INSERT INTO collections (user_id, name) VALUES ($1, $2) RETURNING id`,
|
||||||
pgtype.UUID{Bytes: [16]byte(userA), Valid: true},
|
pgtype.UUID{Bytes: [16]byte(userA), Valid: true},
|
||||||
fmt.Sprintf("Test Builder Sug Coll %d", time.Now().UnixNano())).Scan(&collID)
|
fmt.Sprintf("Test Builder Sug Coll %d", time.Now().UnixNano())).Scan(&collID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
t.Cleanup(func() { _ = setup.DB.DeleteCollection(ctx, collID) })
|
t.Cleanup(func() { _ = setup.DB.DeleteCollection(ctx, collID) })
|
||||||
var tideID pgtype.UUID
|
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)
|
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)
|
`INSERT INTO collection_items (collection_id, media_item_id) VALUES ($1, $2)`, collID, tideID)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
status, results = suggest("collection", " ", "")
|
status, results = suggest("collection", " ", "")
|
||||||
require.Equal(t, http.StatusOK, status)
|
require.Equal(t, http.StatusOK, status)
|
||||||
@@ -632,7 +620,7 @@ func TestFilterFamilyLens(t *testing.T) {
|
|||||||
id pgtype.UUID
|
id pgtype.UUID
|
||||||
title string
|
title string
|
||||||
}{{visibleID, "Lens Vis Book"}, {hiddenID, "Lens Hid Book"}} {
|
}{{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,
|
LibraryID: lib.id, Title: lib.title,
|
||||||
Authors: []string{"Lens Secret Author"},
|
Authors: []string{"Lens Secret Author"},
|
||||||
FilePath: fmt.Sprintf("/tmp/builder/%s-%d.epub", lib.title, time.Now().UnixNano()),
|
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)
|
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)`,
|
`INSERT INTO library_visibility (library_id, user_id, is_visible) VALUES ($1, $2, false)`,
|
||||||
hiddenID, pgtype.UUID{Bytes: [16]byte(userID), Valid: true})
|
hiddenID, pgtype.UUID{Bytes: [16]byte(userID), Valid: true})
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
search := func(params url.Values) (int, []map[string]interface{}) {
|
search := func(params url.Values) (int, []map[string]interface{}) {
|
||||||
return qfSearch(t, setup, setup.RegularToken, params)
|
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,
|
LibraryID: library.ID,
|
||||||
Title: "Ownership Suite Item",
|
Title: "Ownership Suite Item",
|
||||||
Authors: []string{"Suite Author"},
|
Authors: []string{"Suite Author"},
|
||||||
@@ -269,7 +269,7 @@ func (s *OwnershipAndAdminTestSuite) TestDelete_StorageDownRefused() {
|
|||||||
})
|
})
|
||||||
require.NoError(s.T(), err)
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
item, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
item, err := db.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
LibraryID: library.ID,
|
LibraryID: library.ID,
|
||||||
Title: "Offline Storage Item",
|
Title: "Offline Storage Item",
|
||||||
FilePath: "offline.epub",
|
FilePath: "offline.epub",
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ func TestManuallyEditedFilter(t *testing.T) {
|
|||||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||||
|
|
||||||
createBook := func(path string) pgtype.UUID {
|
createBook := func(path string) pgtype.UUID {
|
||||||
created, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
created, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
LibraryID: libID,
|
LibraryID: libID,
|
||||||
Title: path,
|
Title: path,
|
||||||
Authors: []string{},
|
Authors: []string{},
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ func createTestQueueItem(t *testing.T, db *database.Queries, userID uuid.UUID) (
|
|||||||
require.NoError(t, err, "Should create library")
|
require.NoError(t, err, "Should create library")
|
||||||
|
|
||||||
// 4. Create media item
|
// 4. Create media item
|
||||||
mediaItem, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
mediaItem, err := db.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
LibraryID: library.ID,
|
LibraryID: library.ID,
|
||||||
Title: "Test Book for Queue",
|
Title: "Test Book for Queue",
|
||||||
Authors: []string{"Test Author"},
|
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))
|
require.NoError(t, os.WriteFile(apiFile, []byte("api made book content"), 0644))
|
||||||
apiStat, err := os.Stat(apiFile)
|
apiStat, err := os.Stat(apiFile)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
created, err := setup.DB.CreateMediaItem(context.Background(), database.CreateMediaItemParams{
|
created, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
LibraryID: libID,
|
LibraryID: libID,
|
||||||
Title: "API Made Book",
|
Title: "API Made Book",
|
||||||
Authors: []string{"API Author"},
|
Authors: []string{"API Author"},
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ func TestReadingStatsSummary(t *testing.T) {
|
|||||||
media, err = setup.DB.GetMediaItem(ctx, uuidToPGType(uuid.MustParse(reuseMediaID[0])))
|
media, err = setup.DB.GetMediaItem(ctx, uuidToPGType(uuid.MustParse(reuseMediaID[0])))
|
||||||
require.NoError(t, err, "reuse seeded media item")
|
require.NoError(t, err, "reuse seeded media item")
|
||||||
} else {
|
} 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},
|
LibraryID: pgtype.UUID{Bytes: [16]byte(libUUID), Valid: true},
|
||||||
Title: title,
|
Title: title,
|
||||||
FilePath: fmt.Sprintf("/tmp/stats-%s-%d.epub", title, time.Now().UnixNano()),
|
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"))
|
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) {
|
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},
|
LibraryID: pgtype.UUID{Bytes: [16]byte(lib), Valid: true},
|
||||||
Title: title,
|
Title: title,
|
||||||
FilePath: fmt.Sprintf("/tmp/%s-%d.epub", title, time.Now().UnixNano()),
|
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) {
|
func TestLibraryUsageStats(t *testing.T) {
|
||||||
setup := setupTestServer(t)
|
setup := setupTestServer(t)
|
||||||
userID := getRegularUserID(t, setup.DB)
|
userID := getRegularUserID(t, setup.DB)
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
ebooksID := uuid.MustParse(createLibraryDirect(t, setup.DB, userID,
|
ebooksID := uuid.MustParse(createLibraryDirect(t, setup.DB, userID,
|
||||||
fmt.Sprintf("Test Usage Ebooks %d", time.Now().UnixNano()), "ebooks"))
|
fmt.Sprintf("Test Usage Ebooks %d", time.Now().UnixNano()), "ebooks"))
|
||||||
@@ -541,7 +540,7 @@ func TestLibraryUsageStats(t *testing.T) {
|
|||||||
if durationSeconds > 0 {
|
if durationSeconds > 0 {
|
||||||
audioMeta = []byte(fmt.Sprintf(`{"duration_seconds":%d,"chapters":[]}`, durationSeconds))
|
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},
|
LibraryID: pgtype.UUID{Bytes: [16]byte(lib), Valid: true},
|
||||||
Title: title,
|
Title: title,
|
||||||
Authors: []string{author},
|
Authors: []string{author},
|
||||||
@@ -689,7 +688,6 @@ func TestLibraryUsageStats(t *testing.T) {
|
|||||||
func TestLibraryUsageStats_Visibility(t *testing.T) {
|
func TestLibraryUsageStats_Visibility(t *testing.T) {
|
||||||
setup := setupTestServer(t)
|
setup := setupTestServer(t)
|
||||||
userID := getRegularUserID(t, setup.DB)
|
userID := getRegularUserID(t, setup.DB)
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
visibleID := uuid.MustParse(createLibraryDirect(t, setup.DB, userID,
|
visibleID := uuid.MustParse(createLibraryDirect(t, setup.DB, userID,
|
||||||
fmt.Sprintf("Test Usage Visible %d", time.Now().UnixNano()), "ebooks"))
|
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"))
|
fmt.Sprintf("Test Usage Hidden %d", time.Now().UnixNano()), "ebooks"))
|
||||||
|
|
||||||
seed := func(lib uuid.UUID, title string, size int64) {
|
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},
|
LibraryID: pgtype.UUID{Bytes: [16]byte(lib), Valid: true},
|
||||||
Title: title,
|
Title: title,
|
||||||
FilePath: fmt.Sprintf("/tmp/usage-vis-%s-%d.bin", title, time.Now().UnixNano()),
|
FilePath: fmt.Sprintf("/tmp/usage-vis-%s-%d.bin", title, time.Now().UnixNano()),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bookhoard/internal/config"
|
"bookhoard/internal/config"
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
|
"bookhoard/internal/rls"
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -10,6 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -26,11 +28,16 @@ func setupSyncTestDB(t *testing.T) *database.Queries {
|
|||||||
dbConfig, err := pgxpool.ParseConfig(dbURL)
|
dbConfig, err := pgxpool.ParseConfig(dbURL)
|
||||||
require.NoError(t, err, "Failed to parse database URL")
|
require.NoError(t, err, "Failed to parse database URL")
|
||||||
dbConfig.MaxConns = 1
|
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)
|
dbPool, err := pgxpool.NewWithConfig(ctx, dbConfig)
|
||||||
require.NoError(t, err, "Failed to connect to test database")
|
require.NoError(t, err, "Failed to connect to test database")
|
||||||
|
|
||||||
db := database.New(dbPool)
|
db := database.New(database.NewSeedDB(dbPool))
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
// Scoped wipes ONLY: reading_history now carries real captured
|
// Scoped wipes ONLY: reading_history now carries real captured
|
||||||
// sessions for the shared dev accounts, so a table-wide DELETE
|
// 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 {
|
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,
|
LibraryID: libraryID,
|
||||||
Title: "Sync Test Book",
|
Title: "Sync Test Book",
|
||||||
Authors: []string{"Test Author"},
|
Authors: []string{"Test Author"},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"bookhoard/internal/handlers"
|
"bookhoard/internal/handlers"
|
||||||
"bookhoard/internal/middleware"
|
"bookhoard/internal/middleware"
|
||||||
ratelimit "bookhoard/internal/middleware"
|
ratelimit "bookhoard/internal/middleware"
|
||||||
|
"bookhoard/internal/rls"
|
||||||
"bookhoard/internal/router"
|
"bookhoard/internal/router"
|
||||||
"bookhoard/internal/services"
|
"bookhoard/internal/services"
|
||||||
wsync "bookhoard/internal/sync"
|
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
|
// uuidToPGType converts uuid.UUID to pgtype.UUID
|
||||||
func uuidToPGType(u uuid.UUID) pgtype.UUID {
|
func uuidToPGType(u uuid.UUID) pgtype.UUID {
|
||||||
return pgtype.UUID{Bytes: [16]byte(u), Valid: true}
|
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())
|
dbConfig, err := pgxpool.ParseConfig(cfg.DatabaseURL())
|
||||||
require.NoError(t, err, "Failed to parse database URL")
|
require.NoError(t, err, "Failed to parse database URL")
|
||||||
dbConfig.MaxConns = 1
|
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)
|
dbPool, err := pgxpool.NewWithConfig(context.Background(), dbConfig)
|
||||||
require.NoError(t, err, "Failed to connect to test database")
|
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
|
// Wire the tunable settings registry exactly like main.go: Load
|
||||||
// populates the cache (missing rows fall back to compiled defaults)
|
// populates the cache (missing rows fall back to compiled defaults)
|
||||||
@@ -552,7 +603,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
statisticsService := services.NewStatisticsService(queries)
|
statisticsService := services.NewStatisticsService(queries)
|
||||||
statsHandler := handlers.NewStatsHandler(statisticsService)
|
statsHandler := handlers.NewStatsHandler(statisticsService)
|
||||||
seriesHandler := handlers.NewSeriesHandler(queries)
|
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)
|
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
||||||
|
|
||||||
// Create conversion service for OPDS
|
// Create conversion service for OPDS
|
||||||
@@ -756,7 +807,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
// Create TestServerSetup struct with all resources
|
// Create TestServerSetup struct with all resources
|
||||||
setup := &TestServerSetup{
|
setup := &TestServerSetup{
|
||||||
Server: ts,
|
Server: ts,
|
||||||
DB: queries,
|
DB: seedDB, // harness seeding/cleanup handle (see comment above)
|
||||||
DBPool: dbPool,
|
DBPool: dbPool,
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
ConnManager: connManager,
|
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)
|
-- Widen for databases created before the resolution format settled (no-op otherwise)
|
||||||
ALTER TABLE hash_conflicts ALTER COLUMN resolution TYPE VARCHAR(50);
|
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
|
container_name: bookhoard_tests
|
||||||
environment:
|
environment:
|
||||||
# Database Configuration
|
# Database Configuration
|
||||||
|
# Runtime role = bookhoard_srv (non-superuser OWNER, subject to RLS
|
||||||
|
# via FORCE) — same role the app service uses.
|
||||||
DATABASE_HOST: db
|
DATABASE_HOST: db
|
||||||
DATABASE_PORT: ${DB_PORT:-5432}
|
DATABASE_PORT: ${DB_PORT:-5432}
|
||||||
DATABASE_USER: postgres
|
DATABASE_USER: bookhoard_srv
|
||||||
DATABASE_PASSWORD: ${DBPASS}
|
DATABASE_PASSWORD: ${DBSRVPASS}
|
||||||
DATABASE_NAME: bookhoard
|
DATABASE_NAME: bookhoard
|
||||||
COOKIE_SECURE: false
|
COOKIE_SECURE: false
|
||||||
|
|
||||||
|
|||||||
+59
-5
@@ -11,9 +11,6 @@ services:
|
|||||||
PGPORT: ${DB_PORT:-5432}
|
PGPORT: ${DB_PORT:-5432}
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
- ./database/schema:/docker-entrypoint-initdb.d
|
|
||||||
# Make other volumes as needed
|
|
||||||
- ./uploads:/app/uploads
|
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:${DB_PORT:-5432}:${DB_PORT:-5432}"
|
- "127.0.0.1:${DB_PORT:-5432}:${DB_PORT:-5432}"
|
||||||
- "[::1]:${DB_PORT:-5432}:${DB_PORT:-5432}"
|
- "[::1]:${DB_PORT:-5432}:${DB_PORT:-5432}"
|
||||||
@@ -26,6 +23,58 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .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
|
# Bookhoard Application
|
||||||
# In production this image is pulled from the Gitea container registry.
|
# 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").
|
# Override IMAGE_TAG in .env to pin or rollback a specific version (defaults to "latest").
|
||||||
@@ -35,10 +84,13 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
# Database Configuration
|
# 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_HOST: db
|
||||||
DATABASE_PORT: ${DB_PORT:-5432}
|
DATABASE_PORT: ${DB_PORT:-5432}
|
||||||
DATABASE_USER: postgres
|
DATABASE_USER: bookhoard_srv
|
||||||
DATABASE_PASSWORD: ${DBPASS}
|
DATABASE_PASSWORD: ${DBSRVPASS}
|
||||||
DATABASE_NAME: bookhoard
|
DATABASE_NAME: bookhoard
|
||||||
|
|
||||||
# Application Configuration
|
# Application Configuration
|
||||||
@@ -75,6 +127,8 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
db-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
volumes:
|
volumes:
|
||||||
- ./uploads:/app/uploads
|
- ./uploads:/app/uploads
|
||||||
- bookhoard_conversion_cache:/app/cache/kepub
|
- bookhoard_conversion_cache:/app/cache/kepub
|
||||||
|
|||||||
@@ -432,3 +432,14 @@ library_name`.
|
|||||||
partial (layout=dropdown on the same endpoint, HX-Request) instead
|
partial (layout=dropdown on the same endpoint, HX-Request) instead
|
||||||
of the retired innerHTML renderer in web/src/search.ts — same
|
of the retired innerHTML renderer in web/src/search.ts — same
|
||||||
endpoint, same auth, content server-rendered.
|
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
|
DeleteUnlinkedBook(ctx context.Context, id pgtype.UUID) error
|
||||||
DeleteUser(ctx context.Context, id pgtype.UUID) error
|
DeleteUser(ctx context.Context, id pgtype.UUID) error
|
||||||
DeleteUserSystemCollection(ctx context.Context, arg DeleteUserSystemCollectionParams) 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
|
// Device existence probe for capture: the web/app progress paths
|
||||||
// historically stuff the user id into DeviceID (it only fed a broadcast
|
// historically stuff the user id into DeviceID (it only fed a broadcast
|
||||||
// label before stats); a capture row must only reference a REAL device.
|
// 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
|
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
|
const DeviceExistsForUser = `-- name: DeviceExistsForUser :one
|
||||||
SELECT EXISTS(
|
SELECT EXISTS(
|
||||||
SELECT 1 FROM devices
|
SELECT 1 FROM devices
|
||||||
|
|||||||
@@ -2809,6 +2809,14 @@ SET
|
|||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $1;
|
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
|
-- name: DeleteDevice :exec
|
||||||
DELETE FROM devices WHERE id = $1;
|
DELETE FROM devices WHERE id = $1;
|
||||||
|
|
||||||
|
|||||||
@@ -178,6 +178,16 @@ func verifyFunctions(ctx context.Context, db *pgxpool.Pool, expectedFunctions []
|
|||||||
return nil
|
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
|
// executeSchema runs the entire schema.sql in a single transaction
|
||||||
func executeSchema(ctx context.Context, db *pgxpool.Pool) error {
|
func executeSchema(ctx context.Context, db *pgxpool.Pool) error {
|
||||||
tx, err := db.Begin(ctx)
|
tx, err := db.Begin(ctx)
|
||||||
@@ -186,6 +196,12 @@ func executeSchema(ctx context.Context, db *pgxpool.Pool) error {
|
|||||||
}
|
}
|
||||||
defer tx.Rollback(ctx)
|
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)
|
_, err = tx.Exec(ctx, SchemaFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("HINT: Run manually to debug: psql -h localhost -U postgres -d bookhoard -f database/schema/schema.sql")
|
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 (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
"bookhoard/internal/utils"
|
"bookhoard/internal/utils"
|
||||||
"context"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -102,7 +101,7 @@ func (h *AnalyticsHandler) GetReadingStats(c *echo.Context) error {
|
|||||||
// "today") never appear.
|
// "today") never appear.
|
||||||
endTime = endTime.AddDate(0, 0, 1).Add(-time.Nanosecond)
|
endTime = endTime.AddDate(0, 0, 1).Add(-time.Nanosecond)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := c.Request().Context()
|
||||||
|
|
||||||
history, err := h.db.GetUserReadingHistory(ctx, database.GetUserReadingHistoryParams{
|
history, err := h.db.GetUserReadingHistory(ctx, database.GetUserReadingHistoryParams{
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
@@ -220,7 +219,7 @@ func (h *AnalyticsHandler) calculateMostActiveDay(history []database.GetUserRead
|
|||||||
func (h *AnalyticsHandler) GetDeviceUsage(c *echo.Context) error {
|
func (h *AnalyticsHandler) GetDeviceUsage(c *echo.Context) error {
|
||||||
user := c.Get("user").(database.Users)
|
user := c.Get("user").(database.Users)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := c.Request().Context()
|
||||||
|
|
||||||
usage, err := h.db.GetUserDeviceUsage(ctx, user.ID)
|
usage, err := h.db.GetUserDeviceUsage(ctx, user.ID)
|
||||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||||
@@ -268,7 +267,7 @@ func (h *AnalyticsHandler) GetPopularBooks(c *echo.Context) error {
|
|||||||
limitInt = parsedLimit
|
limitInt = parsedLimit
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := c.Request().Context()
|
||||||
|
|
||||||
books, err := h.db.GetPopularBooks(ctx, database.GetPopularBooksParams{
|
books, err := h.db.GetPopularBooks(ctx, database.GetPopularBooksParams{
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ func (h *ConflictHandler) GetConflictsData(c *echo.Context) ([]ConflictDetailRes
|
|||||||
status = "unresolved"
|
status = "unresolved"
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := c.Request().Context()
|
||||||
|
|
||||||
var conflicts interface{}
|
var conflicts interface{}
|
||||||
var err error
|
var err error
|
||||||
@@ -175,7 +175,7 @@ func (h *ConflictHandler) GetConflict(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
|
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 err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return echo.NewHTTPError(http.StatusNotFound, "conflict not found")
|
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")
|
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 {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get media item")
|
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}
|
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 err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return echo.NewHTTPError(http.StatusNotFound, "conflict not found")
|
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 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
|
appliedTo["progress"] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if conflict.ConflictType == "annotation_highlight" || conflict.ConflictType == "annotation_bookmark" || conflict.ConflictType == "annotation_note" {
|
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
|
appliedTo["annotations"] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -283,7 +283,7 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
|
|||||||
|
|
||||||
resolutionDataJSON, _ := json.Marshal(resolutionData)
|
resolutionDataJSON, _ := json.Marshal(resolutionData)
|
||||||
|
|
||||||
_, err = h.db.ResolveSyncConflict(context.Background(), database.ResolveSyncConflictParams{
|
_, err = h.db.ResolveSyncConflict(c.Request().Context(), database.ResolveSyncConflictParams{
|
||||||
ID: conflictUUID,
|
ID: conflictUUID,
|
||||||
ResolutionStatus: pgtype.Text{String: "user_resolved", Valid: true},
|
ResolutionStatus: pgtype.Text{String: "user_resolved", Valid: true},
|
||||||
ResolutionData: resolutionDataJSON,
|
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.
|
// the service's LWW seed instead of being NULLed — that is the fix.
|
||||||
// chapter_progress keeps its stored value instead of being overwritten
|
// chapter_progress keeps its stored value instead of being overwritten
|
||||||
// with the book-level percentage.
|
// 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{
|
req := wsync.SaveProgressRequest{
|
||||||
MediaItemID: mediaItemID,
|
MediaItemID: mediaItemID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
@@ -361,12 +361,11 @@ func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userI
|
|||||||
req.TotalPages = &tp
|
req.TotalPages = &tp
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := h.progressSvc.SaveProgress(context.Background(), req)
|
_, err := h.progressSvc.SaveProgress(ctx, req)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *ConflictHandler) applyAnnotationResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerData map[string]interface{}, conflictType string) error {
|
func (h *ConflictHandler) applyAnnotationResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, winnerData map[string]interface{}, conflictType string) error {
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
dedupKey, _ := winnerData["dedup_key"].(string)
|
dedupKey, _ := winnerData["dedup_key"].(string)
|
||||||
if dedupKey == "" {
|
if dedupKey == "" {
|
||||||
@@ -530,7 +529,7 @@ func (h *ConflictHandler) DeleteConflict(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
|
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 err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return echo.NewHTTPError(http.StatusNotFound, "conflict not found")
|
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")
|
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")
|
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 {
|
func (h *ConflictHandler) DismissAllResolved(c *echo.Context) error {
|
||||||
user := c.Get("user").(database.Users)
|
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 {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to list conflicts")
|
return echo.NewHTTPError(http.StatusInternalServerError, "failed to list conflicts")
|
||||||
}
|
}
|
||||||
@@ -560,7 +559,7 @@ func (h *ConflictHandler) DismissAllResolved(c *echo.Context) error {
|
|||||||
deleted := 0
|
deleted := 0
|
||||||
for _, conflict := range conflicts {
|
for _, conflict := range conflicts {
|
||||||
if conflict.ResolutionStatus.String == "user_resolved" || conflict.ResolutionStatus.String == "bulk_resolved" {
|
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++
|
deleted++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -620,7 +619,7 @@ func (h *ConflictHandler) BulkResolveConflicts(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
|
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 err != nil {
|
||||||
results = append(results, ConflictResult{
|
results = append(results, ConflictResult{
|
||||||
ConflictID: conflictIDStr,
|
ConflictID: conflictIDStr,
|
||||||
@@ -702,7 +701,7 @@ func (h *ConflictHandler) BulkResolveConflicts(c *echo.Context) error {
|
|||||||
continue
|
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{
|
results = append(results, ConflictResult{
|
||||||
ConflictID: conflictIDStr,
|
ConflictID: conflictIDStr,
|
||||||
Status: "error",
|
Status: "error",
|
||||||
@@ -719,7 +718,7 @@ func (h *ConflictHandler) BulkResolveConflicts(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
resolutionDataJSON, _ := json.Marshal(resolutionData)
|
resolutionDataJSON, _ := json.Marshal(resolutionData)
|
||||||
|
|
||||||
_, err = h.db.ResolveSyncConflict(context.Background(), database.ResolveSyncConflictParams{
|
_, err = h.db.ResolveSyncConflict(c.Request().Context(), database.ResolveSyncConflictParams{
|
||||||
ID: conflictUUID,
|
ID: conflictUUID,
|
||||||
ResolutionStatus: pgtype.Text{String: "bulk_resolved", Valid: true},
|
ResolutionStatus: pgtype.Text{String: "bulk_resolved", Valid: true},
|
||||||
ResolutionData: resolutionDataJSON,
|
ResolutionData: resolutionDataJSON,
|
||||||
@@ -817,7 +816,7 @@ func (h *ConflictHandler) BulkDismissConflicts(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
|
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 err != nil {
|
||||||
results = append(results, ConflictResult{
|
results = append(results, ConflictResult{
|
||||||
ConflictID: conflictIDStr,
|
ConflictID: conflictIDStr,
|
||||||
@@ -838,7 +837,7 @@ func (h *ConflictHandler) BulkDismissConflicts(c *echo.Context) error {
|
|||||||
continue
|
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{
|
results = append(results, ConflictResult{
|
||||||
ConflictID: conflictIDStr,
|
ConflictID: conflictIDStr,
|
||||||
Status: "error",
|
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"})
|
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 {
|
if err := h.db.DeleteDevice(c.Request().Context(), pgDeviceID); err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to delete device"})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to delete device"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
|
"bookhoard/internal/rls"
|
||||||
"bookhoard/internal/services"
|
"bookhoard/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,12 +52,17 @@ func (h *JobsHandler) CreateJob(c *echo.Context) error {
|
|||||||
// Add database to params
|
// Add database to params
|
||||||
req.Params["db"] = h.db
|
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
|
// Create job
|
||||||
job := &services.Job{
|
job := &services.Job{
|
||||||
ID: uuid.New().String(),
|
ID: uuid.New().String(),
|
||||||
Type: jobType,
|
Type: jobType,
|
||||||
Params: req.Params,
|
Params: req.Params,
|
||||||
Status: services.JobStatusPending,
|
Status: services.JobStatusPending,
|
||||||
|
Context: rls.WithUser(context.Background(), pgtype.UUID{Bytes: userUUID, Valid: true}),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enqueue job
|
// Enqueue job
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
wsync "bookhoard/internal/sync"
|
wsync "bookhoard/internal/sync"
|
||||||
"bookhoard/internal/utils"
|
"bookhoard/internal/utils"
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"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},
|
UserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
|
||||||
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
|
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
|
||||||
Limit: int32(limit),
|
Limit: int32(limit),
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"bookhoard/internal/rls"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
)
|
)
|
||||||
@@ -127,6 +129,11 @@ func (h *Handler) ScanLibrary(c *echo.Context) error {
|
|||||||
|
|
||||||
jobID := uuid.New().String()
|
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{
|
job := &services.Job{
|
||||||
ID: jobID,
|
ID: jobID,
|
||||||
Type: services.JobTypeScan,
|
Type: services.JobTypeScan,
|
||||||
@@ -140,7 +147,7 @@ func (h *Handler) ScanLibrary(c *echo.Context) error {
|
|||||||
"trigger": "admin",
|
"trigger": "admin",
|
||||||
},
|
},
|
||||||
Status: services.JobStatusPending,
|
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 {
|
if err := h.worker.EnqueueJob(job); err != nil {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
|
|
||||||
jwt "github.com/golang-jwt/jwt/v5"
|
jwt "github.com/golang-jwt/jwt/v5"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"bookhoard/internal/rls"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/labstack/echo/v5"
|
"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}
|
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 {
|
if err != nil {
|
||||||
return map[string]interface{}{"error": "failed to fetch initial state"}
|
return map[string]interface{}{"error": "failed to fetch initial state"}
|
||||||
}
|
}
|
||||||
|
|
||||||
progressMap := make(map[string]map[string]interface{})
|
progressMap := make(map[string]map[string]interface{})
|
||||||
for _, item := range mediaItems {
|
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,
|
MediaItemID: item.ID,
|
||||||
UserID: pgUserID,
|
UserID: pgUserID,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
|
"bookhoard/internal/rls"
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -183,6 +184,13 @@ func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerF
|
|||||||
c.Set("device_ctx", ctx)
|
c.Set("device_ctx", ctx)
|
||||||
c.Set("device_id", device.ID.Bytes)
|
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)
|
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/handlers"
|
||||||
"bookhoard/internal/middleware"
|
"bookhoard/internal/middleware"
|
||||||
ratelimit "bookhoard/internal/middleware"
|
ratelimit "bookhoard/internal/middleware"
|
||||||
|
"bookhoard/internal/rls"
|
||||||
"bookhoard/internal/services"
|
"bookhoard/internal/services"
|
||||||
"bookhoard/internal/sync"
|
"bookhoard/internal/sync"
|
||||||
"bookhoard/templates"
|
"bookhoard/templates"
|
||||||
@@ -155,6 +156,12 @@ func createOptionalAuthMiddleware(cfg *Config) echo.MiddlewareFunc {
|
|||||||
Timezone: liveUser.Timezone,
|
Timezone: liveUser.Timezone,
|
||||||
Theme: liveUser.Theme, // SSR body class; see the required middleware
|
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)
|
return next(c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -222,6 +229,12 @@ func createJWTMiddleware(cfg *Config) echo.MiddlewareFunc {
|
|||||||
// admin/docs theme flash.
|
// admin/docs theme flash.
|
||||||
Theme: liveUser.Theme,
|
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
|
return nil
|
||||||
},
|
},
|
||||||
ErrorHandler: func(c *echo.Context, err error) error {
|
ErrorHandler: func(c *echo.Context, err error) error {
|
||||||
@@ -414,7 +427,15 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
|
|||||||
// startup scans for disk I/O.
|
// startup scans for disk I/O.
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(30 * time.Second)
|
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
|
// Register progress routes with actual handler
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ import (
|
|||||||
epub "github.com/ArcadiaLin/go-epub"
|
epub "github.com/ArcadiaLin/go-epub"
|
||||||
"github.com/fsnotify/fsnotify"
|
"github.com/fsnotify/fsnotify"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"bookhoard/internal/rls"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/nwaples/rardecode"
|
"github.com/nwaples/rardecode"
|
||||||
@@ -621,17 +623,23 @@ func (s *MediaScanner) enqueueLibraryScan(rootFolder, trigger string) {
|
|||||||
folderPaths[i] = f.FolderPath
|
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 := ""
|
adminIDStr := ""
|
||||||
if libRow.CreatedByAdminID.Valid {
|
adminUUID := libRow.CreatedByAdminID
|
||||||
adminIDStr = uuid.UUID(libRow.CreatedByAdminID.Bytes).String()
|
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")
|
fmt.Printf("[MTIME-POLL] Library has no owner, falling back to first admin\n")
|
||||||
fallbackAdmin, err := s.db.GetFirstAdmin(context.Background())
|
fallbackAdmin, err := s.db.GetFirstAdmin(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[MTIME-POLL] Warning: no admin found in database, skipping scan\n")
|
fmt.Printf("[MTIME-POLL] Warning: no admin found in database, skipping scan\n")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
adminUUID = fallbackAdmin
|
||||||
adminIDStr = uuid.UUID(fallbackAdmin.Bytes).String()
|
adminIDStr = uuid.UUID(fallbackAdmin.Bytes).String()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -650,7 +658,7 @@ func (s *MediaScanner) enqueueLibraryScan(rootFolder, trigger string) {
|
|||||||
Type: JobTypeScan,
|
Type: JobTypeScan,
|
||||||
Status: JobStatusPending,
|
Status: JobStatusPending,
|
||||||
UserID: adminIDStr,
|
UserID: adminIDStr,
|
||||||
Context: context.Background(),
|
Context: rls.WithUser(context.Background(), adminUUID),
|
||||||
Params: map[string]any{
|
Params: map[string]any{
|
||||||
"library_id": libraryIDStr,
|
"library_id": libraryIDStr,
|
||||||
"folders": folderPaths,
|
"folders": folderPaths,
|
||||||
@@ -667,6 +675,13 @@ func (s *MediaScanner) enqueueLibraryScan(rootFolder, trigger string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *MediaScanner) ScanFolders(ctx context.Context) error {
|
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
|
// Cancellation is checked at every stage boundary and inside the
|
||||||
// walk callback: the in-flight file finishes, then the scan aborts
|
// walk callback: the in-flight file finishes, then the scan aborts
|
||||||
// and reports context.Canceled (processJob classifies that as a
|
// and reports context.Canceled (processJob classifies that as a
|
||||||
@@ -4771,7 +4786,7 @@ func (s *MediaScanner) performInitialScan(ctx context.Context) {
|
|||||||
Type: JobTypeScan,
|
Type: JobTypeScan,
|
||||||
Status: JobStatusPending,
|
Status: JobStatusPending,
|
||||||
UserID: adminIDStr,
|
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{
|
Params: map[string]any{
|
||||||
"library_id": libraryIDStr,
|
"library_id": libraryIDStr,
|
||||||
"folders": folderPaths,
|
"folders": folderPaths,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user