Compare commits

..

1 Commits

Author SHA1 Message Date
MechaCat02
64e3b519ba feat: clear session.user on 401 from any API call (0.35.0)
Adds a single on401 hook in api/client.ts that the session store
installs at module load. Before, the *OrEmpty wrappers caught 401 and
silently returned empty pages — a mid-session expiry left the UI
rendering as "logged in but no bookmarks/collections/etc." until the
user reloaded. With the hook, session.user flips to null on the first
401, so the layout re-renders the login affordance and the *OrEmpty
helpers keep working for genuine anonymous browsing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 20:02:20 +02:00
8 changed files with 139 additions and 29 deletions

View File

@@ -1,23 +1,13 @@
# Copy to .env for `docker compose up --build`. Local-dev runs (cargo run # Copy to .env for `docker compose up --build`. Local-dev runs (cargo run
# / npm run dev) read backend/.env if present, or pick up the variables # / npm run dev) read backend/.env if present, or pick up the variables
# from your shell. # from your shell.
#
# Production note: COOKIE_SECURE=true (the default below) makes browsers
# refuse to send the session cookie over plain HTTP. Run with a TLS-
# terminating reverse proxy (Caddy, Traefik, nginx) in front — the
# compose file here doesn't ship one. Local/dev runs without HTTPS
# should set COOKIE_SECURE=false.
# ----- Postgres ----- # ----- Postgres -----
# These are read by the Postgres container *and* by DATABASE_URL below; # These are read by the Postgres container *and* by DATABASE_URL below;
# changing them after the first boot won't migrate existing data, so set # changing them after the first boot won't migrate existing data, so set
# them up front for any new deployment. # them up front for any new deployment.
#
# POSTGRES_PASSWORD is REQUIRED — docker-compose.yml fails fast if it
# isn't set in this file, to prevent a deploy without an .env booting
# Postgres with a publicly-known credential.
POSTGRES_USER=mangalord POSTGRES_USER=mangalord
POSTGRES_PASSWORD=change-me-to-a-strong-random-string POSTGRES_PASSWORD=mangalord
POSTGRES_DB=mangalord POSTGRES_DB=mangalord
# ----- Backend ----- # ----- Backend -----

View File

@@ -3,8 +3,6 @@ name: deploy
on: on:
push: push:
branches: [main] branches: [main]
pull_request:
branches: [main]
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -65,10 +63,6 @@ jobs:
build-and-push: build-and-push:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [test-backend, test-frontend] needs: [test-backend, test-frontend]
# PRs only run the test jobs; build + deploy are reserved for
# post-merge pushes to main. Without this gate every PR would push
# a tagged image to the registry and SSH-deploy to prod.
if: github.event_name != 'pull_request'
outputs: outputs:
image_tag: ${{ steps.meta.outputs.image_tag }} image_tag: ${{ steps.meta.outputs.image_tag }}
version: ${{ steps.meta.outputs.version }} version: ${{ steps.meta.outputs.version }}
@@ -123,7 +117,6 @@ jobs:
deploy: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: build-and-push needs: build-and-push
if: github.event_name != 'pull_request'
steps: steps:
- name: SSH deploy - name: SSH deploy
uses: appleboy/ssh-action@v1.0.3 uses: appleboy/ssh-action@v1.0.3

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "mangalord" name = "mangalord"
version = "0.34.0" version = "0.35.0"
edition = "2021" edition = "2021"
default-run = "mangalord" default-run = "mangalord"

View File

