diff --git a/.env.example b/.env.example index dc6e02d..6cfa300 100644 --- a/.env.example +++ b/.env.example @@ -45,7 +45,9 @@ MAX_REQUEST_BYTES=209715200 MAX_FILE_BYTES=20971520 # ----- Frontend ----- -# Public base URL the browser uses to reach the API. Behind a reverse -# proxy that serves /api/* from the frontend host, set this to /api so -# the calls stay same-origin. -PUBLIC_API_BASE=http://localhost:8080/api +# The frontend container runs SvelteKit's Node adapter on :3000 and +# proxies /api/* to BACKEND_URL via src/hooks.server.ts. In compose the +# default `http://backend:8080` reaches the backend service over the +# internal docker network. Override only if you're running the +# frontend container against a backend somewhere else. +BACKEND_URL=http://backend:8080 diff --git a/README.md b/README.md index 6faa1f1..0d53d5b 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,14 @@ cp .env.example .env docker compose up --build ``` -| Service | URL | -| ----------------- | ---------------------------------- | -| Frontend | | -| API base | | -| Health check | | +| 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: @@ -142,21 +145,23 @@ The frontend handles this for you: register → cookie set → writes work. Cook ### 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:8080/api/v1/auth/login \ +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:8080/api/v1/auth/tokens \ +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:8080/api/v1/auth/me + 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. @@ -177,13 +182,13 @@ All variables can be set in `.env` (for `docker compose`) or your shell (for `ca | `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. | -| `PUBLIC_API_BASE` | `http://localhost:8080/api` | Browser-facing API base. | +| `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 on `:3000` and proxy `/api/*` to the backend on `:8080`. With same-origin routing you can leave `CORS_ALLOWED_ORIGINS` empty and the session cookie's `SameSite=Lax` does its job. +- **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. diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 8b4d547..514fdcf 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.9.0" +version = "0.9.1" edition = "2021" [lib] diff --git a/docker-compose.yml b/docker-compose.yml index 02453d0..282b86f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,15 +35,21 @@ services: MAX_FILE_BYTES: ${MAX_FILE_BYTES:-20971520} volumes: - storage-data:/var/lib/mangalord/storage - ports: - - "8080:8080" + # No host port mapping in the default setup — the frontend proxies + # /api/* through its hooks.server.ts. Expose :8080 only if you want + # to hit the API directly from the host (e.g., bot scripts during + # development). + expose: + - "8080" frontend: build: ./frontend depends_on: - backend environment: - PUBLIC_API_BASE: ${PUBLIC_API_BASE:-http://localhost:8080/api} + # SvelteKit's hooks.server.ts proxies /api/* to this URL so the + # browser only ever talks to :3000 and cookies stay same-origin. + BACKEND_URL: http://backend:8080 ports: - "3000:3000" diff --git a/frontend/package.json b/frontend/package.json index dff96a2..a965d55 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.9.0", + "version": "0.9.1", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/hooks.server.test.ts b/frontend/src/hooks.server.test.ts new file mode 100644 index 0000000..59f620d --- /dev/null +++ b/frontend/src/hooks.server.test.ts @@ -0,0 +1,104 @@ +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type MockInstance +} from 'vitest'; +import { handle } from './hooks.server'; + +// `BACKEND_URL` is read at module load time, so the values used in the +// asserts below assume the test env didn't set it. `?? 'http://localhost:8080'` +// is the default. +const DEFAULT_BACKEND = 'http://localhost:8080'; + +function makeEvent(path: string, init?: RequestInit) { + const url = new URL(`http://app.example.com${path}`); + const request = new Request(url, init); + return { url, request } as Parameters[0]['event']; +} + +describe('hooks.server proxy', () => { + let fetchSpy: MockInstance; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch'); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('forwards /api/* requests to the backend, preserving status', async () => { + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify({ status: 'ok' }), { + status: 200, + headers: { 'content-type': 'application/json' } + }) + ); + const resolve = vi.fn(); + + const event = makeEvent('/api/v1/health'); + const resp = await handle({ event, resolve }); + + expect(resolve).not.toHaveBeenCalled(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy.mock.calls[0][0]).toBe(`${DEFAULT_BACKEND}/api/v1/health`); + expect(resp.status).toBe(200); + expect(await resp.json()).toEqual({ status: 'ok' }); + }); + + it('passes through the query string', async () => { + fetchSpy.mockResolvedValueOnce(new Response('[]', { status: 200 })); + const resolve = vi.fn(); + await handle({ + event: makeEvent('/api/v1/mangas?search=narto&limit=10'), + resolve + }); + expect(fetchSpy.mock.calls[0][0]).toBe( + `${DEFAULT_BACKEND}/api/v1/mangas?search=narto&limit=10` + ); + }); + + it('strips the host header so the backend sees its own origin', async () => { + fetchSpy.mockResolvedValueOnce(new Response('[]', { status: 200 })); + const resolve = vi.fn(); + await handle({ + event: makeEvent('/api/v1/health', { + headers: { host: 'app.example.com', cookie: 'mangalord_session=abc' } + }), + resolve + }); + const init = fetchSpy.mock.calls[0][1] as RequestInit; + const headers = init.headers as Headers; + expect(headers.get('host')).toBeNull(); + // Cookies must still be forwarded — that's how the session reaches axum. + expect(headers.get('cookie')).toBe('mangalord_session=abc'); + }); + + it('forwards request bodies on POST', async () => { + fetchSpy.mockResolvedValueOnce(new Response('{}', { status: 201 })); + const resolve = vi.fn(); + await handle({ + event: makeEvent('/api/v1/auth/login', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username: 'alice', password: 'hunter2hunter2' }) + }), + resolve + }); + const init = fetchSpy.mock.calls[0][1] as RequestInit; + expect(init.method).toBe('POST'); + expect(init.body).toBeDefined(); + }); + + it('delegates non-/api requests to SvelteKit', async () => { + const resolve = vi.fn().mockResolvedValue(new Response('page', { status: 200 })); + + await handle({ event: makeEvent('/manga/abc'), resolve }); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(resolve).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts new file mode 100644 index 0000000..770d9af --- /dev/null +++ b/frontend/src/hooks.server.ts @@ -0,0 +1,45 @@ +import type { Handle } from '@sveltejs/kit'; + +// Reverse-proxy `/api/*` requests through to the backend container. +// +// Mangalord's compose runs SvelteKit (this process) on :3000 and axum on +// :8080. The browser only ever talks to :3000, so cookies stay +// same-origin and `CORS_ALLOWED_ORIGINS` can stay empty in the default +// deploy. The backend hostname comes from `BACKEND_URL` (compose wires +// `http://backend:8080`); for `npm run dev` we fall back to the same +// localhost target the vite proxy uses, which keeps the dev story +// consistent even if someone bypasses the vite proxy. + +const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:8080'; + +export const handle: Handle = async ({ event, resolve }) => { + if (event.url.pathname.startsWith('/api/')) { + const target = `${BACKEND_URL}${event.url.pathname}${event.url.search}`; + + // Strip hop-by-hop headers — `host` would mislead the backend + // about the origin, and `content-length` will be recomputed. + const headers = new Headers(event.request.headers); + headers.delete('host'); + headers.delete('content-length'); + + const init: RequestInit & { duplex?: 'half' } = { + method: event.request.method, + headers, + redirect: 'manual' + }; + if (event.request.method !== 'GET' && event.request.method !== 'HEAD') { + init.body = event.request.body; + // Node's fetch requires `duplex: 'half'` when streaming a + // request body; otherwise the stream is rejected. + init.duplex = 'half'; + } + + const upstream = await fetch(target, init); + return new Response(upstream.body, { + status: upstream.status, + statusText: upstream.statusText, + headers: upstream.headers + }); + } + return resolve(event); +};