diff --git a/.gitignore b/.gitignore index d67ef3a..3322bbb 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,14 +40,27 @@ app-data/ rustpost-data/ 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/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" diff --git a/README.md b/README.md index 529ac54..e96d0d4 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,12 +154,13 @@ 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 `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 @@ -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/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..d72b35e --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "rustpost-e2e", + "private": true, + "scripts": { + "test": "playwright test", + "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/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/auth.rs b/src/auth.rs index b36402a..42b68af 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)], @@ -146,13 +157,30 @@ 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| { + 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/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/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/config.rs b/src/config.rs index a077162..9b40a78 100644 --- a/src/config.rs +++ b/src/config.rs @@ -277,11 +277,295 @@ 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(()) } +#[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(); + + 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 +608,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(); diff --git a/src/csrf.rs b/src/csrf.rs index ee840f6..ac84c22 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(accepted_hashes) = csrf_hashes_for_cookie(pool, &token).await? else { anyhow::bail!("missing csrf session"); }; - if expected != hash_token(submitted) { + let submitted = hash_token(submitted); + if !accepted_hashes.iter().any(|hash| hash == &submitted) { 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/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/render.rs b/src/render.rs index 1097b17..652b120 100644 --- a/src/render.rs +++ b/src/render.rs @@ -98,7 +98,7 @@ pub fn layout_with_context( - + {} - {} @@ -237,6 +237,26 @@ 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(); + } +}); + function setButtonState(button, active, label) { button.classList.toggle("active", active); button.setAttribute("aria-pressed", active ? "true" : "false"); @@ -254,19 +274,25 @@ 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; } 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" }); if (!response.ok) { - window.location.assign(response.url || window.location.href); + HTMLFormElement.prototype.submit.call(form); return; } const data = await response.json(); @@ -324,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 { @@ -559,16 +615,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 +655,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 +687,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 +869,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 +977,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/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 6b38972..76b1365 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}; @@ -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, @@ -50,6 +52,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 +103,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 { ( [( @@ -355,7 +367,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( @@ -404,7 +417,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?; } @@ -553,6 +572,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, @@ -589,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 @@ -621,14 +656,22 @@ 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}") } 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()) } @@ -1389,6 +1432,37 @@ 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"); + 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}#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"); + 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) + } +} + +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() @@ -1623,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, @@ -1838,15 +1915,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) @@ -1854,22 +1931,34 @@ 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. + 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| { - conn.execute( - "UPDATE sessions SET csrf_token_hash = ? WHERE token_hash = ? AND csrf_token_hash = ?", - params![new_hash, token_hash, stored_hash], + 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_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( @@ -1914,6 +2003,40 @@ 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"); + } + + #[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), None); + 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; @@ -1978,7 +2101,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( @@ -2213,6 +2337,168 @@ mod tests { assert!(!self_repost.body.contains("internal server error")); } + #[tokio::test] + async fn post_actions_accept_token_from_recent_page_render_history() { + 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); + 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, + "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; @@ -2620,6 +2906,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()); diff --git a/src/terminal.rs b/src/terminal.rs index 38ba692..66842eb 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( @@ -188,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); @@ -420,6 +474,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") ); 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/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..8882b5f --- /dev/null +++ b/tests/e2e/helpers.ts @@ -0,0 +1,204 @@ +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 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).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|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 { + 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[] = []; + + 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(); + 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 article.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 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]') + .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..500c8a5 --- /dev/null +++ b/tests/e2e/mobile.spec.ts @@ -0,0 +1,77 @@ +import { test, expect } from '@playwright/test'; +import { + assertNoQaFailures, + createPost, + expectNotErrorPage, + installQaGuards, + logout, + 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 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); + expect(response?.status(), `${url} should render on mobile`).toBeLessThan(400); + 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(); + }); +}); 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