@@ -1,15 +1,9 @@
# Production-like compose. Requires a populated `.env` next to this
# file: at minimum POSTGRES_PASSWORD must be set to a non-default
# value (the `?required` form below fails fast otherwise). The
# frontend container expects HTTPS in front (Caddy/Traefik/nginx)
# because COOKIE_SECURE=true browsers will refuse to send the session
# cookie over plain HTTP.
services: services:
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
environment: environment:
POSTGRES_USER: ${POSTGRES_USER:-mangalord} POSTGRES_USER: ${POSTGRES_USER:-mangalord}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set in .env} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-mangalord}
POSTGRES_DB: ${POSTGRES_DB:-mangalord} POSTGRES_DB: ${POSTGRES_DB:-mangalord}
volumes: volumes:
- postgres-data:/var/lib/postgresql/data - postgres-data:/var/lib/postgresql/data
@@ -25,7 +19,7 @@ services:
postgres: postgres:
condition: service_healthy condition: service_healthy
environment: environment:
DATABASE_URL: postgres://${POSTGRES_USER:-mangalord}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set in .env}@postgres:5432/${POSTGRES_DB:-mangalord} DATABASE_URL: postgres://${POSTGRES_USER:-mangalord}:${POSTGRES_PASSWORD:-mangalord}@postgres:5432/${POSTGRES_DB:-mangalord}
BIND_ADDRESS: 0.0.0.0:8080 BIND_ADDRESS: 0.0.0.0:8080
STORAGE_DIR: /var/lib/mangalord/storage STORAGE_DIR: /var/lib/mangalord/storage
RUST_LOG: ${RUST_LOG:-info,mangalord=debug} RUST_LOG: ${RUST_LOG:-info,mangalord=debug}

View File

@@ -1,6 +1,6 @@
{ {
"name": "mangalord-frontend", "name": "mangalord-frontend",
"version": "0.34.0", "version": "0.35.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
import { ApiError, request } from './client'; import { ApiError, request, setOn401Hook } from './client';
import { getManga } from './mangas'; import { getManga } from './mangas';
describe('request error envelope parsing', () => { describe('request error envelope parsing', () => {
@@ -73,3 +73,88 @@ describe('request error envelope parsing', () => {
expect(err.code).toBe('http_error'); expect(err.code).toBe('http_error');
}); });
}); });
describe('on401 hook', () => {
let fetchSpy: MockInstance<typeof globalThis.fetch>;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
vi.restoreAllMocks();
// Critical: reset the module-level hook between tests so a
// hook installed by one test doesn't leak into the next.
setOn401Hook(null);
});
it('invokes the hook exactly once on a 401 response and re-throws', async () => {
const hook = vi.fn();
setOn401Hook(hook);
fetchSpy.mockResolvedValueOnce(
new Response(
JSON.stringify({ error: { code: 'unauthenticated', message: 'no auth' } }),
{ status: 401, headers: { 'content-type': 'application/json' } }
)
);
await expect(getManga('x')).rejects.toMatchObject({
status: 401,
code: 'unauthenticated'
});
expect(hook).toHaveBeenCalledTimes(1);
});
it('does not invoke the hook on non-401 errors', async () => {
const hook = vi.fn();
setOn401Hook(hook);
fetchSpy.mockResolvedValueOnce(
new Response(
JSON.stringify({ error: { code: 'not_found', message: 'no' } }),
{ status: 404, headers: { 'content-type': 'application/json' } }
)
);
await expect(getManga('x')).rejects.toMatchObject({ status: 404 });
expect(hook).not.toHaveBeenCalled();
});
it('does not invoke the hook on successful responses', async () => {
const hook = vi.fn();
setOn401Hook(hook);
fetchSpy.mockResolvedValueOnce(
new Response(
JSON.stringify({
id: 'm1',
title: 't',
status: 'ongoing',
alt_titles: [],
description: null,
cover_image_path: null,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
authors: [],
genres: [],
tags: []
}),
{ status: 200, headers: { 'content-type': 'application/json' } }
)
);
await getManga('m1');
expect(hook).not.toHaveBeenCalled();
});
it('swallows hook exceptions so the original ApiError still propagates', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
setOn401Hook(() => {
throw new Error('hook boom');
});
fetchSpy.mockResolvedValueOnce(
new Response(
JSON.stringify({ error: { code: 'unauthenticated', message: 'x' } }),
{ status: 401, headers: { 'content-type': 'application/json' } }
)
);
await expect(getManga('x')).rejects.toMatchObject({ status: 401 });
// The original ApiError won — the hook's panic was logged but
// didn't replace the API error.
expect(consoleSpy).toHaveBeenCalled();
});
});

