From d94aa9948d6ce03fe188fbfcf656e2b9367d077f Mon Sep 17 00:00:00 2001 From: csd113 Date: Mon, 18 May 2026 18:43:33 -0700 Subject: [PATCH 01/10] Add Playwright E2E QA coverage for RustPost - Add repo-local Playwright config and e2e test suite - Add deterministic ignored runtime for local browser tests - Cover auth, posting, replies, media, social actions, search, admin, mobile, and no-JS smoke flows - Fail tests on unexpected console/page errors and 404/405/5xx responses - Ignore Playwright reports, traces, screenshots, videos, runtime data, uploads, logs, and node_modules - Fix duplicate registration returning 500 - Fix enhanced invalid post submission falling through to GET /posts - Remove ignored CSP frame-ancestors meta directive --- .gitignore | 17 ++++ package-lock.json | 76 ++++++++++++++ package.json | 10 ++ playwright.config.ts | 45 +++++++++ src/auth.rs | 11 +++ src/render.rs | 4 +- src/server.rs | 3 +- tests/e2e/fixtures/tiny.png | 1 + tests/e2e/helpers.ts | 160 ++++++++++++++++++++++++++++++ tests/e2e/mobile.spec.ts | 39 ++++++++ tests/e2e/rustpost.spec.ts | 191 ++++++++++++++++++++++++++++++++++++ tests/e2e/start-rustpost.sh | 21 ++++ 12 files changed, 575 insertions(+), 3 deletions(-) create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 playwright.config.ts create mode 100644 tests/e2e/fixtures/tiny.png create mode 100644 tests/e2e/helpers.ts create mode 100644 tests/e2e/mobile.spec.ts create mode 100644 tests/e2e/rustpost.spec.ts create mode 100755 tests/e2e/start-rustpost.sh diff --git a/.gitignore b/.gitignore index d67ef3a..10e3583 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,21 @@ target # Contains mutation testing data **/mutants.out*/ +# Generated by Playwright and browser QA runs +node_modules/ +playwright-report/ +test-results/ +output/playwright/ +playwright/.cache/ +screenshots/ +videos/ +*.webm +*.mp4 +*.zip +trace.zip +*.trace +*.har + # RustRover # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore @@ -25,6 +40,8 @@ app-data/ rustpost-data/ rustpost-data/**/* !rustpost-data/.gitkeep +tmp/ +tmp/**/* *.sqlite3 *.sqlite3-* *.db diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f2ae545 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,76 @@ +{ + "name": "rustpost-e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rustpost-e2e", + "devDependencies": { + "@playwright/test": "1.60.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ea14a87 --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "rustpost-e2e", + "private": true, + "scripts": { + "test:e2e": "playwright test" + }, + "devDependencies": { + "@playwright/test": "1.60.0" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..8074c5e --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,45 @@ +import { defineConfig, devices } from '@playwright/test'; + +const port = Number(process.env.RUSTPOST_E2E_PORT ?? '18080'); +const baseURL = process.env.RUSTPOST_E2E_BASE_URL ?? `http://127.0.0.1:${port}`; + +export default defineConfig({ + testDir: './tests/e2e', + outputDir: './output/playwright/test-results', + timeout: 45_000, + expect: { + timeout: 5_000, + }, + fullyParallel: false, + workers: 1, + reporter: [ + ['list'], + ['html', { outputFolder: 'output/playwright/report', open: 'never' }], + ], + use: { + baseURL, + actionTimeout: 10_000, + navigationTimeout: 15_000, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + webServer: { + command: `RUSTPOST_E2E_PORT=${port} ./tests/e2e/start-rustpost.sh`, + url: baseURL, + reuseExistingServer: false, + timeout: 120_000, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + testIgnore: /mobile\.spec\.ts/, + }, + { + name: 'mobile-chrome', + use: { ...devices['Pixel 7'] }, + testMatch: /mobile\.spec\.ts/, + }, + ], +}); diff --git a/src/auth.rs b/src/auth.rs index b36402a..22c5f1f 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -38,6 +38,17 @@ pub async fn register_user( let hash = hash_password(password)?; let username = username.trim().to_owned(); pool.call(move |conn| { + let existing = conn + .query_row( + "SELECT 1 FROM users WHERE normalized_username = ?", + [&normalized], + |_| Ok(()), + ) + .optional()? + .is_some(); + if existing { + anyhow::bail!("username is already taken"); + } conn.execute( "INSERT INTO users (username, normalized_username, password_hash, display_name, is_admin) VALUES (?, ?, ?, ?, ?)", params![username, normalized, hash, username, i64::from(is_admin)], diff --git a/src/render.rs b/src/render.rs index 1097b17..a6c8e2f 100644 --- a/src/render.rs +++ b/src/render.rs @@ -98,7 +98,7 @@ pub fn layout_with_context( - + {} - {} @@ -266,7 +266,7 @@ document.addEventListener("submit", async (event) => { credentials: "same-origin" }); if (!response.ok) { - window.location.assign(response.url || window.location.href); + HTMLFormElement.prototype.submit.call(form); return; } const data = await response.json(); diff --git a/src/server.rs b/src/server.rs index 6b38972..7731cf5 100644 --- a/src/server.rs +++ b/src/server.rs @@ -355,7 +355,8 @@ async fn register( &form.password, false, ) - .await?; + .await + .map_err(|err| AppError::BadRequest(err.to_string()))?; let session = auth::create_session(&state.pool, user_id).await?; let mut response = Redirect::to("/home").into_response(); response.headers_mut().insert( diff --git a/tests/e2e/fixtures/tiny.png b/tests/e2e/fixtures/tiny.png new file mode 100644 index 0000000..188b97c --- /dev/null +++ b/tests/e2e/fixtures/tiny.png @@ -0,0 +1 @@ +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII= diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts new file mode 100644 index 0000000..e3ee0fb --- /dev/null +++ b/tests/e2e/helpers.ts @@ -0,0 +1,160 @@ +import { expect, type Locator, type Page, type TestInfo } from '@playwright/test'; +import path from 'node:path'; +import fs from 'node:fs'; + +const allowedMissing = new Set(['/favicon.ico']); + +export const adminUser = process.env.RUSTPOST_E2E_ADMIN_USER ?? 'admin_e2e'; +export const adminPassword = + process.env.RUSTPOST_E2E_ADMIN_PASSWORD ?? 'very secure admin password'; + +export function fixturePath(name: string): string { + const file = path.join(process.cwd(), 'tests/e2e/fixtures', name); + if (name === 'tiny.png') { + const raw = fs.readFileSync(file, 'utf8').trim(); + const decoded = Buffer.from(raw, 'base64'); + const pngFile = path.join(process.cwd(), 'output/playwright/tiny.png'); + fs.mkdirSync(path.dirname(pngFile), { recursive: true }); + fs.writeFileSync(pngFile, decoded); + return pngFile; + } + return file; +} + +export async function installQaGuards(page: Page, testInfo: TestInfo): Promise { + const failures: string[] = []; + + page.on('pageerror', (error) => { + failures.push(`page error on ${page.url()}: ${error.message}`); + }); + + page.on('console', (message) => { + if (message.type() === 'error') { + if (/Failed to load resource: the server responded with a status of (400|401|403)/.test(message.text())) { + return; + } + failures.push(`console error on ${page.url()}: ${message.text()}`); + } + }); + + page.on('response', (response) => { + const url = new URL(response.url()); + if (url.origin !== new URL(page.url() || 'http://127.0.0.1').origin) { + return; + } + if (allowedMissing.has(url.pathname)) { + return; + } + const status = response.status(); + if (status === 404 || status === 405 || status >= 500) { + failures.push(`${status} response while at ${page.url()}: ${response.request().method()} ${response.url()}`); + } + }); + + await testInfo.attach('qa-guard-note', { + body: 'Unexpected console errors, page errors, 404, 405, and 5xx responses fail the test.', + contentType: 'text/plain', + }); + + testInfo.attachments.push({ + name: 'qa-failures-ref', + contentType: 'text/plain', + body: Buffer.from('Failures are asserted through assertNoQaFailures(page).'), + }); + + (page as Page & { __qaFailures?: string[] }).__qaFailures = failures; +} + +export function assertNoQaFailures(page: Page): void { + const failures = (page as Page & { __qaFailures?: string[] }).__qaFailures ?? []; + expect(failures, failures.join('\n')).toEqual([]); +} + +export async function register(page: Page, username: string, password: string): Promise { + await page.goto('/register'); + await page.getByLabel('Username').fill(username); + await page.getByLabel('Password', { exact: true }).fill(password); + await page.getByLabel('Confirm password').fill(password); + await Promise.all([ + page.waitForURL(/\/home/), + page.getByRole('button', { name: 'Create account' }).click(), + ]); + await expect(page.getByRole('heading', { name: 'Home Feed' })).toBeVisible(); +} + +export async function login(page: Page, username: string, password: string): Promise { + await page.goto('/login'); + await page.getByLabel('Username').fill(username); + await page.getByLabel('Password', { exact: true }).fill(password); + await Promise.all([ + page.waitForURL(/\/home/), + page.getByRole('button', { name: 'Log in' }).click(), + ]); + await expect(page.getByRole('heading', { name: 'Home Feed' })).toBeVisible(); +} + +export async function logout(page: Page): Promise { + await Promise.all([ + page.waitForURL(/\/home/), + page.getByRole('button', { name: 'Log out' }).click(), + ]); + await expect(page.getByRole('link', { name: 'Log in' })).toBeVisible(); +} + +export async function createPost(page: Page, text: string, media?: string): Promise { + await page.goto('/home'); + await page.getByLabel('What is happening?').fill(text); + if (media) { + await page.locator('input[type="file"][name="media"]').setInputFiles(media); + } + await page.getByRole('button', { name: 'Post', exact: true }).click(); + await expect(page.getByText(text)).toBeVisible(); + const hashId = new URL(page.url()).hash.replace('#post-', ''); + if (hashId) { + return hashId; + } + const postId = await page + .locator('article[data-post-id]') + .filter({ hasText: text }) + .first() + .getAttribute('data-post-id'); + expect(postId, `post id for "${text}"`).not.toBeNull(); + return postId!; +} + +export async function submitFirstPostAction(page: Page, label: string): Promise { + const button = page.getByRole('button', { name: label }).first(); + await expect(button).toBeVisible(); + await Promise.all([ + page.waitForLoadState('networkidle'), + button.click(), + ]); +} + +export async function crawlVisibleInternalLinks(page: Page): Promise { + const hrefs = await page + .locator('a[href]') + .evaluateAll((links) => + Array.from( + new Set( + links + .map((link) => (link as HTMLAnchorElement).getAttribute('href') ?? '') + .filter((href) => href.startsWith('/') && !href.startsWith('//')), + ), + ), + ); + + for (const href of hrefs) { + const response = await page.goto(href); + expect(response, `no response for ${href}`).not.toBeNull(); + expect(response!.status(), `${href} returned ${response!.status()}`).toBeLessThan(400); + } +} + +export async function expectIntentionalClientError(action: () => Promise): Promise { + await action(); +} + +export function firstFormWithAction(page: Page, action: string): Locator { + return page.locator(`form[action="${action}"]`).first(); +} diff --git a/tests/e2e/mobile.spec.ts b/tests/e2e/mobile.spec.ts new file mode 100644 index 0000000..82fa932 --- /dev/null +++ b/tests/e2e/mobile.spec.ts @@ -0,0 +1,39 @@ +import { test, expect } from '@playwright/test'; +import { + assertNoQaFailures, + createPost, + installQaGuards, + register, +} from './helpers'; + +test.beforeEach(async ({ page }, testInfo) => { + await installQaGuards(page, testInfo); +}); + +test.afterEach(async ({ page }) => { + assertNoQaFailures(page); +}); + +test('mobile smoke covers core pages and forms', async ({ page }) => { + const user = `mobile_${Date.now()}`; + const password = 'very secure password'; + + await page.goto('/home'); + await expect(page.getByRole('heading', { name: 'Home Feed' })).toBeVisible(); + await page.goto('/search'); + await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible(); + + await register(page, user, password); + const postId = await createPost(page, 'Mobile viewport post #mobile'); + + for (const url of ['/home', `/posts/${postId}`, `/users/${user}`, '/settings', '/notifications']) { + const response = await page.goto(url); + expect(response?.status(), `${url} should render on mobile`).toBeLessThan(400); + await expect(page.locator('body')).toBeVisible(); + } + + await page.goto('/settings'); + await page.getByLabel('Bio').fill('Updated from mobile smoke.'); + await page.getByRole('button', { name: 'Save settings' }).click(); + await expect(page.getByLabel('Bio')).toHaveValue('Updated from mobile smoke.'); +}); diff --git a/tests/e2e/rustpost.spec.ts b/tests/e2e/rustpost.spec.ts new file mode 100644 index 0000000..a98d00c --- /dev/null +++ b/tests/e2e/rustpost.spec.ts @@ -0,0 +1,191 @@ +import { test, expect } from '@playwright/test'; +import { + adminPassword, + adminUser, + assertNoQaFailures, + createPost, + crawlVisibleInternalLinks, + fixturePath, + firstFormWithAction, + installQaGuards, + login, + logout, + register, + submitFirstPostAction, +} from './helpers'; + +test.beforeEach(async ({ page }, testInfo) => { + await installQaGuards(page, testInfo); +}); + +test.afterEach(async ({ page }) => { + assertNoQaFailures(page); +}); + +test('logged-out navigation, registration validation, and public links are healthy', async ({ page }) => { + for (const url of ['/', '/home', '/local', '/login', '/register', '/search']) { + const response = await page.goto(url); + expect(response?.status(), `${url} should render or redirect cleanly`).toBeLessThan(400); + } + + await page.goto('/register'); + await page.getByLabel('Username').fill('bad_user_name'); + await page.getByLabel('Password', { exact: true }).fill('valid password one'); + await page.getByLabel('Confirm password').fill('valid password two'); + const badRegister = await Promise.all([ + page.waitForResponse((response) => response.url().includes('/register') && response.request().method() === 'POST'), + page.getByRole('button', { name: 'Create account' }).click(), + ]).then(([response]) => response); + expect(badRegister.status()).toBe(400); + await expect(page.getByRole('heading', { name: 'Check the form' })).toBeVisible(); + + await page.goto('/login'); + await page.getByLabel('Username').fill('missing-user'); + await page.getByLabel('Password', { exact: true }).fill('not the password'); + const badLogin = await Promise.all([ + page.waitForResponse((response) => response.url().includes('/login') && response.request().method() === 'POST'), + page.getByRole('button', { name: 'Log in' }).click(), + ]).then(([response]) => response); + expect(badLogin.status()).toBe(401); + await expect(page.getByRole('heading', { name: 'Authentication required' })).toBeVisible(); + + await page.goto('/home'); + await crawlVisibleInternalLinks(page); +}); + +test.describe('no JavaScript fallback', () => { + test.use({ javaScriptEnabled: false }); + +test('account, posting, thread, profile, search, and social actions work', async ({ page }) => { + const alice = `alice_${Date.now()}`; + const bob = `bob_${Date.now()}`; + const password = 'very secure password'; + + await register(page, alice, password); + const postText = `Hello from Playwright #qa @${adminUser}`; + const postId = await createPost(page, postText, fixturePath('tiny.png')); + + await page.goto(`/posts/${postId}`); + await expect(page.getByRole('heading', { name: 'Thread' })).toBeVisible(); + await page.getByLabel('What is happening?').fill('Reply from the same account'); + await Promise.all([ + page.waitForURL(/\/posts\/\d+#reply-\d+/), + page.getByRole('button', { name: 'Post', exact: true }).click(), + ]); + await expect(page.getByText('Reply from the same account')).toBeVisible(); + + await submitFirstPostAction(page, 'Like'); + await expect(page.getByRole('button', { name: 'Unlike' }).first()).toBeVisible(); + await submitFirstPostAction(page, 'Unlike'); + await expect(page.getByRole('button', { name: 'Like' }).first()).toBeVisible(); + await submitFirstPostAction(page, 'Bookmark'); + + await page.goto('/bookmarks'); + await expect(page.getByText(postText)).toBeVisible(); + + await page.goto('/search'); + await page.getByLabel(/Search RustPost/).fill('#qa'); + await Promise.all([ + page.waitForURL(/\/search\?q=%23qa/), + page.getByRole('button', { name: 'Search' }).click(), + ]); + await expect(page.getByText(postText)).toBeVisible(); + + await page.goto('/tags/qa'); + await expect(page.getByText(postText)).toBeVisible(); + + await page.goto('/settings'); + await page.getByLabel('Display name').fill('Alice QA'); + await page.getByLabel('Bio').fill('Testing the full account settings form.'); + await page.getByLabel('Website').fill('https://example.com/alice'); + await page.getByLabel('Profile picture').setInputFiles(fixturePath('tiny.png')); + await page.getByLabel('Banner').setInputFiles(fixturePath('tiny.png')); + await Promise.all([ + page.waitForURL('/settings'), + page.getByRole('button', { name: 'Save settings' }).click(), + ]); + await expect(page.locator('input[name="display_name"]')).toHaveValue('Alice QA'); + + await logout(page); + + await register(page, bob, password); + await page.goto(`/users/${alice}`); + await submitFirstPostAction(page, 'Repost'); + await expect(page.getByRole('button', { name: 'Unrepost' }).first()).toBeVisible(); + await page.getByRole('button', { name: 'Follow this account' }).click(); + await expect(page.getByRole('button', { name: 'Unfollow this account' })).toBeVisible(); + await page.getByRole('button', { name: 'Unfollow this account' }).click(); + await expect(page.getByRole('button', { name: 'Follow this account' })).toBeVisible(); + await page.getByRole('button', { name: 'Block this account' }).click(); + await page.goto('/settings'); + await expect(page.getByRole('button', { name: 'Unblock this account' })).toBeVisible(); + await page.getByRole('button', { name: 'Unblock this account' }).click(); + + await page.goto(`/users/${alice}`); + await page.getByRole('button', { name: 'Mute this account' }).click(); + await expect(page).toHaveURL(/\/users\/alice_/); +}); +}); + +test('authenticated crawl, admin pages, notifications, delete, and backup forms are healthy', async ({ page }) => { + const user = `charlie_${Date.now()}`; + const password = 'very secure password'; + await register(page, user, password); + const postId = await createPost(page, 'Post that will be deleted'); + + await page.goto('/notifications'); + await page.getByRole('button', { name: 'Mark all read' }).click(); + await expect(page).toHaveURL(/\/notifications/); + + await page.goto(`/posts/${postId}/delete?return_to=/home%23post-${postId}`); + await expect(page.getByRole('heading', { name: 'Delete post?' })).toBeVisible(); + await page.getByRole('link', { name: 'Cancel' }).click(); + await expect(page).toHaveURL(new RegExp(`/home#post-${postId}`)); + await page.goto(`/posts/${postId}/delete?return_to=/home%23post-${postId}`); + await page.getByRole('button', { name: 'Confirm delete' }).click(); + await expect(page).toHaveURL(/\/home/); + + await logout(page); + await login(page, adminUser, adminPassword); + + for (const url of ['/admin', '/admin/health', '/admin/users', '/admin/media', '/admin/backups']) { + const response = await page.goto(url); + expect(response?.status(), `${url} should be reachable for admin`).toBeLessThan(400); + } + + await page.goto('/admin/backups'); + await page.getByRole('button', { name: 'Create backup' }).click(); + await expect(page.getByText('Backup created:')).toBeVisible(); + + await page.goto('/home'); + await crawlVisibleInternalLinks(page); +}); + +test('normal UI forms post to real routes and forbidden duplicates are intentional errors', async ({ page }) => { + const user = `dupe_${Date.now()}`; + const password = 'very secure password'; + await register(page, user, password); + await logout(page); + + await page.goto('/register'); + await page.getByLabel('Username').fill(user); + await page.getByLabel('Password', { exact: true }).fill(password); + await page.getByLabel('Confirm password').fill(password); + const duplicate = await Promise.all([ + page.waitForResponse((response) => response.url().includes('/register') && response.request().method() === 'POST'), + page.getByRole('button', { name: 'Create account' }).click(), + ]).then(([response]) => response); + expect(duplicate.status()).toBe(400); + await expect(page.getByRole('heading', { name: 'Check the form' })).toBeVisible(); + + await login(page, user, password); + await page.goto('/home'); + await expect(firstFormWithAction(page, '/posts')).toBeVisible(); + await page.getByLabel('What is happening?').fill(''); + const emptyPost = await Promise.all([ + page.waitForResponse((response) => response.url().includes('/posts') && response.request().method() === 'POST'), + page.getByRole('button', { name: 'Post', exact: true }).click(), + ]).then(([response]) => response); + expect(emptyPost.status()).toBe(400); + await expect(page.getByRole('heading', { name: 'Check the form' })).toBeVisible(); +}); diff --git a/tests/e2e/start-rustpost.sh b/tests/e2e/start-rustpost.sh new file mode 100755 index 0000000..9999ef2 --- /dev/null +++ b/tests/e2e/start-rustpost.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +DATA_DIR="${RUSTPOST_E2E_DATA_DIR:-$ROOT_DIR/output/playwright/runtime}" +PORT="${RUSTPOST_E2E_PORT:-18080}" +ADMIN_USER="${RUSTPOST_E2E_ADMIN_USER:-admin_e2e}" +ADMIN_PASSWORD="${RUSTPOST_E2E_ADMIN_PASSWORD:-very secure admin password}" + +cd "$ROOT_DIR" + +rm -rf "$DATA_DIR" +mkdir -p "$DATA_DIR" + +cargo run --quiet --bin rustpost-cli -- --data-dir "$DATA_DIR" init + +perl -0pi -e "s/port = 8080/port = $PORT/; s/create_admin_on_first_boot = true/create_admin_on_first_boot = false/; s/posts_per_minute = 5/posts_per_minute = 100/; s/replies_per_minute = 10/replies_per_minute = 100/; s/reposts_per_minute = 10/reposts_per_minute = 100/; s/account_creations_per_ip_per_day = 3/account_creations_per_ip_per_day = 100/" "$DATA_DIR/settings.toml" + +cargo run --quiet --bin rustpost-cli -- --data-dir "$DATA_DIR" create-admin "$ADMIN_USER" "$ADMIN_PASSWORD" + +exec cargo run --quiet --bin rustpost-cli -- --data-dir "$DATA_DIR" serve From b96e02f4619aa421aaf53f23e22c86f5f6801f36 Mon Sep 17 00:00:00 2001 From: csd113 Date: Mon, 18 May 2026 18:56:58 -0700 Subject: [PATCH 02/10] Hide redundant thread links and widen upload body limit --- src/auth.rs | 9 +- src/csrf.rs | 7 +- src/db.rs | 10 +- src/render.rs | 28 ++---- src/server.rs | 253 ++++++++++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 270 insertions(+), 37 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index 22c5f1f..ad8ada4 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -157,13 +157,16 @@ pub async fn current_user( .await } -pub async fn csrf_for_cookie(pool: &SqlitePool, token: &str) -> anyhow::Result> { +pub async fn csrf_hashes_for_cookie( + pool: &SqlitePool, + token: &str, +) -> anyhow::Result)>> { let token_hash = hash_token(token); pool.call(move |conn| { conn.query_row( - "SELECT csrf_token_hash FROM sessions WHERE token_hash = ? AND revoked_at IS NULL", + "SELECT csrf_token_hash, previous_csrf_token_hash FROM sessions WHERE token_hash = ? AND revoked_at IS NULL", [token_hash], - |row| row.get(0), + |row| Ok((row.get(0)?, row.get(1)?)), ) .optional() .map_err(Into::into) diff --git a/src/csrf.rs b/src/csrf.rs index ee840f6..ca8e8d8 100644 --- a/src/csrf.rs +++ b/src/csrf.rs @@ -1,6 +1,6 @@ use axum::http::HeaderMap; -use crate::auth::{csrf_for_cookie, hash_token, session_cookie}; +use crate::auth::{csrf_hashes_for_cookie, hash_token, session_cookie}; use crate::db::SqlitePool; pub async fn validate( @@ -11,10 +11,11 @@ pub async fn validate( let Some(token) = session_cookie(headers) else { anyhow::bail!("missing session"); }; - let Some(expected) = csrf_for_cookie(pool, &token).await? else { + let Some((current, previous)) = csrf_hashes_for_cookie(pool, &token).await? else { anyhow::bail!("missing csrf session"); }; - if expected != hash_token(submitted) { + let submitted = hash_token(submitted); + if submitted != current && previous.as_deref() != Some(submitted.as_str()) { anyhow::bail!("invalid csrf token"); } Ok(()) diff --git a/src/db.rs b/src/db.rs index 64df3fb..536eb5b 100644 --- a/src/db.rs +++ b/src/db.rs @@ -88,6 +88,10 @@ pub async fn migrate(pool: &Db) -> anyhow::Result<()> { tx.execute_batch(MIGRATION_1)?; tx.execute("INSERT INTO schema_migrations (version) VALUES (1)", [])?; } + if applied.unwrap_or(0) < 2 { + tx.execute_batch(MIGRATION_2)?; + tx.execute("INSERT INTO schema_migrations (version) VALUES (2)", [])?; + } tx.commit()?; Ok(()) }) @@ -271,6 +275,10 @@ CREATE INDEX idx_bookmarks_post ON bookmarks(post_id, user_id); CREATE INDEX idx_rate_limit_scope_actor_created ON rate_limit_events(scope, actor, created_at); "#; +const MIGRATION_2: &str = r#" +ALTER TABLE sessions ADD COLUMN previous_csrf_token_hash TEXT; +"#; + #[cfg(test)] mod tests { use super::*; @@ -312,7 +320,7 @@ mod tests { .await .expect("settings"); - assert_eq!(versions, 1); + assert_eq!(versions, 2); assert_eq!(foreign_keys, 1); assert_eq!(journal_mode, "wal"); assert!(busy_timeout >= 5_000); diff --git a/src/render.rs b/src/render.rs index a6c8e2f..b8e20a6 100644 --- a/src/render.rs +++ b/src/render.rs @@ -559,16 +559,8 @@ fn post_card_with_options( String::new() }; let reply_link = icon_link(&format!("/posts/{}#reply", post.id), "Reply", "reply"); - let thread_link = if post.parent_post_id.is_none() { - format!( - r#"Open thread"#, - post.id - ) - } else { - String::new() - }; format!( - r#"
{}{}{}{}{}{}
"#, + r#"
{}{}{}{}{}
"#, icon_action_form( &format!("/posts/{}/like", post.id), csrf, @@ -607,12 +599,6 @@ fn post_card_with_options( post.viewer_bookmarked ), delete, - thread_link - ) - } else if post.parent_post_id.is_none() { - format!( - r#""#, - post.id ) } else { String::new() @@ -645,10 +631,7 @@ fn post_card_with_options( html_escape::encode_text(&post.created_at) ) } else { - format!( - r#"Open post"#, - post.id - ) + String::new() }; format!( r#"
{}{}
{}
{}
{}
{}
{}
{} likes{} reposts{} replies
{}
"#, @@ -830,8 +813,8 @@ label{display:block;font-weight:700;margin:.85rem 0 .35rem}input,textarea,button input[type=text].password-visible{padding-right:.8rem}.password-control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.45rem;align-items:center}.password-control input{min-width:0}.password-toggle{background:#fff;color:#24445f;border-color:#cdd7d0;min-width:4.5rem}.auth-submit{margin-top:1.15rem}.auth-form{margin-top:.35rem} input:focus,textarea:focus,button:focus-visible,a:focus-visible{outline:3px solid #93c5fd;outline-offset:2px}button,.primary{border:1px solid #163b2f;background:#163b2f;color:#fff;border-radius:7px;padding:.5rem .8rem;cursor:pointer;font-weight:700}button:hover,.primary:hover{background:#235544;text-decoration:none} .composer-tools{display:flex;align-items:center;justify-content:space-between;gap:.75rem;margin-top:.85rem}.file-control{display:inline-flex;align-items:center;gap:.6rem;margin:0;color:#24445f;font-weight:700}.file-control input{max-width:15rem} -.timeline{display:grid;gap:.65rem}.post{overflow:hidden;position:relative}.post[data-card-href]{cursor:pointer}.post[data-card-href]:hover{border-color:#c7d2ca}.post[data-card-href]:focus-visible{outline:3px solid #93c5fd;outline-offset:2px}.reply-post{margin-left:1.1rem;border-left:4px solid #c8d8d0;background:#fbfcfa}.reply-post::before{content:"";position:absolute;left:-1.1rem;top:1.25rem;width:1.1rem;border-top:2px solid #c8d8d0}.anchor-target{position:absolute;top:-5rem}.post-header{display:flex;justify-content:space-between;gap:.65rem;align-items:flex-start}.author-block{display:flex;gap:.55rem;align-items:center;min-width:0}.post-avatar{width:2rem;height:2rem;object-fit:cover;border-radius:999px;border:1px solid #d0d8d2;background:#eef3f0;flex:0 0 auto;margin:0}.post-avatar.placeholder{display:inline-grid;place-items:center;color:#526159;font-weight:800}.author-name{font-weight:800;color:#202124}.username,.post-time,.post-open-link,.counts{color:#687068;font-size:.92rem}.text{white-space:pre-wrap;margin:.55rem 0;line-height:1.5;overflow-wrap:anywhere}.post img,.post video{display:block;max-width:100%;border-radius:8px;border:1px solid #d9ded6;margin-top:.5rem;background:#f6f7f4}.post img.post-avatar{display:block;margin:0;border-radius:999px} -.counts{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.3rem;min-height:1.4rem}.actions{display:flex;gap:.25rem;flex-wrap:wrap;align-items:center;margin-top:.5rem}.icon-button{width:2.2rem;height:2.2rem;display:inline-flex;align-items:center;justify-content:center;border:1px solid #cdd7d0;border-radius:7px;background:#fff;color:#24445f;padding:0}.icon-button svg{width:1.05rem;height:1.05rem;fill:currentColor}.icon-button:hover,.icon-button.active{background:#eef3f0;color:#163b2f;text-decoration:none}.icon-button.disabled,.icon-button:disabled{color:#9aa39d;background:#f4f5f2;border-color:#dfe4dc;cursor:not-allowed}.icon-button.disabled:hover,.icon-button:disabled:hover{background:#f4f5f2;color:#9aa39d}.thread-link{padding:.42rem .65rem}.follow-button{min-width:6.6rem}.follow-button.active{background:#eef3f0;color:#163b2f;border-color:#9fb9ad}.profile-actions{margin-top:0}.profile-secondary button{background:#fff;color:#8a3d2d;border-color:#e0c4bb;padding:.32rem .5rem;min-height:1.85rem;font-size:.86rem}.profile-secondary button:hover{background:#fff8f5;color:#6f2f22}.profile-title-row{display:flex;align-items:flex-start;justify-content:space-between;gap:.75rem}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.repost-banner{color:#4b655d;font-size:.9rem;font-weight:800;margin-bottom:.35rem}.unavailable{color:#667064}.empty-state{text-align:center;padding:2rem 1rem}.empty-state h2{margin:0;font-size:1.2rem}.notice.error,.error-panel{border-color:#e6b8a8;background:#fff8f5}.notice.success{border-color:#add7b4;background:#f4fbf5}.eyebrow{text-transform:uppercase;letter-spacing:.08em;font-weight:800;color:#6d766e;font-size:.78rem} +.timeline{display:grid;gap:.65rem}.post{overflow:hidden;position:relative}.post[data-card-href]{cursor:pointer}.post[data-card-href]:hover{border-color:#c7d2ca}.post[data-card-href]:focus-visible{outline:3px solid #93c5fd;outline-offset:2px}.reply-post{margin-left:1.1rem;border-left:4px solid #c8d8d0;background:#fbfcfa}.reply-post::before{content:"";position:absolute;left:-1.1rem;top:1.25rem;width:1.1rem;border-top:2px solid #c8d8d0}.anchor-target{position:absolute;top:-5rem}.post-header{display:flex;justify-content:space-between;gap:.65rem;align-items:flex-start}.author-block{display:flex;gap:.55rem;align-items:center;min-width:0}.post-avatar{width:2rem;height:2rem;object-fit:cover;border-radius:999px;border:1px solid #d0d8d2;background:#eef3f0;flex:0 0 auto;margin:0}.post-avatar.placeholder{display:inline-grid;place-items:center;color:#526159;font-weight:800}.author-name{font-weight:800;color:#202124}.username,.post-time,.counts{color:#687068;font-size:.92rem}.text{white-space:pre-wrap;margin:.55rem 0;line-height:1.5;overflow-wrap:anywhere}.post img,.post video{display:block;max-width:100%;border-radius:8px;border:1px solid #d9ded6;margin-top:.5rem;background:#f6f7f4}.post img.post-avatar{display:block;margin:0;border-radius:999px} +.counts{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.3rem;min-height:1.4rem}.actions{display:flex;gap:.25rem;flex-wrap:wrap;align-items:center;margin-top:.5rem}.icon-button{width:2.2rem;height:2.2rem;display:inline-flex;align-items:center;justify-content:center;border:1px solid #cdd7d0;border-radius:7px;background:#fff;color:#24445f;padding:0}.icon-button svg{width:1.05rem;height:1.05rem;fill:currentColor}.icon-button:hover,.icon-button.active{background:#eef3f0;color:#163b2f;text-decoration:none}.icon-button.disabled,.icon-button:disabled{color:#9aa39d;background:#f4f5f2;border-color:#dfe4dc;cursor:not-allowed}.icon-button.disabled:hover,.icon-button:disabled:hover{background:#f4f5f2;color:#9aa39d}.follow-button{min-width:6.6rem}.follow-button.active{background:#eef3f0;color:#163b2f;border-color:#9fb9ad}.profile-actions{margin-top:0}.profile-secondary button{background:#fff;color:#8a3d2d;border-color:#e0c4bb;padding:.32rem .5rem;min-height:1.85rem;font-size:.86rem}.profile-secondary button:hover{background:#fff8f5;color:#6f2f22}.profile-title-row{display:flex;align-items:flex-start;justify-content:space-between;gap:.75rem}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.repost-banner{color:#4b655d;font-size:.9rem;font-weight:800;margin-bottom:.35rem}.unavailable{color:#667064}.empty-state{text-align:center;padding:2rem 1rem}.empty-state h2{margin:0;font-size:1.2rem}.notice.error,.error-panel{border-color:#e6b8a8;background:#fff8f5}.notice.success{border-color:#add7b4;background:#f4fbf5}.eyebrow{text-transform:uppercase;letter-spacing:.08em;font-weight:800;color:#6d766e;font-size:.78rem} .profile-banner{width:100%;max-height:220px;object-fit:cover;border-radius:8px;border:1px solid #d9ded6;background:#dfe9e1}.profile-heading{display:flex;gap:1rem;align-items:flex-start;margin-top:.85rem}.profile-main{min-width:0;flex:1}.profile-picture{width:88px;height:88px;object-fit:cover;border-radius:8px;border:1px solid #d9ded6;background:#eef3f0;flex:0 0 auto}.account-list{display:grid;gap:.65rem}.account-row{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:.75rem;align-items:center;background:#fff;border:1px solid #dfe4dc;border-radius:8px;padding:.85rem}.account-row p{margin:.3rem 0 0;color:#59625a;overflow-wrap:anywhere}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:.85rem}.item-list{margin:.75rem 0 0;padding-left:1.2rem}.item-list li{margin:.45rem 0}.panel dl:not(.dashboard-list){display:grid;grid-template-columns:max-content minmax(0,1fr);gap:.45rem .85rem}.panel dl:not(.dashboard-list) dt{font-weight:800}.panel dl:not(.dashboard-list) dd{margin:0;overflow-wrap:anywhere}table{width:100%;border-collapse:collapse}td,th{border-bottom:1px solid #e3e7e0;text-align:left;padding:.55rem;vertical-align:top}pre{white-space:pre-wrap;overflow:auto;max-width:100%} @media (max-width:900px){.content-shell{grid-template-columns:1fr}.side-panel{position:static;display:none}} @media (max-width:600px){main{padding:.75rem}.header-inner{align-items:flex-start;flex-direction:column}.site-header{position:static}nav{justify-content:flex-start}.composer-tools,.post-header,.profile-heading,.profile-title-row,.account-row{align-items:stretch;grid-template-columns:1fr;flex-direction:column}.panel dl:not(.dashboard-list){grid-template-columns:1fr}table{display:block;max-width:100%;overflow-x:auto}.author-block{align-items:flex-start}.file-control{display:block}.file-control input{display:block;max-width:100%;margin-top:.35rem}.reply-post{margin-left:.65rem;padding-left:.8rem}.reply-post::before{left:-.65rem;width:.65rem}.button-link{padding:.42rem .55rem}.counts{gap:.45rem}.page-header h1,.section-heading h1,.panel h1{font-size:1.25rem}} @@ -938,7 +921,8 @@ mod tests { assert!(body.contains(r#"data-card-href="/posts/42""#)); assert!(body.contains(r#"tabindex="0""#)); - assert!(body.contains(r#"href="/posts/42">Open post"#)); + assert!(!body.contains(r#">Open post"#)); + assert!(!body.contains("Open thread")); assert!(!body.contains("2026-05-18 10:30")); assert!(!body.contains(r#"class="post-time""#)); } diff --git a/src/server.rs b/src/server.rs index 7731cf5..3f56d03 100644 --- a/src/server.rs +++ b/src/server.rs @@ -4,8 +4,8 @@ use std::sync::Arc; use axum::Json; use axum::Router; use axum::extract::connect_info::ConnectInfo; -use axum::extract::{Form, Multipart, Path, Query, State}; -use axum::http::{HeaderMap, HeaderValue, Uri, header}; +use axum::extract::{DefaultBodyLimit, Form, Multipart, Path, Query, State}; +use axum::http::{HeaderMap, HeaderValue, StatusCode, Uri, header}; use axum::response::{Html, IntoResponse as _, Redirect, Response}; use axum::routing::{get, post}; use rusqlite::{OptionalExtension as _, params}; @@ -50,6 +50,7 @@ impl AppState { } pub fn router(state: Arc) -> Router { + let upload_body_limit = upload_body_limit(&state.settings); Router::new() .route("/", get(home)) .route("/assets/rustpost.js", get(client_script)) @@ -100,10 +101,19 @@ pub fn router(state: Arc) -> Router { "/uploads/videos", ServeDir::new(state.paths.uploads_videos.clone()), ) + .layer(DefaultBodyLimit::max(upload_body_limit)) .layer(TraceLayer::new_for_http()) .with_state(state) } +fn upload_body_limit(settings: &Settings) -> usize { + let limit = settings.media.max_video_size.saturating_add(1024 * 1024); + match usize::try_from(limit) { + Ok(limit) => limit, + Err(_overflow) => usize::MAX, + } +} + async fn client_script() -> Response { ( [( @@ -405,7 +415,13 @@ async fn create_post( if user.is_none() && !state.settings.accounts.anonymous_mode_enabled { return Err(AppError::Forbidden); } - let form = parse_post_create(&state, user.as_ref().map(|u| u.id), multipart).await?; + let form = match parse_post_create(&state, user.as_ref().map(|u| u.id), multipart).await { + Ok(form) => form, + Err(AppError::BadRequest(message)) => { + return bad_request_page(&state, user.as_ref(), &message).await; + } + Err(err) => return Err(err), + }; if let Some(parent_id) = form.parent_post_id { ensure_parent_post_exists(&state.pool, parent_id).await?; } @@ -554,6 +570,22 @@ async fn parse_post_create( Ok(form) } +async fn bad_request_page( + state: &AppState, + user: Option<&CurrentUser>, + message: &str, +) -> AppResult { + let body = format!( + r#"

