Add a vertical-scroll continuous mode to the reader alongside the existing single-page mode. A segmented toggle in the reader top bar switches between them; in continuous mode a gap selector (None/Small/Medium/Large → 0/12/32/64px) controls the spacing between stacked pages. Settings page mirrors the same controls. Backend: new user_preferences table (one row per user, lazily inserted, ON DELETE CASCADE) and GET/PATCH /api/v1/auth/me/preferences gated by the existing CurrentUser extractor. Allowed values are enforced both by API validation and table-level CHECK constraints. Eight integration tests cover defaults, persistence, partial updates, validation errors, auth, per-user isolation, and cascade. Frontend: a new preferences store mirrors the theme-store pattern with a localStorage shadow so anonymous browsers get a consistent experience and logged-in users don't flash defaults while the server response is in flight. Server values that the frontend doesn't recognize (forward-compat) are ignored rather than poisoning the UI; non-401 PATCH errors revert the optimistic local update; logout clears the shadow so user A's settings don't follow user B on a shared browser. In continuous mode native scrolling handles Space/PageDown/arrows; Home/End remain wired and call scrollIntoView() so jumping to chapter bounds stays one keystroke. Single-page mode (chevrons, arrow-key pagination, next-page preload) is unchanged. Versions bumped 0.13.0 → 0.14.0 in lockstep. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mangalord
A self-hosted manga and comics reader. Users browse, search, read, bookmark, and upload manga. The same HTTP API is consumed by the SvelteKit web UI and by external bots — there is no separate "bot API" — so anything you can do in the browser you can also do over curl with a bearer token.
Stack
- Backend: Rust, axum 0.7, sqlx 0.8, Postgres 16. Argon2id passwords, HttpOnly SameSite=Lax session cookies, bearer tokens for bots.
- Frontend: SvelteKit 2 with Svelte 5 runes, TypeScript.
- Storage: pluggable
Storagetrait.LocalStorageships in-tree; S3 and friends are first-class extension slots. - Deploy: a single host via Docker Compose.
Quick start
cp .env.example .env
docker compose up --build
| Service | URL |
|---|---|
| Frontend (and API browser) | http://localhost:3000 |
| API health | http://localhost:3000/api/v1/health |
The browser only ever talks to the frontend container on :3000. SvelteKit's hooks.server.ts reverse-proxies /api/* to the backend service over docker's internal network, so cookies stay same-origin and you don't need to publish the backend port or configure CORS to get a working deploy.
If you want to hit the backend directly (bot scripts, ops debugging), publish its port by editing the backend service in docker-compose.yml — change expose to ports: ["8080:8080"].
The first boot runs the migrations automatically. From there:
- Open http://localhost:3000, click Register, create a user.
- Click Upload to create a manga (optionally with a cover) and upload a chapter.
- Open the manga, click the chapter, read with
j/kor the arrow keys.
Local development
For fast iteration, run Postgres in Docker and the backend + frontend natively:
docker compose -f docker-compose.dev.yml up -d
# Backend (separate shell)
cd backend
export DATABASE_URL=postgres://mangalord:mangalord@localhost:5432/mangalord
cargo run
# Frontend (separate shell)
cd frontend
npm install
npm run dev # serves on http://localhost:5173, proxies /api to :8080
Port note:
docker-compose.dev.ymlexposes Postgres on5432. If another Postgres is already bound to that port (or another Docker container is), stop it first or edit the host-port mapping in the dev compose file. Backend tests need a real Postgres that the test user canCREATEDBon; the dev container works because themangalordrole is the cluster superuser.
Tests
This project is developed test-first. Three levels:
# Backend: unit (in-module) + integration (#[sqlx::test] against a real DB)
cd backend && cargo test
cd backend && cargo clippy --all-targets -- -D warnings
# Frontend: Vitest unit tests and svelte-check
cd frontend && npm test
cd frontend && npm run check
# End-to-end: Playwright (mocks the backend at the page level by default)
cd frontend && npm run test:e2e
Backend integration tests provision a fresh, migrated database per test via sqlx::test, so they require DATABASE_URL to point at a Postgres where the configured user has CREATEDB.
Playwright starts the SvelteKit dev server on a dedicated port (5174) so it doesn't reuse whatever you may have running on 5173. To point Playwright at an already-running deployment instead, set E2E_BASE_URL=http://your.host.
API surface
Everything is namespaced under /api/v1/. /api/* outside the version prefix is reserved for future versioning.
Read endpoints (public)
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/health |
Liveness probe. |
| GET | `/api/v1/mangas?search=&sort=recent | title&limit=&offset=` |
| GET | /api/v1/mangas/{id} |
Single manga. |
| GET | /api/v1/mangas/{id}/chapters |
Paginated chapter list, ordered by number. |
| GET | /api/v1/mangas/{id}/chapters/{n} |
Single chapter. |
| GET | /api/v1/mangas/{id}/chapters/{n}/pages |
Page metadata (storage keys + MIME). |
| GET | /api/v1/files/{key} |
Streams a blob (cover or page). See Capability URLs below. |
Write endpoints (require authentication)
| Method | Path | Description |
|---|---|---|
| POST | /api/v1/auth/register |
{username,password} → user + sets session cookie. |
| POST | /api/v1/auth/login |
Same shape; rotates to a fresh session. |
| POST | /api/v1/auth/logout |
Invalidates the session, clears the cookie. |
| GET | /api/v1/auth/me |
Current user, or 401 if anonymous. |
| PATCH | /api/v1/auth/me/password |
{current_password,new_password} → 401 on wrong current; on success deletes all of the user's sessions and mints a fresh one for the caller. Bot tokens stay (use DELETE /tokens/{id} to revoke). |
| POST | /api/v1/auth/tokens |
{name} → bot bearer token (returned once). |
| DELETE | /api/v1/auth/tokens/{id} |
Revoke a bot token (owner-only). |
| POST | /api/v1/mangas |
Multipart: metadata JSON + optional cover image. |
| POST | /api/v1/mangas/{id}/chapters |
Multipart: metadata JSON + N page image parts. |
| POST | /api/v1/bookmarks |
{manga_id, chapter_id?, page?}. |
| DELETE | /api/v1/bookmarks/{id} |
Remove a bookmark (owner-only). |
| GET | /api/v1/me/bookmarks?limit=&offset= |
The caller's bookmarks. |
Envelopes
Errors — every non-2xx response:
{ "error": { "code": "snake_case", "message": "human readable", "details": { "field?": "..." } } }
details is only present on validation_failed (422). code is the stable contract; the message is for humans and may change.
| HTTP | code |
|---|---|
| 400 | invalid_input, bad_file_key |
| 401 | unauthenticated |
| 403 | forbidden |
| 404 | not_found |
| 409 | conflict |
| 413 | payload_too_large |
| 415 | unsupported_media_type |
| 422 | validation_failed |
| 500 | internal_error |
Lists — every paginated endpoint:
{
"items": [ /* ... */ ],
"page": { "limit": 50, "offset": 0, "total": 137 }
}
total is a number when the endpoint computes it (currently /mangas), otherwise null.
Auth quick-start
Browser
The frontend handles this for you: register → cookie set → writes work. Cookies are HttpOnly; SameSite=Lax; Secure (configurable via COOKIE_SECURE).
Capability URLs
GET /api/v1/files/{key} is unauthenticated by design: reads stay public so embedded <img> tags and external readers work without round-tripping a cookie or token. The keys themselves are unguessable — they're scoped under v4 UUIDs (mangas/{manga-uuid}/cover.png, mangas/{manga-uuid}/chapters/{chapter-uuid}/pages/0001.png) — so a leaked URL leaks at most the one file. Treat the URLs as bearer tokens for the bytes they reference and don't paste them into public chat. A future private-libraries feature would gate this endpoint behind a per-key ownership check; the architectural seam is documented in backend/src/api/files.rs.
Bots / scripts (bearer tokens)
The frontend on :3000 proxies /api/* through to the backend, so any URL below works against http://localhost:3000 in the default compose deploy. If you publish the backend port directly, swap in http://localhost:8080.
# 1. Log in once via cookies (or register).
curl -sb -c cookies.txt -X POST http://localhost:3000/api/v1/auth/login \
-H 'content-type: application/json' \
-d '{"username":"alice","password":"hunter2hunter2"}'
# 2. Mint a long-lived bot token. The `bearer` value is shown ONCE.
curl -sb cookies.txt -X POST http://localhost:3000/api/v1/auth/tokens \
-H 'content-type: application/json' \
-d '{"name":"ci-bot"}'
# → { "id": "...", "name": "ci-bot", "bearer": "raw-token-here", ... }
# 3. Use the bearer from anywhere.
curl -H 'Authorization: Bearer raw-token-here' \
http://localhost:3000/api/v1/auth/me
Tokens are stored hashed (sha256) at rest; the raw value never leaves the response that created it. Revoke with DELETE /api/v1/auth/tokens/{id} while authenticated as the owner.
Configuration
All variables can be set in .env (for docker compose) or your shell (for cargo run). See .env.example for the full list with explanations.
| Variable | Default | Purpose |
|---|---|---|
DATABASE_URL |
required | Postgres connection string. |
BIND_ADDRESS |
0.0.0.0:8080 |
Backend listen address. |
STORAGE_DIR |
./data/storage |
Local-storage root. |
RUST_LOG |
info,mangalord=debug |
tracing-subscriber env filter. |
COOKIE_SECURE |
true |
Secure flag on the session cookie. |
COOKIE_DOMAIN |
(none) | Scope the session cookie to a parent domain. |
SESSION_TTL_DAYS |
30 |
Session lifetime. |
CORS_ALLOWED_ORIGINS |
(empty → same-origin) | Comma-separated origin allowlist. |
MAX_REQUEST_BYTES |
209715200 (200 MiB) |
Hard cap on multipart request size. |
MAX_FILE_BYTES |
20971520 (20 MiB) |
Cap on a single image part. |
BACKEND_URL |
http://backend:8080 |
Where the frontend's /api/* proxy points. |
Deployment
For real hosts:
- Front Mangalord with a TLS terminator (Caddy, nginx, traefik). Point
:443at the frontend container on:3000— the SvelteKit proxy handles/api/*internally, so you only need one upstream. With same-origin routing you can leaveCORS_ALLOWED_ORIGINSempty and the session cookie'sSameSite=Laxdoes its job. - Set a strong Postgres password in
.envbefore the firstdocker compose up. The defaults are fine for local dev only. - Keep
COOKIE_SECURE=truebehind HTTPS. Browsers dropSecurecookies on plain HTTP; the dev compose acceptsCOOKIE_SECURE=falsefor that case. - Watch
RUST_LOGif you're noisy withdebug— drop the,mangalord=debugsuffix in production to log atinfofor the app's spans.
Backup and restore
Two stateful volumes:
postgres-data— all metadata (users, mangas, chapters, pages, bookmarks).storage-data— the actual cover and page bytes.
A consistent backup needs both. See docs/backup.md for step-by-step procedures and a restore drill.
Project layout
backend/ # axum + sqlx, see backend/src/ for the hexagonal layout
frontend/ # SvelteKit 2 / Svelte 5
docs/ # operator docs (backup procedure, etc.)
CLAUDE.md # contributor playbook
The contributor guidelines (TDD, Conventional Commits, lockstep SemVer, hexagonal seams) are in CLAUDE.md.