MechaCat02 d81aca42a0 chore: audit-flagged cleanups (no behaviour change)
Four small follow-ups from the 0.9.0 audit, none of them
user-visible:

- Migration 0007 drops `chapters_manga_idx`. The 0001 schema declared
  both `UNIQUE (manga_id, number)` and `CREATE INDEX chapters_manga_idx
  ON (manga_id, number)`, but Postgres maintains an identical index
  for the unique constraint automatically — the explicit one was just
  paying for a second per-write update. Query plans are unchanged
  because the planner already preferred the constraint's index.
- `upload::parse_image` sniffs from the first 64 bytes instead of the
  full image buffer. `infer` only looks at magic bytes anyway, so
  scanning 20 MiB is wasted work. Functionally identical; cheaper in
  the hot path.
- AVIF was on the whitelist but had no test fixture. New `avif_bytes`
  helper produces a minimal `ftyp avif` header that `infer` recognises,
  and a new `accepts_avif` unit test covers the path end-to-end.
- Frontend `request()` sets `credentials: 'include'`. Same-origin
  callers see no change (default was already `'same-origin'`), but the
  first user who configures `CORS_ALLOWED_ORIGINS` for a cross-origin
  deployment gets working cookies without having to chase a runtime
  ApiError trail.

No version bump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:32:02 +02:00
2026-05-16 21:05:16 +02:00
2026-05-16 21:05:16 +02:00
2026-05-16 21:05:16 +02:00

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

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:

  1. Open http://localhost:3000, 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:

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:

# 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).

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.
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).

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 :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 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.

Description
No description provided
Readme 702 KiB
Languages
Rust 59.1%
Svelte 21.9%
TypeScript 18.1%
CSS 0.6%
Dockerfile 0.2%
Other 0.1%