400 error

Check the form

{}

Back to Home Feed

"#, + html_escape::encode_text(message) + ); + Ok(( + StatusCode::BAD_REQUEST, + Html(page_layout(state, user, None, "Check the form", &body).await?), + ) + .into_response()) +} + async fn thread( State(state): State>, headers: HeaderMap, @@ -1855,16 +1887,16 @@ async fn form_csrf(state: &AppState, headers: &HeaderMap) -> Option { }) .await .ok()??; - // CSRF tokens are only shown immediately after login in memory through the cookie session. - // For persisted sessions we rotate a new token and update its hash before rendering forms. + // Keep the last rendered token valid for browser back/forward-cache restores. let plain = auth::secure_token(); let new_hash = auth::hash_token(&plain); state .pool .call(move |conn| { + let previous_hash = stored_hash.clone(); conn.execute( - "UPDATE sessions SET csrf_token_hash = ? WHERE token_hash = ? AND csrf_token_hash = ?", - params![new_hash, token_hash, stored_hash], + "UPDATE sessions SET csrf_token_hash = ?, previous_csrf_token_hash = ? WHERE token_hash = ? AND csrf_token_hash = ?", + params![new_hash, previous_hash, token_hash, stored_hash], )?; Ok(()) }) @@ -1979,7 +2011,8 @@ mod tests { assert!(home.body.contains("hello from the browser-shaped form")); assert!(home.body.contains(r#"class="post""#)); assert!(home.body.contains(r#"data-card-href="/posts/1""#)); - assert!(home.body.contains(r#"href="/posts/1">Open post"#)); + assert!(!home.body.contains(r#">Open post"#)); + assert!(!home.body.contains("Open thread")); assert!(!home.body.contains(r#"class="post-time""#)); let thread = request( @@ -2214,6 +2247,166 @@ mod tests { assert!(!self_repost.body.contains("internal server error")); } + #[tokio::test] + async fn post_actions_accept_token_from_previous_page_render() { + let server = spawn_test_server().await; + let registered = request( + &server.base_url, + "POST", + "/register", + &[("content-type", "application/x-www-form-urlencoded")], + b"username=alice&password=very%20secure%20password&confirm_password=very%20secure%20password".to_vec(), + ) + .await; + let cookie = session_cookie(®istered); + + let home = request( + &server.base_url, + "GET", + "/home", + &[("cookie", &cookie)], + Vec::new(), + ) + .await; + let csrf = csrf_token(&home.body); + let posted = request( + &server.base_url, + "POST", + "/posts", + &[ + ("cookie", &cookie), + ( + "content-type", + "multipart/form-data; boundary=post-boundary", + ), + ], + multipart_body( + "post-boundary", + &[("csrf", csrf.as_str()), ("text", "liked after back")], + false, + ), + ) + .await; + assert_eq!(posted.status, 303); + + let home = request( + &server.base_url, + "GET", + "/home", + &[("cookie", &cookie)], + Vec::new(), + ) + .await; + let home_csrf = csrf_token(&home.body); + let thread = request( + &server.base_url, + "GET", + "/posts/1", + &[("cookie", &cookie)], + Vec::new(), + ) + .await; + assert_eq!(thread.status, 200); + + let liked = request( + &server.base_url, + "POST", + "/posts/1/like", + &[ + ("cookie", &cookie), + ("referer", "/home"), + ("content-type", "application/x-www-form-urlencoded"), + ], + format!("csrf={home_csrf}").into_bytes(), + ) + .await; + assert_eq!(liked.status, 303); + assert_eq!(location(&liked), "/home#post-1"); + } + + #[tokio::test] + async fn image_uploads_over_default_body_limit_are_accepted() { + let server = spawn_test_server().await; + let registered = request( + &server.base_url, + "POST", + "/register", + &[("content-type", "application/x-www-form-urlencoded")], + b"username=alice&password=very%20secure%20password&confirm_password=very%20secure%20password".to_vec(), + ) + .await; + let cookie = session_cookie(®istered); + let home = request( + &server.base_url, + "GET", + "/home", + &[("cookie", &cookie)], + Vec::new(), + ) + .await; + let csrf = csrf_token(&home.body); + + let mut image = tiny_png_bytes(); + image.resize((2 * 1024 * 1024) + 1, 0); + let posted = request( + &server.base_url, + "POST", + "/posts", + &[ + ("cookie", &cookie), + ( + "content-type", + "multipart/form-data; boundary=post-boundary", + ), + ], + multipart_body_with_file( + "post-boundary", + &[("csrf", csrf.as_str()), ("text", "large image")], + "media", + "large.png", + "image/png", + &image, + ), + ) + .await; + + assert_eq!(posted.status, 303); + assert_eq!(location(&posted), "/home#post-1"); + } + + #[tokio::test] + async fn post_multipart_errors_keep_authenticated_layout() { + let server = spawn_test_server().await; + let registered = request( + &server.base_url, + "POST", + "/register", + &[("content-type", "application/x-www-form-urlencoded")], + b"username=alice&password=very%20secure%20password&confirm_password=very%20secure%20password".to_vec(), + ) + .await; + let cookie = session_cookie(®istered); + let failed = request( + &server.base_url, + "POST", + "/posts", + &[ + ("cookie", &cookie), + ( + "content-type", + "multipart/form-data; boundary=post-boundary", + ), + ], + b"--post-boundary\r\nContent-Disposition: form-data; name=\"text\"\r\n\r\nunterminated" + .to_vec(), + ) + .await; + + assert_eq!(failed.status, 400); + assert!(failed.body.contains("Check the form")); + assert!(failed.body.contains(r#"href="/users/alice""#)); + } + #[tokio::test] async fn enhanced_like_updates_in_place_with_stable_action_markup() { let server = spawn_test_server().await; @@ -2621,6 +2814,50 @@ mod tests { body.into_bytes() } + fn multipart_body_with_file( + boundary: &str, + fields: &[(&str, &str)], + file_field: &str, + filename: &str, + content_type: &str, + file: &[u8], + ) -> Vec { + let mut body = multipart_body_without_close(boundary, fields); + body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); + body.extend_from_slice( + format!( + "Content-Disposition: form-data; name=\"{file_field}\"; filename=\"{filename}\"\r\n" + ) + .as_bytes(), + ); + body.extend_from_slice(format!("Content-Type: {content_type}\r\n\r\n").as_bytes()); + body.extend_from_slice(file); + body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes()); + body + } + + fn multipart_body_without_close(boundary: &str, fields: &[(&str, &str)]) -> Vec { + let mut body = Vec::new(); + for (name, value) in fields { + body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); + body.extend_from_slice( + format!("Content-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n") + .as_bytes(), + ); + } + body + } + + fn tiny_png_bytes() -> Vec { + vec![ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, + 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, + 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, + 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x04, 0x00, 0x01, 0xfe, 0xa7, 0x69, 0x9d, + 0x16, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, + ] + } + async fn spawn_test_server() -> TestServer { let temp = tempfile::tempdir().expect("temp dir"); let paths = RuntimePaths::from_data_dir(temp.path().to_path_buf()); From 169bed0eee0571dc4bcfc6c72981c40562aa6284 Mon Sep 17 00:00:00 2001 From: csd113 Date: Mon, 18 May 2026 19:09:25 -0700 Subject: [PATCH 03/10] Support CSRF token history and validation Track and validate a bounded history of CSRF token hashes instead of a single current/previous pair. csrf_hashes_for_cookie now returns a Vec of accepted hashes; validate() checks the submitted hash against that list. form_csrf now reads and updates both csrf_token_hash and previous_csrf_token_hash, composes a newline-separated history capped by CSRF_TOKEN_HISTORY_LIMIT (32), and only returns a new plain token if the DB update actually changed a row. Added csrf_history_with helper to build the bounded history and a unit test to ensure the history is bounded and preserves recent hashes. Also adjusted an integration test to generate multiple page renders so history accumulation is exercised. --- src/auth.rs | 18 +++++++++++-- src/csrf.rs | 4 +-- src/server.rs | 72 +++++++++++++++++++++++++++++++++++++-------------- 3 files changed, 70 insertions(+), 24 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index ad8ada4..42b68af 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -160,13 +160,27 @@ pub async fn current_user( pub async fn csrf_hashes_for_cookie( pool: &SqlitePool, token: &str, -) -> anyhow::Result)>> { +) -> anyhow::Result>> { let token_hash = hash_token(token); pool.call(move |conn| { conn.query_row( "SELECT csrf_token_hash, previous_csrf_token_hash FROM sessions WHERE token_hash = ? AND revoked_at IS NULL", [token_hash], - |row| Ok((row.get(0)?, row.get(1)?)), + |row| { + let current = row.get::<_, String>(0)?; + let previous = row.get::<_, Option>(1)?; + let mut hashes = Vec::with_capacity(8); + hashes.push(current); + if let Some(previous) = previous { + hashes.extend( + previous + .lines() + .filter(|hash| !hash.is_empty()) + .map(ToOwned::to_owned), + ); + } + Ok(hashes) + }, ) .optional() .map_err(Into::into) diff --git a/src/csrf.rs b/src/csrf.rs index ca8e8d8..ac84c22 100644 --- a/src/csrf.rs +++ b/src/csrf.rs @@ -11,11 +11,11 @@ pub async fn validate( let Some(token) = session_cookie(headers) else { anyhow::bail!("missing session"); }; - let Some((current, previous)) = csrf_hashes_for_cookie(pool, &token).await? else { + let Some(accepted_hashes) = csrf_hashes_for_cookie(pool, &token).await? else { anyhow::bail!("missing csrf session"); }; let submitted = hash_token(submitted); - if submitted != current && previous.as_deref() != Some(submitted.as_str()) { + if !accepted_hashes.iter().any(|hash| hash == &submitted) { anyhow::bail!("invalid csrf token"); } Ok(()) diff --git a/src/server.rs b/src/server.rs index 3f56d03..ea2f966 100644 --- a/src/server.rs +++ b/src/server.rs @@ -21,6 +21,8 @@ use crate::ffmpeg::FfmpegStatus; use crate::runtime::RuntimePaths; use crate::{admin, backup, csrf, media, rate_limit, render, social}; +const CSRF_TOKEN_HISTORY_LIMIT: usize = 32; + #[derive(Clone)] pub struct AppState { pub pool: SqlitePool, @@ -1871,15 +1873,15 @@ async fn validate_csrf(pool: &SqlitePool, headers: &HeaderMap, token: &str) -> A async fn form_csrf(state: &AppState, headers: &HeaderMap) -> Option { let token = auth::session_cookie(headers)?; let token_hash = auth::hash_token(&token); - let stored_hash: String = state + let (stored_hash, previous_hashes): (String, Option) = state .pool .call({ let token_hash = token_hash.clone(); move |conn| { conn.query_row( - "SELECT csrf_token_hash FROM sessions WHERE token_hash = ? AND revoked_at IS NULL", + "SELECT csrf_token_hash, previous_csrf_token_hash FROM sessions WHERE token_hash = ? AND revoked_at IS NULL", [token_hash], - |row| row.get(0), + |row| Ok((row.get(0)?, row.get(1)?)), ) .optional() .map_err(Into::into) @@ -1887,22 +1889,34 @@ async fn form_csrf(state: &AppState, headers: &HeaderMap) -> Option { }) .await .ok()??; - // Keep the last rendered token valid for browser back/forward-cache restores. + let previous_hashes = csrf_history_with(&stored_hash, previous_hashes.as_deref()); let plain = auth::secure_token(); let new_hash = auth::hash_token(&plain); - state + let updated = state .pool .call(move |conn| { - let previous_hash = stored_hash.clone(); - conn.execute( + let changed = conn.execute( "UPDATE sessions SET csrf_token_hash = ?, previous_csrf_token_hash = ? WHERE token_hash = ? AND csrf_token_hash = ?", - params![new_hash, previous_hash, token_hash, stored_hash], + params![new_hash, previous_hashes, token_hash, stored_hash], )?; - Ok(()) + Ok(changed == 1) }) .await .ok()?; - Some(plain) + updated.then_some(plain) +} + +fn csrf_history_with(current_hash: &str, previous_hashes: Option<&str>) -> String { + std::iter::once(current_hash) + .chain( + previous_hashes + .into_iter() + .flat_map(str::lines) + .filter(|hash| !hash.is_empty() && *hash != current_hash), + ) + .take(CSRF_TOKEN_HISTORY_LIMIT.saturating_sub(1)) + .collect::>() + .join("\n") } async fn user_post_relation_exists( @@ -1947,6 +1961,22 @@ mod tests { body: String, } + #[test] + fn csrf_history_is_bounded_and_keeps_recent_hashes() { + let prior = (1..CSRF_TOKEN_HISTORY_LIMIT + 3) + .map(|idx| format!("token-{idx}")) + .collect::>() + .join("\n"); + + let history = csrf_history_with("current", Some(&prior)); + let hashes = history.lines().collect::>(); + + assert_eq!(hashes.len(), CSRF_TOKEN_HISTORY_LIMIT - 1); + assert_eq!(hashes[0], "current"); + assert_eq!(hashes[1], "token-1"); + assert_eq!(hashes[CSRF_TOKEN_HISTORY_LIMIT - 2], "token-30"); + } + #[tokio::test] async fn logged_in_ui_post_succeeds_with_empty_media_part_and_appears_on_home_feed() { let server = spawn_test_server().await; @@ -2248,7 +2278,7 @@ mod tests { } #[tokio::test] - async fn post_actions_accept_token_from_previous_page_render() { + async fn post_actions_accept_token_from_recent_page_render_history() { let server = spawn_test_server().await; let registered = request( &server.base_url, @@ -2298,15 +2328,17 @@ mod tests { ) .await; let home_csrf = csrf_token(&home.body); - let thread = request( - &server.base_url, - "GET", - "/posts/1", - &[("cookie", &cookie)], - Vec::new(), - ) - .await; - assert_eq!(thread.status, 200); + for _ in 0..5 { + let thread = request( + &server.base_url, + "GET", + "/posts/1", + &[("cookie", &cookie)], + Vec::new(), + ) + .await; + assert_eq!(thread.status, 200); + } let liked = request( &server.base_url, From 05a517e78cd52cff9a3c0e798dda2ee54447bc9a Mon Sep 17 00:00:00 2001 From: csd113 Date: Mon, 18 May 2026 21:22:10 -0700 Subject: [PATCH 04/10] Add CSRF history regression coverage --- package.json | 1 + src/render.rs | 12 +- src/server.rs | 57 ++++++- tests/e2e/csrf.spec.ts | 341 +++++++++++++++++++++++++++++++++++++++++ tests/e2e/helpers.ts | 42 ++++- 5 files changed, 443 insertions(+), 10 deletions(-) create mode 100644 tests/e2e/csrf.spec.ts diff --git a/package.json b/package.json index ea14a87..d72b35e 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "rustpost-e2e", "private": true, "scripts": { + "test": "playwright test", "test:e2e": "playwright test" }, "devDependencies": { diff --git a/src/render.rs b/src/render.rs index b8e20a6..80f253e 100644 --- a/src/render.rs +++ b/src/render.rs @@ -237,6 +237,14 @@ document.querySelectorAll("textarea[data-character-limit]").forEach((textarea) = textarea.addEventListener("input", () => updateComposerCount(textarea)); }); +window.addEventListener("pageshow", (event) => { + const nav = performance.getEntriesByType("navigation")[0]; + const restored = event.persisted || (nav && nav.type === "back_forward"); + if (restored && document.querySelector('form[method="post"] input[name="csrf"]')) { + window.location.reload(); + } +}); + function setButtonState(button, active, label) { button.classList.toggle("active", active); button.setAttribute("aria-pressed", active ? "true" : "false"); @@ -259,9 +267,11 @@ document.addEventListener("submit", async (event) => { submitter.disabled = true; } try { + const formData = new FormData(form); + const body = form.enctype === "multipart/form-data" ? formData : new URLSearchParams(formData); const response = await fetch(form.action, { method: form.method || "POST", - body: new FormData(form), + body, headers: { "Accept": "application/json", "X-RustPost-Enhance": "1" }, credentials: "same-origin" }); diff --git a/src/server.rs b/src/server.rs index ea2f966..b7a2b77 100644 --- a/src/server.rs +++ b/src/server.rs @@ -624,11 +624,11 @@ async fn delete_confirm( let user = require_user(&state, &headers).await?; let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); let preview = delete_preview(&state.pool, user.id, user.is_admin, id).await?; - let fallback = anchored_return(&headers, id, preview.parent_post_id.is_some(), "/home"); + let fallback = delete_return_fallback(&headers, id, preview.parent_post_id); let return_to = query .return_to .as_deref() - .and_then(safe_return_target) + .and_then(|target| safe_delete_return_target(target, id)) .unwrap_or(fallback); let author = preview .display_name @@ -663,7 +663,7 @@ async fn delete_post( } else { "/home".to_owned() }; - let target = safe_return_target(&form.return_to).unwrap_or(fallback); + let target = safe_delete_return_target(&form.return_to, id).unwrap_or(fallback); Ok(Redirect::to(&target).into_response()) } @@ -1424,6 +1424,36 @@ fn anchored_return(headers: &HeaderMap, post_id: i64, is_reply: bool, fallback: format!("{base}#{anchor}") } +fn delete_return_fallback( + headers: &HeaderMap, + post_id: i64, + parent_post_id: Option, +) -> String { + let fallback = anchored_return(headers, post_id, parent_post_id.is_some(), "/home"); + let self_path = format!("/posts/{post_id}/delete"); + if path_without_query_or_fragment(&fallback) != self_path { + return fallback; + } + parent_post_id.map_or_else( + || format!("/home#post-{post_id}"), + |parent_id| format!("/posts/{parent_id}#reply-{post_id}"), + ) +} + +fn safe_delete_return_target(value: &str, post_id: i64) -> Option { + let target = safe_return_target(value)?; + let self_path = format!("/posts/{post_id}/delete"); + if path_without_query_or_fragment(&target) == self_path { + None + } else { + Some(target) + } +} + +fn path_without_query_or_fragment(target: &str) -> &str { + target.split(['?', '#']).next().unwrap_or_default() +} + fn safe_return_target(value: &str) -> Option { let target = if value.starts_with('/') && !value.starts_with("//") { value.to_owned() @@ -1977,6 +2007,27 @@ mod tests { assert_eq!(hashes[CSRF_TOKEN_HISTORY_LIMIT - 2], "token-30"); } + #[test] + fn delete_return_target_rejects_self_referential_confirmation_paths() { + assert_eq!(safe_delete_return_target("/posts/42/delete", 42), None); + assert_eq!( + safe_delete_return_target("/posts/42/delete#post-42", 42), + None + ); + assert_eq!( + safe_delete_return_target("http://127.0.0.1:18080/posts/42/delete?return_to=/home", 42), + None + ); + assert_eq!( + safe_delete_return_target("/posts/42#post-42", 42), + Some("/posts/42#post-42".to_owned()) + ); + assert_eq!( + safe_delete_return_target("/home#post-42", 42), + Some("/home#post-42".to_owned()) + ); + } + #[tokio::test] async fn logged_in_ui_post_succeeds_with_empty_media_part_and_appears_on_home_feed() { let server = spawn_test_server().await; diff --git a/tests/e2e/csrf.spec.ts b/tests/e2e/csrf.spec.ts new file mode 100644 index 0000000..49fa1ea --- /dev/null +++ b/tests/e2e/csrf.spec.ts @@ -0,0 +1,341 @@ +import { test, expect, type Locator, type Page } from '@playwright/test'; +import { + adminPassword, + adminUser, + assertNoQaFailures, + createPost, + expectNotErrorPage, + fixturePath, + goBackToHealthyPage, + goForwardToHealthyPage, + installQaGuards, + login, + logout, + openPostThread, + register, + uniqueName, +} from './helpers'; + +const password = 'very secure password'; + +test.beforeEach(async ({ page }, testInfo) => { + await installQaGuards(page, testInfo); +}); + +test.afterEach(async ({ page }) => { + assertNoQaFailures(page); +}); + +function postArticle(page: Page, text: string): Locator { + return page.locator('article[data-post-id]').filter({ hasText: text }).first(); +} + +async function clickAndStayHealthy(page: Page, control: Locator): Promise { + await control.click(); + await page.waitForLoadState('domcontentloaded'); + await expectNotErrorPage(page); +} + +async function registerFresh(page: Page, prefix: string): Promise { + const username = uniqueName(prefix); + await register(page, username, password); + await expectNotErrorPage(page); + return username; +} + +test('register, login, and logout survive restored browser history', async ({ page }) => { + const user = uniqueName('auth'); + + await page.goto('/register'); + await expectNotErrorPage(page); + await page.goto('/login'); + await goBackToHealthyPage(page); + await page.getByLabel('Username').fill(user); + await page.getByLabel('Password', { exact: true }).fill(password); + await page.getByLabel('Confirm password').fill(password); + await Promise.all([ + page.waitForURL(/\/home/), + page.getByRole('button', { name: 'Create account' }).click(), + ]); + await expectNotErrorPage(page); + + await page.goto('/settings'); + await expect(page.getByRole('heading', { name: 'Account settings' })).toBeVisible(); + await page.goto('/search'); + await goBackToHealthyPage(page); + await clickAndStayHealthy(page, page.getByRole('button', { name: 'Log out' })); + await expect(page.getByRole('link', { name: 'Log in' })).toBeVisible(); + + await page.goto('/login'); + await goBackToHealthyPage(page); + await goForwardToHealthyPage(page); + await page.getByLabel('Username').fill(user); + await page.getByLabel('Password', { exact: true }).fill(password); + await Promise.all([ + page.waitForURL(/\/home/), + page.getByRole('button', { name: 'Log in' }).click(), + ]); + await expectNotErrorPage(page); +}); + +test('composer, reply, and profile settings forms survive restored browser history', async ({ page }) => { + const user = await registerFresh(page, 'forms'); + const postText = `history composer ${uniqueName('post')}`; + + await page.goto('/home'); + await page.goto('/search'); + await goBackToHealthyPage(page); + await page.getByLabel('What is happening?').fill(postText); + await clickAndStayHealthy(page, page.getByRole('button', { name: 'Post', exact: true })); + await expect(postArticle(page, postText)).toBeVisible(); + const postId = await postArticle(page, postText).getAttribute('data-post-id'); + expect(postId).not.toBeNull(); + + const replyText = `history reply ${uniqueName('reply')}`; + await openPostThread(page, postId!); + await goBackToHealthyPage(page); + await goForwardToHealthyPage(page); + await page.getByLabel('What is happening?').fill(replyText); + await clickAndStayHealthy(page, page.getByRole('button', { name: 'Post', exact: true })); + await expect(page.getByText(replyText)).toBeVisible(); + await expect(page).toHaveURL(/\/posts\/\d+/); + + await page.goto('/settings'); + await page.getByLabel('Display name').fill('History Profile'); + await page.getByLabel('Bio').fill('First profile update from a history test.'); + await page.getByLabel('Website').fill(`https://example.com/${user}`); + await page.getByLabel('Profile picture').setInputFiles(fixturePath('tiny.png')); + await page.getByLabel('Banner').setInputFiles(fixturePath('tiny.png')); + await clickAndStayHealthy(page, page.getByRole('button', { name: 'Save settings' })); + await expect(page.getByLabel('Display name')).toHaveValue('History Profile'); + + await goBackToHealthyPage(page); + await expect(page.getByRole('heading', { name: 'Account settings' })).toBeVisible(); + await page.getByLabel('Bio').fill('Second profile update from a restored page.'); + await clickAndStayHealthy(page, page.getByRole('button', { name: 'Save settings' })); + await expect(page.getByLabel('Bio')).toHaveValue('Second profile update from a restored page.'); +}); + +test('own-post like and bookmark toggles stay on valid pages after Back and Forward', async ({ page }) => { + await registerFresh(page, 'own'); + const postText = `own action ${uniqueName('post')}`; + await createPost(page, postText); + await expectNotErrorPage(page); + const article = postArticle(page, postText); + + await page.goto('/search'); + await goBackToHealthyPage(page); + await expect(article).toBeVisible(); + await clickAndStayHealthy(page, article.getByRole('button', { name: 'Like' })); + await expect(article.getByRole('button', { name: 'Unlike' })).toBeVisible(); + + await goBackToHealthyPage(page); + await goForwardToHealthyPage(page); + await clickAndStayHealthy(page, article.locator('[data-action-kind="like"]')); + await expect(article.getByRole('button', { name: 'Like' })).toBeVisible(); + + await page.goto('/search'); + await goBackToHealthyPage(page); + await expect(article).toBeVisible(); + await clickAndStayHealthy(page, article.getByRole('button', { name: 'Bookmark' })); + await expect(article.getByRole('button', { name: 'Unbookmark' })).toBeVisible(); + + await goBackToHealthyPage(page); + await goForwardToHealthyPage(page); + await clickAndStayHealthy(page, article.locator('[data-action-kind="bookmark"]')); + await expect(article.getByRole('button', { name: 'Bookmark' })).toBeVisible(); +}); + +test('repost, follow, block, unblock, and mute keep profile context after history restores', async ({ page }) => { + const alice = await registerFresh(page, 'alice'); + const postText = `social action ${uniqueName('post')}`; + await createPost(page, postText); + await logout(page); + + await registerFresh(page, 'bob'); + await page.goto(`/users/${alice}`); + await expectNotErrorPage(page); + const article = postArticle(page, postText); + + await page.goto('/search'); + await goBackToHealthyPage(page); + await expect(article).toBeVisible(); + await clickAndStayHealthy(page, article.getByRole('button', { name: 'Repost' })); + await expect(article.getByRole('button', { name: 'Unrepost' })).toBeVisible(); + + await goBackToHealthyPage(page); + await goForwardToHealthyPage(page); + await clickAndStayHealthy(page, article.locator('[data-action-kind="repost"]')); + await expect(article.getByRole('button', { name: 'Repost' })).toBeVisible(); + + await page.goto('/search'); + await goBackToHealthyPage(page); + await clickAndStayHealthy(page, page.getByRole('button', { name: 'Follow this account' })); + await expect(page).toHaveURL(new RegExp(`/users/${alice}`)); + await expect(page.getByRole('button', { name: 'Unfollow this account' })).toBeVisible(); + + await goBackToHealthyPage(page); + await goForwardToHealthyPage(page); + await clickAndStayHealthy(page, page.getByRole('button', { name: 'Unfollow this account' })); + await expect(page).toHaveURL(new RegExp(`/users/${alice}`)); + await expect(page.getByRole('button', { name: 'Follow this account' })).toBeVisible(); + + await page.goto('/search'); + await goBackToHealthyPage(page); + await clickAndStayHealthy(page, page.getByRole('button', { name: 'Block this account' })); + await expect(page).toHaveURL(new RegExp(`/users/${alice}`)); + + await page.goto('/settings'); + await page.goto('/home'); + await goBackToHealthyPage(page); + await clickAndStayHealthy(page, page.getByRole('button', { name: 'Unblock this account' })); + await expect(page).toHaveURL(/\/settings/); + + await page.goto(`/users/${alice}`); + await page.goto('/search'); + await goBackToHealthyPage(page); + await clickAndStayHealthy(page, page.getByRole('button', { name: 'Mute this account' })); + await expect(page).toHaveURL(new RegExp(`/users/${alice}`)); +}); + +test('delete and notification forms remain healthy from restored pages', async ({ page }) => { + await registerFresh(page, 'delete'); + const postText = `delete action ${uniqueName('post')}`; + await createPost(page, postText); + await expectNotErrorPage(page); + + await postArticle(page, postText).getByRole('link', { name: 'Delete', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Delete post?' })).toBeVisible(); + await goBackToHealthyPage(page); + await goForwardToHealthyPage(page); + await clickAndStayHealthy(page, page.getByRole('button', { name: 'Confirm delete' })); + await expect(page).toHaveURL(/\/home/); + await expect(page.getByText(postText)).toHaveCount(0); + + await page.goto('/notifications'); + await page.goto('/home'); + await goBackToHealthyPage(page); + await clickAndStayHealthy(page, page.getByRole('button', { name: 'Mark all read' })); + await expect(page).toHaveURL(/\/notifications/); +}); + +test('admin suspend and backup forms survive restored admin pages', async ({ page }) => { + const managedUser = await registerFresh(page, 'managed'); + await logout(page); + await login(page, adminUser, adminPassword); + await expectNotErrorPage(page); + + await page.goto('/admin/users'); + await page.goto('/admin'); + await goBackToHealthyPage(page); + const managedRow = page.locator('tr').filter({ hasText: managedUser }).first(); + await clickAndStayHealthy(page, managedRow.getByRole('button', { name: 'Suspend' })); + await expect(managedRow.getByRole('button', { name: 'Unsuspend' })).toBeVisible(); + + await goBackToHealthyPage(page); + await goForwardToHealthyPage(page); + await clickAndStayHealthy(page, managedRow.getByRole('button', { name: 'Unsuspend' })); + await expect(managedRow.getByRole('button', { name: 'Suspend' })).toBeVisible(); + + await page.goto('/admin/backups'); + await page.goto('/admin'); + await goBackToHealthyPage(page); + await clickAndStayHealthy(page, page.getByRole('button', { name: 'Create backup' })); + await expect(page.getByText('Backup created:')).toBeVisible(); +}); + +test.describe('no JavaScript fallback', () => { + test.use({ javaScriptEnabled: false }); + + test('composer, reply, like, bookmark, and logout forms work from restored pages without JavaScript', async ({ page }) => { + await registerFresh(page, 'nojs'); + + const firstPost = `nojs first ${uniqueName('post')}`; + await page.goto('/home'); + await page.getByLabel('What is happening?').fill(firstPost); + await Promise.all([ + page.waitForURL(/\/home#post-\d+/), + page.getByRole('button', { name: 'Post', exact: true }).click(), + ]); + await expectNotErrorPage(page); + await expect(page.getByText(firstPost)).toBeVisible(); + + const secondPost = `nojs restored composer ${uniqueName('post')}`; + await goBackToHealthyPage(page); + await page.getByLabel('What is happening?').fill(secondPost); + await Promise.all([ + page.waitForURL(/\/home#post-\d+/), + page.getByRole('button', { name: 'Post', exact: true }).click(), + ]); + await expectNotErrorPage(page); + await expect(page.getByText(secondPost)).toBeVisible(); + const secondId = await postArticle(page, secondPost).getAttribute('data-post-id'); + expect(secondId).not.toBeNull(); + + await clickAndStayHealthy(page, postArticle(page, secondPost).getByRole('button', { name: 'Like' })); + await expect(postArticle(page, secondPost).getByRole('button', { name: 'Unlike' })).toBeVisible(); + await goBackToHealthyPage(page); + await goForwardToHealthyPage(page); + await clickAndStayHealthy(page, postArticle(page, secondPost).getByRole('button', { name: 'Unlike' })); + await expect(postArticle(page, secondPost).getByRole('button', { name: 'Like' })).toBeVisible(); + + await clickAndStayHealthy(page, postArticle(page, secondPost).getByRole('button', { name: 'Bookmark' })); + await expect(postArticle(page, secondPost).getByRole('button', { name: 'Unbookmark' })).toBeVisible(); + await goBackToHealthyPage(page); + await goForwardToHealthyPage(page); + await clickAndStayHealthy(page, postArticle(page, secondPost).getByRole('button', { name: 'Unbookmark' })); + await expect(postArticle(page, secondPost).getByRole('button', { name: 'Bookmark' })).toBeVisible(); + + const replyText = `nojs reply ${uniqueName('reply')}`; + await openPostThread(page, secondId!); + await goBackToHealthyPage(page); + await goForwardToHealthyPage(page); + await page.getByLabel('What is happening?').fill(replyText); + await Promise.all([ + page.waitForURL(/\/posts\/\d+#reply-\d+/), + page.getByRole('button', { name: 'Post', exact: true }).click(), + ]); + await expectNotErrorPage(page); + await expect(page.getByText(replyText)).toBeVisible(); + + await page.goto('/settings'); + await page.goto('/search'); + await goBackToHealthyPage(page); + await clickAndStayHealthy(page, page.getByRole('button', { name: 'Log out' })); + await expect(page.getByRole('link', { name: 'Log in' })).toBeVisible(); + }); +}); + +test('CSRF rejects missing, invalid, and cross-session tokens', async ({ page, browser }) => { + const alice = await registerFresh(page, 'negativea'); + const postId = await createPost(page, `negative csrf ${uniqueName('post')}`); + await page.goto('/home'); + const aliceToken = await page.locator('input[name="csrf"]').first().inputValue(); + + const missing = await page.request.post('/posts', { + multipart: { + text: 'missing csrf should not post', + }, + }); + expect(missing.status()).toBe(403); + + const invalid = await page.request.post('/posts', { + multipart: { + csrf: 'not-a-valid-csrf-token', + text: 'invalid csrf should not post', + }, + }); + expect(invalid.status()).toBe(403); + + const context = await browser.newContext({ baseURL: page.url().replace(/\/home.*/, '') }); + const bobPage = await context.newPage(); + await installQaGuards(bobPage, test.info()); + await register(bobPage, uniqueName('negativeb'), password); + await expectNotErrorPage(bobPage); + const crossSession = await bobPage.request.post(`/posts/${postId}/like`, { + form: { csrf: aliceToken }, + headers: { referer: `/users/${alice}` }, + }); + expect(crossSession.status()).toBe(403); + await context.close(); +}); diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index e3ee0fb..07705ac 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -21,6 +21,39 @@ export function fixturePath(name: string): string { return file; } +export function uniqueName(prefix: string): string { + return `${prefix}${Date.now()}${Math.random().toString(36).slice(2, 8)}`; +} + +export async function expectAppShell(page: Page): Promise { + await expect(page.locator('header.site-header')).toBeVisible(); + await expect(page.locator('main .content-shell')).toBeVisible(); + await expect(page.locator('nav')).toBeVisible(); +} + +export async function expectNotErrorPage(page: Page): Promise { + await page.waitForLoadState('domcontentloaded'); + await expectAppShell(page); + await expect(page.locator('.error-panel')).toHaveCount(0); + await expect(page.getByText(/csrf error|invalid csrf|missing csrf|400 error|403 error|404 error|405 error|access denied|page not found|something went wrong/i)).toHaveCount(0); +} + +export async function goBackToHealthyPage(page: Page): Promise { + await page.goBack({ waitUntil: 'domcontentloaded' }); + await expectNotErrorPage(page); +} + +export async function goForwardToHealthyPage(page: Page): Promise { + await page.goForward({ waitUntil: 'domcontentloaded' }); + await expectNotErrorPage(page); +} + +export async function openPostThread(page: Page, postId: string): Promise { + await page.goto(`/posts/${postId}`); + await expect(page.getByRole('heading', { name: 'Thread' })).toBeVisible(); + await expectNotErrorPage(page); +} + export async function installQaGuards(page: Page, testInfo: TestInfo): Promise { const failures: string[] = []; @@ -108,16 +141,13 @@ export async function createPost(page: Page, text: string, media?: string): Prom await page.locator('input[type="file"][name="media"]').setInputFiles(media); } await page.getByRole('button', { name: 'Post', exact: true }).click(); - await expect(page.getByText(text)).toBeVisible(); + const article = page.locator('article[data-post-id]').filter({ hasText: text }).first(); + await expect(article).toBeVisible(); const hashId = new URL(page.url()).hash.replace('#post-', ''); if (hashId) { return hashId; } - const postId = await page - .locator('article[data-post-id]') - .filter({ hasText: text }) - .first() - .getAttribute('data-post-id'); + const postId = await article.getAttribute('data-post-id'); expect(postId, `post id for "${text}"`).not.toBeNull(); return postId!; } From c9f4d454de96a96d564094a505296ca63c8d38ea Mon Sep 17 00:00:00 2001 From: csd113 Date: Mon, 18 May 2026 21:34:24 -0700 Subject: [PATCH 05/10] Add navigation and duplicate-submit regression coverage --- src/render.rs | 46 +++++ src/server.rs | 24 ++- tests/e2e/helpers.ts | 16 +- tests/e2e/mobile.spec.ts | 40 ++++- tests/e2e/navigation.spec.ts | 315 +++++++++++++++++++++++++++++++++++ 5 files changed, 430 insertions(+), 11 deletions(-) create mode 100644 tests/e2e/navigation.spec.ts diff --git a/src/render.rs b/src/render.rs index 80f253e..652b120 100644 --- a/src/render.rs +++ b/src/render.rs @@ -237,9 +237,21 @@ document.querySelectorAll("textarea[data-character-limit]").forEach((textarea) = textarea.addEventListener("input", () => updateComposerCount(textarea)); }); +function resetSubmittingForms() { + document.querySelectorAll('form[data-submitting="true"]').forEach((form) => { + delete form.dataset.submitting; + form.querySelectorAll("button:disabled").forEach((button) => { + button.disabled = false; + }); + }); +} + window.addEventListener("pageshow", (event) => { const nav = performance.getEntriesByType("navigation")[0]; const restored = event.persisted || (nav && nav.type === "back_forward"); + if (restored) { + resetSubmittingForms(); + } if (restored && document.querySelector('form[method="post"] input[name="csrf"]')) { window.location.reload(); } @@ -262,6 +274,10 @@ document.addEventListener("submit", async (event) => { return; } event.preventDefault(); + if (form.dataset.submitting === "true") { + return; + } + form.dataset.submitting = "true"; const submitter = event.submitter || form.querySelector("button[type=submit]"); if (submitter) { submitter.disabled = true; @@ -334,21 +350,51 @@ document.addEventListener("submit", async (event) => { return; } timeline.insertAdjacentHTML(data.parent_post_id === null ? "afterbegin" : "beforeend", data.html); + if (data.parent_post_id !== null) { + document.querySelectorAll(`[data-post-id="${data.parent_post_id}"] [data-count="replies"]`).forEach((node) => { + const current = Number.parseInt(node.textContent || "0", 10); + const next = Number.isFinite(current) ? current + 1 : 1; + node.textContent = `${next} replies`; + }); + } const created = document.getElementById(`post-${data.post_id}`); if (created) { created.setAttribute("tabindex", "-1"); created.focus({ preventScroll: true }); } + if (window.history && data.redirect) { + window.history.pushState(null, "", data.redirect); + } form.reset(); form.querySelectorAll("textarea[data-character-limit]").forEach(updateComposerCount); } } catch (_err) { form.submit(); } finally { + delete form.dataset.submitting; if (submitter) { submitter.disabled = false; } } +}); + +document.addEventListener("submit", (event) => { + const form = event.target.closest("form"); + if (!form || form.dataset.enhance) { + return; + } + if ((form.method || "GET").toUpperCase() !== "POST") { + return; + } + if (form.dataset.submitting === "true") { + event.preventDefault(); + return; + } + form.dataset.submitting = "true"; + const submitter = event.submitter || form.querySelector("button[type=submit]"); + if (submitter) { + submitter.disabled = true; + } });"#; pub fn client_script() -> &'static str { diff --git a/src/server.rs b/src/server.rs index b7a2b77..d72e7fe 100644 --- a/src/server.rs +++ b/src/server.rs @@ -656,7 +656,15 @@ async fn delete_post( ) -> AppResult { let user = require_user(&state, &headers).await?; validate_csrf(&state.pool, &headers, &form.csrf).await?; - let preview = delete_preview(&state.pool, user.id, user.is_admin, id).await?; + let preview = match delete_preview(&state.pool, user.id, user.is_admin, id).await { + Ok(preview) => preview, + Err(AppError::NotFound) => { + let target = safe_delete_return_target(&form.return_to, id) + .unwrap_or_else(|| "/home".to_owned()); + return Ok(Redirect::to(&target).into_response()); + } + Err(err) => return Err(err), + }; social::delete_post(&state.pool, user.id, id, user.is_admin).await?; let fallback = if let Some(parent_id) = preview.parent_post_id { format!("/posts/{parent_id}#post-{parent_id}") @@ -1430,20 +1438,21 @@ fn delete_return_fallback( parent_post_id: Option, ) -> String { let fallback = anchored_return(headers, post_id, parent_post_id.is_some(), "/home"); - let self_path = format!("/posts/{post_id}/delete"); - if path_without_query_or_fragment(&fallback) != self_path { + if safe_delete_return_target(&fallback, post_id).is_some() { return fallback; } parent_post_id.map_or_else( || format!("/home#post-{post_id}"), - |parent_id| format!("/posts/{parent_id}#reply-{post_id}"), + |parent_id| format!("/posts/{parent_id}#post-{parent_id}"), ) } fn safe_delete_return_target(value: &str, post_id: i64) -> Option { let target = safe_return_target(value)?; let self_path = format!("/posts/{post_id}/delete"); - if path_without_query_or_fragment(&target) == self_path { + let thread_path = format!("/posts/{post_id}"); + let path = path_without_query_or_fragment(&target); + if path == self_path || path == thread_path { None } else { Some(target) @@ -2018,10 +2027,7 @@ mod tests { safe_delete_return_target("http://127.0.0.1:18080/posts/42/delete?return_to=/home", 42), None ); - assert_eq!( - safe_delete_return_target("/posts/42#post-42", 42), - Some("/posts/42#post-42".to_owned()) - ); + assert_eq!(safe_delete_return_target("/posts/42#post-42", 42), None); assert_eq!( safe_delete_return_target("/home#post-42", 42), Some("/home#post-42".to_owned()) diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index 07705ac..8882b5f 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -34,8 +34,10 @@ export async function expectAppShell(page: Page): Promise { export async function expectNotErrorPage(page: Page): Promise { await page.waitForLoadState('domcontentloaded'); await expectAppShell(page); + await expect(page).not.toHaveURL(/\/(?:posts\/\d+\/(?:like|bookmark|repost|reply)|users\/\d+\/(?:follow|unfollow|block|unblock|mute)|logout|notifications\/read|admin\/users\/\d+\/suspend|admin\/posts\/\d+\/delete)(?:$|[?#])/); await expect(page.locator('.error-panel')).toHaveCount(0); - await expect(page.getByText(/csrf error|invalid csrf|missing csrf|400 error|403 error|404 error|405 error|access denied|page not found|something went wrong/i)).toHaveCount(0); + await expect(page.getByText(/csrf error|invalid csrf|missing csrf|400 error|403 error|404 error|405 error|415 error|500 error|access denied|page not found|method not allowed|unsupported media type|something went wrong/i)).toHaveCount(0); + await expect(page.locator('body')).not.toHaveText(/^\s*(bad request|forbidden|not found|method not allowed|unsupported media type|internal server error)\s*$/i); } export async function goBackToHealthyPage(page: Page): Promise { @@ -161,6 +163,18 @@ export async function submitFirstPostAction(page: Page, label: string): Promise< ]); } +export async function expectHealthyRedirect( + page: Page, + action: () => Promise, + expectedUrl: RegExp | string, +): Promise { + await Promise.all([ + page.waitForURL(expectedUrl), + action(), + ]); + await expectNotErrorPage(page); +} + export async function crawlVisibleInternalLinks(page: Page): Promise { const hrefs = await page .locator('a[href]') diff --git a/tests/e2e/mobile.spec.ts b/tests/e2e/mobile.spec.ts index 82fa932..500c8a5 100644 --- a/tests/e2e/mobile.spec.ts +++ b/tests/e2e/mobile.spec.ts @@ -2,7 +2,9 @@ import { test, expect } from '@playwright/test'; import { assertNoQaFailures, createPost, + expectNotErrorPage, installQaGuards, + logout, register, } from './helpers'; @@ -24,7 +26,20 @@ test('mobile smoke covers core pages and forms', async ({ page }) => { await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible(); await register(page, user, password); - const postId = await createPost(page, 'Mobile viewport post #mobile'); + const postText = 'Mobile viewport post #mobile'; + const postId = await createPost(page, postText); + const article = page.locator('article[data-post-id]').filter({ hasText: postText }).first(); + + await article.getByRole('button', { name: 'Like' }).click(); + await expect(article.getByRole('button', { name: 'Unlike' })).toBeVisible(); + await article.getByRole('button', { name: 'Bookmark' }).click(); + await expect(article.getByRole('button', { name: 'Unbookmark' })).toBeVisible(); + + await page.goto('/search'); + await page.goBack({ waitUntil: 'domcontentloaded' }); + await expectNotErrorPage(page); + await page.goForward({ waitUntil: 'domcontentloaded' }); + await expectNotErrorPage(page); for (const url of ['/home', `/posts/${postId}`, `/users/${user}`, '/settings', '/notifications']) { const response = await page.goto(url); @@ -32,8 +47,31 @@ test('mobile smoke covers core pages and forms', async ({ page }) => { await expect(page.locator('body')).toBeVisible(); } + await page.goto(`/posts/${postId}`); + const replyText = `Mobile reply ${Date.now()}`; + await page.getByLabel('What is happening?').fill(replyText); + await Promise.all([ + page.waitForURL(/\/posts\/\d+#reply-\d+/), + page.getByRole('button', { name: 'Post', exact: true }).click(), + ]); + await expect(page.getByText(replyText)).toBeVisible(); + await page.goBack({ waitUntil: 'domcontentloaded' }); + await expectNotErrorPage(page); + await page.goForward({ waitUntil: 'domcontentloaded' }); + await expectNotErrorPage(page); + await page.goto('/settings'); await page.getByLabel('Bio').fill('Updated from mobile smoke.'); await page.getByRole('button', { name: 'Save settings' }).click(); await expect(page.getByLabel('Bio')).toHaveValue('Updated from mobile smoke.'); + + await logout(page); + const follower = `mobile_follower_${Date.now()}`; + await register(page, follower, password); + await page.goto(`/users/${user}`); + await page.getByRole('button', { name: 'Follow this account' }).click(); + await expect(page.getByRole('button', { name: 'Unfollow this account' })).toBeVisible(); + await logout(page); + await page.goto('/login'); + await expect(page.getByRole('heading', { name: 'Log in' })).toBeVisible(); }); diff --git a/tests/e2e/navigation.spec.ts b/tests/e2e/navigation.spec.ts new file mode 100644 index 0000000..75964e4 --- /dev/null +++ b/tests/e2e/navigation.spec.ts @@ -0,0 +1,315 @@ +import { test, expect, type Locator, type Page } from '@playwright/test'; +import { + assertNoQaFailures, + createPost, + expectHealthyRedirect, + expectNotErrorPage, + installQaGuards, + logout, + register, + uniqueName, +} from './helpers'; + +const password = 'very secure password'; + +test.beforeEach(async ({ page }, testInfo) => { + await installQaGuards(page, testInfo); +}); + +test.afterEach(async ({ page }) => { + assertNoQaFailures(page); +}); + +function postArticle(page: Page, text: string): Locator { + return page.locator('article[data-post-id]').filter({ hasText: text }).first(); +} + +async function clickAndStayHealthy(page: Page, control: Locator): Promise { + await control.click(); + await page.waitForLoadState('networkidle'); + await expectNotErrorPage(page); +} + +async function registerFresh(page: Page, prefix: string): Promise { + const username = uniqueName(prefix); + await register(page, username, password); + await expectNotErrorPage(page); + return username; +} + +test('normal post actions preserve feed, thread, anchors, and profile context', async ({ page }) => { + const alice = await registerFresh(page, 'navalice'); + const postText = `navigation source ${uniqueName('post')}`; + const postId = await createPost(page, postText); + const homePostUrl = new RegExp(`/home#post-${postId}$`); + await expect(page).toHaveURL(homePostUrl); + + const homeArticle = postArticle(page, postText); + await clickAndStayHealthy(page, homeArticle.getByRole('button', { name: 'Like' })); + await expect(page).toHaveURL(homePostUrl); + await expect(homeArticle.getByRole('button', { name: 'Unlike' })).toBeVisible(); + + await clickAndStayHealthy(page, homeArticle.getByRole('button', { name: 'Bookmark' })); + await expect(page).toHaveURL(homePostUrl); + await expect(homeArticle.getByRole('button', { name: 'Unbookmark' })).toBeVisible(); + + await page.goto(`/posts/${postId}`); + await expect(page.getByRole('heading', { name: 'Thread' })).toBeVisible(); + await expectNotErrorPage(page); + const threadUrl = new RegExp(`/posts/${postId}$`); + const threadArticle = postArticle(page, postText); + + await clickAndStayHealthy(page, threadArticle.getByRole('button', { name: 'Unlike' })); + await expect(page).toHaveURL(threadUrl); + await clickAndStayHealthy(page, threadArticle.getByRole('button', { name: 'Unbookmark' })); + await expect(page).toHaveURL(threadUrl); + + const replyText = `anchored reply ${uniqueName('reply')}`; + await page.getByLabel('What is happening?').fill(replyText); + await Promise.all([ + page.waitForURL(/\/posts\/\d+#reply-\d+$/), + page.getByRole('button', { name: 'Post', exact: true }).click(), + ]); + await expectNotErrorPage(page); + const replyId = new URL(page.url()).hash.replace('#reply-', ''); + expect(replyId, 'reply id in URL fragment').toMatch(/^\d+$/); + await expect(page.locator(`#reply-${replyId}`)).toBeAttached(); + await expect(postArticle(page, replyText)).toBeVisible(); + + await postArticle(page, replyText).getByRole('link', { name: 'Delete' }).click(); + await expect(page.getByRole('heading', { name: 'Delete post?' })).toBeVisible(); + await expectHealthyRedirect( + page, + () => page.getByRole('button', { name: 'Confirm delete' }).click(), + new RegExp(`/posts/${postId}#post-${postId}$`), + ); + await expect(page.getByText(replyText)).toHaveCount(0); + + await logout(page); + await registerFresh(page, 'navbob'); + await page.goto(`/users/${alice}`); + await expectNotErrorPage(page); + const profileUrl = new RegExp(`/users/${alice}$`); + const profileArticle = postArticle(page, postText); + + await clickAndStayHealthy(page, profileArticle.getByRole('button', { name: 'Repost' })); + await expect(page).toHaveURL(profileUrl); + await expect(profileArticle.getByRole('button', { name: 'Unrepost' })).toBeVisible(); + + await clickAndStayHealthy(page, page.getByRole('button', { name: 'Follow this account' })); + await expect(page).toHaveURL(profileUrl); + await expect(page.getByRole('button', { name: 'Unfollow this account' })).toBeVisible(); + + await expectHealthyRedirect( + page, + () => page.getByRole('button', { name: 'Block this account' }).click(), + profileUrl, + ); + await page.goto('/settings'); + await expectHealthyRedirect( + page, + () => page.getByRole('button', { name: 'Unblock this account' }).click(), + /\/settings$/, + ); + + await page.goto(`/users/${alice}`); + await expectHealthyRedirect( + page, + () => page.getByRole('button', { name: 'Mute this account' }).click(), + profileUrl, + ); +}); + +test('Back and Forward restore current like, bookmark, repost, follow, reply, and settings state', async ({ page }) => { + const alice = await registerFresh(page, 'histalice'); + const postText = `history state ${uniqueName('post')}`; + const postId = await createPost(page, postText); + + await page.goto('/search'); + await page.goBack({ waitUntil: 'domcontentloaded' }); + await expectNotErrorPage(page); + const article = postArticle(page, postText); + + await clickAndStayHealthy(page, article.getByRole('button', { name: 'Like' })); + await page.goBack({ waitUntil: 'domcontentloaded' }); + await expectNotErrorPage(page); + await page.goForward({ waitUntil: 'domcontentloaded' }); + await expectNotErrorPage(page); + await expect(postArticle(page, postText).getByRole('button', { name: 'Unlike' })).toBeVisible(); + + await clickAndStayHealthy(page, postArticle(page, postText).getByRole('button', { name: 'Bookmark' })); + await page.goBack({ waitUntil: 'domcontentloaded' }); + await expectNotErrorPage(page); + await page.goForward({ waitUntil: 'domcontentloaded' }); + await expectNotErrorPage(page); + await expect(postArticle(page, postText).getByRole('button', { name: 'Unbookmark' })).toBeVisible(); + + await page.goto(`/posts/${postId}`); + const replyText = `history visible reply ${uniqueName('reply')}`; + await page.getByLabel('What is happening?').fill(replyText); + await Promise.all([ + page.waitForURL(/\/posts\/\d+#reply-\d+$/), + page.getByRole('button', { name: 'Post', exact: true }).click(), + ]); + await page.goBack({ waitUntil: 'domcontentloaded' }); + await expectNotErrorPage(page); + await page.goForward({ waitUntil: 'domcontentloaded' }); + await expectNotErrorPage(page); + await expect(page.getByText(replyText)).toBeVisible(); + await expect(postArticle(page, postText).locator('[data-count="replies"]')).toHaveText('1 replies'); + + await page.goto('/settings'); + await page.getByLabel('Display name').fill('History Current'); + await expectHealthyRedirect(page, () => page.getByRole('button', { name: 'Save settings' }).click(), /\/settings$/); + await page.goto('/search'); + await page.goBack({ waitUntil: 'domcontentloaded' }); + await expectNotErrorPage(page); + await expect(page.getByLabel('Display name')).toHaveValue('History Current'); + + await logout(page); + await registerFresh(page, 'histbob'); + await page.goto(`/users/${alice}`); + await clickAndStayHealthy(page, postArticle(page, postText).getByRole('button', { name: 'Repost' })); + await page.goto('/search'); + await page.goBack({ waitUntil: 'domcontentloaded' }); + await expectNotErrorPage(page); + await expect(postArticle(page, postText).getByRole('button', { name: 'Unrepost' })).toBeVisible(); + + await clickAndStayHealthy(page, page.getByRole('button', { name: 'Follow this account' })); + await page.goto('/search'); + await page.goBack({ waitUntil: 'domcontentloaded' }); + await expectNotErrorPage(page); + await expect(page.getByRole('button', { name: 'Unfollow this account' })).toBeVisible(); +}); + +test('rapid clicks and duplicate submits do not create duplicate records or raw errors', async ({ page }) => { + await registerFresh(page, 'dupealice'); + + const postText = `double post ${uniqueName('post')}`; + await page.goto('/home'); + await page.getByLabel('What is happening?').fill(postText); + await page.getByRole('button', { name: 'Post', exact: true }).dblclick(); + await expect(postArticle(page, postText)).toHaveCount(1); + await expectNotErrorPage(page); + + const postId = await postArticle(page, postText).getAttribute('data-post-id'); + expect(postId).not.toBeNull(); + await page.goto(`/posts/${postId}`); + const replyText = `double reply ${uniqueName('reply')}`; + await page.getByLabel('What is happening?').fill(replyText); + await page.getByRole('button', { name: 'Post', exact: true }).dblclick(); + await expect(postArticle(page, replyText)).toHaveCount(1); + await expectNotErrorPage(page); + + await postArticle(page, replyText).getByRole('link', { name: 'Delete' }).click(); + await expect(page.getByRole('heading', { name: 'Delete post?' })).toBeVisible(); + const deletePostId = new URL(page.url()).pathname.match(/\d+/)?.[0]; + expect(deletePostId).toBeDefined(); + const deleteForm = page.locator('main form').first(); + const csrf = await deleteForm.locator('input[name="csrf"]').inputValue(); + const returnTo = await deleteForm.locator('input[name="return_to"]').inputValue(); + const firstDelete = await page.request.post(`/posts/${deletePostId}/delete`, { + form: { csrf, return_to: returnTo }, + }); + expect(firstDelete.status(), 'first delete should land on a valid page').toBeLessThan(400); + const secondDelete = await page.request.post(`/posts/${deletePostId}/delete`, { + form: { csrf, return_to: returnTo }, + }); + expect(secondDelete.status(), 'replayed delete should land on a valid page').toBeLessThan(400); + + await page.goto('/home'); + const actionArticle = postArticle(page, postText); + await actionArticle.getByRole('button', { name: 'Like' }).dblclick(); + await expectNotErrorPage(page); + await expect(actionArticle.locator('[data-action-kind="like"]')).toBeEnabled(); + + await actionArticle.getByRole('button', { name: 'Bookmark' }).dblclick(); + await expectNotErrorPage(page); + await expect(actionArticle.locator('[data-action-kind="bookmark"]')).toBeEnabled(); + + await logout(page); + const bob = await registerFresh(page, 'dupebob'); + await logout(page); + await page.goto('/login'); + await page.getByLabel('Username').fill(bob); + await page.getByLabel('Password', { exact: true }).fill(password); + await Promise.all([ + page.waitForURL(/\/home/), + page.getByRole('button', { name: 'Log in' }).dblclick(), + ]); + await expectNotErrorPage(page); + + await logout(page); + const registerUser = uniqueName('duperegister'); + await page.goto('/register'); + await page.getByLabel('Username').fill(registerUser); + await page.getByLabel('Password', { exact: true }).fill(password); + await page.getByLabel('Confirm password').fill(password); + await Promise.all([ + page.waitForURL(/\/home/), + page.getByRole('button', { name: 'Create account' }).dblclick(), + ]); + await expectNotErrorPage(page); +}); + +test.describe('no JavaScript navigation parity', () => { + test.use({ javaScriptEnabled: false }); + + test('core no-JS forms redirect to valid contextual pages', async ({ page }) => { + const alice = await registerFresh(page, 'nojsnavalice'); + const postText = `nojs navigation ${uniqueName('post')}`; + + await page.goto('/home'); + await page.getByLabel('What is happening?').fill(postText); + await expectHealthyRedirect( + page, + () => page.getByRole('button', { name: 'Post', exact: true }).click(), + /\/home#post-\d+$/, + ); + const postId = new URL(page.url()).hash.replace('#post-', ''); + await expect(postArticle(page, postText)).toBeVisible(); + + await expectHealthyRedirect( + page, + () => postArticle(page, postText).getByRole('button', { name: 'Like' }).click(), + new RegExp(`/home#post-${postId}$`), + ); + await expect(postArticle(page, postText).getByRole('button', { name: 'Unlike' })).toBeVisible(); + + await expectHealthyRedirect( + page, + () => postArticle(page, postText).getByRole('button', { name: 'Bookmark' }).click(), + new RegExp(`/home#post-${postId}$`), + ); + + await page.goto(`/posts/${postId}`); + const replyText = `nojs anchored reply ${uniqueName('reply')}`; + await page.getByLabel('What is happening?').fill(replyText); + await expectHealthyRedirect( + page, + () => page.getByRole('button', { name: 'Post', exact: true }).click(), + /\/posts\/\d+#reply-\d+$/, + ); + await expect(page.getByText(replyText)).toBeVisible(); + + await logout(page); + await registerFresh(page, 'nojsnavbob'); + await page.goto(`/users/${alice}`); + await expectHealthyRedirect( + page, + () => page.getByRole('button', { name: 'Follow this account' }).click(), + new RegExp(`/users/${alice}$`), + ); + await expect(page.getByRole('button', { name: 'Unfollow this account' })).toBeVisible(); + + await expectHealthyRedirect( + page, + () => postArticle(page, postText).getByRole('button', { name: 'Repost' }).click(), + new RegExp(`/users/${alice}#post-${postId}$`), + ); + await expect(postArticle(page, postText).getByRole('button', { name: 'Unrepost' })).toBeVisible(); + + await logout(page); + await expect(page.getByRole('link', { name: 'Log in' })).toBeVisible(); + }); +}); From 461c920bdc021b33a8b0e0058a8a835cfe5ac03b Mon Sep 17 00:00:00 2001 From: csd113 Date: Mon, 18 May 2026 21:41:56 -0700 Subject: [PATCH 06/10] Organize RustPost runtime storage layout --- .gitignore | 11 +++ README.md | 12 ++- docs/demo-preview.md | 2 +- src/backup.rs | 15 ++- src/media.rs | 2 +- src/runtime.rs | 227 +++++++++++++++++++++++++++++++++++++++++-- src/server.rs | 5 +- src/terminal.rs | 9 ++ 8 files changed, 270 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index 10e3583..3322bbb 100644 --- a/.gitignore +++ b/.gitignore @@ -42,14 +42,25 @@ rustpost-data/**/* !rustpost-data/.gitkeep tmp/ tmp/**/* +db/ +db/**/* +backups/ +logs/ +media/ +media/**/* *.sqlite3 *.sqlite3-* +*.sqlite +*.sqlite-* *.db *.db-* +rustpost.sqlite3* +app.sqlite3* *.log *.tmp *.upload uploads/ +uploads/**/* # macOS .DS_Store diff --git a/README.md b/README.md index 529ac54..5cbf065 100644 --- a/README.md +++ b/README.md @@ -135,12 +135,17 @@ On first boot, RustPost initializes the data directory automatically: ``` rustpost-data/ ├── settings.toml ← generated config with safe defaults -├── app.sqlite3 ← main database +├── db/ +│ ├── rustpost.sqlite3 ← main database +│ ├── rustpost.sqlite3-wal +│ └── rustpost.sqlite3-shm ├── uploads/ │ ├── originals/ │ ├── images/ │ ├── videos/ │ └── thumbs/ +├── tmp/ +│ └── uploads/ ← interrupted upload staging only ├── backups/ ├── logs/ └── tor/ @@ -149,10 +154,11 @@ rustpost-data/ > **Note:** All runtime paths are derived from `--data-dir` (or the executable location as fallback). RustPost does not rely on the current working directory. > Runtime data is local operator state. Databases, uploads, logs, backups, Tor key material, and temporary upload files under `rustpost-data/` should not be committed to git. +> Existing data directories that still contain `app.sqlite3` at the data-dir root are migrated to `db/rustpost.sqlite3` on startup. If both old and new database files exist, RustPost stops with a conflict error and does not overwrite either file. ### Create Your First Admin -When `serve` starts, RustPost prints the data directory, settings path, database path, bind address, and whether an admin account exists. If no admin exists, the terminal output includes the bootstrap commands. +When `serve` starts, RustPost prints the data directory, settings path, database path, upload/media paths, log path, backup path, bind address, and whether an admin account exists. If no admin exists, the terminal output includes the bootstrap commands. The preferred local setup path hides the password while you type: @@ -452,7 +458,7 @@ Each archive contains `rustpost`, `rustpost-cli`, `README.md`, `LICENSE`, and op
Verified locally -- Fresh `--data-dir` boot creates `settings.toml`, `app.sqlite3`, upload roots, backup/log dirs, and Tor state dirs. +- Fresh `--data-dir` boot creates `settings.toml`, `db/rustpost.sqlite3`, upload roots, temp upload staging, backup/log dirs, and Tor state dirs. - `rustpost-cli check` passes on a fresh data directory with `tor.enabled = false`. - Clearnet serving on `127.0.0.1:8080` loads `/home`. - Registration, login, post creation, replies, repost rendering, likes, bookmarks, follows, notifications, admin health, and CSRF-protected logout all work through live HTTP requests. diff --git a/docs/demo-preview.md b/docs/demo-preview.md index 104a4b7..f98580d 100644 --- a/docs/demo-preview.md +++ b/docs/demo-preview.md @@ -42,4 +42,4 @@ rustpost demo password | Tess Vale | `tess` | Video creator | | Omar Stone | `omar` | Infrastructure/admin focused | -The generated runtime database and uploads stay under `target/debug/rustpost-demo/` and should not be committed. +The generated runtime database, uploads, temp upload staging, logs, and backups stay under `target/debug/rustpost-demo/` and should not be committed. diff --git a/src/backup.rs b/src/backup.rs index abfd26a..832a208 100644 --- a/src/backup.rs +++ b/src/backup.rs @@ -15,7 +15,17 @@ pub fn create_backup(paths: &RuntimePaths, include_tor_keys: bool) -> anyhow::Re )); let file = File::create(&archive_path)?; let mut builder = Builder::new(file); - append_file(&mut builder, &paths.database_path, "app.sqlite3")?; + append_file(&mut builder, &paths.database_path, "db/rustpost.sqlite3")?; + append_file( + &mut builder, + &paths.database_sidecar_path("wal"), + "db/rustpost.sqlite3-wal", + )?; + append_file( + &mut builder, + &paths.database_sidecar_path("shm"), + "db/rustpost.sqlite3-shm", + )?; append_file(&mut builder, &paths.settings_path, "settings.toml")?; append_dir(&mut builder, &paths.uploads_originals, "uploads/originals")?; append_dir(&mut builder, &paths.uploads_images, "uploads/images")?; @@ -128,10 +138,13 @@ mod tests { let paths = RuntimePaths::from_data_dir(temp.path().join("data")); paths.ensure().expect("ensure"); fs::write(&paths.database_path, "db").expect("db"); + fs::write(paths.database_sidecar_path("wal"), "wal").expect("wal"); fs::write(&paths.settings_path, "settings").expect("settings"); fs::write(paths.tor_onion_service_dir.join("secret"), "key").expect("key"); let no_tor = create_backup(&paths, false).expect("backup"); let names = archive_names(&no_tor); + assert!(names.iter().any(|name| name == "db/rustpost.sqlite3")); + assert!(names.iter().any(|name| name == "db/rustpost.sqlite3-wal")); assert!(!names.iter().any(|name| name.contains("tor/onion-service"))); let with_tor = create_backup(&paths, true).expect("backup"); let names = archive_names(&with_tor); diff --git a/src/media.rs b/src/media.rs index fb57c2b..778138e 100644 --- a/src/media.rs +++ b/src/media.rs @@ -51,7 +51,7 @@ pub async fn save_upload( let original_filename = field.file_name().unwrap_or("upload").to_owned(); reject_path_tricks(&original_filename)?; let id = Uuid::new_v4().simple().to_string(); - let staging = paths.uploads_originals.join(format!("{id}.upload")); + let staging = paths.staged_upload_path(&id); let mut file = tokio::fs::File::create(&staging).await?; let mut bytes = 0_u64; while let Some(chunk) = field.chunk().await? { diff --git a/src/runtime.rs b/src/runtime.rs index f292cbb..0520353 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -2,15 +2,21 @@ use std::env; use std::fs; use std::path::{Path, PathBuf}; +use anyhow::Context as _; +use tracing::{info, warn}; + #[derive(Debug, Clone)] pub struct RuntimePaths { pub data_dir: PathBuf, pub settings_path: PathBuf, + pub db_dir: PathBuf, pub database_path: PathBuf, pub uploads_originals: PathBuf, pub uploads_images: PathBuf, pub uploads_videos: PathBuf, pub uploads_thumbs: PathBuf, + pub tmp_dir: PathBuf, + pub tmp_uploads: PathBuf, pub backups_dir: PathBuf, pub logs_dir: PathBuf, pub tor_dir: PathBuf, @@ -28,13 +34,19 @@ impl RuntimePaths { #[must_use] pub fn from_data_dir(data_dir: PathBuf) -> Self { + let db_dir = data_dir.join("db"); + let uploads_dir = data_dir.join("uploads"); + let tmp_dir = data_dir.join("tmp"); Self { settings_path: data_dir.join("settings.toml"), - database_path: data_dir.join("app.sqlite3"), - uploads_originals: data_dir.join("uploads/originals"), - uploads_images: data_dir.join("uploads/images"), - uploads_videos: data_dir.join("uploads/videos"), - uploads_thumbs: data_dir.join("uploads/thumbs"), + database_path: db_dir.join("rustpost.sqlite3"), + db_dir, + uploads_originals: uploads_dir.join("originals"), + uploads_images: uploads_dir.join("images"), + uploads_videos: uploads_dir.join("videos"), + uploads_thumbs: uploads_dir.join("thumbs"), + tmp_uploads: tmp_dir.join("uploads"), + tmp_dir, backups_dir: data_dir.join("backups"), logs_dir: data_dir.join("logs"), tor_dir: data_dir.join("tor"), @@ -53,21 +65,121 @@ impl RuntimePaths { pub fn ensure(&self) -> anyhow::Result<()> { for path in [ &self.data_dir, + &self.db_dir, &self.uploads_originals, &self.uploads_images, &self.uploads_videos, &self.uploads_thumbs, + &self.tmp_dir, + &self.tmp_uploads, &self.backups_dir, &self.logs_dir, &self.tor_dir, &self.tor_onion_service_dir, ] { - fs::create_dir_all(path)?; + fs::create_dir_all(path).with_context(|| { + format!("failed to create runtime directory {}", path.display()) + })?; } + self.migrate_legacy_database_layout()?; restrict_dir(&self.tor_dir)?; restrict_dir(&self.tor_onion_service_dir)?; Ok(()) } + + #[must_use] + pub fn staged_upload_path(&self, id: &str) -> PathBuf { + self.tmp_uploads.join(format!("{id}.upload")) + } + + fn migrate_legacy_database_layout(&self) -> anyhow::Result<()> { + let legacy_database = self.legacy_database_path(); + if legacy_database.exists() && self.database_path.exists() { + anyhow::bail!( + "database layout conflict: both legacy database {} and new database {} exist; move one aside before starting RustPost", + legacy_database.display(), + self.database_path.display() + ); + } + if legacy_database.exists() { + self.ensure_no_legacy_sidecar_conflict("wal")?; + self.ensure_no_legacy_sidecar_conflict("shm")?; + fs::rename(&legacy_database, &self.database_path).with_context(|| { + format!( + "failed to migrate database from {} to {}", + legacy_database.display(), + self.database_path.display() + ) + })?; + info!( + from = %legacy_database.display(), + to = %self.database_path.display(), + "migrated RustPost database into db directory" + ); + self.migrate_legacy_database_sidecar("wal")?; + self.migrate_legacy_database_sidecar("shm")?; + } else { + self.warn_about_orphaned_legacy_sidecar("wal"); + self.warn_about_orphaned_legacy_sidecar("shm"); + } + Ok(()) + } + + fn ensure_no_legacy_sidecar_conflict(&self, suffix: &str) -> anyhow::Result<()> { + let old = self.legacy_database_sidecar_path(suffix); + let new = self.database_sidecar_path(suffix); + if old.exists() && new.exists() { + anyhow::bail!( + "database layout conflict: both legacy SQLite sidecar {} and new SQLite sidecar {} exist; move one aside before starting RustPost", + old.display(), + new.display() + ); + } + Ok(()) + } + + fn migrate_legacy_database_sidecar(&self, suffix: &str) -> anyhow::Result<()> { + let old = self.legacy_database_sidecar_path(suffix); + if !old.exists() { + return Ok(()); + } + let new = self.database_sidecar_path(suffix); + fs::rename(&old, &new).with_context(|| { + format!( + "failed to migrate SQLite sidecar from {} to {}", + old.display(), + new.display() + ) + })?; + info!( + from = %old.display(), + to = %new.display(), + "migrated RustPost SQLite sidecar into db directory" + ); + Ok(()) + } + + fn warn_about_orphaned_legacy_sidecar(&self, suffix: &str) { + let old = self.legacy_database_sidecar_path(suffix); + if old.exists() { + warn!( + path = %old.display(), + "legacy SQLite sidecar exists without legacy database; left in place for operator review" + ); + } + } + + fn legacy_database_path(&self) -> PathBuf { + self.data_dir.join("app.sqlite3") + } + + fn legacy_database_sidecar_path(&self, suffix: &str) -> PathBuf { + self.data_dir.join(format!("app.sqlite3-{suffix}")) + } + + pub fn database_sidecar_path(&self, suffix: &str) -> PathBuf { + self.db_dir.join(format!("rustpost.sqlite3-{suffix}")) + } } #[cfg(unix)] @@ -107,4 +219,107 @@ mod tests { PathBuf::from("/tmp/rustpost-data/privacy/tor/onion-service") ); } + + #[test] + fn runtime_paths_use_dedicated_database_and_temp_upload_dirs() { + let paths = RuntimePaths::from_data_dir(PathBuf::from("/tmp/rustpost-data")); + + assert_eq!( + paths.database_path, + PathBuf::from("/tmp/rustpost-data/db/rustpost.sqlite3") + ); + assert_eq!( + paths.database_sidecar_path("wal"), + PathBuf::from("/tmp/rustpost-data/db/rustpost.sqlite3-wal") + ); + assert_eq!( + paths.staged_upload_path("abc"), + PathBuf::from("/tmp/rustpost-data/tmp/uploads/abc.upload") + ); + } + + #[test] + fn ensure_creates_runtime_directories() { + let temp = tempfile::tempdir().expect("temp dir"); + let paths = RuntimePaths::from_data_dir(temp.path().join("data")); + + paths.ensure().expect("ensure paths"); + + for path in [ + &paths.db_dir, + &paths.uploads_originals, + &paths.uploads_images, + &paths.uploads_videos, + &paths.uploads_thumbs, + &paths.tmp_uploads, + &paths.backups_dir, + &paths.logs_dir, + ] { + assert!(path.is_dir(), "{} should exist", path.display()); + } + } + + #[test] + fn ensure_migrates_legacy_database_when_new_database_is_absent() { + let temp = tempfile::tempdir().expect("temp dir"); + let data_dir = temp.path().join("data"); + fs::create_dir_all(&data_dir).expect("data dir"); + fs::write(data_dir.join("app.sqlite3"), b"db").expect("legacy db"); + fs::write(data_dir.join("app.sqlite3-wal"), b"wal").expect("legacy wal"); + fs::write(data_dir.join("app.sqlite3-shm"), b"shm").expect("legacy shm"); + let paths = RuntimePaths::from_data_dir(data_dir.clone()); + + paths.ensure().expect("ensure paths"); + + assert!(!data_dir.join("app.sqlite3").exists()); + assert_eq!(fs::read(&paths.database_path).expect("new db"), b"db"); + assert_eq!( + fs::read(paths.database_sidecar_path("wal")).expect("new wal"), + b"wal" + ); + assert_eq!( + fs::read(paths.database_sidecar_path("shm")).expect("new shm"), + b"shm" + ); + } + + #[test] + fn ensure_rejects_legacy_and_new_database_conflict_without_overwrite() { + let temp = tempfile::tempdir().expect("temp dir"); + let data_dir = temp.path().join("data"); + fs::create_dir_all(data_dir.join("db")).expect("db dir"); + fs::write(data_dir.join("app.sqlite3"), b"old").expect("legacy db"); + fs::write(data_dir.join("db/rustpost.sqlite3"), b"new").expect("new db"); + let paths = RuntimePaths::from_data_dir(data_dir.clone()); + + let error = paths.ensure().expect_err("conflict"); + + assert!(error.to_string().contains("database layout conflict")); + assert_eq!(fs::read(data_dir.join("app.sqlite3")).expect("old"), b"old"); + assert_eq!( + fs::read(data_dir.join("db/rustpost.sqlite3")).expect("new"), + b"new" + ); + } + + #[test] + fn ensure_rejects_sidecar_conflict_before_moving_database() { + let temp = tempfile::tempdir().expect("temp dir"); + let data_dir = temp.path().join("data"); + fs::create_dir_all(data_dir.join("db")).expect("db dir"); + fs::write(data_dir.join("app.sqlite3"), b"old").expect("legacy db"); + fs::write(data_dir.join("app.sqlite3-wal"), b"old wal").expect("legacy wal"); + fs::write(data_dir.join("db/rustpost.sqlite3-wal"), b"new wal").expect("new wal"); + let paths = RuntimePaths::from_data_dir(data_dir.clone()); + + let error = paths.ensure().expect_err("conflict"); + + assert!(error.to_string().contains("database layout conflict")); + assert_eq!(fs::read(data_dir.join("app.sqlite3")).expect("old"), b"old"); + assert!(!data_dir.join("db/rustpost.sqlite3").exists()); + assert_eq!( + fs::read(data_dir.join("db/rustpost.sqlite3-wal")).expect("new wal"), + b"new wal" + ); + } } diff --git a/src/server.rs b/src/server.rs index d72e7fe..76b1365 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1697,9 +1697,12 @@ async fn admin_health( .bootstrap_status() .unwrap_or_else(|| "unavailable".to_owned()); let body = format!( - r#"

Site health

DB path
{}
Upload path
{}
ffmpeg
{}
WebP support
{}
VP9 support
{}
Tor
{}
Tor enabled
{}
Tor running
{}
Tor bootstrap
{}
Tor error
{}
Onion address
{}
Anonymous mode
{}
Registration
{}

Recent media jobs

{}
"#, + r#"

Site health

DB path
{}
Upload path
{}
Media path
{}
Logs path
{}
Backup path
{}
ffmpeg
{}
WebP support
{}
VP9 support
{}
Tor
{}
Tor enabled
{}
Tor running
{}
Tor bootstrap
{}
Tor error
{}
Onion address
{}
Anonymous mode
{}
Registration
{}

Recent media jobs

{}
"#, html_escape::encode_text(&state.paths.database_path.display().to_string()), html_escape::encode_text(&state.paths.uploads_originals.display().to_string()), + html_escape::encode_text(&state.paths.uploads_images.display().to_string()), + html_escape::encode_text(&state.paths.logs_dir.display().to_string()), + html_escape::encode_text(&state.paths.backups_dir.display().to_string()), html_escape::encode_text(&state.ffmpeg.summary()), state.ffmpeg.supports_webp, state.ffmpeg.supports_vp9, diff --git a/src/terminal.rs b/src/terminal.rs index 38ba692..f00b2f2 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -86,6 +86,8 @@ pub fn render_startup_dashboard(input: &StartupDashboard<'_>) -> String { row("Settings", display(input.settings_path)), row("Database", display(input.paths.database_path.as_path())), row("Uploads", display(input.paths.uploads_originals.as_path())), + row("Media", display(input.paths.uploads_images.as_path())), + row("Logs", display(input.paths.logs_dir.as_path())), row("Backups", display(input.paths.backups_dir.as_path())), ], ); @@ -151,6 +153,9 @@ pub fn render_init(paths: &runtime::RuntimePaths, settings_path: &Path) -> Strin row("Data directory", display(paths.data_dir.as_path())), row("Settings", display(settings_path)), row("Database", display(paths.database_path.as_path())), + row("Uploads", display(paths.uploads_originals.as_path())), + row("Logs", display(paths.logs_dir.as_path())), + row("Backups", display(paths.backups_dir.as_path())), ], ); push_section( @@ -420,6 +425,10 @@ mod tests { assert!(output.contains("http://127.0.0.1:8080")); assert!(output.contains("Data directory")); assert!(output.contains("/tmp/rustpost-data")); + assert!(output.contains("/tmp/rustpost-data/db/rustpost.sqlite3")); + assert!(output.contains("/tmp/rustpost-data/uploads/originals")); + assert!(output.contains("/tmp/rustpost-data/logs")); + assert!(output.contains("/tmp/rustpost-data/backups")); assert!( output.contains("rustpost-cli --data-dir /tmp/rustpost-data create-admin-interactive") ); From 59e23061f9a96f4963c7ea11f16c5e5a0f4998a6 Mon Sep 17 00:00:00 2001 From: csd113 Date: Mon, 18 May 2026 21:45:59 -0700 Subject: [PATCH 07/10] Document generated default settings template --- src/config.rs | 294 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 293 insertions(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index a077162..3527f68 100644 --- a/src/config.rs +++ b/src/config.rs @@ -277,11 +277,291 @@ pub fn write_default_if_missing(path: &Path) -> anyhow::Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } - let settings = toml::to_string_pretty(&Settings::default())?; + let settings = default_settings_toml(); fs::write(path, settings)?; Ok(()) } +fn default_settings_toml() -> String { + let settings = Settings::default(); + + format!( + r#"# RustPost settings +# Generated with conservative defaults. Edit values as needed, keep key names intact, +# and restart RustPost after changing this file. + +# Site identity + +[site] +# Display name shown in the page title, header, footer, and user-facing copy. +name = {site_name} + + +# HTTP listener and public access + +[server] +# Address RustPost listens on. 127.0.0.1 is safest behind a reverse proxy. +host = {server_host} + +# HTTP port for the RustPost listener. +port = {server_port} + +# Public base URL used for absolute links. Leave empty for local or proxy-only setups. +public_url = {public_url} + +# SECURITY: Set true only when users always reach RustPost over HTTPS. +# This marks session cookies as Secure. +cookie_secure = {cookie_secure} + +# SECURITY: Reverse-proxy CIDRs allowed to provide forwarded client IP headers. +# Keep this list narrow; do not add public networks. +trusted_proxy_cidrs = {trusted_proxy_cidrs} + + +# Accounts and profiles + +[accounts] +# Allow visitors to create accounts from the web UI. +registration_enabled = {registration_enabled} + +# Allow posting without accounts. Rate limits are keyed by client IP. +anonymous_mode_enabled = {anonymous_mode_enabled} + +# Minimum account password length. Values below 10 are rejected. +min_password_length = {min_password_length} + +# Maximum username length in bytes. Usernames are also format-validated. +max_username_len = {max_username_len} + +# Maximum display-name length in bytes. +max_display_name_len = {max_display_name_len} + +# Maximum profile bio length in bytes. +max_bio_len = {max_bio_len} + +# Allow users to upload profile banner images. +allow_profile_banners = {allow_profile_banners} + +# Allow users to upload profile picture images. +allow_profile_pictures = {allow_profile_pictures} + + +# Posts and interactions + +[posts] +# Maximum post text length in characters. +max_text_chars = {max_text_chars} + +# Maximum image attachments allowed on one post. +max_images_per_post = {max_images_per_post} + +# Maximum video attachments allowed on one post. +max_videos_per_post = {max_videos_per_post} + +# Maximum total media attachments allowed on one post. +max_media_per_post = {max_media_per_post} + +# Allow users to repost posts. +allow_reposts = {allow_reposts} + +# Allow replies to posts. +allow_replies = {allow_replies} + +# Allow likes on posts. +allow_likes = {allow_likes} + +# Allow users to bookmark posts. +allow_bookmarks = {allow_bookmarks} + +# Link hashtag text to hashtag feeds. +allow_hashtags = {allow_hashtags} + +# Link @mentions to user profiles. +allow_mentions = {allow_mentions} + + +# Uploads and media processing + +[media] +# ffmpeg executable name or path used for media conversion and thumbnails. +ffmpeg_path = {ffmpeg_path} + +# Convert uploaded images to WebP when ffmpeg supports the input. +convert_images_to_webp = {convert_images_to_webp} + +# Convert uploaded videos to WebM/VP9 when ffmpeg supports the input. +convert_videos_to_webm = {convert_videos_to_webm} + +# SECURITY: Keep original uploaded files after conversion. +# false reduces stored untrusted file formats. +keep_original_uploads = {keep_original_uploads} + +# SECURITY: Maximum accepted image upload size in bytes. Default is 50 MiB. +max_image_size = {max_image_size} + +# SECURITY: Maximum accepted video upload size in bytes. Default is 150 MiB. +max_video_size = {max_video_size} + +# Generate thumbnail images for uploaded videos. +generate_video_thumbnails = {generate_video_thumbnails} + +# SECURITY: Accepted image MIME types after content sniffing. +# Do not add wildcard or scriptable formats. +allowed_image_mime_types = {allowed_image_mime_types} + +# SECURITY: Accepted video MIME types after content sniffing. +allowed_video_mime_types = {allowed_video_mime_types} + +# WebP image quality, 0 to 100. Higher means larger files. +webp_quality = {webp_quality} + +# VP9 quality, 0 to 63. Lower means higher quality and larger files. +vp9_crf = {vp9_crf} + +# VP9 encoder deadline. Typical values are "good", "best", or "realtime". +vp9_deadline = {vp9_deadline} + + +# Tor onion service + +[tor] +# SECURITY: Start the embedded Arti onion service. +enabled = {tor_enabled} + +# SECURITY: Serve only through Tor. Requires tor.enabled = true. +tor_only = {tor_only} + +# Relative directory for Tor state under the RustPost data directory. +data_dir = {tor_data_dir} + +# Local name for the onion service state directory. +onion_service_name = {onion_service_name} + +# Seconds to wait for Tor bootstrap during Tor-only startup. +bootstrap_timeout_secs = {bootstrap_timeout_secs} + +# Maximum concurrent onion streams accepted by the service. +max_concurrent_streams = {max_concurrent_streams} + +# SECURITY: Include onion service keys when backups are created by default. +# Keep false unless backups are encrypted and access-controlled. +include_tor_keys_in_backups_by_default = {include_tor_keys_in_backups_by_default} + + +# Rate limits + +[moderation] +# Posts allowed per authenticated user per minute. +posts_per_minute = {posts_per_minute} + +# Replies allowed per authenticated user per minute. +replies_per_minute = {replies_per_minute} + +# Reposts allowed per authenticated user per minute. +reposts_per_minute = {reposts_per_minute} + +# Account creations allowed per client IP per day. +account_creations_per_ip_per_day = {account_creations_per_ip_per_day} + +# Failed login attempts allowed per client IP per 15 minutes. +failed_login_attempts_per_15m = {failed_login_attempts_per_15m} + +# Anonymous posts allowed per client IP per hour. +anonymous_posts_per_ip_per_hour = {anonymous_posts_per_ip_per_hour} + + +# Administration + +[admin] +# SECURITY: Create the first admin account during first boot. +# Disable after initial setup if you want manual admin provisioning only. +create_admin_on_first_boot = {create_admin_on_first_boot} + + +# Backups + +[backup] +# Enable the built-in tar backup command. +enabled = {backup_enabled} + +# SECURITY: Relative directory for backup archives under the RustPost data directory. +backup_dir = {backup_dir} +"#, + site_name = toml_string(&settings.site.name), + server_host = toml_string(&settings.server.host), + server_port = settings.server.port, + public_url = toml_string(&settings.server.public_url), + cookie_secure = settings.server.cookie_secure, + trusted_proxy_cidrs = toml_string_array(&settings.server.trusted_proxy_cidrs), + registration_enabled = settings.accounts.registration_enabled, + anonymous_mode_enabled = settings.accounts.anonymous_mode_enabled, + min_password_length = settings.accounts.min_password_length, + max_username_len = settings.accounts.max_username_len, + max_display_name_len = settings.accounts.max_display_name_len, + max_bio_len = settings.accounts.max_bio_len, + allow_profile_banners = settings.accounts.allow_profile_banners, + allow_profile_pictures = settings.accounts.allow_profile_pictures, + max_text_chars = settings.posts.max_text_chars, + max_images_per_post = settings.posts.max_images_per_post, + max_videos_per_post = settings.posts.max_videos_per_post, + max_media_per_post = settings.posts.max_media_per_post, + allow_reposts = settings.posts.allow_reposts, + allow_replies = settings.posts.allow_replies, + allow_likes = settings.posts.allow_likes, + allow_bookmarks = settings.posts.allow_bookmarks, + allow_hashtags = settings.posts.allow_hashtags, + allow_mentions = settings.posts.allow_mentions, + ffmpeg_path = toml_string(&settings.media.ffmpeg_path), + convert_images_to_webp = settings.media.convert_images_to_webp, + convert_videos_to_webm = settings.media.convert_videos_to_webm, + keep_original_uploads = settings.media.keep_original_uploads, + max_image_size = settings.media.max_image_size, + max_video_size = settings.media.max_video_size, + generate_video_thumbnails = settings.media.generate_video_thumbnails, + allowed_image_mime_types = toml_string_array(&settings.media.allowed_image_mime_types), + allowed_video_mime_types = toml_string_array(&settings.media.allowed_video_mime_types), + webp_quality = settings.media.webp_quality, + vp9_crf = settings.media.vp9_crf, + vp9_deadline = toml_string(&settings.media.vp9_deadline), + tor_enabled = settings.tor.enabled, + tor_only = settings.tor.tor_only, + tor_data_dir = toml_string(&settings.tor.data_dir), + onion_service_name = toml_string(&settings.tor.onion_service_name), + bootstrap_timeout_secs = settings.tor.bootstrap_timeout_secs, + max_concurrent_streams = settings.tor.max_concurrent_streams, + include_tor_keys_in_backups_by_default = + settings.tor.include_tor_keys_in_backups_by_default, + posts_per_minute = settings.moderation.posts_per_minute, + replies_per_minute = settings.moderation.replies_per_minute, + reposts_per_minute = settings.moderation.reposts_per_minute, + account_creations_per_ip_per_day = settings.moderation.account_creations_per_ip_per_day, + failed_login_attempts_per_15m = settings.moderation.failed_login_attempts_per_15m, + anonymous_posts_per_ip_per_hour = settings.moderation.anonymous_posts_per_ip_per_hour, + create_admin_on_first_boot = settings.admin.create_admin_on_first_boot, + backup_enabled = settings.backup.enabled, + backup_dir = toml_string(&settings.backup.backup_dir), + ) +} + +fn toml_string(value: &str) -> String { + toml::Value::String(value.to_owned()).to_string() +} + +fn toml_string_array(values: &[String]) -> String { + if values.is_empty() { + return "[]".to_owned(); + } + + let mut output = "[\n".to_owned(); + for value in values { + output.push_str(" "); + output.push_str(&toml_string(value)); + output.push_str(",\n"); + } + output.push(']'); + output +} + pub fn validate_relative_path(value: &str, field: &str) -> anyhow::Result<()> { let path = Path::new(value); if value.is_empty() || path.is_absolute() { @@ -324,6 +604,18 @@ mod tests { Settings::default().validate().expect("default config"); } + #[test] + fn generated_default_settings_toml_parses_and_preserves_defaults() { + let generated = default_settings_toml(); + let parsed: Settings = toml::from_str(&generated).expect("generated config parses"); + + assert_eq!( + toml::to_string(&parsed).expect("parsed settings serialize"), + toml::to_string(&Settings::default()).expect("default settings serialize") + ); + parsed.validate().expect("generated config validates"); + } + #[test] fn tor_only_requires_tor_enabled() { let mut settings = Settings::default(); From e6a36304e3988303efd4b44e94066c4e8c2182d5 Mon Sep 17 00:00:00 2001 From: csd113 Date: Mon, 18 May 2026 21:53:14 -0700 Subject: [PATCH 08/10] Add interactive first-admin startup setup --- README.md | 4 +- src/admin.rs | 26 ++++++ src/cli.rs | 226 ++++++++++++++++++++++++++++++++++++++++++++++-- src/terminal.rs | 49 +++++++++++ 4 files changed, 298 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 5cbf065..e96d0d4 100644 --- a/README.md +++ b/README.md @@ -158,9 +158,9 @@ rustpost-data/ ### Create Your First Admin -When `serve` starts, RustPost prints the data directory, settings path, database path, upload/media paths, log path, backup path, bind address, and whether an admin account exists. If no admin exists, the terminal output includes the bootstrap commands. +When `serve` starts, RustPost prints the data directory, settings path, database path, upload/media paths, log path, backup path, bind address, and whether an admin account exists. If `admin.create_admin_on_first_boot` is enabled and no admin exists, interactive startup enters a `Create admin account` step before the server begins accepting requests. If stdin is not interactive, RustPost prints the bootstrap commands instead of waiting for input. -The preferred local setup path hides the password while you type: +You can also create the first admin explicitly. The preferred local setup path hides the password while you type: ```sh ./target/release/rustpost --data-dir ./rustpost-data create-admin-interactive diff --git a/src/admin.rs b/src/admin.rs index 43c45b6..5b31a2c 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -13,6 +13,32 @@ pub async fn create_admin( auth::register_user(pool, settings, username, password, true).await } +pub async fn create_admin_with_display_name( + pool: &SqlitePool, + settings: &Settings, + username: &str, + password: &str, + display_name: Option<&str>, +) -> anyhow::Result { + let display_name = display_name.map(str::trim).filter(|name| !name.is_empty()); + if let Some(display_name) = display_name { + crate::validation::validate_profile_text(display_name, "", settings)?; + } + let user_id = create_admin(pool, settings, username, password).await?; + if let Some(display_name) = display_name { + let display_name = display_name.to_owned(); + pool.call(move |conn| { + conn.execute( + "UPDATE users SET display_name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + params![display_name, user_id], + )?; + Ok(()) + }) + .await?; + } + Ok(user_id) +} + pub async fn reset_admin_password( pool: &SqlitePool, settings: &Settings, diff --git a/src/cli.rs b/src/cli.rs index c604e2d..0279689 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,4 +1,4 @@ -use std::io::{self, Write as _}; +use std::io::{self, IsTerminal as _, Write as _}; use std::net::SocketAddr; use std::path::PathBuf; use std::process::Command as ProcessCommand; @@ -279,7 +279,7 @@ async fn serve( ) -> anyhow::Result<()> { let pool = db::connect(&paths.database_path).await?; db::migrate(&pool).await?; - let admin_count = admin::admin_count(&pool).await?; + let admin_count = ensure_first_admin_interactive(&pool, &settings, &paths).await?; let ffmpeg = crate::ffmpeg::probe(&settings.media).await; if !ffmpeg.available { warn!("ffmpeg unavailable; uploads will safely fall back to allowed originals"); @@ -288,9 +288,6 @@ async fn serve( let onion_target = onion_listener_target(onion_listener.as_ref()); let tor_status = initial_tor_status(&settings.tor, &paths, onion_target).await?; let tor_status_for_background = tor_status.clone(); - if settings.admin.create_admin_on_first_boot { - admin::ensure_first_boot_admin_hint(&pool).await?; - } print_startup_dashboard( &pool, StartupPrintContext { @@ -327,6 +324,60 @@ async fn serve( serve_clearnet(app, &settings.server, shutdown_rx).await } +async fn ensure_first_admin_interactive( + pool: &db::SqlitePool, + settings: &config::Settings, + paths: &runtime::RuntimePaths, +) -> anyhow::Result { + let count = admin::admin_count(pool).await?; + if count > 0 || !settings.admin.create_admin_on_first_boot { + return Ok(count); + } + if !stdin_is_interactive() { + stderr_raw(format_args!( + "{}", + terminal::render_first_admin_non_interactive(paths) + )); + admin::ensure_first_boot_admin_hint(pool).await?; + return Ok(count); + } + stderr_raw(format_args!( + "{}", + terminal::render_first_admin_setup(paths) + )); + let credentials = prompt_first_admin_credentials(settings)?; + admin::create_admin_with_display_name( + pool, + settings, + &credentials.username, + &credentials.password, + credentials.display_name.as_deref(), + ) + .await?; + stdout_raw(format_args!( + "{}", + terminal::render_command_success( + "RustPost admin created", + &[ + terminal::row("Username", credentials.username), + terminal::row( + "Display name", + credentials + .display_name + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| "(same as username)".to_owned()), + ), + terminal::row("Next step", "starting RustPost"), + ], + ) + ))?; + admin::admin_count(pool).await +} + +fn stdin_is_interactive() -> bool { + io::stdin().is_terminal() +} + async fn bind_onion_listener( settings: &config::TorSettings, ) -> anyhow::Result> { @@ -488,6 +539,55 @@ fn prompt_admin_credentials() -> anyhow::Result<(String, String)> { Ok((username, password)) } +#[derive(Debug, PartialEq, Eq)] +struct FirstAdminCredentials { + username: String, + display_name: Option, + password: String, +} + +fn prompt_first_admin_credentials( + settings: &config::Settings, +) -> anyhow::Result { + stdout_raw(format_args!("Admin username: "))?; + let mut username = String::new(); + io::stdin().read_line(&mut username)?; + stdout_raw(format_args!("Admin display name (optional): "))?; + let mut display_name = String::new(); + io::stdin().read_line(&mut display_name)?; + let password = read_secret("Admin password: ")?; + let confirm = read_secret("Confirm admin password: ")?; + validate_first_admin_credentials(settings, &username, &display_name, password, &confirm) +} + +fn validate_first_admin_credentials( + settings: &config::Settings, + username: &str, + display_name: &str, + password: String, + confirm: &str, +) -> anyhow::Result { + let username = username.trim().to_owned(); + if username.is_empty() { + anyhow::bail!("username cannot be empty"); + } + if password.is_empty() { + anyhow::bail!("password cannot be empty"); + } + if password != confirm { + anyhow::bail!("passwords do not match"); + } + let display_name = display_name.trim().to_owned(); + if !display_name.is_empty() { + crate::validation::validate_profile_text(&display_name, "", settings)?; + } + Ok(FirstAdminCredentials { + username, + display_name: (!display_name.is_empty()).then_some(display_name), + password, + }) +} + #[cfg(unix)] fn read_secret(prompt: &str) -> anyhow::Result { stdout_raw(format_args!("{prompt}"))?; @@ -562,3 +662,119 @@ fn stderr_raw(args: std::fmt::Arguments<'_>) { let mut stderr = io::stderr().lock(); let _write_result = stderr.write_fmt(args); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn first_admin_credentials_reject_empty_username() { + let result = validate_first_admin_credentials( + &config::Settings::default(), + " ", + "", + "very secure password".to_owned(), + "very secure password", + ); + + assert!(result.is_err()); + assert_eq!( + result.expect_err("empty username").to_string(), + "username cannot be empty" + ); + } + + #[test] + fn first_admin_credentials_reject_empty_password() { + let result = validate_first_admin_credentials( + &config::Settings::default(), + "admin-user", + "", + String::new(), + "", + ); + + assert!(result.is_err()); + assert_eq!( + result.expect_err("empty password").to_string(), + "password cannot be empty" + ); + } + + #[test] + fn first_admin_credentials_reject_password_mismatch() { + let result = validate_first_admin_credentials( + &config::Settings::default(), + "admin-user", + "", + "very secure password".to_owned(), + "very different password", + ); + + assert!(result.is_err()); + assert_eq!( + result.expect_err("mismatch").to_string(), + "passwords do not match" + ); + } + + #[test] + fn first_admin_credentials_accept_optional_display_name() { + let credentials = validate_first_admin_credentials( + &config::Settings::default(), + " Admin-User ", + " Ada Admin ", + "very secure password".to_owned(), + "very secure password", + ) + .expect("valid credentials"); + + assert_eq!( + credentials, + FirstAdminCredentials { + username: "Admin-User".to_owned(), + display_name: Some("Ada Admin".to_owned()), + password: "very secure password".to_owned(), + } + ); + } + + #[tokio::test] + async fn first_admin_creation_sets_optional_display_name() { + let temp = tempfile::tempdir().expect("temp dir"); + let pool = db::connect(&temp.path().join("test.sqlite3")) + .await + .expect("connect"); + db::migrate(&pool).await.expect("migrate"); + let settings = config::Settings::default(); + + let user_id = admin::create_admin_with_display_name( + &pool, + &settings, + "admin-user", + "very secure password", + Some("Ada Admin"), + ) + .await + .expect("create admin"); + + let row: (String, bool, String) = pool + .call(move |conn| { + Ok(conn.query_row( + "SELECT username, is_admin, display_name FROM users WHERE id = ?", + [user_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)? != 0, + row.get::<_, String>(2)?, + )) + }, + )?) + }) + .await + .expect("load admin"); + + assert_eq!(row, ("admin-user".to_owned(), true, "Ada Admin".to_owned())); + } +} diff --git a/src/terminal.rs b/src/terminal.rs index f00b2f2..66842eb 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -193,6 +193,55 @@ pub fn render_command_success(title: &str, rows: &[Row]) -> String { out } +#[must_use] +pub fn render_first_admin_setup(paths: &runtime::RuntimePaths) -> String { + let mut out = String::with_capacity(512); + push_header(&mut out, "Create admin account"); + push_section( + &mut out, + "Setup", + &[ + row("Why", "no admin account exists"), + row("Data directory", display(paths.data_dir.as_path())), + row("Password input", "hidden where supported"), + ], + ); + out.push_str(RULE); + out.push('\n'); + out +} + +#[must_use] +pub fn render_first_admin_non_interactive(paths: &runtime::RuntimePaths) -> String { + let mut out = String::with_capacity(640); + push_header(&mut out, "RustPost first admin required"); + push_section( + &mut out, + "Setup", + &[ + row("Status", "no admin account exists"), + row("Input", "stdin is not interactive, so setup was skipped"), + row( + "Create admin", + format!( + "rustpost-cli --data-dir {} create-admin-interactive", + display(paths.data_dir.as_path()) + ), + ), + row( + "Alternative", + format!( + "rustpost-cli --data-dir {} create-admin ", + display(paths.data_dir.as_path()) + ), + ), + ], + ); + out.push_str(RULE); + out.push('\n'); + out +} + #[must_use] pub fn render_error(error: &Error) -> String { let mut out = String::with_capacity(512); From 4d227623966cb72c0a88d15acd8372b41d0e7dee Mon Sep 17 00:00:00 2001 From: csd113 Date: Mon, 18 May 2026 21:54:44 -0700 Subject: [PATCH 09/10] Bump version to 0.1.2 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index acc70a3..80c1d59 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3145,7 +3145,7 @@ dependencies = [ [[package]] name = "rustpost" -version = "0.1.0" +version = "0.1.2" dependencies = [ "anyhow", "argon2", diff --git a/Cargo.toml b/Cargo.toml index add43af..0d5e802 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpost" -version = "0.1.0" +version = "0.1.2" edition = "2024" license = "MIT" description = "A single-binary self-hosted microblogging app" From 3086fe9a69f6965b4a213e559d8ec26629ff6334 Mon Sep 17 00:00:00 2001 From: csd113 Date: Mon, 18 May 2026 21:57:51 -0700 Subject: [PATCH 10/10] Update config.rs --- src/config.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/config.rs b/src/config.rs index 3527f68..9b40a78 100644 --- a/src/config.rs +++ b/src/config.rs @@ -282,6 +282,10 @@ pub fn write_default_if_missing(path: &Path) -> anyhow::Result<()> { Ok(()) } +#[expect( + clippy::too_many_lines, + reason = "the generated settings template is intentionally kept in one readable TOML block" +)] fn default_settings_toml() -> String { let settings = Settings::default();