# 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 `Storage` trait. `LocalStorage` ships in-tree; S3 and friends are first-class extension slots. - **Deploy**: a single host via Docker Compose. ## Quick start ```bash cp .env.example .env docker compose up --build ``` | Service | URL | | -------------------------- | ------------------------------------------------------------------ | | Frontend (and API browser) | | | API health | | The browser only ever talks to the frontend container on `:3000`. SvelteKit's [`hooks.server.ts`](frontend/src/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](docker-compose.yml) — change `expose` to `ports: ["8080:8080"]`. The first boot runs the migrations automatically. From there: 1. Open , click **Register**, create a user. 2. Click **Upload** to create a manga (optionally with a cover) and upload a chapter. 3. Open the manga, click the chapter, read with `j`/`k` or the arrow keys. ## Local development For fast iteration, run Postgres in Docker and the backend + frontend natively: ```bash 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.yml` exposes Postgres on `5432`. 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 can `CREATEDB` on; the dev container works because the `mangalord` role is the cluster superuser. ## Tests This project is developed test-first. Three levels: ```bash # 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=` | List/search mangas. Trigram fuzzy search. | | 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). | ### 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: ```json { "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: ```json { "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`). ### 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`. ```bash # 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 `:443` at the frontend container on `:3000` — the SvelteKit proxy handles `/api/*` internally, so you only need one upstream. With same-origin routing you can leave `CORS_ALLOWED_ORIGINS` empty and the session cookie's `SameSite=Lax` does its job. - **Set a strong Postgres password** in `.env` before the first `docker compose up`. The defaults are fine for local dev only. - **Keep `COOKIE_SECURE=true`** behind HTTPS. Browsers drop `Secure` cookies on plain HTTP; the dev compose accepts `COOKIE_SECURE=false` for that case. - **Watch `RUST_LOG`** if you're noisy with `debug` — drop the `,mangalord=debug` suffix in production to log at `info` for 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](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](CLAUDE.md).