From 6c1d04aaf4afffe48b5468df7c98c96f02dfe85c Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 16 May 2026 21:05:16 +0200 Subject: [PATCH] chore: initial project scaffold Set up Mangalord with a Rust/axum backend, SvelteKit frontend, Postgres, and Docker Compose deployment. Establishes the architecture and TDD patterns the project will extend: - Hexagonal-ish backend layering (domain / repo / storage / api) with a pluggable Storage trait (LocalStorage today, S3 as a future impl). - Initial migration: users, mangas, chapters, bookmarks. - Vertical slice for mangas (list, search, create, get) with #[sqlx::test] integration coverage and storage unit tests. - SvelteKit frontend using Svelte 5 runes, typed API client, Vitest unit tests and Playwright e2e with route mocking. - CLAUDE.md documenting layering, TDD/git/SemVer workflow rules, and extension points (tags, fulltext search, OCR, S3, auth). - Project-scoped .claude/settings.json with permission allowlist for the toolchain (git, cargo, npm/vite, docker, psql, gh, doc fetches). Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/settings.json | 121 ++++++++++++++++++++++++ .env.example | 15 +++ .gitignore | 39 ++++++++ CLAUDE.md | 140 ++++++++++++++++++++++++++++ README.md | 69 ++++++++++++++ backend/.gitignore | 3 + backend/Cargo.toml | 34 +++++++ backend/Dockerfile | 27 ++++++ backend/migrations/0001_init.sql | 49 ++++++++++ backend/src/api/files.rs | 39 ++++++++ backend/src/api/health.rs | 12 +++ backend/src/api/mangas.rs | 59 ++++++++++++ backend/src/api/mod.rs | 14 +++ backend/src/app.rs | 36 +++++++ backend/src/config.rs | 22 +++++ backend/src/domain/bookmark.rs | 14 +++ backend/src/domain/chapter.rs | 20 ++++ backend/src/domain/manga.rs | 22 +++++ backend/src/domain/mod.rs | 9 ++ backend/src/domain/user.rs | 11 +++ backend/src/error.rs | 45 +++++++++ backend/src/lib.rs | 7 ++ backend/src/main.rs | 21 +++++ backend/src/repo/manga.rs | 76 +++++++++++++++ backend/src/repo/mod.rs | 1 + backend/src/storage/local.rs | 97 +++++++++++++++++++ backend/src/storage/mod.rs | 31 ++++++ backend/tests/api_mangas.rs | 77 +++++++++++++++ backend/tests/common/mod.rs | 44 +++++++++ backend/tests/health.rs | 14 +++ docker-compose.dev.yml | 21 +++++ docker-compose.yml | 42 +++++++++ frontend/.gitignore | 7 ++ frontend/Dockerfile | 17 ++++ frontend/e2e/manga-list.spec.ts | 57 +++++++++++ frontend/package.json | 31 ++++++ frontend/playwright.config.ts | 18 ++++ frontend/src/app.d.ts | 11 +++ frontend/src/app.html | 13 +++ frontend/src/lib/api/client.ts | 33 +++++++ frontend/src/lib/api/mangas.test.ts | 69 ++++++++++++++ frontend/src/lib/api/mangas.ts | 36 +++++++ frontend/src/routes/+layout.svelte | 30 ++++++ frontend/src/routes/+page.svelte | 57 +++++++++++ frontend/static/.gitkeep | 0 frontend/svelte.config.js | 12 +++ frontend/tsconfig.json | 15 +++ frontend/vite.config.ts | 20 ++++ 48 files changed, 1657 insertions(+) create mode 100644 .claude/settings.json create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 backend/.gitignore create mode 100644 backend/Cargo.toml create mode 100644 backend/Dockerfile create mode 100644 backend/migrations/0001_init.sql create mode 100644 backend/src/api/files.rs create mode 100644 backend/src/api/health.rs create mode 100644 backend/src/api/mangas.rs create mode 100644 backend/src/api/mod.rs create mode 100644 backend/src/app.rs create mode 100644 backend/src/config.rs create mode 100644 backend/src/domain/bookmark.rs create mode 100644 backend/src/domain/chapter.rs create mode 100644 backend/src/domain/manga.rs create mode 100644 backend/src/domain/mod.rs create mode 100644 backend/src/domain/user.rs create mode 100644 backend/src/error.rs create mode 100644 backend/src/lib.rs create mode 100644 backend/src/main.rs create mode 100644 backend/src/repo/manga.rs create mode 100644 backend/src/repo/mod.rs create mode 100644 backend/src/storage/local.rs create mode 100644 backend/src/storage/mod.rs create mode 100644 backend/tests/api_mangas.rs create mode 100644 backend/tests/common/mod.rs create mode 100644 backend/tests/health.rs create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.yml create mode 100644 frontend/.gitignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/e2e/manga-list.spec.ts create mode 100644 frontend/package.json create mode 100644 frontend/playwright.config.ts create mode 100644 frontend/src/app.d.ts create mode 100644 frontend/src/app.html create mode 100644 frontend/src/lib/api/client.ts create mode 100644 frontend/src/lib/api/mangas.test.ts create mode 100644 frontend/src/lib/api/mangas.ts create mode 100644 frontend/src/routes/+layout.svelte create mode 100644 frontend/src/routes/+page.svelte create mode 100644 frontend/static/.gitkeep create mode 100644 frontend/svelte.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..a744f0d --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,121 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "permissions": { + "allow": [ + "Bash(ls:*)", + "Bash(pwd)", + "Bash(tree:*)", + "Bash(find:*)", + "Bash(grep:*)", + "Bash(rg:*)", + "Bash(cat:*)", + "Bash(head:*)", + "Bash(tail:*)", + "Bash(wc:*)", + "Bash(file:*)", + "Bash(stat:*)", + "Bash(du:*)", + "Bash(df:*)", + "Bash(diff:*)", + "Bash(which:*)", + "Bash(whereis:*)", + "Bash(env)", + "Bash(printenv:*)", + "Bash(date:*)", + + "Bash(awk:*)", + "Bash(sed:*)", + "Bash(cut:*)", + "Bash(sort:*)", + "Bash(uniq:*)", + "Bash(tr:*)", + "Bash(jq:*)", + "Bash(yq:*)", + "Bash(xargs:*)", + "Bash(echo:*)", + "Bash(printf:*)", + + "Bash(mkdir:*)", + "Bash(touch:*)", + "Bash(cp:*)", + "Bash(mv:*)", + + "Bash(git status:*)", + "Bash(git diff:*)", + "Bash(git log:*)", + "Bash(git show:*)", + "Bash(git ls-files:*)", + "Bash(git rev-parse:*)", + "Bash(git branch:*)", + "Bash(git switch:*)", + "Bash(git checkout:*)", + "Bash(git add:*)", + "Bash(git commit:*)", + "Bash(git restore:*)", + "Bash(git stash:*)", + "Bash(git tag:*)", + "Bash(git merge:*)", + "Bash(git rebase:*)", + "Bash(git cherry-pick:*)", + "Bash(git remote:*)", + "Bash(git fetch:*)", + "Bash(git pull:*)", + "Bash(git clean -n:*)", + "Bash(git config --get:*)", + "Bash(git config --list:*)", + "Bash(gh:*)", + + "Bash(cargo:*)", + "Bash(rustc:*)", + "Bash(rustup:*)", + "Bash(rustfmt:*)", + "Bash(clippy-driver:*)", + "Bash(sqlx:*)", + + "Bash(node:*)", + "Bash(npm:*)", + "Bash(npx:*)", + "Bash(pnpm:*)", + "Bash(yarn:*)", + "Bash(tsc:*)", + "Bash(vite:*)", + "Bash(vitest:*)", + "Bash(playwright:*)", + "Bash(svelte-kit:*)", + "Bash(svelte-check:*)", + "Bash(eslint:*)", + "Bash(prettier:*)", + + "Bash(docker:*)", + "Bash(docker compose:*)", + "Bash(docker-compose:*)", + + "Bash(psql:*)", + "Bash(pg_isready:*)", + "Bash(pg_dump:*)", + "Bash(pg_restore:*)", + "Bash(createdb:*)", + "Bash(dropdb:*)", + + "Bash(curl http://localhost:*)", + "Bash(curl http://127.0.0.1:*)", + "Bash(curl -s http://localhost:*)", + "Bash(curl -s http://127.0.0.1:*)", + "Bash(wget http://localhost:*)", + "Bash(wget http://127.0.0.1:*)", + + "WebFetch(domain:docs.rs)", + "WebFetch(domain:doc.rust-lang.org)", + "WebFetch(domain:rust-lang.org)", + "WebFetch(domain:crates.io)", + "WebFetch(domain:svelte.dev)", + "WebFetch(domain:kit.svelte.dev)", + "WebFetch(domain:vitejs.dev)", + "WebFetch(domain:vitest.dev)", + "WebFetch(domain:playwright.dev)", + "WebFetch(domain:docs.docker.com)", + "WebFetch(domain:www.postgresql.org)", + "WebFetch(domain:postgresql.org)" + ] + } +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..08d753d --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Copy to .env for `docker compose up`. +# Local dev (cargo run / npm run dev) reads backend/.env if present. + +POSTGRES_USER=mangalord +POSTGRES_PASSWORD=mangalord +POSTGRES_DB=mangalord + +DATABASE_URL=postgres://mangalord:mangalord@postgres:5432/mangalord +BIND_ADDRESS=0.0.0.0:8080 +STORAGE_DIR=/var/lib/mangalord/storage +RUST_LOG=info,mangalord=debug + +# Public base URL the frontend uses to reach the API from the browser. +# In docker compose this is exposed on the host. +PUBLIC_API_BASE=http://localhost:8080/api diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a69147b --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# Rust +/backend/target +/backend/.sqlx + +# Node / SvelteKit +/frontend/node_modules +/frontend/.svelte-kit +/frontend/build +/frontend/test-results +/frontend/playwright-report + +# Local storage volume (manga files) +/data + +# Env +.env +.env.local +.env.*.local + +# Claude Code (personal overrides only; .claude/settings.json is committed) +.claude/settings.local.json +.claude/.session/ + +# IDEs / editors +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3c561fa --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,140 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project + +Mangalord is a manga / comics reader. Users browse, search, read, bookmark, and upload manga. The same HTTP API is consumed by both the SvelteKit frontend and external bots/scripts — there is no separate "bot API" or "internal API." Deployment is a single server via Docker Compose. + +## Stack + +- **Backend**: Rust, axum 0.7, sqlx 0.8 (Postgres), tokio +- **Database**: Postgres 16 +- **Frontend**: SvelteKit 2 with Svelte 5 runes, TypeScript, Vite +- **Storage**: `Storage` trait + `LocalStorage` impl. S3 and friends are planned implementations, not refactors. +- **Tests**: `cargo test` (unit + integration via `#[sqlx::test]`), Vitest (frontend unit), Playwright (E2E) + +## Development workflow rules + +These rules apply to every change. They are not negotiable per-task — if a change can't be made under them, raise the conflict before writing code. + +### TDD + +Tests describing the expected behaviour are written **before** the implementation, in the same commit or one immediately preceding. A commit that adds production code without a corresponding test (or that updates a test only after the fact to match what was built) does not meet this bar — split it. + +See *TDD workflow* below for picking the right test level. + +### Git + +- One branch per logical change, prefixed by intent: `feat/`, `bugfix/`, `chore/`, `docs/`, `refactor/`, `test/`. Never commit directly to `main`. +- Commit messages follow Conventional Commits (`feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `test:`). One concern per commit. +- Each branch lands via a reviewed PR (self-review counts when working solo; the diff must still be read end-to-end before merge). Squash-merge into `main` to keep history linear. +- Do not push `main` directly, do not force-push shared branches, and do not bypass hooks. + +### Semantic versioning + +The project version is tracked in [backend/Cargo.toml](backend/Cargo.toml) and [frontend/package.json](frontend/package.json) and bumped on every merge to `main`: + +- `feat:` → bump **minor** +- `fix:` / `bugfix:` → bump **patch** +- breaking change (only valid before 1.0, otherwise needs a major bump after 1.0) → bump **minor** while under 1.0 +- `chore:` / `docs:` / `test:` / `refactor:` with no behaviour change → no bump + +While the project is pre-release the version stays **below `1.0.0`**. The `1.0.0` release happens **only at the user's explicit instruction** — do not bump to or past it autonomously. + +Both manifests must stay in lockstep (same version string). + +## TDD workflow + +The project is developed test-first. For each change: + +1. Write a failing test at the right level. +2. Implement the minimum to make it pass. +3. Refactor with tests green. + +Pick the level deliberately: + +- **Pure logic / mappers / validation** → unit test next to the code. In Rust: `#[cfg(test)] mod tests` in the same file. In the frontend: a sibling `*.test.ts`. +- **Anything that touches the DB** → integration test in [backend/tests/](backend/tests/), using `#[sqlx::test(migrations = "./migrations")]`. Each test gets a fresh, migrated database. +- **Cross-component user journeys** → Playwright in [frontend/e2e/](frontend/e2e/). Mock the network at the Playwright route level when the journey doesn't require a real backend. + +Run these before claiming a change works: + +```bash +(cd backend && cargo test) +(cd frontend && npm test) +(cd frontend && npm run test:e2e) # needs dev server, see playwright.config.ts +``` + +Backend integration tests require `DATABASE_URL` to point at a Postgres where the test user can `CREATEDB` (the `#[sqlx::test]` macro provisions a fresh database per test). + +## Backend layout + +Hexagonal-ish layering. Handlers depend on `repo` and `storage`, never the other way around. This is the seam new extensions plug into. + +- [backend/src/domain/](backend/src/domain/) — pure data types, no I/O. Add new types here first. +- [backend/src/repo/](backend/src/repo/) — DB access functions taking `&PgPool`. Plain async fns rather than a repository struct; tests target them directly. +- [backend/src/storage/](backend/src/storage/) — `Storage` trait + `LocalStorage`. **Add `S3Storage` here when needed**, do not branch on backend type in handlers. +- [backend/src/api/](backend/src/api/) — axum handlers and route wiring. One module per resource. +- [backend/src/app.rs](backend/src/app.rs) — `AppState` and router assembly. Integration tests use the same `router(state)` function with a test `AppState`. +- [backend/src/error.rs](backend/src/error.rs) — `AppError` is the single error type returned by handlers. New variants map to status codes in `IntoResponse`. + +When adding a new resource: + +1. Add a migration `backend/migrations/NNNN_.sql`. +2. Add the domain type in [backend/src/domain/](backend/src/domain/). +3. Add repo functions in [backend/src/repo/](backend/src/repo/). +4. Add handlers in `backend/src/api/.rs` and merge in [backend/src/api/mod.rs](backend/src/api/mod.rs). +5. Add integration tests in [backend/tests/](backend/tests/), reusing [backend/tests/common/mod.rs](backend/tests/common/mod.rs). + +### Database access + +Queries use `sqlx::query_as::<_, T>(...)` (runtime-checked) with `#[derive(FromRow)]` on the domain types, so the project builds without a live DB. If you want compile-time SQL checking later, switch to `sqlx::query!`/`query_as!` macros and commit a `.sqlx` directory via `cargo sqlx prepare`. + +Migrations live in [backend/migrations/](backend/migrations/) and run automatically at startup via `sqlx::migrate!`. + +### Storage trait + +Do not call `tokio::fs` or any filesystem API from handlers. Go through `state.storage` (the `Storage` trait). Keys are `/`-separated paths; the local backend rejects `..` and empty segments. Suggested key layout: + +- `mangas/{manga_id}/cover.{ext}` +- `mangas/{manga_id}/chapters/{chapter_id}/pages/{nnnn}.{ext}` + +## Frontend layout + +- [frontend/src/lib/api/](frontend/src/lib/api/) — typed API client. **All backend calls go through here**, not raw `fetch` in components. The base URL is `import.meta.env.VITE_API_BASE`, defaulting to `/api` (which Vite dev-proxies to the backend). +- [frontend/src/routes/](frontend/src/routes/) — SvelteKit routes. +- Use Svelte 5 runes (`$state`, `$derived`, `$effect`, `$props`). Do not use the legacy `let`-reactive syntax or `on:event=` directive form — prefer `onevent={...}`. +- The Node adapter is configured for production; `npm run build && node build` is what the Dockerfile runs. + +## Common commands + +```bash +# Full stack (compose), production-like +docker compose up --build + +# Local dev: Postgres in compose, backend + frontend native +docker compose -f docker-compose.dev.yml up -d +(cd backend && cargo run) +(cd frontend && npm run dev) + +# Backend tests +(cd backend && cargo test) +(cd backend && cargo test --test api_mangas) # single integration test file +(cd backend && cargo test list_is_empty_initially) # single test by name + +# Frontend tests +(cd frontend && npm test) # vitest, all +(cd frontend && npx vitest run src/lib/api/mangas.test.ts) # single file +(cd frontend && npm run test:e2e) # playwright (auto-starts vite) +``` + +## Extension points + +These are first-class slots in the architecture. When adding any of them, plug into the existing seam rather than building parallel infrastructure. + +- **Tags / lists**: new tables joined to `mangas`. New `domain`, `repo`, and `api` modules; the existing manga endpoints do not need to change. +- **Full-text / fuzzy search**: enable `pg_trgm` in a migration and add a GIN index on `mangas.title`; swap the `WHERE` in `repo::manga::list` to use `%` operator or `tsvector`. The API shape (`?search=...`) does not change. +- **OCR / autotagging**: a background worker (a separate binary or a tokio task spawned in `app::build`) that reads pages from `storage::Storage` and writes tag rows. Do not couple OCR to upload handlers — it runs asynchronously. +- **S3 storage**: add `storage::S3Storage` implementing `Storage`. Branch in `app::build` based on a config field (e.g., `STORAGE_BACKEND=s3`). Handlers do not change. +- **Auth**: an axum middleware producing a `CurrentUser` extractor. Bots use an API token header, browser users a session cookie — both should populate the same extractor so handlers stay backend-agnostic. Until this lands, treat all endpoints as unauthenticated (this is acknowledged technical debt, not a final design). diff --git a/README.md b/README.md new file mode 100644 index 0000000..0034e27 --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# Mangalord + +A self-hosted manga and comics reader. Browse, search, read, bookmark, and upload manga and chapters. The HTTP API is consumed by both the SvelteKit web UI and external bots/scripts that perform the same actions programmatically. + +## Stack + +- **Backend**: Rust, axum, sqlx +- **Database**: Postgres 16 +- **Frontend**: SvelteKit 2 (Svelte 5 runes), TypeScript, Vite +- **File storage**: pluggable `Storage` trait — local FS today, S3 (and friends) as future impls +- **Deploy**: Docker Compose on a single server + +## Quick start + +```bash +cp .env.example .env +docker compose up --build +``` + +- Frontend: http://localhost:3000 +- API: http://localhost:8080/api +- API health: http://localhost:8080/api/health + +## Local development + +Run only Postgres in Docker; run backend and frontend natively for fast iteration: + +```bash +docker compose -f docker-compose.dev.yml up -d + +# backend +cd backend +export DATABASE_URL=postgres://mangalord:mangalord@localhost:5432/mangalord +cargo run + +# frontend (separate shell) +cd frontend +npm install +npm run dev +``` + +The Vite dev server proxies `/api` to `http://localhost:8080`. + +## Tests + +This project is developed test-first. Tests live at three levels: + +```bash +# Backend: unit (in-module) + integration (tests/, per-test DB via #[sqlx::test]) +cd backend && cargo test + +# Frontend: unit / module tests (Vitest) +cd frontend && npm test + +# Frontend: end-to-end (Playwright; spins up dev server, mocks API by default) +cd frontend && npm run test:e2e +``` + +## API surface + +| Method | Path | Purpose | +| ------ | ------------------- | -------------------------------------- | +| GET | `/api/health` | Liveness | +| GET | `/api/mangas` | List / search mangas | +| POST | `/api/mangas` | Create a manga | +| GET | `/api/mangas/{id}` | Get a manga | +| GET | `/api/files/{key}` | Stream a blob (cover, chapter page) | + +Chapters, uploads, and bookmarks are next — the patterns to extend are documented in [`CLAUDE.md`](./CLAUDE.md). diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..b691d12 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,3 @@ +/target +/.sqlx +.env diff --git a/backend/Cargo.toml b/backend/Cargo.toml new file mode 100644 index 0000000..930c7b1 --- /dev/null +++ b/backend/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "mangalord" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/lib.rs" + +[[bin]] +name = "mangalord" +path = "src/main.rs" + +[dependencies] +axum = { version = "0.7", features = ["macros"] } +tokio = { version = "1", features = ["full"] } +sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono", "macros", "migrate"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +uuid = { version = "1", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tower = { version = "0.5", features = ["util"] } +tower-http = { version = "0.6", features = ["trace", "cors"] } +thiserror = "1" +anyhow = "1" +async-trait = "0.1" +dotenvy = "0.15" + +[dev-dependencies] +tempfile = "3" +tower = { version = "0.5", features = ["util"] } +http-body-util = "0.1" +mime = "0.3" diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..ebf2c21 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,27 @@ +# Multi-stage build for the Rust backend. +FROM rust:1-slim AS builder +WORKDIR /app +RUN apt-get update \ + && apt-get install -y --no-install-recommends pkg-config libssl-dev ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Cache deps separately from sources. +COPY Cargo.toml ./ +RUN mkdir src && echo "fn main() {}" > src/main.rs && echo "" > src/lib.rs \ + && cargo build --release \ + && rm -rf src + +COPY src ./src +COPY migrations ./migrations +RUN touch src/main.rs src/lib.rs && cargo build --release + +FROM debian:bookworm-slim +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY --from=builder /app/target/release/mangalord /usr/local/bin/mangalord +COPY --from=builder /app/migrations /app/migrations +ENV STORAGE_DIR=/var/lib/mangalord/storage +EXPOSE 8080 +CMD ["mangalord"] diff --git a/backend/migrations/0001_init.sql b/backend/migrations/0001_init.sql new file mode 100644 index 0000000..76a876f --- /dev/null +++ b/backend/migrations/0001_init.sql @@ -0,0 +1,49 @@ +-- Initial schema for Mangalord. +-- Designed for future extensions: tags, lists, fulltext/fuzzy search, +-- OCR-derived metadata. New concepts get new tables joined here; do +-- not jam them onto existing rows. + +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +CREATE TABLE users ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + username text NOT NULL UNIQUE, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE mangas ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + title text NOT NULL, + author text, + description text, + cover_image_path text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX mangas_created_at_idx ON mangas (created_at DESC); +CREATE INDEX mangas_title_lower_idx ON mangas (lower(title)); + +CREATE TABLE chapters ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + manga_id uuid NOT NULL REFERENCES mangas(id) ON DELETE CASCADE, + number integer NOT NULL, + title text, + page_count integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (manga_id, number) +); + +CREATE INDEX chapters_manga_idx ON chapters (manga_id, number); + +CREATE TABLE bookmarks ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + manga_id uuid NOT NULL REFERENCES mangas(id) ON DELETE CASCADE, + chapter_id uuid REFERENCES chapters(id) ON DELETE SET NULL, + page integer, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (user_id, manga_id, chapter_id) +); + +CREATE INDEX bookmarks_user_idx ON bookmarks (user_id, created_at DESC); diff --git a/backend/src/api/files.rs b/backend/src/api/files.rs new file mode 100644 index 0000000..1fc819a --- /dev/null +++ b/backend/src/api/files.rs @@ -0,0 +1,39 @@ +//! Serves blobs from the `Storage` trait. Same endpoint serves manga +//! covers and chapter pages; the key embedded in the URL is whatever +//! the writer stored. + +use axum::extract::{Path, State}; +use axum::http::header; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; +use axum::Router; + +use crate::app::AppState; +use crate::error::AppResult; +use crate::storage::StorageError; + +pub fn routes() -> Router { + Router::new().route("/files/*key", get(serve)) +} + +async fn serve(State(state): State, Path(key): Path) -> AppResult { + let bytes = match state.storage.get(&key).await { + Ok(b) => b, + Err(StorageError::NotFound) => return Err(crate::error::AppError::NotFound), + Err(e) => return Err(e.into()), + }; + let ct = content_type_for(&key); + Ok(([(header::CONTENT_TYPE, ct)], bytes).into_response()) +} + +fn content_type_for(key: &str) -> &'static str { + let ext = key.rsplit('.').next().unwrap_or("").to_ascii_lowercase(); + match ext.as_str() { + "jpg" | "jpeg" => "image/jpeg", + "png" => "image/png", + "webp" => "image/webp", + "gif" => "image/gif", + "avif" => "image/avif", + _ => "application/octet-stream", + } +} diff --git a/backend/src/api/health.rs b/backend/src/api/health.rs new file mode 100644 index 0000000..4dc38fc --- /dev/null +++ b/backend/src/api/health.rs @@ -0,0 +1,12 @@ +use axum::{routing::get, Json, Router}; +use serde_json::{json, Value}; + +use crate::app::AppState; + +pub fn routes() -> Router { + Router::new().route("/health", get(health)) +} + +async fn health() -> Json { + Json(json!({ "status": "ok" })) +} diff --git a/backend/src/api/mangas.rs b/backend/src/api/mangas.rs new file mode 100644 index 0000000..99c95ab --- /dev/null +++ b/backend/src/api/mangas.rs @@ -0,0 +1,59 @@ +use axum::extract::{Path, Query, State}; +use axum::routing::get; +use axum::{Json, Router}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::app::AppState; +use crate::domain::manga::{Manga, NewManga}; +use crate::error::{AppError, AppResult}; +use crate::repo; + +pub fn routes() -> Router { + Router::new() + .route("/mangas", get(list).post(create)) + .route("/mangas/:id", get(get_one)) +} + +#[derive(Debug, Deserialize)] +pub struct ListParams { + #[serde(default)] + pub search: Option, + #[serde(default = "default_limit")] + pub limit: i64, + #[serde(default)] + pub offset: i64, +} + +fn default_limit() -> i64 { + 50 +} + +async fn list( + State(state): State, + Query(params): Query, +) -> AppResult>> { + let q = repo::manga::ListQuery { + search: params.search.filter(|s| !s.trim().is_empty()), + limit: params.limit.clamp(1, 200), + offset: params.offset.max(0), + }; + Ok(Json(repo::manga::list(&state.db, &q).await?)) +} + +async fn get_one( + State(state): State, + Path(id): Path, +) -> AppResult> { + Ok(Json(repo::manga::get(&state.db, id).await?)) +} + +async fn create( + State(state): State, + Json(input): Json, +) -> AppResult> { + if input.title.trim().is_empty() { + return Err(AppError::InvalidInput("title is required".into())); + } + Ok(Json(repo::manga::create(&state.db, input).await?)) +} diff --git a/backend/src/api/mod.rs b/backend/src/api/mod.rs new file mode 100644 index 0000000..3fac132 --- /dev/null +++ b/backend/src/api/mod.rs @@ -0,0 +1,14 @@ +pub mod files; +pub mod health; +pub mod mangas; + +use axum::Router; + +use crate::app::AppState; + +pub fn routes() -> Router { + Router::new() + .merge(health::routes()) + .merge(mangas::routes()) + .merge(files::routes()) +} diff --git a/backend/src/app.rs b/backend/src/app.rs new file mode 100644 index 0000000..72dddb5 --- /dev/null +++ b/backend/src/app.rs @@ -0,0 +1,36 @@ +use std::sync::Arc; + +use axum::Router; +use sqlx::postgres::PgPoolOptions; +use sqlx::PgPool; +use tower_http::trace::TraceLayer; + +use crate::config::Config; +use crate::storage::{LocalStorage, Storage}; + +#[derive(Clone)] +pub struct AppState { + pub db: PgPool, + pub storage: Arc, +} + +pub async fn build(config: Config) -> anyhow::Result { + let db = PgPoolOptions::new() + .max_connections(10) + .connect(&config.database_url) + .await?; + sqlx::migrate!("./migrations").run(&db).await?; + + let storage: Arc = Arc::new(LocalStorage::new(config.storage_dir.clone())); + + Ok(router(AppState { db, storage })) +} + +/// Build a router from a pre-assembled state. Used by integration tests +/// so they can swap in a test DB pool and a `tempfile`-backed storage. +pub fn router(state: AppState) -> Router { + Router::new() + .nest("/api", crate::api::routes()) + .with_state(state) + .layer(TraceLayer::new_for_http()) +} diff --git a/backend/src/config.rs b/backend/src/config.rs new file mode 100644 index 0000000..179e717 --- /dev/null +++ b/backend/src/config.rs @@ -0,0 +1,22 @@ +use std::path::PathBuf; + +#[derive(Clone, Debug)] +pub struct Config { + pub database_url: String, + pub bind_address: String, + pub storage_dir: PathBuf, +} + +impl Config { + pub fn from_env() -> anyhow::Result { + Ok(Self { + database_url: std::env::var("DATABASE_URL") + .map_err(|_| anyhow::anyhow!("DATABASE_URL must be set"))?, + bind_address: std::env::var("BIND_ADDRESS") + .unwrap_or_else(|_| "0.0.0.0:8080".to_string()), + storage_dir: std::env::var("STORAGE_DIR") + .unwrap_or_else(|_| "./data/storage".to_string()) + .into(), + }) + } +} diff --git a/backend/src/domain/bookmark.rs b/backend/src/domain/bookmark.rs new file mode 100644 index 0000000..62b8dd8 --- /dev/null +++ b/backend/src/domain/bookmark.rs @@ -0,0 +1,14 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct Bookmark { + pub id: Uuid, + pub user_id: Uuid, + pub manga_id: Uuid, + pub chapter_id: Option, + pub page: Option, + pub created_at: DateTime, +} diff --git a/backend/src/domain/chapter.rs b/backend/src/domain/chapter.rs new file mode 100644 index 0000000..69a9cb5 --- /dev/null +++ b/backend/src/domain/chapter.rs @@ -0,0 +1,20 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct Chapter { + pub id: Uuid, + pub manga_id: Uuid, + pub number: i32, + pub title: Option, + pub page_count: i32, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct NewChapter { + pub number: i32, + pub title: Option, +} diff --git a/backend/src/domain/manga.rs b/backend/src/domain/manga.rs new file mode 100644 index 0000000..31c0a59 --- /dev/null +++ b/backend/src/domain/manga.rs @@ -0,0 +1,22 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct Manga { + pub id: Uuid, + pub title: String, + pub author: Option, + pub description: Option, + pub cover_image_path: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct NewManga { + pub title: String, + pub author: Option, + pub description: Option, +} diff --git a/backend/src/domain/mod.rs b/backend/src/domain/mod.rs new file mode 100644 index 0000000..0264a39 --- /dev/null +++ b/backend/src/domain/mod.rs @@ -0,0 +1,9 @@ +pub mod bookmark; +pub mod chapter; +pub mod manga; +pub mod user; + +pub use bookmark::Bookmark; +pub use chapter::Chapter; +pub use manga::Manga; +pub use user::User; diff --git a/backend/src/domain/user.rs b/backend/src/domain/user.rs new file mode 100644 index 0000000..efb2f10 --- /dev/null +++ b/backend/src/domain/user.rs @@ -0,0 +1,11 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct User { + pub id: Uuid, + pub username: String, + pub created_at: DateTime, +} diff --git a/backend/src/error.rs b/backend/src/error.rs new file mode 100644 index 0000000..c213c1c --- /dev/null +++ b/backend/src/error.rs @@ -0,0 +1,45 @@ +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde_json::json; + +use crate::storage::StorageError; + +#[derive(thiserror::Error, Debug)] +pub enum AppError { + #[error("not found")] + NotFound, + #[error("invalid input: {0}")] + InvalidInput(String), + #[error(transparent)] + Database(#[from] sqlx::Error), + #[error(transparent)] + Storage(#[from] StorageError), + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +pub type AppResult = Result; + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let (status, message) = match &self { + AppError::NotFound => (StatusCode::NOT_FOUND, self.to_string()), + AppError::InvalidInput(_) => (StatusCode::BAD_REQUEST, self.to_string()), + AppError::Database(sqlx::Error::RowNotFound) => { + (StatusCode::NOT_FOUND, "not found".to_string()) + } + AppError::Storage(StorageError::NotFound) => { + (StatusCode::NOT_FOUND, "not found".to_string()) + } + AppError::Storage(StorageError::BadKey) => { + (StatusCode::BAD_REQUEST, "invalid file key".to_string()) + } + AppError::Database(_) | AppError::Storage(_) | AppError::Other(_) => { + tracing::error!(error = ?self, "internal error"); + (StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_string()) + } + }; + (status, Json(json!({ "error": message }))).into_response() + } +} diff --git a/backend/src/lib.rs b/backend/src/lib.rs new file mode 100644 index 0000000..89dec77 --- /dev/null +++ b/backend/src/lib.rs @@ -0,0 +1,7 @@ +pub mod api; +pub mod app; +pub mod config; +pub mod domain; +pub mod error; +pub mod repo; +pub mod storage; diff --git a/backend/src/main.rs b/backend/src/main.rs new file mode 100644 index 0000000..1a82224 --- /dev/null +++ b/backend/src/main.rs @@ -0,0 +1,21 @@ +use std::net::SocketAddr; +use tracing_subscriber::EnvFilter; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + dotenvy::dotenv().ok(); + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| "info,mangalord=debug".into()), + ) + .init(); + + let config = mangalord::config::Config::from_env()?; + let addr: SocketAddr = config.bind_address.parse()?; + let app = mangalord::app::build(config).await?; + + tracing::info!(%addr, "mangalord listening"); + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app).await?; + Ok(()) +} diff --git a/backend/src/repo/manga.rs b/backend/src/repo/manga.rs new file mode 100644 index 0000000..c82e755 --- /dev/null +++ b/backend/src/repo/manga.rs @@ -0,0 +1,76 @@ +//! Manga persistence. +//! +//! Plain async functions over `&PgPool` rather than a repository struct — +//! each function is easy to test in isolation with `#[sqlx::test]`, and +//! handlers depend only on `sqlx::PgPool`, not on a trait object. Swap to +//! a trait + impl if a second backend ever becomes necessary. + +use sqlx::PgPool; +use uuid::Uuid; + +use crate::domain::manga::{Manga, NewManga}; +use crate::error::{AppError, AppResult}; + +#[derive(Debug, Clone)] +pub struct ListQuery { + pub search: Option, + pub limit: i64, + pub offset: i64, +} + +impl Default for ListQuery { + fn default() -> Self { + Self { search: None, limit: 50, offset: 0 } + } +} + +pub async fn list(pool: &PgPool, query: &ListQuery) -> AppResult> { + let pattern = query.search.as_deref().map(|s| format!("%{}%", s)); + let rows = sqlx::query_as::<_, Manga>( + r#" + SELECT id, title, author, description, cover_image_path, created_at, updated_at + FROM mangas + WHERE $1::text IS NULL + OR title ILIKE $1 + OR COALESCE(author, '') ILIKE $1 + ORDER BY created_at DESC + LIMIT $2 OFFSET $3 + "#, + ) + .bind(pattern) + .bind(query.limit) + .bind(query.offset) + .fetch_all(pool) + .await?; + Ok(rows) +} + +pub async fn get(pool: &PgPool, id: Uuid) -> AppResult { + sqlx::query_as::<_, Manga>( + r#" + SELECT id, title, author, description, cover_image_path, created_at, updated_at + FROM mangas + WHERE id = $1 + "#, + ) + .bind(id) + .fetch_optional(pool) + .await? + .ok_or(AppError::NotFound) +} + +pub async fn create(pool: &PgPool, input: NewManga) -> AppResult { + let row = sqlx::query_as::<_, Manga>( + r#" + INSERT INTO mangas (title, author, description) + VALUES ($1, $2, $3) + RETURNING id, title, author, description, cover_image_path, created_at, updated_at + "#, + ) + .bind(&input.title) + .bind(&input.author) + .bind(&input.description) + .fetch_one(pool) + .await?; + Ok(row) +} diff --git a/backend/src/repo/mod.rs b/backend/src/repo/mod.rs new file mode 100644 index 0000000..5e18302 --- /dev/null +++ b/backend/src/repo/mod.rs @@ -0,0 +1 @@ +pub mod manga; diff --git a/backend/src/storage/local.rs b/backend/src/storage/local.rs new file mode 100644 index 0000000..9fc5730 --- /dev/null +++ b/backend/src/storage/local.rs @@ -0,0 +1,97 @@ +use std::path::{Path, PathBuf}; + +use async_trait::async_trait; +use tokio::fs; + +use super::{Storage, StorageError}; + +pub struct LocalStorage { + root: PathBuf, +} + +impl LocalStorage { + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + fn resolve(&self, key: &str) -> Result { + let key = key.trim_start_matches('/'); + if key.is_empty() { + return Err(StorageError::BadKey); + } + if key.split('/').any(|seg| seg.is_empty() || seg == "." || seg == "..") { + return Err(StorageError::BadKey); + } + Ok(self.root.join(key)) + } +} + +#[async_trait] +impl Storage for LocalStorage { + async fn put(&self, key: &str, bytes: &[u8]) -> Result<(), StorageError> { + let path = self.resolve(key)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).await?; + } + fs::write(path, bytes).await?; + Ok(()) + } + + async fn get(&self, key: &str) -> Result, StorageError> { + let path = self.resolve(key)?; + match fs::read(&path).await { + Ok(b) => Ok(b), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(StorageError::NotFound), + Err(e) => Err(e.into()), + } + } + + async fn delete(&self, key: &str) -> Result<(), StorageError> { + let path = self.resolve(key)?; + match fs::remove_file(&path).await { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(StorageError::NotFound), + Err(e) => Err(e.into()), + } + } + + async fn exists(&self, key: &str) -> Result { + let path: &Path = &self.resolve(key)?; + Ok(fs::try_exists(path).await?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[tokio::test] + async fn put_get_delete_roundtrip() { + let dir = tempdir().unwrap(); + let s = LocalStorage::new(dir.path()); + + s.put("mangas/abc/cover.jpg", b"hello").await.unwrap(); + assert!(s.exists("mangas/abc/cover.jpg").await.unwrap()); + assert_eq!(s.get("mangas/abc/cover.jpg").await.unwrap(), b"hello"); + s.delete("mangas/abc/cover.jpg").await.unwrap(); + assert!(!s.exists("mangas/abc/cover.jpg").await.unwrap()); + } + + #[tokio::test] + async fn rejects_path_traversal() { + let dir = tempdir().unwrap(); + let s = LocalStorage::new(dir.path()); + assert!(matches!(s.put("../escape", b"x").await, Err(StorageError::BadKey))); + assert!(matches!(s.get("a/../../b").await, Err(StorageError::BadKey))); + assert!(matches!(s.exists("").await, Err(StorageError::BadKey))); + } + + #[tokio::test] + async fn missing_key_is_not_found() { + let dir = tempdir().unwrap(); + let s = LocalStorage::new(dir.path()); + assert!(matches!(s.get("nope").await, Err(StorageError::NotFound))); + assert!(matches!(s.delete("nope").await, Err(StorageError::NotFound))); + } +} diff --git a/backend/src/storage/mod.rs b/backend/src/storage/mod.rs new file mode 100644 index 0000000..898b218 --- /dev/null +++ b/backend/src/storage/mod.rs @@ -0,0 +1,31 @@ +//! Pluggable blob storage. +//! +//! Handlers depend on the `Storage` trait, never on a concrete backend. +//! Add new backends (S3, GCS, …) as new impls in this module and wire +//! them up in `app::build` based on config. + +mod local; + +use std::io; + +use async_trait::async_trait; + +pub use local::LocalStorage; + +#[derive(thiserror::Error, Debug)] +pub enum StorageError { + #[error(transparent)] + Io(#[from] io::Error), + #[error("not found")] + NotFound, + #[error("invalid storage key")] + BadKey, +} + +#[async_trait] +pub trait Storage: Send + Sync { + async fn put(&self, key: &str, bytes: &[u8]) -> Result<(), StorageError>; + async fn get(&self, key: &str) -> Result, StorageError>; + async fn delete(&self, key: &str) -> Result<(), StorageError>; + async fn exists(&self, key: &str) -> Result; +} diff --git a/backend/tests/api_mangas.rs b/backend/tests/api_mangas.rs new file mode 100644 index 0000000..b53c949 --- /dev/null +++ b/backend/tests/api_mangas.rs @@ -0,0 +1,77 @@ +mod common; + +use axum::http::StatusCode; +use serde_json::json; +use sqlx::PgPool; +use tower::ServiceExt; + +#[sqlx::test(migrations = "./migrations")] +async fn list_is_empty_initially(pool: PgPool) { + let h = common::harness(pool); + let resp = h.app.oneshot(common::get("/api/mangas")).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(common::body_json(resp).await, json!([])); +} + +#[sqlx::test(migrations = "./migrations")] +async fn create_then_list_roundtrip(pool: PgPool) { + let h = common::harness(pool); + + let created = h.app.clone().oneshot(common::post_json( + "/api/mangas", + json!({ "title": "Berserk", "author": "Kentaro Miura", "description": null }), + )).await.unwrap(); + assert_eq!(created.status(), StatusCode::OK); + let body = common::body_json(created).await; + assert_eq!(body["title"], "Berserk"); + assert_eq!(body["author"], "Kentaro Miura"); + assert!(body["id"].as_str().is_some()); + + let listed = h.app.oneshot(common::get("/api/mangas")).await.unwrap(); + let listed_body = common::body_json(listed).await; + assert_eq!(listed_body.as_array().unwrap().len(), 1); + assert_eq!(listed_body[0]["title"], "Berserk"); +} + +#[sqlx::test(migrations = "./migrations")] +async fn search_filters_by_title_and_author(pool: PgPool) { + let h = common::harness(pool); + + for (title, author) in [ + ("One Piece", "Eiichiro Oda"), + ("Berserk", "Kentaro Miura"), + ("Vinland Saga", "Makoto Yukimura"), + ] { + let _ = h.app.clone().oneshot(common::post_json( + "/api/mangas", + json!({ "title": title, "author": author }), + )).await.unwrap(); + } + + let resp = h.app.clone().oneshot(common::get("/api/mangas?search=miura")).await.unwrap(); + let body = common::body_json(resp).await; + let titles: Vec<&str> = body.as_array().unwrap().iter().map(|m| m["title"].as_str().unwrap()).collect(); + assert_eq!(titles, vec!["Berserk"]); + + let resp = h.app.oneshot(common::get("/api/mangas?search=saga")).await.unwrap(); + let body = common::body_json(resp).await; + let titles: Vec<&str> = body.as_array().unwrap().iter().map(|m| m["title"].as_str().unwrap()).collect(); + assert_eq!(titles, vec!["Vinland Saga"]); +} + +#[sqlx::test(migrations = "./migrations")] +async fn create_rejects_empty_title(pool: PgPool) { + let h = common::harness(pool); + let resp = h.app.oneshot(common::post_json( + "/api/mangas", + json!({ "title": " ", "author": null }), + )).await.unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[sqlx::test(migrations = "./migrations")] +async fn get_unknown_id_is_404(pool: PgPool) { + let h = common::harness(pool); + let resp = h.app.oneshot(common::get("/api/mangas/00000000-0000-0000-0000-000000000000")).await.unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs new file mode 100644 index 0000000..140dce4 --- /dev/null +++ b/backend/tests/common/mod.rs @@ -0,0 +1,44 @@ +use std::sync::Arc; + +use axum::body::Body; +use axum::http::Request; +use axum::Router; +use http_body_util::BodyExt; +use sqlx::PgPool; +use tempfile::TempDir; + +use mangalord::app::{router, AppState}; +use mangalord::storage::LocalStorage; + +pub struct Harness { + pub app: Router, + // Kept alive for the lifetime of the test so the temp dir is not dropped. + pub _storage_dir: TempDir, +} + +pub fn harness(pool: PgPool) -> Harness { + let storage_dir = tempfile::tempdir().expect("tempdir"); + let state = AppState { + db: pool, + storage: Arc::new(LocalStorage::new(storage_dir.path())), + }; + Harness { app: router(state), _storage_dir: storage_dir } +} + +pub async fn body_json(response: axum::response::Response) -> serde_json::Value { + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&bytes).expect("body is JSON") +} + +pub fn get(uri: &str) -> Request { + Request::builder().uri(uri).body(Body::empty()).unwrap() +} + +pub fn post_json(uri: &str, body: serde_json::Value) -> Request { + Request::builder() + .method("POST") + .uri(uri) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap() +} diff --git a/backend/tests/health.rs b/backend/tests/health.rs new file mode 100644 index 0000000..0e4124d --- /dev/null +++ b/backend/tests/health.rs @@ -0,0 +1,14 @@ +mod common; + +use axum::http::StatusCode; +use sqlx::PgPool; +use tower::ServiceExt; + +#[sqlx::test(migrations = "./migrations")] +async fn health_returns_ok(pool: PgPool) { + let h = common::harness(pool); + let resp = h.app.oneshot(common::get("/api/health")).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = common::body_json(resp).await; + assert_eq!(body["status"], "ok"); +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..14e31d0 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,21 @@ +# Spin up just Postgres for local dev. Run backend & frontend natively +# against it (cargo run / npm run dev) for fast iteration. +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: mangalord + POSTGRES_PASSWORD: mangalord + POSTGRES_DB: mangalord + ports: + - "5432:5432" + volumes: + - mangalord-postgres-dev:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U mangalord"] + interval: 5s + timeout: 5s + retries: 10 + +volumes: + mangalord-postgres-dev: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6efca54 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: ${POSTGRES_USER:-mangalord} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-mangalord} + POSTGRES_DB: ${POSTGRES_DB:-mangalord} + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-mangalord}"] + interval: 5s + timeout: 5s + retries: 10 + + backend: + build: ./backend + depends_on: + postgres: + condition: service_healthy + environment: + DATABASE_URL: postgres://${POSTGRES_USER:-mangalord}:${POSTGRES_PASSWORD:-mangalord}@postgres:5432/${POSTGRES_DB:-mangalord} + BIND_ADDRESS: 0.0.0.0:8080 + STORAGE_DIR: /var/lib/mangalord/storage + RUST_LOG: ${RUST_LOG:-info,mangalord=debug} + volumes: + - storage-data:/var/lib/mangalord/storage + ports: + - "8080:8080" + + frontend: + build: ./frontend + depends_on: + - backend + environment: + PUBLIC_API_BASE: ${PUBLIC_API_BASE:-http://localhost:8080/api} + ports: + - "3000:3000" + +volumes: + postgres-data: + storage-data: diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..654ad1c --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,7 @@ +node_modules +.svelte-kit +build +test-results +playwright-report +.env +.env.local diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..31031d2 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,17 @@ +FROM node:22-alpine AS builder +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm install +COPY . . +RUN npm run build + +FROM node:22-alpine +WORKDIR /app +ENV NODE_ENV=production +ENV HOST=0.0.0.0 +ENV PORT=3000 +COPY --from=builder /app/build ./build +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package.json ./ +EXPOSE 3000 +CMD ["node", "build"] diff --git a/frontend/e2e/manga-list.spec.ts b/frontend/e2e/manga-list.spec.ts new file mode 100644 index 0000000..b0e7f1b --- /dev/null +++ b/frontend/e2e/manga-list.spec.ts @@ -0,0 +1,57 @@ +import { test, expect } from '@playwright/test'; + +// These E2E tests run against the dev server (vite on :5173) which proxies +// /api to the backend. Set E2E_BASE_URL to point at a different deployment. +// +// A live backend (and Postgres) must be reachable. Routes mock the network +// where possible to keep journeys deterministic. + +test('home page renders the Mangalord heading and search input', async ({ page }) => { + // Mock the list endpoint so the test doesn't depend on DB state. + await page.route('**/api/mangas*', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([]) + }); + }); + + await page.goto('/'); + await expect(page.getByRole('link', { name: 'Mangalord' })).toBeVisible(); + await expect(page.getByTestId('search-input')).toBeVisible(); + await expect(page.getByTestId('empty')).toContainText('No mangas yet'); +}); + +test('search updates the manga list', async ({ page }) => { + let lastSearch: string | null = null; + await page.route('**/api/mangas*', async (route) => { + const url = new URL(route.request().url()); + lastSearch = url.searchParams.get('search'); + const body = + lastSearch === 'berserk' + ? [ + { + id: 'b1', + title: 'Berserk', + author: 'Kentaro Miura', + description: null, + cover_image_path: null, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z' + } + ] + : []; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(body) + }); + }); + + await page.goto('/'); + await page.getByTestId('search-input').fill('berserk'); + await page.getByRole('button', { name: 'Search' }).click(); + + await expect(page.getByTestId('manga-list')).toContainText('Berserk'); + expect(lastSearch).toBe('berserk'); +}); diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..8316922 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,31 @@ +{ + "name": "mangalord-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "test": "vitest run", + "test:watch": "vitest", + "test:e2e": "playwright test" + }, + "devDependencies": { + "@playwright/test": "^1.48.0", + "@sveltejs/adapter-node": "^5.2.0", + "@sveltejs/kit": "^2.7.0", + "@sveltejs/vite-plugin-svelte": "^4.0.0", + "@testing-library/jest-dom": "^6.6.0", + "@testing-library/svelte": "^5.2.0", + "@types/node": "^22.7.0", + "jsdom": "^25.0.0", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "tslib": "^2.7.0", + "typescript": "^5.6.0", + "vite": "^5.4.0", + "vitest": "^2.1.0" + } +} diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..e31a061 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: 'e2e', + timeout: 30_000, + use: { + baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:5173', + trace: 'retain-on-failure' + }, + webServer: process.env.E2E_BASE_URL + ? undefined + : { + command: 'npm run dev', + port: 5173, + reuseExistingServer: !process.env.CI, + timeout: 120_000 + } +}); diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts new file mode 100644 index 0000000..de4ade8 --- /dev/null +++ b/frontend/src/app.d.ts @@ -0,0 +1,11 @@ +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/frontend/src/app.html b/frontend/src/app.html new file mode 100644 index 0000000..28219e1 --- /dev/null +++ b/frontend/src/app.html @@ -0,0 +1,13 @@ + + + + + + + Mangalord + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts new file mode 100644 index 0000000..98fb867 --- /dev/null +++ b/frontend/src/lib/api/client.ts @@ -0,0 +1,33 @@ +// All backend calls go through this module. Components and routes import +// the typed helpers below — they do not call fetch directly. + +const BASE = (typeof import.meta !== 'undefined' && import.meta.env?.VITE_API_BASE) || '/api'; + +export class ApiError extends Error { + constructor( + public readonly status: number, + message: string + ) { + super(message); + this.name = 'ApiError'; + } +} + +export async function request(path: string, init?: RequestInit): Promise { + const res = await fetch(`${BASE}${path}`, init); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new ApiError(res.status, text || `${res.status} ${res.statusText}`); + } + return (await res.json()) as T; +} + +export type Manga = { + id: string; + title: string; + author: string | null; + description: string | null; + cover_image_path: string | null; + created_at: string; + updated_at: string; +}; diff --git a/frontend/src/lib/api/mangas.test.ts b/frontend/src/lib/api/mangas.test.ts new file mode 100644 index 0000000..f2bb081 --- /dev/null +++ b/frontend/src/lib/api/mangas.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { listMangas, createManga, getManga } from './mangas'; + +function ok(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' } + }); +} + +function fail(status: number, body = ''): Response { + return new Response(body, { status }); +} + +describe('mangas api client', () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch'); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('listMangas hits /mangas with no params by default', async () => { + fetchSpy.mockResolvedValueOnce(ok([])); + await listMangas(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch(/\/mangas$/); + }); + + it('listMangas encodes search, limit, offset', async () => { + fetchSpy.mockResolvedValueOnce(ok([])); + await listMangas({ search: 'one piece', limit: 10, offset: 20 }); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toContain('search=one+piece'); + expect(url).toContain('limit=10'); + expect(url).toContain('offset=20'); + }); + + it('createManga POSTs JSON', async () => { + fetchSpy.mockResolvedValueOnce( + ok({ + id: 'abc', + title: 'Berserk', + author: 'Miura', + description: null, + cover_image_path: null, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z' + }) + ); + const m = await createManga({ title: 'Berserk', author: 'Miura' }); + expect(m.title).toBe('Berserk'); + const init = fetchSpy.mock.calls[0][1] as RequestInit; + expect(init.method).toBe('POST'); + expect(init.headers).toMatchObject({ 'content-type': 'application/json' }); + expect(JSON.parse(init.body as string)).toEqual({ title: 'Berserk', author: 'Miura' }); + }); + + it('getManga throws ApiError on non-2xx', async () => { + fetchSpy.mockResolvedValue(fail(404, 'not found')); + await expect(getManga('missing')).rejects.toMatchObject({ + name: 'ApiError', + status: 404 + }); + }); +}); diff --git a/frontend/src/lib/api/mangas.ts b/frontend/src/lib/api/mangas.ts new file mode 100644 index 0000000..94f267b --- /dev/null +++ b/frontend/src/lib/api/mangas.ts @@ -0,0 +1,36 @@ +import { request, type Manga } from './client'; + +export type ListOptions = { + search?: string; + limit?: number; + offset?: number; +}; + +export async function listMangas(opts: ListOptions = {}): Promise { + const params = new URLSearchParams(); + if (opts.search) params.set('search', opts.search); + if (opts.limit != null) params.set('limit', String(opts.limit)); + if (opts.offset != null) params.set('offset', String(opts.offset)); + const qs = params.toString(); + return request(`/mangas${qs ? `?${qs}` : ''}`); +} + +export async function getManga(id: string): Promise { + return request(`/mangas/${encodeURIComponent(id)}`); +} + +export type NewManga = { + title: string; + author?: string | null; + description?: string | null; +}; + +export async function createManga(input: NewManga): Promise { + return request('/mangas', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(input) + }); +} + +export type { Manga }; diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte new file mode 100644 index 0000000..212acad --- /dev/null +++ b/frontend/src/routes/+layout.svelte @@ -0,0 +1,30 @@ + + +
+ +
+ +
+ {@render children()} +
+ + diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte new file mode 100644 index 0000000..a097390 --- /dev/null +++ b/frontend/src/routes/+page.svelte @@ -0,0 +1,57 @@ + + +

Mangas

+ +
{ + e.preventDefault(); + load(); + }} +> + + +
+ +{#if loading} +

Loading…

+{:else if error} +

{error}

+{:else if mangas.length === 0} +

No mangas yet. Upload one.

+{:else} +
    + {#each mangas as m (m.id)} +
  • + {m.title} + {#if m.author} — {m.author}{/if} +
  • + {/each} +
+{/if} diff --git a/frontend/static/.gitkeep b/frontend/static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js new file mode 100644 index 0000000..2a214ea --- /dev/null +++ b/frontend/svelte.config.js @@ -0,0 +1,12 @@ +import adapter from '@sveltejs/adapter-node'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter({ out: 'build' }) + } +}; + +export default config; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..5abf51b --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler", + "types": ["@testing-library/jest-dom", "vitest/globals"] + } +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..8161457 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,20 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sveltekit()], + server: { + port: 5173, + proxy: { + '/api': { + target: process.env.BACKEND_URL ?? 'http://localhost:8080', + changeOrigin: true + } + } + }, + test: { + environment: 'jsdom', + include: ['src/**/*.test.ts'], + globals: false + } +});