- TypeScript 63.8%
- Rust 34.3%
- JavaScript 0.9%
- Dockerfile 0.8%
- HTML 0.2%
Non-interactive shells and GUI git clients don't run the shell profile, so a lazily-loaded nvm left npx off PATH and the hook failed with "npx: command not found". - Source nvm.sh from NVM_DIR, ~/.nvm or Homebrew when npx isn't on PATH - No-op when npx is already available (interactive shells, CI) |
||
|---|---|---|
| .forgejo/workflows | ||
| .husky | ||
| backend | ||
| e2e | ||
| frontend | ||
| .dockerignore | ||
| .gitignore | ||
| CLAUDE.md | ||
| docker-compose.yml | ||
| Dockerfile | ||
| package-lock.json | ||
| package.json | ||
| playwright.config.ts | ||
| README.md | ||
manga-lib
A personal manga collection tracker. Track which volumes you own, who has read what, manage wishlists, and see what to buy next — for multiple users sharing a single library.
Stack
- Backend: Rust + Axum + sqlx — Postgres only (no SQLite dev fallback; unlike Knex, sqlx has no dialect-agnostic query builder, so supporting both would mean maintaining two full sets of queries)
- Frontend: React 18 + Vite + Tailwind CSS + TanStack Query
- Validation: hand-written
.validate()methods on the backend's request structs (mirroring the old Zod schemas); Zod on the frontend where it still applies - Testing:
cargo test(backend unit tests) + Vitest (frontend unit) + Playwright (E2E)
Getting Started
Local development
Requires a reachable Postgres instance — this repo doesn't run one for you (see backend/.env.example).
npm run setup # install root + frontend deps, fetch backend crates
npm run dev # backend on :3001, Vite on :5173
Docker (production)
docker compose up -d
The app is served on port 3000. Data lives in the shared Postgres instance (pg-shared-db, see shared-services) — this repo's own compose stack has no database volume of its own.
compose.yaml includes Traefik labels for running behind a Traefik reverse proxy. This needs the external Docker network Traefik listens on (default traefik, override via TRAEFIK_NETWORK) and TRAEFIK_HOST set to the desired domain.
| Variable | Default | Description |
|---|---|---|
HOST_PORT |
3000 |
Host port the container's port 3000 is mapped to |
TRAEFIK_HOST |
manga-lib.example.com |
Domain Traefik routes to this app |
TRAEFIK_NETWORK |
traefik |
External Docker network Traefik listens on |
TRAEFIK_CERT_RESOLVER |
letsencrypt |
Traefik cert resolver name |
MEILI_HOST |
http://meilisearch:7700 |
Meilisearch instance for library search (see shared-services); unset to fall back to client-side title matching |
MEILI_API_KEY |
— | API key matching the shared Meilisearch instance's MEILI_MASTER_KEY |
S3_ENDPOINT |
http://minio:9000 |
S3-compatible endpoint (MinIO, on the external minio-net network) covers are cached in; unset to keep hot-linking covers from MangaDex/MyAnimeList |
S3_REGION |
eu-central-1 |
Region passed to the S3 client (MinIO ignores the value but still requires one) |
S3_BUCKET |
manga-lib-covers |
Bucket covers are stored in — must already exist |
S3_ACCESS_KEY_ID |
— | Access key for S3_BUCKET |
S3_SECRET_ACCESS_KEY |
— | Secret key for S3_BUCKET |
Cover caching
When S3_ENDPOINT is set, a manga's or wishlist item's cover is downloaded once when it is created (or its cover is changed) and stored in the bucket, so the library keeps working when MangaDex/MyAnimeList rename or remove an image. cover_url then holds /api/covers/<key>, served by the app itself out of the private bucket. Only https covers from uploads.mangadex.org and cdn.myanimelist.net (the hosts the CSP already allows) are fetched; anything else is stored as-is. Caching is best-effort — if the download or upload fails, it is saved with the external URL and picked up by the next startup's backfill, which caches every still-external cover in the background. Replaced or deleted covers are removed from the bucket once no manga or wishlist item references them.
Access control (mTLS)
Instead of a password, the router requires a valid client certificate signed by a shared personal CA — see the separate mtls-ca repo for the CA, device-cert issuing script, and the actual TLS Options definition (Docker labels can only reference a tls.options entry with clientAuth settings, not define one, so it's defined via Traefik's file provider and referenced here with the @file suffix).
manga-lib also attaches mtls-ca's pass-client-cert middleware, which forwards the verified cert's CN to the app via the X-Forwarded-Tls-Client-Cert-Info header. This app uses that to map a device's certificate to one of its own user profiles (users.cert_cn): link one with
curl -X PUT https://<domain>/api/users/<id>/cert-cn \
-H "Content-Type: application/json" \
-d '{"cert_cn": "<Name>"}'
where <Name> matches the CN the cert was issued with in mtls-ca (./issue-client-cert.sh "<Name>" — use the person's name, not a device name, so the same cert works from any of their devices). GET /api/me returns the profile the current request's cert is mapped to, or {"user": null} if unmapped.
Once linked, that cert can only act as its own profile for reading progress, shelf status, hidden-manga flags, and creating your own wishlist entries — the API returns 403 if it tries to act as a different profile.
Everything else that isn't scoped to your own profile — managing profiles (add/remove/color/minor flag/cert linking), adding/editing/deleting mangas, the shared buy-list/volumes-owned/borrowed bookkeeping, refreshing original titles, and editing or deleting someone else's wishlist entry — is a household-admin action and requires the linked cert's profile to be an adult (is_minor false); minors get a 403. Editing or deleting your own wishlist entry is always allowed, regardless of age. A request with no linked cert is treated as unrestricted for this check specifically (see the read-only rule above for what that means in production vs. local dev).
In production (APP_ENV=production, e.g. the Docker image), any request without a cert mapped to a profile — no cert at all, or a cert not yet linked — is read-only: every non-GET/HEAD/OPTIONS request gets a 403, no exceptions. This covers the case where the app's host port is reached directly instead of through Traefik's mTLS gate. Local dev has no Traefik in front at all, so it's exempt from this check.
Scripts
| Command | Description |
|---|---|
npm run setup |
Install root + frontend deps, fetch backend crates |
npm run dev |
Start dev servers (backend :3001, frontend :5173) |
npm run build |
Production build → frontend/dist/ |
npm start |
Start backend in release mode on :3000 |
npm run typecheck |
cargo check (backend) + TypeScript check (frontend) |
npm run lint |
cargo clippy -D warnings (backend) + ESLint (frontend) |
npm run format |
cargo fmt (backend) + Prettier (frontend) |
npm run test |
cargo test (backend) + Vitest unit tests (frontend) |
npm run test:e2e |
Playwright E2E tests (headless) |
npm run test:e2e:ui |
Playwright E2E tests (interactive UI) |
npm run migrate:make -- <name> |
Create a new sqlx migration file in backend/migrations/ (requires sqlx-cli: cargo install sqlx-cli --no-default-features --features postgres,rustls) |
API
| Method | Path | Description |
|---|---|---|
GET |
/api/health |
Health check ({"status":"ok","database":"connected"}, 503 if the DB ping fails) |
GET |
/api/version |
Git commit hash baked in at build time ({"version":"<hash>"}, "unknown" outside Docker) |
GET |
/api/covers/:key |
Cached cover image out of the S3 bucket (immutable, long-lived Cache-Control); 503 if S3_ENDPOINT isn't set |
GET |
/api/mangas/search?q= |
Typo-tolerant library search via Meilisearch (title + title_original); 503 if MEILI_HOST isn't set — the frontend falls back to client-side substring matching in that case |
Project Structure
backend/
src/main.rs Axum app setup, middleware, routing
src/db.rs sqlx query layer (async), batch fetching, row → domain-type mapping
src/auth.rs mTLS cert-CN extraction, cert-user middleware, requireAdult/enforceCertUser
src/validation.rs Request body validation (one struct + `.validate()` per shape)
src/search.rs Meilisearch client wrapper
src/models.rs Shared domain types (Manga, User, Volume, WishlistItem, HistoryEntry)
src/routes/ One module per resource (mangas, users, wishlist, history, mangadex, events, me)
migrations/ sqlx migration files (plain SQL)
frontend/src/
api.ts Typed fetch wrappers
types.ts Shared TypeScript types
hooks/ Business logic hooks
components/ UI components
context/ MangaContext (users, currentUserId, colorOf)
locale/ i18n strings (de/en)
e2e/ Playwright tests
Features
- Track owned volumes per manga series
- Per-user reading status
- Wishlist per user
- Buy list (volumes needed to complete a series)
- Up Next view (what to read next)
- Reading history
- Hide manga per user
- Borrowed volumes tracking
- Multi-user with colour coding
- PWA support
- German/English UI