No description
  • TypeScript 63.8%
  • Rust 34.3%
  • JavaScript 0.9%
  • Dockerfile 0.8%
  • HTML 0.2%
Find a file
Freydis Mende ba12516bc6
All checks were successful
CI / Frontend – typecheck, lint, test & build (push) Successful in 37s
CI / Backend – fmt, clippy, test & build (push) Successful in 4m23s
CI / Docker – build image (push) Successful in 4m11s
CI / deploy (push) Successful in 1s
chore(husky): load nvm in pre-commit when npx is missing
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)
2026-10-28 21:50:36 +01:00
.forgejo/workflows Switch CI to the custom ci-slim runner image 2026-09-20 21:49:23 +02:00
.husky chore(husky): load nvm in pre-commit when npx is missing 2026-10-28 21:50:36 +01:00
backend fix(backend): cache wishlist covers in MinIO too 2026-10-28 21:49:03 +01:00
e2e Replace manual user picker with mTLS cert-derived identity 2026-08-01 16:00:21 +02:00
frontend fix(frontend): inline apple-touch-icon so iOS home-screen icon loads behind mTLS 2026-10-21 21:50:15 +02:00
.dockerignore Rewrite backend in Rust (Axum + sqlx), Postgres-only 2026-09-20 12:33:45 +02:00
.gitignore Rewrite backend in Rust (Axum + sqlx), Postgres-only 2026-09-20 12:33:45 +02:00
CLAUDE.md feat(backend): cache manga covers in MinIO 2026-10-11 17:42:27 +02:00
docker-compose.yml feat(backend): cache manga covers in MinIO 2026-10-11 17:42:27 +02:00
Dockerfile Fix missing wget in healthcheck, switch base images to trixie 2026-09-20 22:04:48 +02:00
package-lock.json Move data layer to Knex (SQLite for dev, Postgres-ready for prod) 2026-08-12 15:57:00 +02:00
package.json Rewrite backend in Rust (Axum + sqlx), Postgres-only 2026-09-20 12:33:45 +02:00
playwright.config.ts add e2e tests 2026-05-31 21:44:08 +02:00
README.md fix(backend): cache wishlist covers in MinIO too 2026-10-28 21:49:03 +01:00

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