View File

@@ -25,6 +25,21 @@ export class ApiError extends Error {
type ErrorEnvelope = { error?: { code?: unknown; message?: unknown } }; type ErrorEnvelope = { error?: { code?: unknown; message?: unknown } };
/**
* Optional hook fired the first moment `request()` observes a 401 on
* any endpoint. Used by the session store to clear the cached user
* when the server reports the session is no longer valid (expired
* cookie, rotated server-side, password changed on another device).
*
* Set to `null` (or `undefined`) to disable. Tests that don't want
* the side effect should leave it unset.
*/
let on401Hook: (() => void) | null = null;
export function setOn401Hook(handler: (() => void) | null): void {
on401Hook = handler;
}
export async function request<T>(path: string, init?: RequestInit): Promise<T> { export async function request<T>(path: string, init?: RequestInit): Promise<T> {
// Forward credentials (session cookie) explicitly so cross-origin // Forward credentials (session cookie) explicitly so cross-origin
// deployments — those configured via CORS_ALLOWED_ORIGINS — keep // deployments — those configured via CORS_ALLOWED_ORIGINS — keep
@@ -54,6 +69,16 @@ export async function request<T>(path: string, init?: RequestInit): Promise<T> {
} catch { } catch {
// Body wasn't parseable; keep the http_error fallback. // Body wasn't parseable; keep the http_error fallback.
} }
if (res.status === 401 && on401Hook) {
// Fire before throwing so the session store updates even
// if the caller swallows the ApiError (e.g. the *OrEmpty
// wrappers used by guest-rendering pages).
try {
on401Hook();
} catch (e) {
console.error('on401 hook threw:', e);
}
}
throw new ApiError(res.status, code, message); throw new ApiError(res.status, code, message);
} }
// Any empty body (not just 204) returns undefined — the manga-add // Any empty body (not just 204) returns undefined — the manga-add

View File

@@ -3,7 +3,17 @@
// Only mutated client-side (onMount / form submits) so the module-level // Only mutated client-side (onMount / form submits) so the module-level
// instance can't leak across SSR requests — SSR always renders the // instance can't leak across SSR requests — SSR always renders the
// `loaded === false` state, and the client refreshes after hydration. // `loaded === false` state, and the client refreshes after hydration.
//
// IMPORTANT: do not call any `api/*` helper from `+page.server.ts` /
// `+layout.server.ts`. The `setOn401Hook` below is registered at
// module load (gated on `browser`, so it only fires in the client
// bundle), so a 401 from a server-side fetch would mutate this
// module-level `session.user` across SvelteKit requests — a real
// cross-request state leak. The `if (browser)` guard makes that
// failure mode mechanical rather than convention-based.
import { browser } from '$app/environment';
import { setOn401Hook } from './api/client';
import { me, type User } from './api/auth'; import { me, type User } from './api/auth';
class SessionStore { class SessionStore {
@@ -31,3 +41,16 @@ class SessionStore {
} }
export const session = new SessionStore(); export const session = new SessionStore();
// When any backend call returns 401, drop the cached user. Before this
// hook, the `*OrEmpty` wrappers silently returned empty pages on 401
// — so a mid-session expiry left the UI rendering as "logged in but
// no bookmarks/collections/etc." until the user manually reloaded.
// With the hook the session.user reactive store flips to null on the
// first 401, so the layout re-renders the login affordance.
//
// Gated on `browser` so it's only installed in the client bundle.
// See the module-level comment above for the SSR rationale.
if (browser) {
setOn401Hook(() => session.setUser(null));
}