From e50fc093c3238c2aeba43f282c10c16df066a653 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Mon, 1 Jun 2026 20:05:30 +0200 Subject: [PATCH] feat: add PRIVATE_MODE site-wide auth gate (0.48.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `PRIVATE_MODE=true`, every API path except a small allowlist (`/health`, `/auth/{config,login,logout,register}`) requires a valid session cookie or bearer token — anonymous reads are rejected with 401. Self-registration is force-disabled in private mode regardless of `ALLOW_SELF_REGISTER`, so a locked-down instance flips with a single switch (admins still mint accounts via `POST /admin/users`). The backend gate is a tower middleware that reuses the existing `CurrentUser` extractor, so the cookie + bearer paths cannot drift from per-handler auth. `/auth/config` now exposes the flag plus the effective `self_register_enabled` value so the frontend can render the navbar correctly on the first paint. On the frontend, a new universal root `+layout.ts` fetches the config and redirects anonymous visitors to `/login?next=` before page-specific loads fire. The redirect is UX only — the backend middleware is the source of truth, so crafted requests still 401. Defaults stay public (`PRIVATE_MODE=false`); existing deployments need no env change. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/Cargo.lock | 2 +- backend/Cargo.toml | 2 +- backend/src/api/auth.rs | 19 ++- backend/src/app.rs | 57 +++++++- backend/src/config.rs | 42 ++++++ backend/tests/api_private_mode.rs | 189 +++++++++++++++++++++++++ backend/tests/common/mod.rs | 15 ++ frontend/e2e/private-mode.spec.ts | 101 +++++++++++++ frontend/package.json | 2 +- frontend/src/lib/api/auth.ts | 6 +- frontend/src/lib/auth-config.svelte.ts | 12 ++ frontend/src/routes/+layout.svelte | 12 +- frontend/src/routes/+layout.ts | 41 ++++++ frontend/src/routes/layout.test.ts | 113 +++++++++++++++ 14 files changed, 600 insertions(+), 13 deletions(-) create mode 100644 backend/tests/api_private_mode.rs create mode 100644 frontend/e2e/private-mode.spec.ts create mode 100644 frontend/src/routes/+layout.ts create mode 100644 frontend/src/routes/layout.test.ts diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 86c58d5..7b44851 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.47.0" +version = "0.48.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 4ffc57d..323f95c 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.47.0" +version = "0.48.0" edition = "2021" default-run = "mangalord" diff --git a/backend/src/api/auth.rs b/backend/src/api/auth.rs index d4253c9..321c9e0 100644 --- a/backend/src/api/auth.rs +++ b/backend/src/api/auth.rs @@ -42,18 +42,22 @@ pub fn routes() -> Router { .route("/auth/tokens/:id", delete(delete_token)) } -/// Public, unauthenticated. Exposes anonymous-relevant auth policy -/// (currently just whether self-registration is open) so the frontend -/// can render its login / register affordances correctly without a -/// probe request that would conflate "disabled" with "rate-limited". +/// Public, unauthenticated. Exposes anonymous-relevant auth policy so +/// the frontend can render its login / register affordances correctly +/// without a probe request that would conflate "disabled" with +/// "rate-limited". `self_register_enabled` is the *effective* value +/// (`allow_self_register && !private_mode`), so a private-mode +/// instance reports `false` even if the raw flag is on. #[derive(Debug, Serialize)] pub struct AuthConfigResponse { pub self_register_enabled: bool, + pub private_mode: bool, } async fn auth_config(State(state): State) -> Json { Json(AuthConfigResponse { - self_register_enabled: state.auth.allow_self_register, + self_register_enabled: state.auth.allow_self_register && !state.auth.private_mode, + private_mode: state.auth.private_mode, }) } @@ -103,7 +107,10 @@ async fn register( // disabled and enabled paths both consume a token, and disabled // returns 403 instead of running argon2. check_auth_rate_limit(&state, "register")?; - if !state.auth.allow_self_register { + // Private mode force-blocks self-registration regardless of + // ALLOW_SELF_REGISTER — operators of locked-down instances mint + // accounts via `POST /admin/users` instead. + if !state.auth.allow_self_register || state.auth.private_mode { return Err(AppError::Forbidden); } let username = input.username.trim(); diff --git a/backend/src/app.rs b/backend/src/app.rs index 331d95d..f29a1e8 100644 --- a/backend/src/app.rs +++ b/backend/src/app.rs @@ -3,8 +3,10 @@ use std::sync::atomic::AtomicBool; use anyhow::Context; use async_trait::async_trait; -use axum::extract::DefaultBodyLimit; +use axum::extract::{DefaultBodyLimit, FromRequestParts, Request, State}; use axum::http::{HeaderName, HeaderValue, Method}; +use axum::middleware::{self, Next}; +use axum::response::Response; use axum::Router; use sqlx::postgres::PgPoolOptions; use sqlx::PgPool; @@ -12,7 +14,9 @@ use tokio_util::sync::CancellationToken; use tower_http::cors::{AllowOrigin, CorsLayer}; use tower_http::trace::TraceLayer; +use crate::auth::extractor::CurrentUser; use crate::auth::rate_limit::AuthRateLimiter; +use crate::error::AppError; use crate::config::{AuthConfig, Config, CrawlerConfig, UploadConfig}; use crate::crawler::browser_manager::{self, BrowserManager}; use crate::crawler::content::{self, SyncOutcome}; @@ -353,11 +357,62 @@ pub fn router(state: AppState) -> Router { let max_request_bytes = state.upload.max_request_bytes; Router::new() .nest("/api/v1", crate::api::routes()) + .layer(middleware::from_fn_with_state( + state.clone(), + private_mode_guard, + )) .layer(DefaultBodyLimit::max(max_request_bytes)) .with_state(state) .layer(TraceLayer::new_for_http()) } +/// Paths reachable anonymously even when `PRIVATE_MODE=true`. Login and +/// logout are needed for the auth flow itself; `/health` is reserved +/// for load-balancer probes; `/auth/config` lets the frontend decide +/// whether to render the login form or its anonymous alternatives; +/// `/auth/register` is exempted from the gate so the handler can +/// return its informative `registration_disabled` 403 (the same code +/// public-mode deployments use when `ALLOW_SELF_REGISTER=false`) — +/// the handler itself force-blocks the request body in private mode, +/// so no account ever gets created here. Everything else demands a +/// valid session cookie or bearer token. +fn is_public_in_private_mode(path: &str) -> bool { + matches!( + path, + "/api/v1/health" + | "/api/v1/auth/config" + | "/api/v1/auth/login" + | "/api/v1/auth/logout" + | "/api/v1/auth/register" + ) +} + +/// Site-wide auth gate for `PRIVATE_MODE=true`. With the flag off this +/// is a no-op pass-through, so public deployments take no extra DB +/// hit. With it on, the guard reuses [`CurrentUser`] — the same +/// session-cookie-then-bearer-token logic the per-handler extractor +/// uses — so the two paths can never drift. +async fn private_mode_guard( + State(state): State, + req: Request, + next: Next, +) -> Result { + if !state.auth.private_mode { + return Ok(next.run(req).await); + } + if is_public_in_private_mode(req.uri().path()) { + return Ok(next.run(req).await); + } + let (mut parts, body) = req.into_parts(); + match CurrentUser::from_request_parts(&mut parts, &state).await { + Ok(_) => { + let req = Request::from_parts(parts, body); + Ok(next.run(req).await) + } + Err(_) => Err(AppError::Unauthenticated), + } +} + pub(crate) fn cors_layer(allowed_origins: &[String]) -> CorsLayer { if allowed_origins.is_empty() { // Same-origin only — no CORS headers emitted. diff --git a/backend/src/config.rs b/backend/src/config.rs index fb0821d..b54aadc 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -19,6 +19,14 @@ pub struct AuthConfig { /// `POST /admin/users`. Defaults to `true` (open registration) /// for backward compatibility. pub allow_self_register: bool, + /// When `true`, every API path except a small allowlist + /// (`/health`, `/auth/config`, `/auth/login`, `/auth/logout`) + /// requires a valid session cookie or bearer token — anonymous + /// reads are rejected with 401. Self-registration is also + /// force-disabled regardless of [`Self::allow_self_register`] + /// so a private instance is locked down with a single switch. + /// Defaults to `false` (current public behaviour). + pub private_mode: bool, } impl Default for AuthConfig { @@ -33,6 +41,7 @@ impl Default for AuthConfig { // defaults. rate_limit: crate::auth::rate_limit::RateLimitConfig::default(), allow_self_register: true, + private_mode: false, } } } @@ -181,6 +190,7 @@ impl Config { ) as u32, }, allow_self_register: env_bool("ALLOW_SELF_REGISTER", true), + private_mode: env_bool("PRIVATE_MODE", false), }, upload: UploadConfig { max_request_bytes: env_usize("MAX_REQUEST_BYTES", 200 * 1024 * 1024), @@ -373,5 +383,37 @@ mod tests { let cfg = CrawlerConfig::from_env().expect("from_env"); assert_eq!(cfg.manga_limit, 0); } + + #[test] + fn private_mode_env_parses_true() { + let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner()); + std::env::set_var("PRIVATE_MODE", "true"); + std::env::set_var("DATABASE_URL", "postgres://test"); + let cfg = Config::from_env().expect("from_env"); + std::env::remove_var("PRIVATE_MODE"); + std::env::remove_var("DATABASE_URL"); + assert!(cfg.auth.private_mode); + } + + #[test] + fn private_mode_env_parses_false() { + let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner()); + std::env::set_var("PRIVATE_MODE", "false"); + std::env::set_var("DATABASE_URL", "postgres://test"); + let cfg = Config::from_env().expect("from_env"); + std::env::remove_var("PRIVATE_MODE"); + std::env::remove_var("DATABASE_URL"); + assert!(!cfg.auth.private_mode); + } + + #[test] + fn private_mode_defaults_to_false() { + let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner()); + std::env::remove_var("PRIVATE_MODE"); + std::env::set_var("DATABASE_URL", "postgres://test"); + let cfg = Config::from_env().expect("from_env"); + std::env::remove_var("DATABASE_URL"); + assert!(!cfg.auth.private_mode); + } } diff --git a/backend/tests/api_private_mode.rs b/backend/tests/api_private_mode.rs new file mode 100644 index 0000000..fe97715 --- /dev/null +++ b/backend/tests/api_private_mode.rs @@ -0,0 +1,189 @@ +//! Site-wide auth gate (`PRIVATE_MODE=true`). +//! +//! With private mode on, every API path except a small allowlist +//! (`/health`, `/auth/config`, `/auth/login`, `/auth/logout`) requires +//! a valid session cookie or bearer token, and `/auth/register` is +//! force-blocked regardless of `ALLOW_SELF_REGISTER`. With private mode +//! off (the default), nothing changes — the `public_mode_*` test +//! pins that regression guard. + +mod common; + +use serde_json::json; +use sqlx::PgPool; +use tower::ServiceExt; + +use axum::http::StatusCode; + +#[sqlx::test(migrations = "./migrations")] +async fn private_mode_blocks_anonymous_manga_list(pool: PgPool) { + let h = common::harness_with_private_mode(pool); + let resp = h.app.oneshot(common::get("/api/v1/mangas")).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[sqlx::test(migrations = "./migrations")] +async fn private_mode_blocks_anonymous_files(pool: PgPool) { + let h = common::harness_with_private_mode(pool); + // The path doesn't have to exist — the guard runs before routing, + // so the response is 401 (not 404). That's the property the test + // is pinning: nothing leaks via crafted URLs. + let resp = h + .app + .oneshot(common::get("/api/v1/files/anything.png")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[sqlx::test(migrations = "./migrations")] +async fn private_mode_allows_session_cookie_read(pool: PgPool) { + // Register through a non-private harness sharing the same DB pool + // so the session row exists. Then exercise the gate using a fresh + // private-mode harness against the same DB. + let public = common::harness(pool.clone()); + let (_, cookie) = common::register_user(&public.app).await; + + let private = common::harness_with_private_mode(pool); + let resp = private + .app + .oneshot(common::get_with_cookie("/api/v1/mangas", &cookie)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); +} + +#[sqlx::test(migrations = "./migrations")] +async fn private_mode_allows_bearer_token_read(pool: PgPool) { + let public = common::harness(pool.clone()); + let (_, cookie) = common::register_user(&public.app).await; + + let resp = public + .app + .clone() + .oneshot(common::post_json_with_cookie( + "/api/v1/auth/tokens", + json!({ "name": "private-mode-bot" }), + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::CREATED); + let body = common::body_json(resp).await; + let bearer = body["bearer"].as_str().unwrap().to_string(); + + let private = common::harness_with_private_mode(pool); + let resp = private + .app + .oneshot(common::get_with_bearer("/api/v1/mangas", &bearer)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); +} + +#[sqlx::test(migrations = "./migrations")] +async fn private_mode_allows_login_endpoint_anonymous(pool: PgPool) { + // Seed a user via the public harness so login has credentials to + // verify against. + let public = common::harness(pool.clone()); + let _ = public + .app + .clone() + .oneshot(common::post_json( + "/api/v1/auth/register", + json!({ "username": "alice", "password": "hunter2hunter2" }), + )) + .await + .unwrap(); + + let private = common::harness_with_private_mode(pool); + let resp = private + .app + .oneshot(common::post_json( + "/api/v1/auth/login", + json!({ "username": "alice", "password": "hunter2hunter2" }), + )) + .await + .unwrap(); + // Reaches the login handler and succeeds — *not* 401 from the + // gate. That's the property we're pinning. + assert_eq!(resp.status(), StatusCode::OK); +} + +#[sqlx::test(migrations = "./migrations")] +async fn private_mode_allows_health_and_config_anonymous(pool: PgPool) { + let h = common::harness_with_private_mode(pool); + + let r = h + .app + .clone() + .oneshot(common::get("/api/v1/health")) + .await + .unwrap(); + assert_eq!(r.status(), StatusCode::OK); + + let r = h + .app + .oneshot(common::get("/api/v1/auth/config")) + .await + .unwrap(); + assert_eq!(r.status(), StatusCode::OK); +} + +#[sqlx::test(migrations = "./migrations")] +async fn private_mode_blocks_register_even_when_self_register_enabled(pool: PgPool) { + // harness_with_private_mode keeps `allow_self_register=true` (the + // default) — private mode is supposed to force-block register + // regardless. That's what this test pins. + let h = common::harness_with_private_mode(pool); + let resp = h + .app + .oneshot(common::post_json( + "/api/v1/auth/register", + json!({ "username": "alice", "password": "hunter2hunter2" }), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + let body = common::body_json(resp).await; + assert_eq!(body["error"]["code"], "forbidden"); +} + +#[sqlx::test(migrations = "./migrations")] +async fn auth_config_reports_private_mode_and_effective_self_register(pool: PgPool) { + let h = common::harness_with_private_mode(pool); + let resp = h + .app + .oneshot(common::get("/api/v1/auth/config")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = common::body_json(resp).await; + assert_eq!(body["private_mode"], true); + // Effective value: `allow_self_register && !private_mode` is false + // here even though the raw `allow_self_register` is true. + assert_eq!(body["self_register_enabled"], false); +} + +#[sqlx::test(migrations = "./migrations")] +async fn public_mode_does_not_gate_anonymous_reads(pool: PgPool) { + // Regression guard: with private_mode off (the default), the gate + // must be a no-op so existing public deployments stay public. + let h = common::harness(pool); + let resp = h.app.oneshot(common::get("/api/v1/mangas")).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); +} + +#[sqlx::test(migrations = "./migrations")] +async fn public_mode_reports_private_mode_false(pool: PgPool) { + let h = common::harness(pool); + let resp = h + .app + .oneshot(common::get("/api/v1/auth/config")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = common::body_json(resp).await; + assert_eq!(body["private_mode"], false); + assert_eq!(body["self_register_enabled"], true); +} diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs index 1fceb89..8817d08 100644 --- a/backend/tests/common/mod.rs +++ b/backend/tests/common/mod.rs @@ -92,6 +92,21 @@ pub fn harness_with_self_register_disabled(pool: PgPool) -> Harness { harness_with_auth_config(pool, storage, storage_dir, auth) } +/// Like [`harness`] but flips `PRIVATE_MODE` on so the site-wide auth +/// gate is exercised. `allow_self_register` stays at its default `true` +/// to verify that private mode force-disables self-registration on top +/// of whatever `ALLOW_SELF_REGISTER` says. +pub fn harness_with_private_mode(pool: PgPool) -> Harness { + let storage_dir = tempfile::tempdir().expect("tempdir"); + let storage = Arc::new(LocalStorage::new(storage_dir.path())); + let auth = AuthConfig { + cookie_secure: false, + private_mode: true, + ..AuthConfig::default() + }; + harness_with_auth_config(pool, storage, storage_dir, auth) +} + /// Like [`harness`] but configures a tight auth rate limit. Used by /// the brute-force-rate-limiting test. pub fn harness_with_auth_rate_limit( diff --git a/frontend/e2e/private-mode.spec.ts b/frontend/e2e/private-mode.spec.ts new file mode 100644 index 0000000..c4ebb41 --- /dev/null +++ b/frontend/e2e/private-mode.spec.ts @@ -0,0 +1,101 @@ +import { test, expect, type Page } from '@playwright/test'; + +// Network-level mocks for the private-mode UX. The backend integration +// tests (api_private_mode.rs) cover the actual gate; here we only +// verify that the SvelteKit universal load redirects anonymous +// visitors to /login and then back to where they were going. + +const userFixture = { + id: 'user-1', + username: 'alice', + created_at: '2026-01-01T00:00:00Z', + is_admin: false +}; +const emptyPage = { items: [], page: { limit: 50, offset: 0, total: null } }; + +async function stubPrivateInstance(page: Page) { + let loggedIn = false; + + // The flag that flips the gate on. Frontend reads it in + // `+layout.ts` to decide whether to redirect. + await page.route('**/api/v1/auth/config', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + self_register_enabled: false, + private_mode: true + }) + }); + }); + + await page.route('**/api/v1/auth/me', async (route) => { + if (loggedIn) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ user: userFixture }) + }); + } else { + await route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ + error: { code: 'unauthenticated', message: 'unauthenticated' } + }) + }); + } + }); + + await page.route('**/api/v1/auth/login', async (route) => { + loggedIn = true; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ user: userFixture }) + }); + }); + + // The real backend would 401 these too in private mode; we stub + // success so the post-login navigation can render the home page + // without an additional redirect cycle. + await page.route('**/api/v1/mangas*', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(emptyPage) + }); + }); +} + +test('private mode: anonymous visit to / redirects to /login?next=%2F', async ({ page }) => { + await stubPrivateInstance(page); + await page.goto('/'); + await expect(page).toHaveURL(/\/login\?next=%2F$/); + await expect(page.getByTestId('login-username')).toBeVisible(); +}); + +test('private mode: register link is hidden', async ({ page }) => { + await stubPrivateInstance(page); + await page.goto('/login'); + await expect(page.getByTestId('nav-login')).toBeVisible(); + // self_register_enabled is the effective value (false in private + // mode regardless of ALLOW_SELF_REGISTER), so the navbar must + // never render the register affordance here. + await expect(page.getByTestId('nav-register')).toHaveCount(0); +}); + +test('private mode: after login the user lands back on the requested page', async ({ page }) => { + await stubPrivateInstance(page); + + // Visit a deep link → bounced to /login with next= preserving it. + await page.goto('/'); + await expect(page).toHaveURL(/\/login\?next=%2F$/); + + await page.getByTestId('login-username').fill('alice'); + await page.getByTestId('login-password').fill('hunter2hunter2'); + await page.getByTestId('login-submit').click(); + + // Authenticated → can now reach the home page without bouncing. + await expect(page.getByTestId('session-user')).toContainText('alice'); +}); diff --git a/frontend/package.json b/frontend/package.json index c25b253..7aa908a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.47.0", + "version": "0.48.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/api/auth.ts b/frontend/src/lib/api/auth.ts index e27ccad..5f65317 100644 --- a/frontend/src/lib/api/auth.ts +++ b/frontend/src/lib/api/auth.ts @@ -102,10 +102,14 @@ export async function deleteToken(id: string): Promise { } export type AuthConfig = { - /** When false, /v1/auth/register returns 403 and the UI should + /** Effective value (`allow_self_register && !private_mode`). + * When false, /v1/auth/register returns 403 and the UI should * hide its register affordance. Admins can still mint accounts * via POST /v1/admin/users. */ self_register_enabled: boolean; + /** When true, every read endpoint requires auth and anonymous + * visitors are redirected to `/login` (see `+layout.ts`). */ + private_mode: boolean; }; /** Public — no auth, no cookie required. */ diff --git a/frontend/src/lib/auth-config.svelte.ts b/frontend/src/lib/auth-config.svelte.ts index 8bfa377..acbbcd5 100644 --- a/frontend/src/lib/auth-config.svelte.ts +++ b/frontend/src/lib/auth-config.svelte.ts @@ -16,6 +16,7 @@ import { getAuthConfig } from './api/auth'; class AuthConfigStore { self_register_enabled = $state(true); + private_mode = $state(false); loaded = $state(false); private loading = false; @@ -25,6 +26,7 @@ class AuthConfigStore { try { const cfg = await getAuthConfig(); this.self_register_enabled = cfg.self_register_enabled; + this.private_mode = cfg.private_mode; this.loaded = true; } catch { // Keep optimistic default; next page mount will retry. @@ -32,6 +34,16 @@ class AuthConfigStore { this.loading = false; } } + + /** Seed from server-rendered layout data so the very first paint + * doesn't flash the loading state. Used by `+layout.ts` / + * `+layout.svelte` on the universal-load path. Safe to call from + * SSR (no `browser` guard) since it touches only reactive state. */ + seed(cfg: { self_register_enabled: boolean; private_mode: boolean }): void { + this.self_register_enabled = cfg.self_register_enabled; + this.private_mode = cfg.private_mode; + this.loaded = true; + } } export const authConfig = new AuthConfigStore(); diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 270d2f2..c934a44 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -14,15 +14,23 @@ import Shield from '@lucide/svelte/icons/shield'; import '$lib/styles/tokens.css'; - let { children } = $props(); + let { children, data } = $props(); let loggingOut = $state(false); let headerEl: HTMLElement | undefined = $state(); + // Seed authConfig from the universal layout load. $effect keeps + // the store in sync if `data` is replaced by a subsequent layout + // load (client-side nav). The first run also covers initial + // hydration so the navbar's register link reflects the real + // server flag without a separate fetch. + $effect(() => { + authConfig.seed(data.authConfig); + }); + onMount(() => { theme.init(); preferences.init(); if (!session.loaded) session.refresh(); - if (!authConfig.loaded) authConfig.load(); // Publish the header's measured height as a CSS custom // property so sticky descendants (e.g. the reader nav) can diff --git a/frontend/src/routes/+layout.ts b/frontend/src/routes/+layout.ts new file mode 100644 index 0000000..5586c13 --- /dev/null +++ b/frontend/src/routes/+layout.ts @@ -0,0 +1,41 @@ +// Universal root load. Surfaces /auth/config to every page so the +// navbar + layout can render without an extra round-trip, and — when +// the backend reports PRIVATE_MODE=true — bounces anonymous visitors +// to /login before any page-specific load fires. The backend +// middleware is still the source of truth for the gate; this just +// matches the UX so users don't see a page full of failed fetches. +import type { LayoutLoad } from './$types'; +import { redirect } from '@sveltejs/kit'; +import { getAuthConfig, me, type AuthConfig } from '$lib/api/auth'; + +// Paths reachable anonymously even when private_mode is on. /login is +// the entry point of the auth flow; everything else (including +// /register, which is force-blocked in private mode) bounces. +const PRIVATE_MODE_BYPASS = new Set(['/login']); + +const PUBLIC_DEFAULTS: AuthConfig = { + self_register_enabled: true, + private_mode: false +}; + +export const load: LayoutLoad = async ({ url }) => { + let authConfig: AuthConfig = PUBLIC_DEFAULTS; + try { + authConfig = await getAuthConfig(); + } catch { + // Fail-soft: keep the optimistic public-mode defaults so a + // backend hiccup doesn't lock anyone out of the login page. + // No private data can leak through here — the backend + // middleware is still authoritative for the gate. + } + + if (authConfig.private_mode && !PRIVATE_MODE_BYPASS.has(url.pathname)) { + const user = await me().catch(() => null); + if (!user) { + const next = url.pathname + url.search; + redirect(302, `/login?next=${encodeURIComponent(next)}`); + } + } + + return { authConfig }; +}; diff --git a/frontend/src/routes/layout.test.ts b/frontend/src/routes/layout.test.ts new file mode 100644 index 0000000..a98e5bc --- /dev/null +++ b/frontend/src/routes/layout.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock the API client *before* importing the load function so the +// module under test picks up the mock when it resolves its imports. +vi.mock('$lib/api/auth', () => ({ + getAuthConfig: vi.fn(), + me: vi.fn() +})); + +import { load } from './+layout'; +import { getAuthConfig, me, type AuthConfig } from '$lib/api/auth'; + +type MinimalLoadEvent = { url: { pathname: string; search: string } }; + +function event(pathname: string, search = ''): MinimalLoadEvent { + return { url: { pathname, search } }; +} + +// `LayoutLoad`'s declared return type is `void | …`. Our `load` +// always returns `{ authConfig }`, but TypeScript can't narrow on +// that at the call site. Wrap to remove the `void` arm so the +// assertions stay terse. +async function callLoad(ev: MinimalLoadEvent): Promise<{ authConfig: AuthConfig }> { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await load(ev as any); + return result as { authConfig: AuthConfig }; +} + +const PUBLIC_CFG = { self_register_enabled: true, private_mode: false }; +const PRIVATE_CFG = { self_register_enabled: false, private_mode: true }; + +const aliceUser = { + id: 'u1', + username: 'alice', + created_at: '2026-01-01T00:00:00Z', + is_admin: false +}; + +describe('root +layout load', () => { + beforeEach(() => { + vi.mocked(getAuthConfig).mockReset(); + vi.mocked(me).mockReset(); + }); + + it('public mode: returns authConfig data, never calls me()', async () => { + vi.mocked(getAuthConfig).mockResolvedValue(PUBLIC_CFG); + const data = await callLoad(event('/')); + expect(data.authConfig).toEqual(PUBLIC_CFG); + expect(me).not.toHaveBeenCalled(); + }); + + it('private mode + anonymous on `/`: throws redirect(302) to /login with next=', async () => { + vi.mocked(getAuthConfig).mockResolvedValue(PRIVATE_CFG); + vi.mocked(me).mockResolvedValue(null); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await expect(load(event('/') as any)).rejects.toMatchObject({ + status: 302, + location: '/login?next=%2F' + }); + }); + + it('private mode + anonymous on `/login`: passes through without redirect', async () => { + vi.mocked(getAuthConfig).mockResolvedValue(PRIVATE_CFG); + const data = await callLoad(event('/login')); + expect(data.authConfig.private_mode).toBe(true); + // me() must not run on the login page itself, otherwise anonymous + // visits make an extra round-trip every page load. + expect(me).not.toHaveBeenCalled(); + }); + + it('private mode + logged-in user: no redirect, returns authConfig', async () => { + vi.mocked(getAuthConfig).mockResolvedValue(PRIVATE_CFG); + vi.mocked(me).mockResolvedValue(aliceUser); + const data = await callLoad(event('/')); + expect(data.authConfig).toEqual(PRIVATE_CFG); + }); + + it('private mode + anonymous: preserves pathname AND search in next=', async () => { + vi.mocked(getAuthConfig).mockResolvedValue(PRIVATE_CFG); + vi.mocked(me).mockResolvedValue(null); + await expect( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + load(event('/manga/abc', '?page=3') as any) + ).rejects.toMatchObject({ + status: 302, + location: '/login?next=%2Fmanga%2Fabc%3Fpage%3D3' + }); + }); + + it('private mode + anonymous on /register: redirects to /login (register is never reachable in private mode)', async () => { + vi.mocked(getAuthConfig).mockResolvedValue(PRIVATE_CFG); + vi.mocked(me).mockResolvedValue(null); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await expect(load(event('/register') as any)).rejects.toMatchObject({ + status: 302, + location: '/login?next=%2Fregister' + }); + }); + + it('getAuthConfig failure: falls back to public-mode defaults, no redirect', async () => { + // The backend middleware is the source of truth for the gate; + // if the config probe blips, fail soft so a brief outage doesn't + // lock everyone out of even the login page. No private data + // can leak because the backend still 401s every request. + vi.mocked(getAuthConfig).mockRejectedValue(new Error('network')); + const data = await callLoad(event('/')); + expect(data.authConfig).toEqual({ + self_register_enabled: true, + private_mode: false + }); + expect(me).not.toHaveBeenCalled(); + }); +});