diff --git a/.gitignore b/.gitignore index c4dcc7a..32e4e5e 100644 --- a/.gitignore +++ b/.gitignore @@ -28,8 +28,9 @@ trace.zip *.trace *.har tests/* -!tests/playwright/ -!tests/playwright/** +tests/playwright/ +tests/playwright/* +tests/playwright/nsfw-media.mjs package-lock.json package.json playwright.config.ts @@ -72,3 +73,6 @@ uploads/**/* .DS_Store **/.DS_Store /tests +!/tests/ +!/tests/playwright/ +/tests/playwright/* diff --git a/CHANGELOG.md b/CHANGELOG.md index 8be59be..99ca029 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## v0.1.5 - Safety, Media, and Link Previews + +### Posts and Composer +- Added YouTube link previews for posts so shared video links render with richer inline context. +- Polished the post composer UI and tightened the shared shell sidebar spacing. +- Added a compact quote action icon while preserving the existing posting flow. +- Added a no-JS banner and progressive enhancement fallback behavior for interactive page controls. + +### Media Safety and Storage +- Added NSFW media marking, blur-by-default rendering, per-user NSFW blur preferences, and admin controls for marking existing media posts. +- Added a migration and settings support for global NSFW blur defaults. +- Added duplicate media detection with canonical media reuse and cleanup so repeated uploads can share stored variants safely. +- Improved original upload handling so original and transcoded variants share stable basenames. + +### Account and Admin Controls +- Added optional registration CAPTCHA support with single-use challenge validation. +- Exposed CAPTCHA and NSFW blur settings through deep admin settings. +- Expanded the admin users investigation panel with richer search and post-context tooling. + +### Privacy, Networking, and Performance +- Added gzip compression for browser text responses while leaving media and binary uploads uncompressed. +- Added configurable Tor mirror display in the header and refined the Tor header into a compact status link. +- Kept the embedded Tor client alive through shared ownership for more reliable background onion service operation. + +### Release Prep +- Bumped the crate version to `0.1.5` and refreshed `Cargo.lock`. +- Preserved the ignored Playwright artifact policy while removing tracked Playwright test artifacts from the branch. +- Fixed focused Clippy findings in the composer, CAPTCHA, media, and rendering paths. + ## v0.1.4 - Layout Foundation ### Core UI Layout diff --git a/Cargo.lock b/Cargo.lock index e954e94..7f7d5df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -336,9 +336,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "axum" @@ -399,6 +399,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.22.1" @@ -421,6 +427,12 @@ dependencies = [ "unty", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.1" @@ -490,9 +502,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "by_address" @@ -518,6 +530,19 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "captcha" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae9afd180651081c88cd709f87e16a55ba85a3e3c573355b2de84aa8b4e8f468" +dependencies = [ + "base64 0.13.1", + "image", + "lodepng", + "rand 0.9.4", + "serde_json", +] + [[package]] name = "caret" version = "0.9.0" @@ -627,6 +652,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.5" @@ -1159,9 +1190,9 @@ dependencies = [ [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "elliptic-curve" @@ -1282,6 +1313,15 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "ff" version = "0.13.1" @@ -1335,6 +1375,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -1949,6 +1990,19 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.24.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "num-traits", + "png", +] + [[package]] name = "imara-diff" version = "0.2.0" @@ -1997,7 +2051,7 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" dependencies = [ - "bitflags", + "bitflags 2.11.1", "inotify-sys", "libc", ] @@ -2052,9 +2106,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.98" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ "cfg-if", "futures-util", @@ -2107,7 +2161,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags", + "bitflags 2.11.1", "libc", ] @@ -2179,6 +2233,18 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "lodepng" +version = "3.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe7982db11054edc023a1b424dddcc65be18f71fa46ec6bde2efcfc1fb6b22da" +dependencies = [ + "crc32fast", + "flate2", + "libc", + "rgb", +] + [[package]] name = "log" version = "0.4.29" @@ -2310,7 +2376,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags", + "bitflags 2.11.1", "inotify", "kqueue", "libc", @@ -2327,7 +2393,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] @@ -2438,7 +2504,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] @@ -2691,6 +2757,19 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "postage" version = "0.5.0" @@ -2923,7 +3002,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] @@ -3006,6 +3085,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + [[package]] name = "ring" version = "0.17.14" @@ -3057,7 +3145,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" dependencies = [ - "bitflags", + "bitflags 2.11.1", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -3091,7 +3179,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys", @@ -3135,15 +3223,17 @@ dependencies = [ [[package]] name = "rustpost" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", "argon2", "arti-client", "axum", - "base64", + "base64 0.22.1", + "captcha", "chrono", "clap", + "flate2", "futures-util", "html-escape", "http-body-util", @@ -3331,9 +3421,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -3389,7 +3479,7 @@ version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" dependencies = [ - "base64", + "base64 0.22.1", "bs58", "chrono", "hex", @@ -4098,7 +4188,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9efd607ea595674678f73ebf77c245caf04e1d229eae8caf58d2989ff6c63959" dependencies = [ "amplify", - "bitflags", + "bitflags 2.11.1", "bytes", "caret", "derive-deftly", @@ -4826,7 +4916,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1351ffe2fff8bad7d5f2b79ec88ea188e159fe94cf705b1e485d8566526a7b5" dependencies = [ "async-trait", - "bitflags", + "bitflags 2.11.1", "derive_more", "digest 0.10.7", "futures", @@ -5143,7 +5233,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags", + "bitflags 2.11.1", "bytes", "futures-core", "futures-util", @@ -5441,9 +5531,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -5454,9 +5544,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5464,9 +5554,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", @@ -5477,9 +5567,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] @@ -5512,7 +5602,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap 2.14.0", "semver", @@ -5922,7 +6012,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.1", "indexmap 2.14.0", "log", "serde", @@ -6113,6 +6203,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 9c29e13..b519a6f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpost" -version = "0.1.4" +version = "0.1.5" edition = "2024" license = "MIT" description = "A single-binary self-hosted microblogging app" @@ -20,9 +20,11 @@ arti-client = { version = "=0.42.0", default-features = false, features = ["toki argon2 = "0.5" axum = { version = "0.8.9", features = ["multipart"] } base64 = "0.22" +captcha = { version = "1.0", default-features = false } chrono = { version = "0.4", features = ["serde"] } clap = { version = "4.5", features = ["derive", "env"] } futures-util = "0.3" +flate2 = "1.1" html-escape = "0.2" infer = "0.19" rand_core = { version = "0.6", features = ["getrandom"] } diff --git a/README.md b/README.md index e96d0d4..962c142 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ This repository is a **production-oriented MVP** — not a toy, but not overengi | Backups | Tar archive of DB + settings + media + optional Tor keys | | Restore | Path validation rejects traversal, symlinks, absolute paths, and Unicode bypass attempts | | Admin dashboard | Site health, users, media jobs, conversion state, and backup management | +| HTTP compression | Browser text responses use gzip when requested; media and binary uploads are intentionally left uncompressed | | Tor / Arti | Embedded onion-service startup — clearnet-only, Tor-only, or dual mode | --- @@ -218,6 +219,16 @@ name = "RustPost" Changing `site.name` updates the rendered browser title, header brand, footer, and user-facing site copy. Binary names, package names, cookie names, and data paths remain `rustpost` for compatibility. +### Account creation + +```toml +[accounts] +registration_enabled = true +registration_captcha_enabled = false +``` + +`registration_captcha_enabled` adds a single-use CAPTCHA challenge to registration only. Login is unchanged. + ### Clearnet only (default) ```toml @@ -438,6 +449,7 @@ Each archive contains `rustpost`, `rustpost-cli`, `README.md`, `LICENSE`, and op | `argon2` | Argon2id password hashing | | `rand_core` + `uuid` | Secure salts and opaque generated tokens/filenames | | `infer` | Content-based media type detection | +| `flate2` | Gzip response compression for browser text responses | | `tower-http` | Static upload serving and HTTP tracing | | `clap` | CLI argument parsing | | `toml` + `serde` | Config load/save | diff --git a/src/account.rs b/src/account.rs index 88b2f19..5b45085 100644 --- a/src/account.rs +++ b/src/account.rs @@ -2,7 +2,7 @@ use std::collections::BTreeSet; use std::fs; use std::path::{Component, Path, PathBuf}; -use rusqlite::params; +use rusqlite::{OptionalExtension as _, params}; use crate::auth; use crate::db::SqlitePool; @@ -70,6 +70,7 @@ pub async fn delete_account( #[derive(Debug, Clone)] struct AccountMedia { id: i64, + original_path: Option, stored_path: String, thumbnail_path: Option, } @@ -80,7 +81,7 @@ fn account_media_in_tx( ) -> anyhow::Result> { let mut stmt = tx.prepare( r#" - SELECT DISTINCT m.id, m.stored_path, m.thumbnail_path + SELECT DISTINCT m.id, m.original_path, m.stored_path, m.thumbnail_path FROM media m WHERE m.owner_user_id = ? OR m.id IN ( @@ -95,8 +96,9 @@ fn account_media_in_tx( .query_map(params![user_id, user_id], |row| { Ok(AccountMedia { id: row.get(0)?, - stored_path: row.get(1)?, - thumbnail_path: row.get(2)?, + original_path: row.get(1)?, + stored_path: row.get(2)?, + thumbnail_path: row.get(3)?, }) })? .collect::, _>>()?; @@ -117,11 +119,20 @@ async fn scrub_account_rows( [user_id], )?; let media = account_media_in_tx(&tx, user_id)?; - let mut media_files = BTreeSet::new(); + let mut media_paths = Vec::new(); for item in &media { - media_files.extend(safe_media_path(&paths, &item.stored_path)?); + if let Some(original_path) = &item.original_path { + media_paths.push(original_path.clone()); + } + media_paths.push(item.stored_path.clone()); if let Some(thumbnail_path) = &item.thumbnail_path { - media_files.extend(safe_media_path(&paths, thumbnail_path)?); + media_paths.push(thumbnail_path.clone()); + } + } + let mut safe_paths = Vec::new(); + for path in &media_paths { + if let Some(safe_path) = safe_media_path(&paths, path)? { + safe_paths.push((path.clone(), safe_path)); } } let media_ids = media.into_iter().map(|item| item.id).collect::>(); @@ -165,9 +176,21 @@ async fn scrub_account_rows( )?; tx.execute("DELETE FROM posts WHERE user_id = ?", [user_id])?; for media_id in media_ids { + promote_canonical_references(&tx, media_id)?; tx.execute("DELETE FROM media_jobs WHERE media_id = ?", [media_id])?; tx.execute("DELETE FROM media WHERE id = ?", [media_id])?; } + let mut media_files = BTreeSet::new(); + for (raw_path, safe_path) in safe_paths { + let remaining: i64 = tx.query_row( + "SELECT COUNT(*) FROM media WHERE original_path = ? OR stored_path = ? OR thumbnail_path = ?", + params![raw_path, raw_path, raw_path], + |row| row.get(0), + )?; + if remaining == 0 { + media_files.insert(safe_path); + } + } tx.execute("DELETE FROM users WHERE id = ?", [user_id])?; tx.commit()?; Ok(media_files) @@ -229,6 +252,35 @@ fn has_parent_component(path: &Path) -> bool { .any(|component| matches!(component, Component::ParentDir)) } +fn promote_canonical_references( + tx: &rusqlite::Transaction<'_>, + media_id: i64, +) -> anyhow::Result<()> { + let replacement: Option = tx + .query_row( + "SELECT id FROM media WHERE canonical_media_id = ? ORDER BY id ASC LIMIT 1", + [media_id], + |row| row.get(0), + ) + .optional()?; + let Some(replacement) = replacement else { + return Ok(()); + }; + tx.execute( + "UPDATE media SET original_sha256 = '', normalized_sha256 = NULL WHERE id = ?", + [media_id], + )?; + tx.execute( + "UPDATE media SET canonical_media_id = NULL WHERE id = ?", + [replacement], + )?; + tx.execute( + "UPDATE media SET canonical_media_id = ? WHERE canonical_media_id = ?", + params![replacement, media_id], + )?; + Ok(()) +} + fn remove_media_files(paths: &BTreeSet, user_id: i64) -> usize { let mut deleted = 0; for path in paths { @@ -252,7 +304,6 @@ fn remove_media_files(paths: &BTreeSet, user_id: i64) -> usize { mod tests { use super::*; use crate::{auth, config::Settings, db, social}; - use rusqlite::OptionalExtension as _; async fn fixture() -> ( tempfile::TempDir, @@ -413,6 +464,86 @@ mod tests { assert_eq!(rows, 0); } + #[tokio::test] + async fn delete_account_keeps_shared_media_files_referenced_by_other_rows() { + let (_temp, paths, pool, _settings, alice, bob) = fixture().await; + let media_path = paths.uploads_images.join("shared.webp"); + fs::write(&media_path, b"shared").expect("shared media file"); + let media_path_string = media_path.to_string_lossy().to_string(); + pool.call(move |conn| { + conn.execute( + "INSERT INTO media (owner_user_id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len) VALUES (?, 'alice.webp', ?, '/uploads/images/shared.webp', 'image/webp', 'image', 6)", + params![alice, media_path_string], + )?; + let media_path_string = media_path_string.clone(); + conn.execute( + "INSERT INTO media (owner_user_id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len) VALUES (?, 'bob.webp', ?, '/uploads/images/shared.webp', 'image/webp', 'image', 6)", + params![bob, media_path_string], + )?; + Ok(()) + }) + .await + .expect("shared media rows"); + + let summary = delete_account(&pool, &paths, alice, "very secure password") + .await + .expect("delete account"); + + assert_eq!(summary.deleted_media_files, 0); + assert!(media_path.exists()); + let bob_rows: i64 = pool + .call(move |conn| { + Ok(conn.query_row( + "SELECT COUNT(*) FROM media WHERE owner_user_id = ?", + [bob], + |row| row.get(0), + )?) + }) + .await + .expect("bob media count"); + assert_eq!(bob_rows, 1); + } + + #[tokio::test] + async fn delete_account_promotes_shared_canonical_media_for_remaining_rows() { + let (_temp, paths, pool, _settings, alice, bob) = fixture().await; + let media_path = paths.uploads_images.join("promoted.webp"); + fs::write(&media_path, b"shared").expect("shared media file"); + let media_path_string = media_path.to_string_lossy().to_string(); + pool.call(move |conn| { + conn.execute( + "INSERT INTO media (id, owner_user_id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len, original_sha256, normalized_sha256) VALUES (100, ?, 'alice.webp', ?, '/uploads/images/promoted.webp', 'image/webp', 'image', 6, 'alice-raw', 'shared-normalized')", + params![alice, media_path_string], + )?; + let media_path_string = media_path_string.clone(); + conn.execute( + "INSERT INTO media (owner_user_id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len, original_sha256, normalized_sha256, canonical_media_id) VALUES (?, 'bob.webp', ?, '/uploads/images/promoted.webp', 'image/webp', 'image', 6, 'bob-raw', 'shared-normalized', 100)", + params![bob, media_path_string], + )?; + Ok(()) + }) + .await + .expect("shared media rows"); + + let summary = delete_account(&pool, &paths, alice, "very secure password") + .await + .expect("delete account"); + + assert_eq!(summary.deleted_media_files, 0); + assert!(media_path.exists()); + let bob_canonical: Option = pool + .call(move |conn| { + Ok(conn.query_row( + "SELECT canonical_media_id FROM media WHERE owner_user_id = ?", + [bob], + |row| row.get(0), + )?) + }) + .await + .expect("bob canonical"); + assert_eq!(bob_canonical, None); + } + #[tokio::test] async fn delete_account_treats_file_cleanup_failure_as_success_after_db_scrub() { let (_temp, paths, pool, _settings, alice, _bob) = fixture().await; diff --git a/src/admin.rs b/src/admin.rs index e8e0c82..0a1e239 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -3,7 +3,7 @@ use std::io::Write as _; use std::path::Path; use anyhow::Context as _; -use rusqlite::params; +use rusqlite::{Row, params, params_from_iter}; use serde::Deserialize; use crate::auth; @@ -28,6 +28,7 @@ pub struct DeepSettingsForm { pub allow_hashtags: String, pub allow_mentions: String, pub registration_enabled: String, + pub registration_captcha_enabled: String, pub anonymous_mode_enabled: String, pub min_password_length: String, pub max_username_len: String, @@ -35,6 +36,7 @@ pub struct DeepSettingsForm { pub max_bio_len: String, pub allow_profile_banners: String, pub allow_profile_pictures: String, + pub nsfw_blur_enabled: String, pub max_image_size_mb: String, pub max_video_size_mb: String, } @@ -53,6 +55,7 @@ pub enum DeepSettingsField { AllowHashtags, AllowMentions, RegistrationEnabled, + RegistrationCaptchaEnabled, AnonymousModeEnabled, MinPasswordLength, MaxUsernameLen, @@ -60,6 +63,7 @@ pub enum DeepSettingsField { MaxBioLen, AllowProfileBanners, AllowProfilePictures, + NsfwBlurEnabled, MaxImageSizeMb, MaxVideoSizeMb, } @@ -85,6 +89,7 @@ pub struct DeepSettingsValues { pub allow_hashtags: bool, pub allow_mentions: bool, pub registration_enabled: bool, + pub registration_captcha_enabled: bool, pub anonymous_mode_enabled: bool, pub min_password_length: usize, pub max_username_len: usize, @@ -92,6 +97,7 @@ pub struct DeepSettingsValues { pub max_bio_len: usize, pub allow_profile_banners: bool, pub allow_profile_pictures: bool, + pub nsfw_blur_enabled: bool, pub max_image_size_mb: u64, pub max_video_size_mb: u64, } @@ -104,7 +110,7 @@ pub struct DeepSettingsChange { } impl DeepSettingsField { - pub const ALL: [Self; 21] = [ + pub const ALL: [Self; 23] = [ Self::SiteName, Self::MaxTextChars, Self::MaxImagesPerPost, @@ -117,6 +123,7 @@ impl DeepSettingsField { Self::AllowHashtags, Self::AllowMentions, Self::RegistrationEnabled, + Self::RegistrationCaptchaEnabled, Self::AnonymousModeEnabled, Self::MinPasswordLength, Self::MaxUsernameLen, @@ -124,6 +131,7 @@ impl DeepSettingsField { Self::MaxBioLen, Self::AllowProfileBanners, Self::AllowProfilePictures, + Self::NsfwBlurEnabled, Self::MaxImageSizeMb, Self::MaxVideoSizeMb, ]; @@ -143,6 +151,7 @@ impl DeepSettingsField { | Self::AllowHashtags | Self::AllowMentions => "Posts", Self::RegistrationEnabled + | Self::RegistrationCaptchaEnabled | Self::AnonymousModeEnabled | Self::MinPasswordLength | Self::MaxUsernameLen @@ -150,7 +159,7 @@ impl DeepSettingsField { | Self::MaxBioLen | Self::AllowProfileBanners | Self::AllowProfilePictures => "Accounts", - Self::MaxImageSizeMb | Self::MaxVideoSizeMb => "Media limits", + Self::NsfwBlurEnabled | Self::MaxImageSizeMb | Self::MaxVideoSizeMb => "Media", } } @@ -169,6 +178,7 @@ impl DeepSettingsField { | Self::AllowHashtags | Self::AllowMentions => "posts", Self::RegistrationEnabled + | Self::RegistrationCaptchaEnabled | Self::AnonymousModeEnabled | Self::MinPasswordLength | Self::MaxUsernameLen @@ -176,7 +186,7 @@ impl DeepSettingsField { | Self::MaxBioLen | Self::AllowProfileBanners | Self::AllowProfilePictures => "accounts", - Self::MaxImageSizeMb | Self::MaxVideoSizeMb => "media", + Self::NsfwBlurEnabled | Self::MaxImageSizeMb | Self::MaxVideoSizeMb => "media", } } @@ -195,6 +205,7 @@ impl DeepSettingsField { Self::AllowHashtags => "allow_hashtags", Self::AllowMentions => "allow_mentions", Self::RegistrationEnabled => "registration_enabled", + Self::RegistrationCaptchaEnabled => "registration_captcha_enabled", Self::AnonymousModeEnabled => "anonymous_mode_enabled", Self::MinPasswordLength => "min_password_length", Self::MaxUsernameLen => "max_username_len", @@ -202,6 +213,7 @@ impl DeepSettingsField { Self::MaxBioLen => "max_bio_len", Self::AllowProfileBanners => "allow_profile_banners", Self::AllowProfilePictures => "allow_profile_pictures", + Self::NsfwBlurEnabled => "nsfw_blur_enabled", Self::MaxImageSizeMb => "max_image_size", Self::MaxVideoSizeMb => "max_video_size", } @@ -222,6 +234,7 @@ impl DeepSettingsField { Self::AllowHashtags => "allow_hashtags", Self::AllowMentions => "allow_mentions", Self::RegistrationEnabled => "registration_enabled", + Self::RegistrationCaptchaEnabled => "registration_captcha_enabled", Self::AnonymousModeEnabled => "anonymous_mode_enabled", Self::MinPasswordLength => "min_password_length", Self::MaxUsernameLen => "max_username_len", @@ -229,6 +242,7 @@ impl DeepSettingsField { Self::MaxBioLen => "max_bio_len", Self::AllowProfileBanners => "allow_profile_banners", Self::AllowProfilePictures => "allow_profile_pictures", + Self::NsfwBlurEnabled => "nsfw_blur_enabled", Self::MaxImageSizeMb => "max_image_size_mb", Self::MaxVideoSizeMb => "max_video_size_mb", } @@ -249,6 +263,7 @@ impl DeepSettingsField { Self::AllowHashtags => "Allow hashtags", Self::AllowMentions => "Allow mentions", Self::RegistrationEnabled => "Registration enabled", + Self::RegistrationCaptchaEnabled => "Registration CAPTCHA enabled", Self::AnonymousModeEnabled => "Anonymous posting enabled", Self::MinPasswordLength => "Minimum password length", Self::MaxUsernameLen => "Maximum username length", @@ -256,6 +271,7 @@ impl DeepSettingsField { Self::MaxBioLen => "Maximum bio length", Self::AllowProfileBanners => "Allow profile banners", Self::AllowProfilePictures => "Allow profile pictures", + Self::NsfwBlurEnabled => "Blur NSFW media", Self::MaxImageSizeMb => "Maximum image size", Self::MaxVideoSizeMb => "Maximum video size", } @@ -273,6 +289,10 @@ impl DeepSettingsField { Some("Attachments per post.") } Self::MinPasswordLength => Some("Characters. Recommended default is 10."), + Self::RegistrationCaptchaEnabled => Some("Requires a CAPTCHA on registration only."), + Self::NsfwBlurEnabled => Some( + "When true, flagged media is blurred unless a user disables their own blur setting.", + ), Self::MaxImageSizeMb | Self::MaxVideoSizeMb => Some("MB."), _ => None, } @@ -289,9 +309,11 @@ impl DeepSettingsField { | Self::AllowHashtags | Self::AllowMentions | Self::RegistrationEnabled + | Self::RegistrationCaptchaEnabled | Self::AnonymousModeEnabled | Self::AllowProfileBanners - | Self::AllowProfilePictures => DeepSettingsInputKind::Boolean, + | Self::AllowProfilePictures + | Self::NsfwBlurEnabled => DeepSettingsInputKind::Boolean, _ => DeepSettingsInputKind::Number, } } @@ -313,6 +335,7 @@ impl DeepSettingsValues { allow_hashtags: settings.posts.allow_hashtags, allow_mentions: settings.posts.allow_mentions, registration_enabled: settings.accounts.registration_enabled, + registration_captcha_enabled: settings.accounts.registration_captcha_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, @@ -320,6 +343,7 @@ impl DeepSettingsValues { max_bio_len: settings.accounts.max_bio_len, allow_profile_banners: settings.accounts.allow_profile_banners, allow_profile_pictures: settings.accounts.allow_profile_pictures, + nsfw_blur_enabled: settings.media.nsfw_blur_enabled, max_image_size_mb: bytes_to_mb(settings.media.max_image_size), max_video_size_mb: bytes_to_mb(settings.media.max_video_size), } @@ -340,6 +364,9 @@ impl DeepSettingsValues { DeepSettingsField::AllowHashtags => self.allow_hashtags.to_string(), DeepSettingsField::AllowMentions => self.allow_mentions.to_string(), DeepSettingsField::RegistrationEnabled => self.registration_enabled.to_string(), + DeepSettingsField::RegistrationCaptchaEnabled => { + self.registration_captcha_enabled.to_string() + } DeepSettingsField::AnonymousModeEnabled => self.anonymous_mode_enabled.to_string(), DeepSettingsField::MinPasswordLength => self.min_password_length.to_string(), DeepSettingsField::MaxUsernameLen => self.max_username_len.to_string(), @@ -347,6 +374,7 @@ impl DeepSettingsValues { DeepSettingsField::MaxBioLen => self.max_bio_len.to_string(), DeepSettingsField::AllowProfileBanners => self.allow_profile_banners.to_string(), DeepSettingsField::AllowProfilePictures => self.allow_profile_pictures.to_string(), + DeepSettingsField::NsfwBlurEnabled => self.nsfw_blur_enabled.to_string(), DeepSettingsField::MaxImageSizeMb => self.max_image_size_mb.to_string(), DeepSettingsField::MaxVideoSizeMb => self.max_video_size_mb.to_string(), } @@ -383,6 +411,7 @@ impl DeepSettingsValues { updated.posts.allow_hashtags = self.allow_hashtags; updated.posts.allow_mentions = self.allow_mentions; updated.accounts.registration_enabled = self.registration_enabled; + updated.accounts.registration_captcha_enabled = self.registration_captcha_enabled; updated.accounts.anonymous_mode_enabled = self.anonymous_mode_enabled; updated.accounts.min_password_length = self.min_password_length; updated.accounts.max_username_len = self.max_username_len; @@ -390,6 +419,7 @@ impl DeepSettingsValues { updated.accounts.max_bio_len = self.max_bio_len; updated.accounts.allow_profile_banners = self.allow_profile_banners; updated.accounts.allow_profile_pictures = self.allow_profile_pictures; + updated.media.nsfw_blur_enabled = self.nsfw_blur_enabled; updated.media.max_image_size = self.max_image_size_mb * MIB; updated.media.max_video_size = self.max_video_size_mb * MIB; updated @@ -425,6 +455,10 @@ pub fn parse_deep_settings_form( &form.registration_enabled, DeepSettingsField::RegistrationEnabled, )?, + registration_captcha_enabled: parse_bool( + &form.registration_captcha_enabled, + DeepSettingsField::RegistrationCaptchaEnabled, + )?, anonymous_mode_enabled: parse_bool( &form.anonymous_mode_enabled, DeepSettingsField::AnonymousModeEnabled, @@ -447,6 +481,7 @@ pub fn parse_deep_settings_form( &form.allow_profile_pictures, DeepSettingsField::AllowProfilePictures, )?, + nsfw_blur_enabled: parse_bool(&form.nsfw_blur_enabled, DeepSettingsField::NsfwBlurEnabled)?, max_image_size_mb: parse_mb(&form.max_image_size_mb, DeepSettingsField::MaxImageSizeMb)?, max_video_size_mb: parse_mb(&form.max_video_size_mb, DeepSettingsField::MaxVideoSizeMb)?, }; @@ -644,6 +679,9 @@ fn toml_value(field: DeepSettingsField, settings: &Settings) -> String { DeepSettingsField::RegistrationEnabled => { settings.accounts.registration_enabled.to_string() } + DeepSettingsField::RegistrationCaptchaEnabled => { + settings.accounts.registration_captcha_enabled.to_string() + } DeepSettingsField::AnonymousModeEnabled => { settings.accounts.anonymous_mode_enabled.to_string() } @@ -657,6 +695,7 @@ fn toml_value(field: DeepSettingsField, settings: &Settings) -> String { DeepSettingsField::AllowProfilePictures => { settings.accounts.allow_profile_pictures.to_string() } + DeepSettingsField::NsfwBlurEnabled => settings.media.nsfw_blur_enabled.to_string(), DeepSettingsField::MaxImageSizeMb => settings.media.max_image_size.to_string(), DeepSettingsField::MaxVideoSizeMb => settings.media.max_video_size.to_string(), } @@ -719,6 +758,156 @@ pub struct MediaJobFailure { pub error_summary: String, } +const ADMIN_USERS_LIMIT: i64 = 100; +const ADMIN_POST_SEARCH_TERM_LIMIT: usize = 8; +const ADMIN_USERS_SQL: &str = r" +WITH name_matches AS ( + __NAME_MATCH_SQL__ +), +matching_posts AS ( + SELECT p.id, p.user_id, p.text, p.created_at + FROM posts p + WHERE p.user_id IS NOT NULL + AND p.is_deleted = 0 + AND (__POST_MATCH_SQL__) +), +post_match_counts AS ( + SELECT user_id, COUNT(*) AS matching_post_count + FROM matching_posts + GROUP BY user_id +), +ranked_post_matches AS ( + SELECT user_id, text, + ROW_NUMBER() OVER ( + PARTITION BY user_id + ORDER BY created_at DESC, id DESC + ) AS rank + FROM matching_posts +), +post_match_previews AS ( + SELECT user_id, text + FROM ranked_post_matches + WHERE rank = 1 +), +post_stats AS ( + SELECT user_id, COUNT(*) AS total_posts, MAX(created_at) AS last_post_at + FROM posts + WHERE user_id IS NOT NULL AND is_deleted = 0 + GROUP BY user_id +), +media_stats AS ( + SELECT owner_user_id AS user_id, COUNT(*) AS uploaded_media_count + FROM media + WHERE owner_user_id IS NOT NULL + GROUP BY owner_user_id +), +report_stats AS ( + SELECT p.user_id, COUNT(r.id) AS reports_on_posts_count + FROM reports r + JOIN posts p ON p.id = r.post_id + WHERE p.user_id IS NOT NULL + GROUP BY p.user_id +), +session_stats AS ( + SELECT user_id, MAX(created_at) AS last_session_at + FROM sessions + GROUP BY user_id +), +audit_stats AS ( + SELECT target, COUNT(*) AS moderation_action_count + FROM admin_audit_log + WHERE target LIKE 'user:%' + GROUP BY target +) +SELECT + u.id, + u.username, + u.display_name, + u.is_admin, + u.is_suspended, + u.is_deleted, + u.created_at, + u.updated_at, + session_stats.last_session_at, + post_stats.last_post_at, + COALESCE(post_stats.total_posts, 0), + COALESCE(media_stats.uploaded_media_count, 0), + COALESCE(report_stats.reports_on_posts_count, 0), + COALESCE(audit_stats.moderation_action_count, 0), + COALESCE(post_match_counts.matching_post_count, 0), + post_match_previews.text, + name_matches.id IS NOT NULL +FROM users u +LEFT JOIN name_matches ON name_matches.id = u.id +LEFT JOIN post_match_counts ON post_match_counts.user_id = u.id +LEFT JOIN post_match_previews ON post_match_previews.user_id = u.id +LEFT JOIN post_stats ON post_stats.user_id = u.id +LEFT JOIN media_stats ON media_stats.user_id = u.id +LEFT JOIN report_stats ON report_stats.user_id = u.id +LEFT JOIN session_stats ON session_stats.user_id = u.id +LEFT JOIN audit_stats ON audit_stats.target = ('user:' || u.id) +__FILTER_SQL__ +ORDER BY + CASE WHEN name_matches.id IS NOT NULL THEN 0 ELSE 1 END, + COALESCE(post_match_counts.matching_post_count, 0) DESC, + u.id DESC +LIMIT __LIMIT__ +"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AdminUserInvestigation { + pub id: i64, + pub username: String, + pub display_name: String, + pub is_admin: bool, + pub is_suspended: bool, + pub is_deleted: bool, + pub created_at: String, + pub updated_at: String, + pub last_session_at: Option, + pub last_post_at: Option, + pub total_posts: i64, + pub uploaded_media_count: i64, + pub reports_on_posts_count: i64, + pub moderation_action_count: i64, + pub matching_post_count: i64, + pub post_match_preview: Option, + pub matched_name: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AdminUserSearch { + pub user_query: Option, + pub post_search: ParsedAdminPostSearch, +} + +impl AdminUserSearch { + #[must_use] + pub fn new(user_query: &str, post_query: &str) -> Self { + Self { + user_query: normalize_admin_user_search(user_query), + post_search: parse_admin_post_search(post_query), + } + } + + #[must_use] + pub fn has_filter(&self) -> bool { + self.user_query.is_some() || self.post_search.mode.is_some() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedAdminPostSearch { + pub mode: Option, + pub malformed_quotes: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AdminPostSearchMode { + Keywords(Vec), + ExactPhrase(String), +} + pub async fn create_admin( pool: &SqlitePool, settings: &Settings, @@ -844,17 +1033,151 @@ pub async fn audit( .await } -pub async fn users(pool: &SqlitePool) -> anyhow::Result> { - pool.call(|conn| { - let mut stmt = conn.prepare("SELECT id, username, is_admin, is_suspended FROM users WHERE is_deleted = 0 ORDER BY id DESC LIMIT 100")?; +#[must_use] +pub fn normalize_admin_user_search(query: &str) -> Option { + let trimmed = query.trim().trim_start_matches('@').trim(); + (!trimmed.is_empty()).then(|| trimmed.to_ascii_lowercase()) +} + +#[must_use] +pub fn parse_admin_post_search(query: &str) -> ParsedAdminPostSearch { + let trimmed = query.trim(); + if trimmed.is_empty() { + return ParsedAdminPostSearch { + mode: None, + malformed_quotes: false, + }; + } + + if let Some(quoted) = trimmed + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + { + let phrase = quoted.trim(); + return ParsedAdminPostSearch { + mode: (!phrase.is_empty()).then(|| AdminPostSearchMode::ExactPhrase(phrase.to_owned())), + malformed_quotes: false, + }; + } + + let malformed_quotes = trimmed.contains('"'); + let cleaned = trimmed.replace('"', " "); + let terms = cleaned + .split_whitespace() + .take(ADMIN_POST_SEARCH_TERM_LIMIT) + .map(str::to_ascii_lowercase) + .collect::>(); + + ParsedAdminPostSearch { + mode: (!terms.is_empty()).then_some(AdminPostSearchMode::Keywords(terms)), + malformed_quotes, + } +} + +pub async fn users( + pool: &SqlitePool, + search: AdminUserSearch, +) -> anyhow::Result> { + pool.call(move |conn| { + let (sql, sql_params) = admin_users_sql(&search); + let mut stmt = conn.prepare(&sql)?; let rows = stmt - .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get::<_, i64>(2)? != 0, row.get::<_, i64>(3)? != 0)))? + .query_map(params_from_iter(sql_params.iter()), admin_user_from_row)? .collect::, _>>()?; Ok(rows) }) .await } +fn admin_users_sql(search: &AdminUserSearch) -> (String, Vec) { + let mut sql_params = Vec::new(); + let name_match_sql = name_match_sql(search.user_query.as_deref(), &mut sql_params); + let post_match_sql = search.post_search.mode.as_ref().map_or_else( + || "0".to_owned(), + |mode| post_match_condition(mode, &mut sql_params), + ); + let filter_sql = if search.has_filter() { + "WHERE name_matches.id IS NOT NULL OR post_match_counts.matching_post_count IS NOT NULL" + } else { + "" + }; + let sql = ADMIN_USERS_SQL + .replace("__NAME_MATCH_SQL__", name_match_sql) + .replace("__POST_MATCH_SQL__", &post_match_sql) + .replace("__FILTER_SQL__", filter_sql) + .replace("__LIMIT__", &ADMIN_USERS_LIMIT.to_string()); + (sql, sql_params) +} + +fn name_match_sql(user_query: Option<&str>, sql_params: &mut Vec) -> &'static str { + if let Some(user_query) = user_query { + let like = format!("%{}%", escape_like(user_query)); + sql_params.extend([like.clone(), like.clone(), like]); + r" + SELECT id + FROM users + WHERE normalized_username LIKE ? ESCAPE '\' + OR lower(username) LIKE ? ESCAPE '\' + OR lower(display_name) LIKE ? ESCAPE '\' + " + } else { + "SELECT id FROM users WHERE 0" + } +} + +fn admin_user_from_row(row: &Row<'_>) -> rusqlite::Result { + Ok(AdminUserInvestigation { + id: row.get(0)?, + username: row.get(1)?, + display_name: row.get(2)?, + is_admin: row.get::<_, i64>(3)? != 0, + is_suspended: row.get::<_, i64>(4)? != 0, + is_deleted: row.get::<_, i64>(5)? != 0, + created_at: row.get(6)?, + updated_at: row.get(7)?, + last_session_at: row.get(8)?, + last_post_at: row.get(9)?, + total_posts: row.get(10)?, + uploaded_media_count: row.get(11)?, + reports_on_posts_count: row.get(12)?, + moderation_action_count: row.get(13)?, + matching_post_count: row.get(14)?, + post_match_preview: row.get(15)?, + matched_name: row.get::<_, i64>(16)? != 0, + }) +} + +fn post_match_condition(mode: &AdminPostSearchMode, sql_params: &mut Vec) -> String { + match mode { + AdminPostSearchMode::ExactPhrase(phrase) => { + sql_params.push(format!("%{}%", escape_like(&phrase.to_ascii_lowercase()))); + "lower(p.text) LIKE ? ESCAPE '\\'".to_owned() + } + AdminPostSearchMode::Keywords(terms) => { + sql_params.extend(terms.iter().map(|term| format!("%{}%", escape_like(term)))); + terms + .iter() + .map(|_| "lower(p.text) LIKE ? ESCAPE '\\'") + .collect::>() + .join(" AND ") + } + } +} + +fn escape_like(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for character in value.chars() { + match character { + '%' | '_' | '\\' => { + escaped.push('\\'); + escaped.push(character); + } + _ => escaped.push(character), + } + } + escaped +} + pub async fn recent_media_jobs(pool: &SqlitePool) -> anyhow::Result> { pool.call(|conn| { let mut stmt = conn.prepare( @@ -946,6 +1269,31 @@ mod tests { use super::*; + async fn test_pool() -> (tempfile::TempDir, SqlitePool, Settings) { + let temp = tempdir().expect("temp dir"); + let pool = crate::db::connect(&temp.path().join("test.sqlite3")) + .await + .expect("connect"); + crate::db::migrate(&pool).await.expect("migrate"); + (temp, pool, Settings::default()) + } + + async fn register(pool: &SqlitePool, settings: &Settings, username: &str) -> i64 { + auth::register_user(pool, settings, username, "very secure password", false) + .await + .expect("register user") + } + + async fn create_post(pool: &SqlitePool, settings: &Settings, user_id: i64, text: &str) -> i64 { + crate::social::create_post(pool, settings, Some(user_id), text, None, &[]) + .await + .expect("create post") + } + + fn usernames(rows: &[AdminUserInvestigation]) -> Vec<&str> { + rows.iter().map(|row| row.username.as_str()).collect() + } + fn form_from_settings(settings: &Settings) -> DeepSettingsForm { let values = DeepSettingsValues::from_settings(settings); DeepSettingsForm { @@ -963,6 +1311,7 @@ mod tests { allow_hashtags: values.allow_hashtags.to_string(), allow_mentions: values.allow_mentions.to_string(), registration_enabled: values.registration_enabled.to_string(), + registration_captcha_enabled: values.registration_captcha_enabled.to_string(), anonymous_mode_enabled: values.anonymous_mode_enabled.to_string(), min_password_length: values.min_password_length.to_string(), max_username_len: values.max_username_len.to_string(), @@ -970,11 +1319,120 @@ mod tests { max_bio_len: values.max_bio_len.to_string(), allow_profile_banners: values.allow_profile_banners.to_string(), allow_profile_pictures: values.allow_profile_pictures.to_string(), + nsfw_blur_enabled: values.nsfw_blur_enabled.to_string(), max_image_size_mb: values.max_image_size_mb.to_string(), max_video_size_mb: values.max_video_size_mb.to_string(), } } + #[test] + fn admin_post_search_parses_keywords_quotes_and_malformed_quotes() { + assert_eq!( + parse_admin_post_search("rust admin"), + ParsedAdminPostSearch { + mode: Some(AdminPostSearchMode::Keywords(vec![ + "rust".to_owned(), + "admin".to_owned() + ])), + malformed_quotes: false, + } + ); + assert_eq!( + parse_admin_post_search(r#""hello world""#), + ParsedAdminPostSearch { + mode: Some(AdminPostSearchMode::ExactPhrase("hello world".to_owned())), + malformed_quotes: false, + } + ); + assert_eq!( + parse_admin_post_search(r#""hello world"#), + ParsedAdminPostSearch { + mode: Some(AdminPostSearchMode::Keywords(vec![ + "hello".to_owned(), + "world".to_owned() + ])), + malformed_quotes: true, + } + ); + } + + #[tokio::test] + async fn admin_username_search_matches_username_handle_and_display_name() { + let (_temp, pool, settings) = test_pool().await; + let alice = register(&pool, &settings, "alice").await; + register(&pool, &settings, "bob").await; + pool.call(move |conn| { + conn.execute( + "UPDATE users SET display_name = 'Ada Admin' WHERE id = ?", + [alice], + )?; + Ok(()) + }) + .await + .expect("display name"); + + let by_handle = users(&pool, AdminUserSearch::new("@ali", "")) + .await + .expect("search by handle"); + let by_display = users(&pool, AdminUserSearch::new("ada", "")) + .await + .expect("search by display"); + + assert_eq!(usernames(&by_handle), vec!["alice"]); + assert!(by_handle[0].matched_name); + assert_eq!(usernames(&by_display), vec!["alice"]); + } + + #[tokio::test] + async fn admin_plain_post_keyword_search_matches_accounts_with_all_terms() { + let (_temp, pool, settings) = test_pool().await; + let alice = register(&pool, &settings, "alice").await; + let bob = register(&pool, &settings, "bob").await; + create_post(&pool, &settings, alice, "Rust admin investigation notes").await; + create_post(&pool, &settings, bob, "Rust release notes").await; + + let rows = users(&pool, AdminUserSearch::new("", "rust investigation")) + .await + .expect("post search"); + + assert_eq!(usernames(&rows), vec!["alice"]); + assert_eq!(rows[0].matching_post_count, 1); + assert_eq!( + rows[0].post_match_preview.as_deref(), + Some("Rust admin investigation notes") + ); + } + + #[tokio::test] + async fn admin_quoted_post_search_matches_exact_substring_phrase() { + let (_temp, pool, settings) = test_pool().await; + let alice = register(&pool, &settings, "alice").await; + let bob = register(&pool, &settings, "bob").await; + create_post(&pool, &settings, alice, "hello world from alice").await; + create_post(&pool, &settings, bob, "hello careful world from bob").await; + + let rows = users(&pool, AdminUserSearch::new("", r#""hello world""#)) + .await + .expect("phrase search"); + + assert_eq!(usernames(&rows), vec!["alice"]); + } + + #[tokio::test] + async fn admin_post_search_escapes_like_special_characters() { + let (_temp, pool, settings) = test_pool().await; + let alice = register(&pool, &settings, "alice").await; + let bob = register(&pool, &settings, "bob").await; + create_post(&pool, &settings, alice, "token 100%_safe").await; + create_post(&pool, &settings, bob, "token 1000 safe").await; + + let rows = users(&pool, AdminUserSearch::new("", "100%_safe")) + .await + .expect("escaped search"); + + assert_eq!(usernames(&rows), vec!["alice"]); + } + #[test] fn deep_settings_form_parsing_accepts_valid_values() { let settings = Settings::default(); @@ -982,12 +1440,14 @@ mod tests { form.site_name = "Custom Site".to_owned(); form.max_bio_len = "300".to_owned(); form.allow_profile_pictures = "false".to_owned(); + form.nsfw_blur_enabled = "false".to_owned(); let parsed = parse_deep_settings_form(&form, &settings).expect("valid form"); assert_eq!(parsed.site_name, "Custom Site"); assert_eq!(parsed.max_bio_len, 300); assert!(!parsed.allow_profile_pictures); + assert!(!parsed.nsfw_blur_enabled); } #[test] @@ -1035,6 +1495,7 @@ mod tests { let mut form = form_from_settings(&settings); form.max_bio_len = "300".to_owned(); form.allow_profile_pictures = "false".to_owned(); + form.nsfw_blur_enabled = "false".to_owned(); let parsed = parse_deep_settings_form(&form, &settings).expect("valid form"); let diff = diff_deep_settings(&settings, &parsed); @@ -1052,6 +1513,11 @@ mod tests { old_value: "true".to_owned(), new_value: "false".to_owned(), }, + DeepSettingsChange { + label: "Blur NSFW media", + old_value: "true".to_owned(), + new_value: "false".to_owned(), + }, ] ); } @@ -1089,6 +1555,7 @@ mod tests { let mut settings = Settings::load(&path).expect("load settings"); settings.site.name = "Written Site".to_owned(); settings.accounts.max_bio_len = 300; + settings.media.nsfw_blur_enabled = false; settings.media.max_image_size = 8 * MIB; write_deep_settings(&path, &settings).expect("write settings"); @@ -1098,6 +1565,7 @@ mod tests { assert!(raw.contains("# RustPost settings")); assert_eq!(parsed.site.name, "Written Site"); assert_eq!(parsed.accounts.max_bio_len, 300); + assert!(!parsed.media.nsfw_blur_enabled); assert_eq!(parsed.media.max_image_size, 8 * MIB); assert_eq!(parsed.server.port, Settings::default().server.port); assert_eq!( diff --git a/src/auth.rs b/src/auth.rs index 7eb82a4..c8b5eaa 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -44,6 +44,7 @@ pub struct CurrentUser { pub is_admin: bool, pub is_suspended: bool, pub theme: Theme, + pub nsfw_blur_enabled: bool, } #[derive(Debug, Clone)] @@ -218,7 +219,7 @@ pub async fn current_user( pool.call(move |conn| { conn.query_row( r#" - SELECT u.id, u.username, u.display_name, u.is_admin, u.is_suspended, u.theme + SELECT u.id, u.username, u.display_name, u.is_admin, u.is_suspended, u.theme, u.nsfw_blur_enabled FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ? AND s.revoked_at IS NULL AND s.expires_at > CURRENT_TIMESTAMP AND u.is_deleted = 0 @@ -232,6 +233,7 @@ pub async fn current_user( is_admin: row.get::<_, i64>(3)? != 0, is_suspended: row.get::<_, i64>(4)? != 0, theme: Theme::from(row.get::<_, String>(5)?.as_str()), + nsfw_blur_enabled: row.get::<_, i64>(6)? != 0, }) }, ) diff --git a/src/compression.rs b/src/compression.rs new file mode 100644 index 0000000..a7d31b5 --- /dev/null +++ b/src/compression.rs @@ -0,0 +1,433 @@ +use std::io::Write as _; + +use axum::body::{Body, to_bytes}; +use axum::extract::Request; +use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header}; +use axum::middleware::Next; +use axum::response::Response; +use flate2::Compression; +use flate2::write::GzEncoder; + +const MIN_COMPRESSIBLE_BYTES: usize = 1024; +const MAX_BUFFERED_RESPONSE_BYTES: usize = 8 * 1024 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ResponseEncoding { + Gzip, +} + +impl ResponseEncoding { + const fn header_value(self) -> &'static str { + match self { + Self::Gzip => "gzip", + } + } +} + +pub async fn response_compression(mut request: Request, next: Next) -> Response { + let is_head = request.method() == Method::HEAD; + let path = request.uri().path().to_owned(); + let encoding = choose_response_encoding(request.headers()); + + if is_head { + *request.method_mut() = Method::GET; + } + + let response = next.run(request).await; + let Some(encoding) = encoding else { + if is_head && should_buffer_head_response(&path, &response) { + return head_response_with_get_length(response).await; + } + return head_safe_response(response, is_head); + }; + + if should_skip_path(&path) || !response_is_compressible(&response) { + if is_head && should_buffer_head_response(&path, &response) { + return head_response_with_get_length(response).await; + } + return head_safe_response(response, is_head); + } + + let (mut parts, body) = response.into_parts(); + let body = match to_bytes(body, MAX_BUFFERED_RESPONSE_BYTES).await { + Ok(body) => body, + Err(error) => { + tracing::warn!(error = %error, "failed to buffer response for compression"); + return Response::from_parts(parts, Body::empty()); + } + }; + + if !meets_minimum_size_threshold(body.len()) { + set_content_length(&mut parts.headers, body.len()); + return Response::from_parts(parts, body_for_method(body.to_vec(), is_head)); + } + + let compressed = match gzip(&body) { + Ok(compressed) => compressed, + Err(error) => { + tracing::warn!(error = %error, "failed to compress response"); + set_content_length(&mut parts.headers, body.len()); + return Response::from_parts(parts, body_for_method(body.to_vec(), is_head)); + } + }; + + if compressed.len() >= body.len() { + set_content_length(&mut parts.headers, body.len()); + return Response::from_parts(parts, body_for_method(body.to_vec(), is_head)); + } + + parts.headers.insert( + header::CONTENT_ENCODING, + HeaderValue::from_static(encoding.header_value()), + ); + merge_vary_accept_encoding(&mut parts.headers); + set_content_length(&mut parts.headers, compressed.len()); + Response::from_parts(parts, body_for_method(compressed, is_head)) +} + +fn response_is_compressible(response: &Response) -> bool { + status_allows_body(response.status()) + && !has_content_encoding(response.headers()) + && content_length_allows_buffering(response.headers()) + && response + .headers() + .get(header::CONTENT_TYPE) + .is_some_and(is_compressible_content_type) +} + +const fn meets_minimum_size_threshold(len: usize) -> bool { + len >= MIN_COMPRESSIBLE_BYTES +} + +fn head_safe_response(response: Response, is_head: bool) -> Response { + if !is_head { + return response; + } + let (parts, _body) = response.into_parts(); + Response::from_parts(parts, Body::empty()) +} + +fn should_buffer_head_response(path: &str, response: &Response) -> bool { + !path.starts_with("/uploads/") + && !response.headers().contains_key(header::CONTENT_LENGTH) + && status_allows_body(response.status()) + && content_length_allows_buffering(response.headers()) +} + +async fn head_response_with_get_length(response: Response) -> Response { + let (mut parts, body) = response.into_parts(); + match to_bytes(body, MAX_BUFFERED_RESPONSE_BYTES).await { + Ok(body) => { + set_content_length(&mut parts.headers, body.len()); + Response::from_parts(parts, Body::empty()) + } + Err(error) => { + tracing::warn!(error = %error, "failed to buffer HEAD response"); + Response::from_parts(parts, Body::empty()) + } + } +} + +fn body_for_method(body: Vec, is_head: bool) -> Body { + if is_head { + Body::empty() + } else { + Body::from(body) + } +} + +fn content_length_allows_buffering(headers: &HeaderMap) -> bool { + headers + .get(header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .is_none_or(|len| len <= MAX_BUFFERED_RESPONSE_BYTES) +} + +fn set_content_length(headers: &mut HeaderMap, len: usize) { + let value = len.to_string(); + match HeaderValue::from_str(&value) { + Ok(value) => { + headers.insert(header::CONTENT_LENGTH, value); + } + Err(error) => { + tracing::warn!(error = %error, "failed to set compressed content length"); + headers.remove(header::CONTENT_LENGTH); + } + } +} + +fn status_allows_body(status: StatusCode) -> bool { + !status.is_informational() + && status != StatusCode::NO_CONTENT + && status != StatusCode::NOT_MODIFIED +} + +fn should_skip_path(path: &str) -> bool { + path == "/favicon.ico" || path.starts_with("/uploads/") +} + +fn has_content_encoding(headers: &HeaderMap) -> bool { + headers.contains_key(header::CONTENT_ENCODING) +} + +fn choose_response_encoding(headers: &HeaderMap) -> Option { + let mut gzip = EncodingPreference::absent(); + for value in headers.get_all(header::ACCEPT_ENCODING) { + let Ok(value) = value.to_str() else { + continue; + }; + for accepted in parse_accept_encoding(value) { + if accepted.name.eq_ignore_ascii_case("gzip") { + gzip.merge(accepted.q); + } + } + } + + if gzip.is_accepted() { + Some(ResponseEncoding::Gzip) + } else { + None + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +struct EncodingPreference { + q: f32, + present: bool, +} + +impl EncodingPreference { + const fn absent() -> Self { + Self { + q: 0.0, + present: false, + } + } + + fn merge(&mut self, q: f32) { + self.present = true; + self.q = self.q.max(q); + } + + const fn is_accepted(self) -> bool { + self.present && self.q > 0.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +struct AcceptedEncoding<'a> { + name: &'a str, + q: f32, +} + +fn parse_accept_encoding(value: &str) -> impl Iterator> { + value.split(',').filter_map(|part| { + let mut pieces = part.split(';'); + let name = pieces.next()?.trim(); + if name.is_empty() { + return None; + } + let mut q = 1.0_f32; + for parameter in pieces { + let Some((key, value)) = parameter.trim().split_once('=') else { + continue; + }; + if key.trim().eq_ignore_ascii_case("q") { + q = value.trim().parse::().unwrap_or(0.0).clamp(0.0, 1.0); + } + } + Some(AcceptedEncoding { name, q }) + }) +} + +fn is_compressible_content_type(value: &HeaderValue) -> bool { + let Ok(value) = value.to_str() else { + return false; + }; + let media_type = value + .split_once(';') + .map_or(value, |(media_type, _parameters)| media_type) + .trim() + .to_ascii_lowercase(); + + media_type.starts_with("text/") + || matches!( + media_type.as_str(), + "application/javascript" + | "application/json" + | "application/manifest+json" + | "application/rss+xml" + | "application/xhtml+xml" + | "application/xml" + | "image/svg+xml" + ) + || media_type.ends_with("+json") + || media_type.ends_with("+xml") +} + +fn gzip(body: &[u8]) -> std::io::Result> { + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(body)?; + encoder.finish() +} + +fn merge_vary_accept_encoding(headers: &mut HeaderMap) { + let mut values = headers + .get_all(header::VARY) + .iter() + .filter_map(|value| value.to_str().ok()) + .collect::>(); + + if values.iter().any(|value| { + vary_contains_token(value, "*") || vary_contains_token(value, "accept-encoding") + }) { + return; + } + + values.push("Accept-Encoding"); + let merged = values.join(", "); + match HeaderValue::from_str(&merged) { + Ok(value) => { + headers.insert(header::VARY, value); + } + Err(error) => { + tracing::warn!(error = %error, "failed to merge Vary header"); + } + } +} + +fn vary_contains_token(value: &str, token: &str) -> bool { + value + .split(',') + .any(|part| part.trim().eq_ignore_ascii_case(token)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accept_encoding_parser_reads_q_values() { + let encodings = parse_accept_encoding("br, gzip;q=0.7, deflate; q=0").collect::>(); + + assert_eq!( + encodings, + vec![ + AcceptedEncoding { name: "br", q: 1.0 }, + AcceptedEncoding { + name: "gzip", + q: 0.7 + }, + AcceptedEncoding { + name: "deflate", + q: 0.0 + }, + ] + ); + } + + #[test] + fn chooses_gzip_only_when_explicitly_accepted() { + let mut headers = HeaderMap::new(); + assert_eq!(choose_response_encoding(&headers), None); + + headers.insert(header::ACCEPT_ENCODING, HeaderValue::from_static("br")); + assert_eq!(choose_response_encoding(&headers), None); + + headers.insert( + header::ACCEPT_ENCODING, + HeaderValue::from_static("gzip;q=0"), + ); + assert_eq!(choose_response_encoding(&headers), None); + + headers.insert( + header::ACCEPT_ENCODING, + HeaderValue::from_static("br, gzip;q=0.5"), + ); + assert_eq!( + choose_response_encoding(&headers), + Some(ResponseEncoding::Gzip) + ); + } + + #[test] + fn content_type_decision_accepts_text_candidates() { + for value in [ + "text/html; charset=utf-8", + "text/css", + "application/javascript; charset=utf-8", + "application/json", + "application/activity+json", + "application/xml", + "image/svg+xml", + ] { + assert!(is_compressible_content_type(&HeaderValue::from_static( + value + ))); + } + } + + #[test] + fn content_type_decision_rejects_binary_media() { + for value in [ + "image/png", + "image/webp", + "video/mp4", + "audio/mpeg", + "application/pdf", + "application/zip", + "application/octet-stream", + ] { + assert!(!is_compressible_content_type(&HeaderValue::from_static( + value + ))); + } + } + + #[test] + fn already_encoded_responses_are_not_candidates() { + let mut response = Response::builder() + .header(header::CONTENT_TYPE, "text/html") + .body(Body::from("")) + .expect("response"); + assert!(response_is_compressible(&response)); + + response + .headers_mut() + .insert(header::CONTENT_ENCODING, HeaderValue::from_static("br")); + assert!(!response_is_compressible(&response)); + } + + #[test] + fn content_length_over_buffer_limit_is_not_candidate() { + let response = Response::builder() + .header(header::CONTENT_TYPE, "text/html") + .header( + header::CONTENT_LENGTH, + (MAX_BUFFERED_RESPONSE_BYTES + 1).to_string(), + ) + .body(Body::empty()) + .expect("response"); + + assert!(!response_is_compressible(&response)); + } + + #[test] + fn size_threshold_skips_tiny_responses() { + assert!(!meets_minimum_size_threshold(MIN_COMPRESSIBLE_BYTES - 1)); + assert!(meets_minimum_size_threshold(MIN_COMPRESSIBLE_BYTES)); + } + + #[test] + fn vary_accept_encoding_is_merged_without_clobbering() { + let mut headers = HeaderMap::new(); + headers.insert(header::VARY, HeaderValue::from_static("Cookie")); + + merge_vary_accept_encoding(&mut headers); + assert_eq!(headers[header::VARY], "Cookie, Accept-Encoding"); + + merge_vary_accept_encoding(&mut headers); + assert_eq!(headers[header::VARY], "Cookie, Accept-Encoding"); + } +} diff --git a/src/config.rs b/src/config.rs index c071f03..3c870ae 100644 --- a/src/config.rs +++ b/src/config.rs @@ -42,6 +42,8 @@ pub struct ServerSettings { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AccountSettings { pub registration_enabled: bool, + #[serde(default)] + pub registration_captcha_enabled: bool, pub anonymous_mode_enabled: bool, pub min_password_length: usize, pub max_username_len: usize, @@ -71,6 +73,8 @@ pub struct MediaSettings { pub convert_images_to_webp: bool, pub convert_videos_to_webm: bool, pub keep_original_uploads: bool, + #[serde(default = "default_true")] + pub nsfw_blur_enabled: bool, pub max_image_size: u64, pub max_video_size: u64, pub generate_video_thumbnails: bool, @@ -87,6 +91,8 @@ pub struct TorSettings { pub tor_only: bool, pub data_dir: String, pub onion_service_name: String, + #[serde(default)] + pub display_onion_address: String, pub bootstrap_timeout_secs: u64, pub max_concurrent_streams: usize, pub include_tor_keys_in_backups_by_default: bool, @@ -126,6 +132,7 @@ impl Default for Settings { }, accounts: AccountSettings { registration_enabled: true, + registration_captcha_enabled: false, anonymous_mode_enabled: false, min_password_length: 10, max_username_len: 32, @@ -151,6 +158,7 @@ impl Default for Settings { convert_images_to_webp: true, convert_videos_to_webm: true, keep_original_uploads: false, + nsfw_blur_enabled: true, max_image_size: 52_428_800, max_video_size: 157_286_400, generate_video_thumbnails: true, @@ -174,6 +182,7 @@ impl Default for Settings { tor_only: false, data_dir: "tor".to_owned(), onion_service_name: "microblog".to_owned(), + display_onion_address: String::new(), bootstrap_timeout_secs: 120, max_concurrent_streams: 512, include_tor_keys_in_backups_by_default: false, @@ -217,6 +226,7 @@ impl Settings { anyhow::bail!("tor.tor_only requires tor.enabled"); } validate_onion_service_name(&self.tor.onion_service_name)?; + validate_display_onion_address(&self.tor.display_onion_address)?; Ok(()) } } @@ -279,6 +289,9 @@ trusted_proxy_cidrs = {trusted_proxy_cidrs} # Allow visitors to create accounts from the web UI. registration_enabled = {registration_enabled} +# Require a CAPTCHA challenge on account creation. Login is not affected. +registration_captcha_enabled = {registration_captcha_enabled} + # Allow posting without accounts. Rate limits are keyed by client IP. anonymous_mode_enabled = {anonymous_mode_enabled} @@ -351,6 +364,9 @@ convert_videos_to_webm = {convert_videos_to_webm} # false reduces stored untrusted file formats. keep_original_uploads = {keep_original_uploads} +# Blur media that users or admins mark as NSFW. Safe default is true. +nsfw_blur_enabled = {nsfw_blur_enabled} + # SECURITY: Maximum accepted image upload size in bytes. Default is 50 MiB. max_image_size = {max_image_size} @@ -392,6 +408,10 @@ data_dir = {tor_data_dir} # Local name for the onion service state directory. onion_service_name = {onion_service_name} +# Optional stable onion address to show in the site header before Arti has +# reported the active service address. Leave blank to use the runtime address. +display_onion_address = {display_onion_address} + # Seconds to wait for Tor bootstrap during Tor-only startup. bootstrap_timeout_secs = {bootstrap_timeout_secs} @@ -449,6 +469,7 @@ backup_dir = {backup_dir} cookie_secure = settings.server.cookie_secure, trusted_proxy_cidrs = toml_string_array(&settings.server.trusted_proxy_cidrs), registration_enabled = settings.accounts.registration_enabled, + registration_captcha_enabled = settings.accounts.registration_captcha_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, @@ -470,6 +491,7 @@ backup_dir = {backup_dir} 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, + nsfw_blur_enabled = settings.media.nsfw_blur_enabled, max_image_size = settings.media.max_image_size, max_video_size = settings.media.max_video_size, generate_video_thumbnails = settings.media.generate_video_thumbnails, @@ -482,6 +504,7 @@ backup_dir = {backup_dir} 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), + display_onion_address = toml_string(&settings.tor.display_onion_address), bootstrap_timeout_secs = settings.tor.bootstrap_timeout_secs, max_concurrent_streams = settings.tor.max_concurrent_streams, include_tor_keys_in_backups_by_default = @@ -547,6 +570,32 @@ fn validate_onion_service_name(value: &str) -> anyhow::Result<()> { Ok(()) } +fn validate_display_onion_address(value: &str) -> anyhow::Result<()> { + if value.is_empty() { + return Ok(()); + } + if value.trim() != value { + anyhow::bail!("tor.display_onion_address must not contain surrounding whitespace"); + } + let Some(service_id) = value.strip_suffix(".onion") else { + anyhow::bail!("tor.display_onion_address must end with .onion"); + }; + if service_id.len() != 56 { + anyhow::bail!("tor.display_onion_address must be a v3 onion address"); + } + if !service_id + .bytes() + .all(|byte| byte.is_ascii_lowercase() || (b'2'..=b'7').contains(&byte)) + { + anyhow::bail!("tor.display_onion_address must contain only lowercase base32 characters"); + } + Ok(()) +} + +const fn default_true() -> bool { + true +} + #[cfg(test)] mod tests { use super::*; @@ -568,6 +617,32 @@ mod tests { parsed.validate().expect("generated config validates"); } + #[test] + fn missing_nsfw_blur_setting_defaults_to_safe_enabled() { + let raw = toml::to_string(&Settings::default()) + .expect("settings toml") + .lines() + .filter(|line| !line.trim_start().starts_with("nsfw_blur_enabled")) + .collect::>() + .join("\n"); + let parsed: Settings = toml::from_str(&raw).expect("legacy settings parse"); + + assert!(parsed.media.nsfw_blur_enabled); + } + + #[test] + fn missing_display_onion_address_defaults_to_blank() { + let raw = toml::to_string(&Settings::default()) + .expect("settings toml") + .lines() + .filter(|line| !line.trim_start().starts_with("display_onion_address")) + .collect::>() + .join("\n"); + let parsed: Settings = toml::from_str(&raw).expect("legacy settings parse"); + + assert!(parsed.tor.display_onion_address.is_empty()); + } + #[test] fn tor_only_requires_tor_enabled() { let mut settings = Settings::default(); @@ -590,6 +665,21 @@ mod tests { assert!(settings.validate().is_err()); } + #[test] + fn validates_display_onion_address() { + let mut settings = Settings::default(); + settings.tor.display_onion_address = + "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion".to_owned(); + settings.validate().expect("valid display onion address"); + + settings.tor.display_onion_address = "examplehiddenservice.onion".to_owned(); + assert!(settings.validate().is_err()); + + settings.tor.display_onion_address = + "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWX.onion".to_owned(); + assert!(settings.validate().is_err()); + } + #[test] fn allows_operator_chosen_numeric_limits() { let mut settings = Settings::default(); @@ -622,6 +712,7 @@ mod tests { [accounts] registration_enabled = true + registration_captcha_enabled = false anonymous_mode_enabled = false min_password_length = 10 max_username_len = 32 @@ -683,9 +774,27 @@ mod tests { ) .expect("settings without site"); assert_eq!(settings.site.name, "RustPost"); + assert!(!settings.accounts.registration_captcha_enabled); let mut settings = Settings::default(); settings.site.name = " Custom".to_owned(); assert!(settings.validate().is_err()); } + + #[test] + fn registration_captcha_defaults_disabled_when_missing() { + let generated = default_settings_toml(); + let without_captcha = generated + .lines() + .filter(|line| { + !line + .trim_start() + .starts_with("registration_captcha_enabled") + }) + .collect::>() + .join("\n"); + let settings: Settings = toml::from_str(&without_captcha).expect("legacy settings parse"); + + assert!(!settings.accounts.registration_captcha_enabled); + } } diff --git a/src/db.rs b/src/db.rs index 31fb991..bceec93 100644 --- a/src/db.rs +++ b/src/db.rs @@ -120,6 +120,14 @@ pub async fn migrate(pool: &Db) -> anyhow::Result<()> { tx.execute_batch(MIGRATION_9)?; tx.execute("INSERT INTO schema_migrations (version) VALUES (9)", [])?; } + if applied.unwrap_or(0) < 10 { + tx.execute_batch(MIGRATION_10)?; + tx.execute("INSERT INTO schema_migrations (version) VALUES (10)", [])?; + } + if applied.unwrap_or(0) < 11 { + tx.execute_batch(MIGRATION_11)?; + tx.execute("INSERT INTO schema_migrations (version) VALUES (11)", [])?; + } tx.commit()?; Ok(()) }) @@ -352,6 +360,76 @@ CREATE INDEX IF NOT EXISTS idx_notifications_user_id_desc ON notifications(user_ CREATE INDEX IF NOT EXISTS idx_notifications_dedupe ON notifications(user_id, actor_user_id, post_id, kind); "#; +const MIGRATION_10: &str = r#" +ALTER TABLE media ADD COLUMN is_nsfw INTEGER NOT NULL DEFAULT 0; +ALTER TABLE users ADD COLUMN nsfw_blur_enabled INTEGER NOT NULL DEFAULT 1; + +CREATE INDEX IF NOT EXISTS idx_media_is_nsfw ON media(is_nsfw); +"#; + +const MIGRATION_11: &str = r#" +ALTER TABLE media ADD COLUMN original_path TEXT; +ALTER TABLE media ADD COLUMN original_public_path TEXT; +ALTER TABLE media ADD COLUMN original_sha256 TEXT NOT NULL DEFAULT ''; +ALTER TABLE media ADD COLUMN normalized_sha256 TEXT; +ALTER TABLE media ADD COLUMN canonical_media_id INTEGER REFERENCES media(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_media_original_sha256 + ON media(media_kind, original_sha256) + WHERE original_sha256 != ''; +CREATE INDEX IF NOT EXISTS idx_media_normalized_sha256 + ON media(media_kind, normalized_sha256) + WHERE normalized_sha256 IS NOT NULL AND normalized_sha256 != ''; +CREATE INDEX IF NOT EXISTS idx_media_canonical_media_id ON media(canonical_media_id); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_media_unique_canonical_original_sha256 + ON media(media_kind, original_sha256) + WHERE canonical_media_id IS NULL AND original_sha256 != ''; +CREATE UNIQUE INDEX IF NOT EXISTS idx_media_unique_canonical_normalized_sha256 + ON media(media_kind, normalized_sha256) + WHERE canonical_media_id IS NULL AND normalized_sha256 IS NOT NULL AND normalized_sha256 != ''; + +CREATE TRIGGER IF NOT EXISTS media_canonical_ref_insert +BEFORE INSERT ON media +WHEN NEW.canonical_media_id IS NOT NULL +BEGIN + SELECT CASE + WHEN NEW.id = NEW.canonical_media_id + THEN RAISE(ABORT, 'media cannot reference itself as canonical') + END; + SELECT CASE + WHEN NOT EXISTS ( + SELECT 1 + FROM media canonical + WHERE canonical.id = NEW.canonical_media_id + AND canonical.canonical_media_id IS NULL + AND canonical.media_kind = NEW.media_kind + ) + THEN RAISE(ABORT, 'media canonical reference must point to same-kind canonical media') + END; +END; + +CREATE TRIGGER IF NOT EXISTS media_canonical_ref_update +BEFORE UPDATE OF canonical_media_id, media_kind ON media +WHEN NEW.canonical_media_id IS NOT NULL +BEGIN + SELECT CASE + WHEN NEW.id = NEW.canonical_media_id + THEN RAISE(ABORT, 'media cannot reference itself as canonical') + END; + SELECT CASE + WHEN NOT EXISTS ( + SELECT 1 + FROM media canonical + WHERE canonical.id = NEW.canonical_media_id + AND canonical.canonical_media_id IS NULL + AND canonical.media_kind = NEW.media_kind + ) + THEN RAISE(ABORT, 'media canonical reference must point to same-kind canonical media') + END; +END; +"#; + #[cfg(test)] mod tests { use super::*; @@ -393,12 +471,48 @@ mod tests { .await .expect("settings"); - assert_eq!(versions, 9); + assert_eq!(versions, 11); assert_eq!(foreign_keys, 1); assert_eq!(journal_mode, "wal"); assert!(busy_timeout >= 5_000); } + #[tokio::test] + async fn canonical_media_constraints_reject_unsafe_references() { + let temp = tempfile::tempdir().expect("temp dir"); + let pool = connect(&temp.path().join("test.sqlite3")) + .await + .expect("connect"); + migrate(&pool).await.expect("migrate"); + + pool.call(|conn| { + conn.execute( + "INSERT INTO media (id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len, original_sha256, normalized_sha256) VALUES (10, 'image.webp', '/tmp/image.webp', '/uploads/images/image.webp', 'image/webp', 'image', 1, 'raw', 'norm')", + [], + )?; + let duplicate_canonical = conn.execute( + "INSERT INTO media (original_filename, stored_path, public_path, mime_type, media_kind, byte_len, original_sha256) VALUES ('copy.webp', '/tmp/copy.webp', '/uploads/images/copy.webp', 'image/webp', 'image', 1, 'raw')", + [], + ); + assert!(duplicate_canonical.is_err()); + + let cross_kind = conn.execute( + "INSERT INTO media (original_filename, stored_path, public_path, mime_type, media_kind, byte_len, canonical_media_id) VALUES ('clip.webm', '/tmp/clip.webm', '/uploads/videos/clip.webm', 'video/webm', 'video', 1, 10)", + [], + ); + assert!(cross_kind.is_err()); + + let self_reference = conn.execute( + "INSERT INTO media (id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len, canonical_media_id) VALUES (11, 'self.webp', '/tmp/self.webp', '/uploads/images/self.webp', 'image/webp', 'image', 1, 11)", + [], + ); + assert!(self_reference.is_err()); + Ok(()) + }) + .await + .expect("constraint checks"); + } + #[tokio::test] async fn users_get_light_theme_by_default() { let temp = tempfile::tempdir().expect("temp dir"); @@ -461,6 +575,42 @@ mod tests { assert_eq!(location, ""); } + #[tokio::test] + async fn nsfw_defaults_are_safe_for_users_and_media() { + let temp = tempfile::tempdir().expect("temp dir"); + let pool = connect(&temp.path().join("test.sqlite3")) + .await + .expect("connect"); + migrate(&pool).await.expect("migrate"); + + let (blur_enabled, is_nsfw): (i64, i64) = pool + .call(|conn| { + conn.execute( + "INSERT INTO users (username, normalized_username, password_hash, display_name) VALUES ('Alice', 'alice', 'hash', 'Alice')", + [], + )?; + conn.execute( + "INSERT INTO media (owner_user_id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len) VALUES (1, 'x.png', '/tmp/x.png', '/uploads/images/x.png', 'image/png', 'image', 1)", + [], + )?; + Ok(( + conn.query_row( + "SELECT nsfw_blur_enabled FROM users WHERE normalized_username = 'alice'", + [], + |row| row.get(0), + )?, + conn.query_row("SELECT is_nsfw FROM media WHERE id = 1", [], |row| { + row.get(0) + })?, + )) + }) + .await + .expect("defaults"); + + assert_eq!(blur_enabled, 1); + assert_eq!(is_nsfw, 0); + } + #[tokio::test] async fn posts_support_quote_repost_references() { let temp = tempfile::tempdir().expect("temp dir"); diff --git a/src/favicon.rs b/src/favicon.rs index 230c023..d835b1c 100644 --- a/src/favicon.rs +++ b/src/favicon.rs @@ -14,12 +14,41 @@ use crate::runtime::RuntimePaths; const FAVICON_BASENAME: &str = "favicon"; const FAVICON_EXTENSIONS: [&str; 3] = ["ico", "png", "svg"]; const DEFAULT_FAVICON: &[u8] = &[ - 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x20, 0x00, 0x45, 0x00, + 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x20, 0x20, 0x00, 0x00, 0x01, 0x00, 0x20, 0x00, 0x12, 0x02, 0x00, 0x00, 0x16, 0x00, 0x00, 0x00, 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, + 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x20, 0x08, 0x06, + 0x00, 0x00, 0x00, 0x73, 0x7a, 0x7a, 0xf4, 0x00, 0x00, 0x01, 0xd9, 0x49, 0x44, 0x41, 0x54, 0x78, + 0xda, 0xcd, 0x57, 0x3d, 0x4b, 0x03, 0x41, 0x10, 0xcd, 0xaf, 0xb0, 0xb6, 0xb2, 0x93, 0x2b, 0x6c, + 0x14, 0x51, 0x04, 0x1b, 0x53, 0x58, 0xa9, 0x95, 0xc8, 0x69, 0x27, 0x82, 0x76, 0x41, 0x4b, 0x41, + 0x11, 0x11, 0x8b, 0x13, 0x05, 0x0b, 0x03, 0x82, 0xad, 0xc8, 0x15, 0xb1, 0x08, 0x5a, 0x2c, 0x88, + 0xda, 0x28, 0x5c, 0x7e, 0x81, 0x04, 0xd1, 0x2e, 0xf1, 0x02, 0x76, 0x2a, 0x8c, 0xf7, 0x16, 0x37, + 0x6c, 0x2e, 0x9b, 0xec, 0x5e, 0xb2, 0x17, 0x73, 0xf0, 0xc8, 0x24, 0x99, 0x9b, 0x79, 0xf3, 0xde, + 0xde, 0xc7, 0x66, 0x32, 0xfd, 0x78, 0x0c, 0x8c, 0x3b, 0x6e, 0x04, 0x3f, 0x02, 0x8b, 0x40, 0x5d, + 0x82, 0xfd, 0xd5, 0x72, 0xb5, 0x8d, 0x87, 0xb2, 0x93, 0x4e, 0x84, 0x20, 0x02, 0xa5, 0x04, 0xd4, + 0x76, 0xda, 0x35, 0x0f, 0x6d, 0x35, 0x1b, 0x99, 0xcf, 0xd2, 0xce, 0xa9, 0x47, 0xde, 0x45, 0x9e, + 0x63, 0xca, 0x5d, 0x10, 0xff, 0x85, 0x4a, 0x12, 0x36, 0x27, 0x9f, 0x5d, 0x5b, 0xa1, 0x6a, 0xed, + 0x83, 0x3e, 0x6b, 0x55, 0x7a, 0x79, 0x7e, 0xa4, 0xca, 0x5b, 0x99, 0xbe, 0x7f, 0xbe, 0x28, 0x77, + 0xb8, 0x5b, 0x57, 0xa2, 0xc9, 0x73, 0x9b, 0x93, 0xa3, 0x79, 0xe9, 0xfa, 0x8a, 0x8e, 0x66, 0xc6, + 0xe8, 0x60, 0x62, 0x98, 0xe3, 0xd6, 0xdb, 0xe3, 0x24, 0x16, 0x73, 0xeb, 0x3c, 0xaf, 0x61, 0x4d, + 0x60, 0x91, 0xa8, 0x8a, 0x41, 0x3a, 0x14, 0x5b, 0xdd, 0xde, 0x32, 0x8a, 0x71, 0x0e, 0x64, 0xc7, + 0xe4, 0x72, 0x73, 0x01, 0xa8, 0x51, 0xbc, 0x67, 0x82, 0x80, 0x2f, 0x13, 0x60, 0x2a, 0x02, 0x28, + 0x0c, 0xd6, 0x38, 0xc9, 0x24, 0x16, 0xa4, 0xd1, 0x28, 0xde, 0x1c, 0xb8, 0xcb, 0x1f, 0xd3, 0x43, + 0xf0, 0x24, 0x08, 0x30, 0x99, 0x80, 0x52, 0x4e, 0x4c, 0x85, 0xc2, 0xf0, 0xd4, 0x24, 0x16, 0x04, + 0xe0, 0xb9, 0x8a, 0x00, 0x6c, 0x91, 0x08, 0x90, 0x96, 0x40, 0x5c, 0x5e, 0x1d, 0x90, 0x0f, 0x35, + 0x00, 0x78, 0x2e, 0x37, 0x3f, 0x5f, 0x9e, 0x6b, 0x58, 0x88, 0x46, 0x04, 0xe2, 0xf2, 0xea, 0x20, + 0xf2, 0xcb, 0xef, 0xaf, 0xfc, 0x13, 0x56, 0x40, 0x76, 0x4c, 0x8e, 0xef, 0x97, 0xc5, 0x42, 0x3d, + 0xd7, 0x88, 0x40, 0x5c, 0x5e, 0x1d, 0xe4, 0x7c, 0xac, 0x76, 0xc4, 0x90, 0x1c, 0x90, 0x2e, 0xc1, + 0x74, 0x2d, 0x30, 0xc9, 0x1f, 0x9c, 0x1e, 0x4d, 0xd7, 0x82, 0x76, 0xf9, 0xa2, 0x79, 0x13, 0x01, + 0x15, 0x96, 0x36, 0x37, 0xa8, 0xc0, 0x6e, 0xf8, 0x6d, 0xd4, 0xe4, 0xc1, 0x93, 0x34, 0x5f, 0x4b, + 0x60, 0xff, 0xec, 0x84, 0x2a, 0x61, 0x95, 0x17, 0x36, 0x89, 0x93, 0x3e, 0x29, 0xb5, 0x04, 0x50, + 0x18, 0x92, 0x62, 0x2a, 0x93, 0xd8, 0x3a, 0x01, 0x59, 0x52, 0x93, 0xd8, 0x3a, 0x81, 0x56, 0xf2, + 0x76, 0x23, 0x7b, 0xc7, 0x16, 0x98, 0xfc, 0x9e, 0xaa, 0x05, 0xdd, 0xac, 0x76, 0xeb, 0x16, 0xd8, + 0xb2, 0xa3, 0x63, 0x0b, 0x6c, 0xd9, 0x61, 0xe5, 0x46, 0x64, 0xeb, 0x2a, 0x60, 0x36, 0xe4, 0x4d, + 0x98, 0xdf, 0xf0, 0x42, 0xe2, 0xdb, 0x90, 0x37, 0x61, 0xbe, 0x1f, 0xdf, 0x88, 0xf4, 0xfa, 0x59, + 0xe0, 0xc6, 0xdf, 0x8c, 0x03, 0x0b, 0xbb, 0x20, 0x53, 0x04, 0xaa, 0xed, 0x98, 0x13, 0x21, 0xec, + 0x41, 0x73, 0xf4, 0x70, 0x5a, 0xed, 0x09, 0x9d, 0x94, 0x95, 0x08, 0x5a, 0x36, 0xef, 0x8b, 0xcd, + 0xe9, 0x7f, 0x1c, 0xbf, 0x6a, 0x7c, 0x6f, 0x4e, 0x20, 0xcf, 0x38, 0xda, 0x00, 0x00, 0x00, 0x00, + 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, ]; // Favicons are small browser chrome assets. 256 KiB leaves room for ICO files diff --git a/src/ffmpeg.rs b/src/ffmpeg.rs index 8cc0d89..e315390 100644 --- a/src/ffmpeg.rs +++ b/src/ffmpeg.rs @@ -228,6 +228,7 @@ mod tests { convert_images_to_webp: true, convert_videos_to_webm: true, keep_original_uploads: false, + nsfw_blur_enabled: true, max_image_size: 1, max_video_size: 1, generate_video_thumbnails: false, @@ -252,6 +253,7 @@ mod tests { convert_images_to_webp: true, convert_videos_to_webm: true, keep_original_uploads: false, + nsfw_blur_enabled: true, max_image_size: 1, max_video_size: 1, generate_video_thumbnails: false, diff --git a/src/lib.rs b/src/lib.rs index d19cef7..73ce77f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,7 @@ pub mod admin; pub mod auth; pub mod backup; pub mod cli; +pub mod compression; pub mod config; pub mod csrf; pub mod db; @@ -30,6 +31,7 @@ pub mod ffmpeg; pub mod logging; pub mod media; pub mod rate_limit; +pub mod registration_captcha; pub mod render; pub mod runtime; pub mod server; diff --git a/src/media.rs b/src/media.rs index 9e351a8..a7e108d 100644 --- a/src/media.rs +++ b/src/media.rs @@ -1,7 +1,9 @@ -use std::path::{Path, PathBuf}; +use std::collections::BTreeSet; +use std::path::{Component, Path, PathBuf}; use axum::extract::multipart::Field; use rusqlite::{OptionalExtension as _, params}; +use sha2::{Digest as _, Sha256}; use tokio::io::AsyncWriteExt as _; use uuid::Uuid; @@ -11,6 +13,7 @@ use crate::ffmpeg::FfmpegStatus; use crate::runtime::RuntimePaths; const PROFILE_PICTURE_THUMB_SIZE: u16 = 96; +const HASH_PREFIX_LEN: usize = 16; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MediaKind { @@ -24,6 +27,78 @@ pub enum ProfileMediaSlot { Banner, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MediaHashKind { + Original, + Normalized, +} + +#[derive(Debug, Clone)] +struct StoredMedia { + id: i64, + stored_path: String, + public_path: String, + mime_type: String, + media_kind: String, + original_sha256: String, + normalized_sha256: Option, +} + +#[derive(Debug, Clone)] +struct StoredVariant { + path: PathBuf, + public_path: String, + mime_type: String, + state: String, + stderr: String, +} + +#[derive(Debug, Clone)] +struct OriginalVariant { + path: PathBuf, + public_path: String, +} + +struct UploadContext<'a> { + pool: &'a SqlitePool, + settings: &'a Settings, + paths: &'a RuntimePaths, + ffmpeg: &'a FfmpegStatus, +} + +struct StagedUpload { + owner_user_id: Option, + original_filename: String, + staging: PathBuf, + bytes: u64, +} + +struct PreparedUpload { + owner_user_id: Option, + original_filename: String, + staging: PathBuf, + bytes: u64, + mime: String, + media_kind: MediaKind, + original_sha256: String, +} + +struct NewMediaRecord { + owner_user_id: Option, + original_filename: String, + original_path: Option, + original_public_path: Option, + stored_path: String, + public_path: String, + mime_type: String, + media_kind: String, + byte_len: i64, + conversion_state: String, + ffmpeg_stderr: String, + original_sha256: String, + normalized_sha256: String, +} + impl ProfileMediaSlot { fn column(self) -> &'static str { match self { @@ -55,92 +130,299 @@ pub async fn save_upload( let id = Uuid::new_v4().simple().to_string(); let staging = paths.staged_upload_path(&id); let bytes = write_upload_to_staging(settings, &staging, &mut field).await?; - let data = tokio::fs::read(&staging).await?; + let context = UploadContext { + pool, + settings, + paths, + ffmpeg, + }; + save_staged_upload( + &context, + StagedUpload { + owner_user_id, + original_filename, + staging, + bytes, + }, + ) + .await +} + +async fn save_staged_upload( + context: &UploadContext<'_>, + upload: StagedUpload, +) -> anyhow::Result { + let prepared = prepare_staged_upload(context.settings, upload).await?; + if let Some(media_id) = try_insert_duplicate( + context, + &prepared, + MediaHashKind::Original, + &prepared.original_sha256, + ) + .await? + { + remove_staged_upload(&prepared.staging).await; + return Ok(media_id); + } + store_new_upload(context, prepared).await +} + +async fn prepare_staged_upload( + settings: &Settings, + upload: StagedUpload, +) -> anyhow::Result { + let data = tokio::fs::read(&upload.staging).await?; let Some(kind) = infer::get(&data) else { - remove_staged_upload(&staging).await; + remove_staged_upload(&upload.staging).await; anyhow::bail!("unsupported media type"); }; let mime = kind.mime_type().to_owned(); - let media_kind = match classify(settings, &mime, bytes) { + let media_kind = match classify(settings, &mime, upload.bytes) { Ok(media_kind) => media_kind, Err(error) => { - remove_staged_upload(&staging).await; + remove_staged_upload(&upload.staging).await; return Err(error); } }; - let ext = safe_extension(&mime, media_kind); - let original_path = paths.uploads_originals.join(format!("{id}.{ext}")); - tokio::fs::rename(&staging, &original_path).await?; - let (stored_path, public_path, served_mime, state, stderr) = convert_or_original( - settings, - paths, - ffmpeg, - &original_path, - &id, + Ok(PreparedUpload { + owner_user_id: upload.owner_user_id, + original_filename: upload.original_filename, + staging: upload.staging, + bytes: upload.bytes, + mime, media_kind, - &mime, + original_sha256: sha256_hex(&data), + }) +} + +async fn try_insert_duplicate( + context: &UploadContext<'_>, + upload: &PreparedUpload, + hash_kind: MediaHashKind, + hash: &str, +) -> anyhow::Result> { + if let Some(canonical) = find_duplicate_media( + context.pool, + context.paths, + upload.media_kind, + hash_kind, + hash, + ) + .await? + { + tracing::info!( + canonical_media_id = canonical.id, + media_kind = upload.media_kind.as_str(), + hash_kind = ?hash_kind, + "reusing media by content hash" + ); + return insert_duplicate_media( + context.pool, + upload.owner_user_id, + upload.original_filename.clone(), + upload.bytes, + upload.original_sha256.clone(), + canonical, + ) + .await + .map(Some); + } + Ok(None) +} + +async fn store_new_upload( + context: &UploadContext<'_>, + upload: PreparedUpload, +) -> anyhow::Result { + let ext = safe_extension(&upload.mime, upload.media_kind); + let mut basename = stable_media_basename(&upload.original_filename, &upload.original_sha256); + let original_path = unique_media_path(&context.paths.uploads_originals, &basename, ext); + if let Some(stem) = original_path.file_stem().and_then(|stem| stem.to_str()) { + stem.clone_into(&mut basename); + } + let original_public_path = public_upload_path(context.paths, &original_path)?; + tokio::fs::rename(&upload.staging, &original_path).await?; + let stored = convert_or_original( + context.settings, + context.paths, + context.ffmpeg, + &original_path, + &basename, + upload.media_kind, + &upload.mime, ) .await; - if state == "converted" && !settings.media.keep_original_uploads { + let normalized_sha256 = hash_file(&stored.path).await?; + if stored.state == "converted" && !context.settings.media.keep_original_uploads { let _ = tokio::fs::remove_file(&original_path).await; } - let cleanup_paths = [original_path.clone(), stored_path.clone()]; - let stored_path = stored_path.to_string_lossy().to_string(); - let media_kind = media_kind.as_str().to_owned(); - let byte_len = i64::try_from(bytes)?; - let db_state = state.clone(); - let db_stderr = stderr.clone(); - let media_insert = pool - .call(move |conn| { - conn.execute( - "INSERT INTO media (owner_user_id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len, conversion_state, ffmpeg_stderr) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - params![ - owner_user_id, - original_filename, - stored_path, - public_path, - served_mime, - media_kind, - byte_len, - db_state, - db_stderr - ], - )?; - Ok(conn.last_insert_rowid()) - }) - .await; - let media_id = match media_insert { + + if let Some(media_id) = try_insert_duplicate( + context, + &upload, + MediaHashKind::Normalized, + &normalized_sha256, + ) + .await? + { + cleanup_unreferenced_uploads([&original_path, &stored.path]).await; + return Ok(media_id); + } + + let original_variant = + if stored.state == "converted" && !context.settings.media.keep_original_uploads { + None + } else { + Some(OriginalVariant { + path: original_path.clone(), + public_path: original_public_path, + }) + }; + let cleanup_paths = [original_path.clone(), stored.path.clone()]; + let original_path = original_variant + .as_ref() + .map(|variant| variant.path.to_string_lossy().to_string()); + let record = NewMediaRecord { + owner_user_id: upload.owner_user_id, + original_filename: upload.original_filename.clone(), + original_path, + original_public_path: original_variant.map(|variant| variant.public_path), + stored_path: stored.path.to_string_lossy().to_string(), + public_path: stored.public_path, + mime_type: stored.mime_type, + media_kind: upload.media_kind.as_str().to_owned(), + byte_len: i64::try_from(upload.bytes)?, + conversion_state: stored.state, + ffmpeg_stderr: stored.stderr, + original_sha256: upload.original_sha256.clone(), + normalized_sha256: normalized_sha256.clone(), + }; + let media_id = match insert_new_media_record(context.pool, record).await { Ok(media_id) => media_id, Err(error) => { - for path in cleanup_paths { - if let Err(remove_error) = tokio::fs::remove_file(&path).await - && remove_error.kind() != std::io::ErrorKind::NotFound - { - tracing::warn!( - path = %path.display(), - error = %remove_error, - "failed to remove uploaded file after media database insert failed" - ); - } + if let Some(media_id) = + try_insert_duplicate_after_canonical_conflict(context, &upload, &normalized_sha256) + .await? + { + cleanup_unreferenced_uploads([&cleanup_paths[0], &cleanup_paths[1]]).await; + return Ok(media_id); } + cleanup_unreferenced_uploads([&cleanup_paths[0], &cleanup_paths[1]]).await; return Err(error); } }; - if state == "converted" || state == "fallback" { - let status = state; - let stderr_summary = stderr_summary_for_db(&stderr); - pool.call(move |conn| { - conn.execute( - "INSERT INTO media_jobs (media_id, status, stderr_summary, finished_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)", - params![media_id, status, stderr_summary], - )?; - Ok(()) - }) - .await?; - } Ok(media_id) } +async fn insert_new_media_record(pool: &SqlitePool, record: NewMediaRecord) -> anyhow::Result { + pool.call(move |conn| { + let tx = conn.transaction()?; + tx.execute( + "INSERT INTO media (owner_user_id, original_filename, original_path, original_public_path, stored_path, public_path, mime_type, media_kind, byte_len, conversion_state, ffmpeg_stderr, original_sha256, normalized_sha256) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + record.owner_user_id, + record.original_filename, + record.original_path, + record.original_public_path, + record.stored_path, + record.public_path, + record.mime_type, + record.media_kind, + record.byte_len, + record.conversion_state, + record.ffmpeg_stderr, + record.original_sha256, + record.normalized_sha256, + ], + )?; + let media_id = tx.last_insert_rowid(); + record_media_job_in_tx( + &tx, + media_id, + &record.conversion_state, + &record.ffmpeg_stderr, + )?; + tx.commit()?; + Ok(media_id) + }) + .await +} + +fn record_media_job_in_tx( + tx: &rusqlite::Transaction<'_>, + media_id: i64, + state: &str, + stderr: &str, +) -> anyhow::Result<()> { + if state != "converted" && state != "fallback" { + return Ok(()); + } + let stderr_summary = stderr_summary_for_db(stderr); + tx.execute( + "INSERT INTO media_jobs (media_id, status, stderr_summary, finished_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)", + params![media_id, state, stderr_summary], + )?; + Ok(()) +} + +async fn try_insert_duplicate_after_canonical_conflict( + context: &UploadContext<'_>, + upload: &PreparedUpload, + normalized_sha256: &str, +) -> anyhow::Result> { + if let Some(media_id) = try_insert_duplicate( + context, + upload, + MediaHashKind::Original, + &upload.original_sha256, + ) + .await? + { + return Ok(Some(media_id)); + } + try_insert_duplicate( + context, + upload, + MediaHashKind::Normalized, + normalized_sha256, + ) + .await +} + +async fn insert_duplicate_media( + pool: &SqlitePool, + owner_user_id: Option, + original_filename: String, + bytes: u64, + original_sha256: String, + canonical: StoredMedia, +) -> anyhow::Result { + let byte_len = i64::try_from(bytes)?; + let normalized_sha256 = canonical + .normalized_sha256 + .clone() + .filter(|hash| !hash.is_empty()); + pool.call(move |conn| { + conn.execute( + "INSERT INTO media (owner_user_id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len, conversion_state, original_sha256, normalized_sha256, canonical_media_id) VALUES (?, ?, ?, ?, ?, ?, ?, 'duplicate', ?, ?, ?)", + params![ + owner_user_id, + original_filename, + canonical.stored_path, + canonical.public_path, + canonical.mime_type, + canonical.media_kind, + byte_len, + original_sha256, + normalized_sha256, + canonical.id, + ], + )?; + Ok(conn.last_insert_rowid()) + }) + .await +} + async fn write_upload_to_staging( settings: &Settings, staging: &Path, @@ -245,6 +527,294 @@ async fn remove_staged_upload(path: &Path) { } } +async fn cleanup_unreferenced_uploads(paths: [&Path; N]) { + let unique_paths = paths.into_iter().collect::>(); + for path in unique_paths { + if let Err(error) = tokio::fs::remove_file(path).await + && error.kind() != std::io::ErrorKind::NotFound + { + tracing::warn!( + path = %path.display(), + error = %error, + "failed to remove duplicate upload scratch file" + ); + } + } +} + +async fn find_duplicate_media( + pool: &SqlitePool, + paths: &RuntimePaths, + media_kind: MediaKind, + hash_kind: MediaHashKind, + hash: &str, +) -> anyhow::Result> { + let media_kind_str = media_kind.as_str().to_owned(); + let hash = hash.to_owned(); + let query_hash = hash.clone(); + let candidates = pool + .call(move |conn| { + let sql = match hash_kind { + MediaHashKind::Original => { + "SELECT id, stored_path, public_path, mime_type, media_kind, original_sha256, normalized_sha256 FROM media WHERE canonical_media_id IS NULL AND media_kind = ? AND original_sha256 = ? ORDER BY id ASC LIMIT 8" + } + MediaHashKind::Normalized => { + "SELECT id, stored_path, public_path, mime_type, media_kind, original_sha256, normalized_sha256 FROM media WHERE canonical_media_id IS NULL AND media_kind = ? AND normalized_sha256 = ? ORDER BY id ASC LIMIT 8" + } + }; + let mut stmt = conn.prepare(sql)?; + let rows = stmt + .query_map(params![media_kind_str, query_hash], |row| { + Ok(StoredMedia { + id: row.get(0)?, + stored_path: row.get(1)?, + public_path: row.get(2)?, + mime_type: row.get(3)?, + media_kind: row.get(4)?, + original_sha256: row.get(5)?, + normalized_sha256: row.get(6)?, + }) + })? + .collect::, _>>()?; + Ok(rows) + }) + .await?; + + for candidate in candidates { + if validate_canonical_media(paths, &candidate, media_kind, hash_kind, hash.as_str()).await { + return Ok(Some(candidate)); + } + tracing::warn!( + media_id = candidate.id, + media_kind = media_kind.as_str(), + hash_kind = ?hash_kind, + "ignoring invalid duplicate media candidate" + ); + quarantine_invalid_canonical_media(pool, candidate.id).await?; + } + Ok(None) +} + +async fn quarantine_invalid_canonical_media( + pool: &SqlitePool, + media_id: i64, +) -> anyhow::Result<()> { + pool.call(move |conn| { + conn.execute( + "UPDATE media SET original_sha256 = '', normalized_sha256 = NULL WHERE id = ? AND canonical_media_id IS NULL", + [media_id], + )?; + Ok(()) + }) + .await +} + +async fn validate_canonical_media( + paths: &RuntimePaths, + candidate: &StoredMedia, + expected_kind: MediaKind, + hash_kind: MediaHashKind, + expected_hash: &str, +) -> bool { + if candidate.media_kind != expected_kind.as_str() + || !mime_matches_kind(&candidate.mime_type, expected_kind) + { + return false; + } + let path = PathBuf::from(&candidate.stored_path); + if !safe_stored_media_path(paths, &path, expected_kind) { + return false; + } + let Ok(public_path) = public_upload_path(paths, &path) else { + return false; + }; + if public_path != candidate.public_path { + return false; + } + if !stored_file_is_safe(paths, &path, expected_kind, &candidate.mime_type).await { + return false; + } + let hash_to_check = match hash_kind { + MediaHashKind::Original => candidate + .normalized_sha256 + .as_deref() + .filter(|hash| !hash.is_empty()) + .or_else(|| { + (!candidate.original_sha256.is_empty()) + .then_some(candidate.original_sha256.as_str()) + }), + MediaHashKind::Normalized => Some(expected_hash), + }; + let Some(hash_to_check) = hash_to_check else { + return false; + }; + match hash_file(&path).await { + Ok(actual_hash) => actual_hash == hash_to_check, + Err(_) => false, + } +} + +fn safe_stored_media_path(paths: &RuntimePaths, path: &Path, media_kind: MediaKind) -> bool { + if !path.is_absolute() || has_parent_component(path) { + return false; + } + let allowed_roots = match media_kind { + MediaKind::Image => [&paths.uploads_images, &paths.uploads_originals], + MediaKind::Video => [&paths.uploads_videos, &paths.uploads_originals], + }; + allowed_roots.iter().any(|root| path.starts_with(root)) +} + +async fn stored_file_is_safe( + paths: &RuntimePaths, + path: &Path, + media_kind: MediaKind, + mime_type: &str, +) -> bool { + if !path_extension_matches_mime(path, media_kind, mime_type) { + return false; + } + let Ok(metadata) = tokio::fs::symlink_metadata(path).await else { + return false; + }; + if !metadata.is_file() { + return false; + } + let Ok(canonical_path) = tokio::fs::canonicalize(path).await else { + return false; + }; + let allowed_roots = match media_kind { + MediaKind::Image => [&paths.uploads_images, &paths.uploads_originals], + MediaKind::Video => [&paths.uploads_videos, &paths.uploads_originals], + }; + for root in allowed_roots { + let Ok(canonical_root) = tokio::fs::canonicalize(root).await else { + return false; + }; + if canonical_path.starts_with(canonical_root) { + return true; + } + } + false +} + +fn path_extension_matches_mime(path: &Path, media_kind: MediaKind, mime_type: &str) -> bool { + let Some(extension) = path + .extension() + .and_then(|extension| extension.to_str()) + .map(str::to_ascii_lowercase) + else { + return false; + }; + match (media_kind, mime_type) { + (MediaKind::Image, "image/jpeg") => extension == "jpg" || extension == "jpeg", + (MediaKind::Image, "image/png") => extension == "png", + (MediaKind::Image, "image/gif") => extension == "gif", + (MediaKind::Image, "image/webp") => extension == "webp", + (MediaKind::Video, "video/webm") => extension == "webm", + (MediaKind::Video, "video/quicktime") => extension == "mov", + (MediaKind::Video, _) => extension == "mp4", + (MediaKind::Image, _) => extension == "img", + } +} + +fn mime_matches_kind(mime_type: &str, media_kind: MediaKind) -> bool { + match media_kind { + MediaKind::Image => mime_type.starts_with("image/"), + MediaKind::Video => mime_type.starts_with("video/"), + } +} + +async fn hash_file(path: &Path) -> anyhow::Result { + let data = tokio::fs::read(path).await?; + Ok(sha256_hex(&data)) +} + +fn sha256_hex(data: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(data); + format!("{:x}", hasher.finalize()) +} + +fn stable_media_basename(original_filename: &str, original_sha256: &str) -> String { + let stem = Path::new(original_filename) + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or("upload"); + let mut sanitized = String::with_capacity(stem.len().min(64) + 1 + HASH_PREFIX_LEN); + let mut last_was_separator = false; + for character in stem.chars().flat_map(char::to_lowercase) { + let next = if character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') { + Some(character) + } else if character.is_ascii_whitespace() { + Some('-') + } else { + None + }; + let Some(next) = next else { + continue; + }; + if matches!(next, '-' | '_' | '.') { + if sanitized.is_empty() || last_was_separator { + continue; + } + last_was_separator = true; + } else { + last_was_separator = false; + } + sanitized.push(next); + if sanitized.len() >= 64 { + break; + } + } + while sanitized.ends_with(['-', '_', '.']) { + sanitized.pop(); + } + if sanitized.is_empty() { + sanitized.push_str("upload"); + } + let prefix_len = original_sha256.len().min(HASH_PREFIX_LEN); + format!("{}-{}", sanitized, &original_sha256[..prefix_len]) +} + +fn unique_media_path(dir: &Path, basename: &str, extension: &str) -> PathBuf { + let path = dir.join(format!("{basename}.{extension}")); + if !path.exists() { + return path; + } + dir.join(format!( + "{}-{}.{}", + basename, + Uuid::new_v4().simple(), + extension + )) +} + +fn public_upload_path(paths: &RuntimePaths, path: &Path) -> anyhow::Result { + let filename = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| anyhow::anyhow!("media path has no file name"))?; + if path.starts_with(&paths.uploads_originals) { + return Ok(format!("/uploads/originals/{filename}")); + } + if path.starts_with(&paths.uploads_images) { + return Ok(format!("/uploads/images/{filename}")); + } + if path.starts_with(&paths.uploads_videos) { + return Ok(format!("/uploads/videos/{filename}")); + } + if path.starts_with(&paths.uploads_thumbs) { + return Ok(format!("/uploads/thumbs/{filename}")); + } + anyhow::bail!("media path is outside upload directories"); +} + +fn has_parent_component(path: &Path) -> bool { + path.components() + .any(|component| matches!(component, Component::ParentDir)) +} + pub async fn set_profile_media( pool: &SqlitePool, user_id: i64, @@ -310,33 +880,111 @@ pub async fn clear_profile_media( } pub async fn delete_media(pool: &SqlitePool, media_id: i64) -> anyhow::Result<()> { - let media_paths: Option<(String, Option)> = pool + let paths_to_remove = pool .call(move |conn| { - conn.query_row( - "SELECT stored_path, thumbnail_path FROM media WHERE id = ?", - [media_id], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .optional() - .map_err(Into::into) + let tx = conn.transaction()?; + let media_paths: Option<(Option, String, Option)> = tx + .query_row( + "SELECT original_path, stored_path, thumbnail_path FROM media WHERE id = ?", + [media_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .optional()?; + let Some((original_path, stored_path, thumbnail_path)) = media_paths else { + return Ok(Vec::new()); + }; + promote_canonical_references(&tx, media_id)?; + tx.execute("DELETE FROM media_jobs WHERE media_id = ?", [media_id])?; + tx.execute("DELETE FROM media WHERE id = ?", [media_id])?; + let mut paths = BTreeSet::new(); + for path in [original_path, Some(stored_path), thumbnail_path] + .into_iter() + .flatten() + .filter(|path| !path.is_empty()) + { + let remaining: i64 = tx.query_row( + "SELECT COUNT(*) FROM media WHERE original_path = ? OR stored_path = ? OR thumbnail_path = ?", + params![path, path, path], + |row| row.get(0), + )?; + if remaining == 0 { + paths.insert(PathBuf::from(path)); + } + } + tx.commit()?; + Ok(paths.into_iter().collect::>()) }) .await?; - if let Some((stored_path, thumbnail_path)) = media_paths { - if !stored_path.is_empty() { - let _ = tokio::fs::remove_file(stored_path).await; - } - if let Some(thumbnail_path) = thumbnail_path.filter(|path| !path.is_empty()) { - let _ = tokio::fs::remove_file(thumbnail_path).await; + for path in paths_to_remove { + if let Err(error) = tokio::fs::remove_file(&path).await + && error.kind() != std::io::ErrorKind::NotFound + { + tracing::warn!( + media_id, + path = %path.display(), + error = %error, + "failed to remove unreferenced media file after media deletion" + ); } - pool.call(move |conn| { - conn.execute("DELETE FROM media WHERE id = ?", [media_id])?; - Ok(()) - }) - .await?; } Ok(()) } +fn promote_canonical_references( + tx: &rusqlite::Transaction<'_>, + media_id: i64, +) -> anyhow::Result<()> { + let replacement: Option = tx + .query_row( + "SELECT id FROM media WHERE canonical_media_id = ? ORDER BY id ASC LIMIT 1", + [media_id], + |row| row.get(0), + ) + .optional()?; + let Some(replacement) = replacement else { + return Ok(()); + }; + tx.execute( + "UPDATE media SET original_sha256 = '', normalized_sha256 = NULL WHERE id = ?", + [media_id], + )?; + tx.execute( + "UPDATE media SET canonical_media_id = NULL WHERE id = ?", + [replacement], + )?; + tx.execute( + "UPDATE media SET canonical_media_id = ? WHERE canonical_media_id = ?", + params![replacement, media_id], + )?; + Ok(()) +} + +pub async fn set_media_nsfw( + pool: &SqlitePool, + media_ids: &[i64], + is_nsfw: bool, +) -> anyhow::Result<()> { + if media_ids.is_empty() { + return Ok(()); + } + let media_ids = media_ids.to_vec(); + pool.call(move |conn| { + let tx = conn.transaction()?; + for media_id in media_ids { + let changed = tx.execute( + "UPDATE media SET is_nsfw = ? WHERE id = ?", + params![i64::from(is_nsfw), media_id], + )?; + if changed != 1 { + anyhow::bail!("media attachment not found"); + } + } + tx.commit()?; + Ok(()) + }) + .await +} + fn classify(settings: &Settings, mime: &str, bytes: u64) -> anyhow::Result { if settings .media @@ -368,23 +1016,24 @@ async fn convert_or_original( paths: &RuntimePaths, ffmpeg: &FfmpegStatus, original: &Path, - id: &str, + basename: &str, media_kind: MediaKind, original_mime: &str, -) -> (PathBuf, String, String, String, String) { +) -> StoredVariant { if ffmpeg.available { match media_kind { MediaKind::Image if settings.media.convert_images_to_webp && ffmpeg.supports_webp => { - let out = paths.uploads_images.join(format!("{id}.webp")); + let out = unique_media_path(&paths.uploads_images, basename, "webp"); match crate::ffmpeg::convert_image_to_webp(&settings.media, original, &out).await { Ok(stderr) => { - return ( - out, - format!("/uploads/images/{id}.webp"), - "image/webp".to_owned(), - "converted".to_owned(), + return StoredVariant { + public_path: public_upload_path(paths, &out) + .unwrap_or_else(|_| format!("/uploads/images/{basename}.webp")), + path: out, + mime_type: "image/webp".to_owned(), + state: "converted".to_owned(), stderr, - ); + }; } Err(err) => { return original_fallback( @@ -399,16 +1048,17 @@ async fn convert_or_original( } } MediaKind::Video if settings.media.convert_videos_to_webm && ffmpeg.supports_vp9 => { - let out = paths.uploads_videos.join(format!("{id}.webm")); + let out = unique_media_path(&paths.uploads_videos, basename, "webm"); match crate::ffmpeg::convert_video_to_webm(&settings.media, original, &out).await { Ok(stderr) => { - return ( - out, - format!("/uploads/videos/{id}.webm"), - "video/webm".to_owned(), - "converted".to_owned(), + return StoredVariant { + public_path: public_upload_path(paths, &out) + .unwrap_or_else(|_| format!("/uploads/videos/{basename}.webm")), + path: out, + mime_type: "video/webm".to_owned(), + state: "converted".to_owned(), stderr, - ); + }; } Err(err) => { return original_fallback( @@ -431,26 +1081,24 @@ async fn convert_or_original( fn original_fallback( original: &Path, paths: &RuntimePaths, - media_kind: MediaKind, + _media_kind: MediaKind, mime: &str, state: &str, stderr: &str, -) -> (PathBuf, String, String, String, String) { - let base = original - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("upload"); - let public = match media_kind { - MediaKind::Image | MediaKind::Video => format!("/uploads/originals/{base}"), - }; - let path = paths.uploads_originals.join(base); - ( - path, - public, - mime.to_owned(), - state.to_owned(), - stderr.to_owned(), - ) +) -> StoredVariant { + StoredVariant { + path: original.to_owned(), + public_path: public_upload_path(paths, original).unwrap_or_else(|_| { + let base = original + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("upload"); + format!("/uploads/originals/{base}") + }), + mime_type: mime.to_owned(), + state: state.to_owned(), + stderr: stderr.to_owned(), + } } fn stderr_summary_for_db(stderr: &str) -> String { @@ -515,6 +1163,399 @@ mod tests { assert!(classify(&settings, "video/mp4", settings.media.max_video_size + 1).is_err()); } + #[tokio::test] + async fn exact_same_file_uploaded_twice_reuses_canonical_media() { + let (_temp, paths, pool, settings, ffmpeg, user_id) = media_fixture().await; + + let first = save_test_upload( + &pool, + &settings, + &paths, + &ffmpeg, + user_id, + "Photo.PNG", + &tiny_png_bytes(), + ) + .await; + let second = save_test_upload( + &pool, + &settings, + &paths, + &ffmpeg, + user_id, + "Photo.PNG", + &tiny_png_bytes(), + ) + .await; + + let first = media_row(&pool, first).await; + let second = media_row(&pool, second).await; + assert_eq!(second.canonical_media_id, Some(first.id)); + assert_eq!(second.stored_path, first.stored_path); + assert_eq!(second.public_path, first.public_path); + assert_eq!(second.original_sha256, first.original_sha256); + assert!(Path::new(&first.stored_path).exists()); + } + + #[cfg(unix)] + #[tokio::test] + async fn existing_transcoded_webm_is_reused_for_matching_mp4_output() { + let (temp, paths, pool, settings, mut ffmpeg, user_id) = media_fixture().await; + let normalized = b"normalized-video"; + let normalized_hash = sha256_hex(normalized); + let canonical_path = paths.uploads_videos.join("clip-canonical.webm"); + tokio::fs::write(&canonical_path, normalized) + .await + .expect("canonical webm"); + let canonical_id = insert_canonical_media( + &pool, + user_id, + TestCanonicalMedia { + stored_path: &canonical_path, + public_path: "/uploads/videos/clip-canonical.webm", + mime_type: "video/webm", + media_kind: "video", + original_sha256: "seed-original", + normalized_sha256: &normalized_hash, + }, + ) + .await; + ffmpeg.available = true; + ffmpeg.supports_vp9 = true; + let mut settings = settings; + settings.media.ffmpeg_path = fake_ffmpeg_output(&temp, normalized).display().to_string(); + + let uploaded = save_test_upload( + &pool, + &settings, + &paths, + &ffmpeg, + user_id, + "clip.mp4", + &tiny_mp4_bytes(), + ) + .await; + + let row = media_row(&pool, uploaded).await; + assert_eq!(row.canonical_media_id, Some(canonical_id)); + assert_eq!(row.stored_path, canonical_path.to_string_lossy()); + assert!(!paths.uploads_videos.join("clip.mp4").exists()); + } + + #[cfg(unix)] + #[tokio::test] + async fn different_image_formats_with_same_webp_output_reuse_canonical() { + let (temp, paths, pool, mut settings, mut ffmpeg, user_id) = media_fixture().await; + let normalized = b"normalized-image"; + settings.media.ffmpeg_path = fake_ffmpeg_output(&temp, normalized).display().to_string(); + ffmpeg.available = true; + ffmpeg.supports_webp = true; + + let png = save_test_upload( + &pool, + &settings, + &paths, + &ffmpeg, + user_id, + "image.png", + &tiny_png_bytes(), + ) + .await; + let jpg = save_test_upload( + &pool, + &settings, + &paths, + &ffmpeg, + user_id, + "image.jpg", + &tiny_jpeg_bytes(), + ) + .await; + + let png = media_row(&pool, png).await; + let jpg = media_row(&pool, jpg).await; + assert_eq!(jpg.canonical_media_id, Some(png.id)); + assert_eq!(jpg.stored_path, png.stored_path); + assert_eq!(jpg.normalized_sha256, png.normalized_sha256); + } + + #[tokio::test] + async fn missing_canonical_file_disables_duplicate_reuse_safely() { + let (_temp, paths, pool, settings, ffmpeg, user_id) = media_fixture().await; + let raw_hash = sha256_hex(&tiny_png_bytes()); + let missing = paths.uploads_images.join("missing.webp"); + let canonical_id = insert_canonical_media( + &pool, + user_id, + TestCanonicalMedia { + stored_path: &missing, + public_path: "/uploads/images/missing.webp", + mime_type: "image/webp", + media_kind: "image", + original_sha256: &raw_hash, + normalized_sha256: &raw_hash, + }, + ) + .await; + + let uploaded = save_test_upload( + &pool, + &settings, + &paths, + &ffmpeg, + user_id, + "photo.png", + &tiny_png_bytes(), + ) + .await; + + let row = media_row(&pool, uploaded).await; + assert_ne!(row.id, canonical_id); + assert_eq!(row.canonical_media_id, None); + assert_ne!(row.stored_path, missing.to_string_lossy()); + assert!(Path::new(&row.stored_path).exists()); + } + + #[tokio::test] + async fn misleading_canonical_extension_disables_duplicate_reuse_safely() { + let (_temp, paths, pool, settings, ffmpeg, user_id) = media_fixture().await; + let raw = tiny_png_bytes(); + let raw_hash = sha256_hex(&raw); + let misleading = paths.uploads_originals.join("photo.mp4"); + tokio::fs::write(&misleading, &raw) + .await + .expect("misleading canonical file"); + let canonical_id = insert_canonical_media( + &pool, + user_id, + TestCanonicalMedia { + stored_path: &misleading, + public_path: "/uploads/originals/photo.mp4", + mime_type: "image/png", + media_kind: "image", + original_sha256: &raw_hash, + normalized_sha256: &raw_hash, + }, + ) + .await; + + let uploaded = save_test_upload( + &pool, + &settings, + &paths, + &ffmpeg, + user_id, + "photo.png", + &raw, + ) + .await; + + let row = media_row(&pool, uploaded).await; + assert_ne!(row.id, canonical_id); + assert_eq!(row.canonical_media_id, None); + assert_ne!(row.stored_path, misleading.to_string_lossy()); + assert!(Path::new(&row.stored_path).exists()); + } + + #[tokio::test] + async fn deleting_one_shared_media_row_keeps_canonical_file_until_last_reference() { + let (_temp, paths, pool, _settings, _ffmpeg, user_id) = media_fixture().await; + let shared = paths.uploads_images.join("shared.webp"); + tokio::fs::write(&shared, b"shared").await.expect("shared"); + let shared_hash = sha256_hex(b"shared"); + let first = insert_canonical_media( + &pool, + user_id, + TestCanonicalMedia { + stored_path: &shared, + public_path: "/uploads/images/shared.webp", + mime_type: "image/webp", + media_kind: "image", + original_sha256: "first", + normalized_sha256: &shared_hash, + }, + ) + .await; + let second = insert_duplicate_row(&pool, user_id, first, &shared).await; + + delete_media(&pool, second).await.expect("delete duplicate"); + assert!(shared.exists()); + delete_media(&pool, first).await.expect("delete canonical"); + assert!(!shared.exists()); + } + + #[tokio::test] + async fn deleting_canonical_media_promotes_remaining_shared_reference() { + let (_temp, paths, pool, _settings, _ffmpeg, user_id) = media_fixture().await; + let shared = paths.uploads_images.join("promoted.webp"); + tokio::fs::write(&shared, b"shared").await.expect("shared"); + let shared_hash = sha256_hex(b"shared"); + let first = insert_canonical_media( + &pool, + user_id, + TestCanonicalMedia { + stored_path: &shared, + public_path: "/uploads/images/promoted.webp", + mime_type: "image/webp", + media_kind: "image", + original_sha256: "first", + normalized_sha256: &shared_hash, + }, + ) + .await; + let second = insert_duplicate_row(&pool, user_id, first, &shared).await; + + delete_media(&pool, first).await.expect("delete canonical"); + + assert!(shared.exists()); + let promoted = media_row(&pool, second).await; + assert_eq!(promoted.canonical_media_id, None); + assert_eq!(promoted.stored_path, shared.to_string_lossy()); + } + + #[tokio::test] + async fn different_files_with_same_name_do_not_collide() { + let (_temp, paths, pool, settings, ffmpeg, user_id) = media_fixture().await; + let mut other_png = tiny_png_bytes(); + other_png.push(0); + + let first = save_test_upload( + &pool, + &settings, + &paths, + &ffmpeg, + user_id, + "same.png", + &tiny_png_bytes(), + ) + .await; + let second = save_test_upload( + &pool, &settings, &paths, &ffmpeg, user_id, "same.png", &other_png, + ) + .await; + + let first = media_row(&pool, first).await; + let second = media_row(&pool, second).await; + assert_ne!(first.stored_path, second.stored_path); + assert_ne!(first.public_path, second.public_path); + assert!(Path::new(&first.stored_path).exists()); + assert!(Path::new(&second.stored_path).exists()); + } + + #[cfg(unix)] + #[tokio::test] + async fn different_media_types_never_dedupe_into_each_other() { + let (temp, paths, pool, mut settings, mut ffmpeg, user_id) = media_fixture().await; + let normalized = b"same-normalized-output"; + let normalized_hash = sha256_hex(normalized); + let image_path = paths.uploads_images.join("image.webp"); + tokio::fs::write(&image_path, normalized) + .await + .expect("image"); + insert_canonical_media( + &pool, + user_id, + TestCanonicalMedia { + stored_path: &image_path, + public_path: "/uploads/images/image.webp", + mime_type: "image/webp", + media_kind: "image", + original_sha256: "seed-image", + normalized_sha256: &normalized_hash, + }, + ) + .await; + settings.media.ffmpeg_path = fake_ffmpeg_output(&temp, normalized).display().to_string(); + ffmpeg.available = true; + ffmpeg.supports_vp9 = true; + + let video = save_test_upload( + &pool, + &settings, + &paths, + &ffmpeg, + user_id, + "clip.mp4", + &tiny_mp4_bytes(), + ) + .await; + + let row = media_row(&pool, video).await; + assert_eq!(row.media_kind, "video"); + assert_eq!(row.canonical_media_id, None); + assert_ne!(row.stored_path, image_path.to_string_lossy()); + } + + #[cfg(unix)] + #[tokio::test] + async fn original_and_transcoded_variants_share_stable_basename() { + let (temp, paths, pool, mut settings, mut ffmpeg, user_id) = media_fixture().await; + settings.media.keep_original_uploads = true; + settings.media.ffmpeg_path = fake_ffmpeg_output(&temp, b"webp-output") + .display() + .to_string(); + ffmpeg.available = true; + ffmpeg.supports_webp = true; + ffmpeg.supports_vp9 = true; + + let media_id = save_test_upload( + &pool, + &settings, + &paths, + &ffmpeg, + user_id, + "My Photo.PNG", + &tiny_png_bytes(), + ) + .await; + + let row = media_row(&pool, media_id).await; + let original_path = row.original_path.expect("original path"); + let original_stem = Path::new(&original_path) + .file_stem() + .and_then(|stem| stem.to_str()) + .expect("original stem"); + let stored_stem = Path::new(&row.stored_path) + .file_stem() + .and_then(|stem| stem.to_str()) + .expect("stored stem"); + assert_eq!(original_stem, stored_stem); + assert!(original_stem.starts_with("my-photo-")); + assert!(row.public_path.ends_with(&format!("{stored_stem}.webp"))); + assert!(matches!( + row.original_public_path.as_deref(), + Some(path) if path.ends_with(&format!("{original_stem}.png")) + )); + + let video_id = save_test_upload( + &pool, + &settings, + &paths, + &ffmpeg, + user_id, + "My Clip.MP4", + &tiny_mp4_bytes(), + ) + .await; + let video = media_row(&pool, video_id).await; + let original_path = video.original_path.expect("video original path"); + let original_stem = Path::new(&original_path) + .file_stem() + .and_then(|stem| stem.to_str()) + .expect("video original stem"); + let stored_stem = Path::new(&video.stored_path) + .file_stem() + .and_then(|stem| stem.to_str()) + .expect("video stored stem"); + assert_eq!(original_stem, stored_stem); + assert!(original_stem.starts_with("my-clip-")); + assert!(video.public_path.ends_with(&format!("{stored_stem}.webm"))); + assert!(matches!( + video.original_public_path.as_deref(), + Some(path) if path.ends_with(&format!("{original_stem}.mp4")) + )); + } + #[tokio::test] async fn profile_media_replacement_deletes_previous_file() { let temp = tempfile::tempdir().expect("temp dir"); @@ -668,6 +1709,187 @@ mod tests { assert!(thumbnail_public_path.is_none()); } + #[derive(Debug)] + struct MediaRow { + id: i64, + original_path: Option, + original_public_path: Option, + stored_path: String, + public_path: String, + media_kind: String, + original_sha256: String, + normalized_sha256: Option, + canonical_media_id: Option, + } + + async fn media_fixture() -> ( + tempfile::TempDir, + RuntimePaths, + SqlitePool, + Settings, + FfmpegStatus, + i64, + ) { + let temp = tempfile::tempdir().expect("temp dir"); + let paths = RuntimePaths::from_data_dir(temp.path().join("data")); + paths.ensure().expect("paths"); + let pool = crate::db::connect(&paths.database_path) + .await + .expect("connect"); + crate::db::migrate(&pool).await.expect("migrate"); + let settings = Settings::default(); + let ffmpeg = FfmpegStatus { + available: false, + version: String::new(), + supports_webp: false, + supports_vp9: false, + error: Some("disabled in tests".to_owned()), + }; + let user_id = + crate::auth::register_user(&pool, &settings, "alice", "very secure password", false) + .await + .expect("user"); + (temp, paths, pool, settings, ffmpeg, user_id) + } + + async fn save_test_upload( + pool: &SqlitePool, + settings: &Settings, + paths: &RuntimePaths, + ffmpeg: &FfmpegStatus, + user_id: i64, + filename: &str, + bytes: &[u8], + ) -> i64 { + let staging = paths.staged_upload_path(&Uuid::new_v4().simple().to_string()); + tokio::fs::write(&staging, bytes).await.expect("staging"); + let context = UploadContext { + pool, + settings, + paths, + ffmpeg, + }; + save_staged_upload( + &context, + StagedUpload { + owner_user_id: Some(user_id), + original_filename: filename.to_owned(), + staging, + bytes: u64::try_from(bytes.len()).expect("byte len"), + }, + ) + .await + .expect("save upload") + } + + async fn media_row(pool: &SqlitePool, media_id: i64) -> MediaRow { + pool.call(move |conn| { + conn.query_row( + "SELECT id, original_path, original_public_path, stored_path, public_path, media_kind, original_sha256, normalized_sha256, canonical_media_id FROM media WHERE id = ?", + [media_id], + |row| { + Ok(MediaRow { + id: row.get(0)?, + original_path: row.get(1)?, + original_public_path: row.get(2)?, + stored_path: row.get(3)?, + public_path: row.get(4)?, + media_kind: row.get(5)?, + original_sha256: row.get(6)?, + normalized_sha256: row.get(7)?, + canonical_media_id: row.get(8)?, + }) + }, + ) + .map_err(Into::into) + }) + .await + .expect("media row") + } + + struct TestCanonicalMedia<'a> { + stored_path: &'a Path, + public_path: &'a str, + mime_type: &'a str, + media_kind: &'a str, + original_sha256: &'a str, + normalized_sha256: &'a str, + } + + async fn insert_canonical_media( + pool: &SqlitePool, + user_id: i64, + media: TestCanonicalMedia<'_>, + ) -> i64 { + let stored_path = media.stored_path.to_string_lossy().to_string(); + let public_path = media.public_path.to_owned(); + let mime_type = media.mime_type.to_owned(); + let media_kind = media.media_kind.to_owned(); + let original_sha256 = media.original_sha256.to_owned(); + let normalized_sha256 = media.normalized_sha256.to_owned(); + pool.call(move |conn| { + conn.execute( + "INSERT INTO media (owner_user_id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len, original_sha256, normalized_sha256) VALUES (?, 'canonical', ?, ?, ?, ?, 1, ?, ?)", + params![ + user_id, + stored_path, + public_path, + mime_type, + media_kind, + original_sha256, + normalized_sha256, + ], + )?; + Ok(conn.last_insert_rowid()) + }) + .await + .expect("canonical media") + } + + async fn insert_duplicate_row( + pool: &SqlitePool, + user_id: i64, + canonical_id: i64, + stored_path: &Path, + ) -> i64 { + let stored_path = stored_path.to_string_lossy().to_string(); + pool.call(move |conn| { + conn.execute( + "INSERT INTO media (owner_user_id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len, canonical_media_id) VALUES (?, 'duplicate', ?, '/uploads/images/shared.webp', 'image/webp', 'image', 1, ?)", + params![user_id, stored_path, canonical_id], + )?; + Ok(conn.last_insert_rowid()) + }) + .await + .expect("duplicate media") + } + + 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, + ] + } + + fn tiny_jpeg_bytes() -> Vec { + vec![ + 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, b'J', b'F', b'I', b'F', 0x00, 0x01, 0x01, 0x00, + 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xff, 0xd9, + ] + } + + fn tiny_mp4_bytes() -> Vec { + let mut bytes = vec![ + 0x00, 0x00, 0x00, 0x18, b'f', b't', b'y', b'p', b'i', b's', b'o', b'm', 0x00, 0x00, + 0x00, 0x00, b'i', b's', b'o', b'm', b'm', b'p', b'4', b'2', + ]; + bytes.extend_from_slice(b"rustpost-test-video"); + bytes + } + async fn insert_test_media( pool: &SqlitePool, user_id: i64, @@ -702,10 +1924,19 @@ mod tests { #[cfg(unix)] fn fake_ffmpeg(temp: &tempfile::TempDir) -> PathBuf { + fake_ffmpeg_output(temp, b"webp") + } + + #[cfg(unix)] + fn fake_ffmpeg_output(temp: &tempfile::TempDir, output: &[u8]) -> PathBuf { let path = temp.path().join("fake-ffmpeg"); + let output = std::str::from_utf8(output).expect("fake output utf8"); + assert!(!output.contains('\'')); std::fs::write( &path, - "#!/bin/sh\nlast=\"\"\nfor arg do\n last=\"$arg\"\ndone\nprintf webp > \"$last\"\n", + format!( + "#!/bin/sh\nlast=\"\"\nfor arg do\n last=\"$arg\"\ndone\nprintf '%s' '{output}' > \"$last\"\n" + ), ) .expect("fake ffmpeg"); let mut permissions = std::fs::metadata(&path) diff --git a/src/registration_captcha.rs b/src/registration_captcha.rs new file mode 100644 index 0000000..1b75419 --- /dev/null +++ b/src/registration_captcha.rs @@ -0,0 +1,222 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use anyhow::Context as _; +use base64::Engine as _; +use captcha::Captcha; +use captcha::filters::{Dots, Noise, Wave}; +use rand_core::{OsRng, RngCore as _}; +use sha2::{Digest as _, Sha256}; +use tokio::sync::Mutex; +use uuid::Uuid; + +const CAPTCHA_CHARS: &[u8] = b"123456789ABCDEFGHJKMNPQRSTUVWXYZ"; +const CAPTCHA_LENGTH: usize = 5; +const CAPTCHA_TTL: Duration = Duration::from_secs(10 * 60); +#[cfg(debug_assertions)] +const CAPTCHA_E2E_ANSWER_ENV: &str = "RUSTPOST_E2E_CAPTCHA_ANSWER"; + +#[derive(Debug, Clone)] +pub struct RegistrationCaptchaChallenge { + pub token: String, + pub image_data_uri: String, + pub expires_minutes: u64, + #[cfg(test)] + pub answer: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RegistrationCaptchaError { + MissingChallenge, + MissingAnswer, + ExpiredOrUsed, + Incorrect, +} + +impl RegistrationCaptchaError { + #[must_use] + pub const fn message(self) -> &'static str { + match self { + Self::MissingChallenge => "CAPTCHA challenge is missing. Please try again.", + Self::MissingAnswer => "CAPTCHA answer is required.", + Self::ExpiredOrUsed => { + "CAPTCHA challenge expired or was already used. Please try again." + } + Self::Incorrect => "CAPTCHA answer was incorrect. Please try again.", + } + } +} + +#[derive(Clone, Default)] +pub struct RegistrationCaptchaStore { + challenges: Arc>>, +} + +#[derive(Debug, Clone)] +struct StoredChallenge { + answer_digest: [u8; 32], + expires_at: Instant, +} + +impl RegistrationCaptchaStore { + pub async fn create_challenge(&self) -> anyhow::Result { + let token = Uuid::new_v4().to_string(); + let answer = challenge_answer(); + let image_data_uri = captcha_image_data_uri(&answer)?; + let expires_at = Instant::now() + CAPTCHA_TTL; + let answer_digest = answer_digest(&token, &answer); + + let mut challenges = self.challenges.lock().await; + remove_expired(&mut challenges, Instant::now()); + challenges.insert( + token.clone(), + StoredChallenge { + answer_digest, + expires_at, + }, + ); + drop(challenges); + + Ok(RegistrationCaptchaChallenge { + token, + image_data_uri, + expires_minutes: CAPTCHA_TTL.as_secs() / 60, + #[cfg(test)] + answer, + }) + } + + pub async fn validate( + &self, + token: Option<&str>, + answer: Option<&str>, + ) -> Result<(), RegistrationCaptchaError> { + let token = token + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or(RegistrationCaptchaError::MissingChallenge)?; + let answer = answer + .map(normalize_answer) + .filter(|value| !value.is_empty()) + .ok_or(RegistrationCaptchaError::MissingAnswer)?; + + let now = Instant::now(); + let Some(challenge) = ({ + let mut challenges = self.challenges.lock().await; + remove_expired(&mut challenges, now); + challenges.remove(token) + }) else { + return Err(RegistrationCaptchaError::ExpiredOrUsed); + }; + if challenge.expires_at <= now { + return Err(RegistrationCaptchaError::ExpiredOrUsed); + } + if answer_digest(token, &answer) == challenge.answer_digest { + Ok(()) + } else { + Err(RegistrationCaptchaError::Incorrect) + } + } + + #[cfg(test)] + pub async fn expire_for_test(&self, token: &str) { + let mut challenges = self.challenges.lock().await; + if let Some(challenge) = challenges.get_mut(token) { + let now = Instant::now(); + challenge.expires_at = now.checked_sub(Duration::from_secs(1)).unwrap_or(now); + } + } +} + +fn challenge_answer() -> String { + #[cfg(debug_assertions)] + if let Ok(answer) = std::env::var(CAPTCHA_E2E_ANSWER_ENV) { + let normalized = normalize_answer(&answer); + if normalized.len() == CAPTCHA_LENGTH + && normalized.bytes().all(|byte| CAPTCHA_CHARS.contains(&byte)) + { + return normalized; + } + } + + let mut bytes = [0_u8; CAPTCHA_LENGTH]; + OsRng.fill_bytes(&mut bytes); + bytes + .iter() + .map(|byte| CAPTCHA_CHARS[usize::from(*byte) % CAPTCHA_CHARS.len()] as char) + .collect() +} + +fn captcha_image_data_uri(answer: &str) -> anyhow::Result { + let mut captcha = Captcha::new(); + for ch in answer.chars() { + captcha.set_chars(&[ch]).add_char(); + } + captcha + .apply_filter(Noise::new(0.18)) + .apply_filter(Wave::new(1.6, 14.0).horizontal()) + .view(220, 90) + .apply_filter(Dots::new(12)); + let png = captcha.as_png().context("failed to render CAPTCHA image")?; + let encoded = base64::engine::general_purpose::STANDARD.encode(png); + Ok(format!("data:image/png;base64,{encoded}")) +} + +fn answer_digest(token: &str, answer: &str) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + hasher.update([0]); + hasher.update(normalize_answer(answer).as_bytes()); + hasher.finalize().into() +} + +fn normalize_answer(answer: &str) -> String { + answer.trim().to_ascii_uppercase() +} + +fn remove_expired(challenges: &mut HashMap, now: Instant) { + challenges.retain(|_, challenge| challenge.expires_at > now); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn challenge_validates_once_case_insensitively() { + let store = RegistrationCaptchaStore::default(); + let challenge = store.create_challenge().await.expect("challenge"); + + store + .validate( + Some(&challenge.token), + Some(&challenge.answer.to_lowercase()), + ) + .await + .expect("valid answer"); + assert_eq!( + store + .validate(Some(&challenge.token), Some(&challenge.answer)) + .await, + Err(RegistrationCaptchaError::ExpiredOrUsed) + ); + } + + #[tokio::test] + async fn wrong_answer_consumes_challenge() { + let store = RegistrationCaptchaStore::default(); + let challenge = store.create_challenge().await.expect("challenge"); + + assert_eq!( + store.validate(Some(&challenge.token), Some("WRONG")).await, + Err(RegistrationCaptchaError::Incorrect) + ); + assert_eq!( + store + .validate(Some(&challenge.token), Some(&challenge.answer)) + .await, + Err(RegistrationCaptchaError::ExpiredOrUsed) + ); + } +} diff --git a/src/render.rs b/src/render.rs index b0200e4..5ecd962 100644 --- a/src/render.rs +++ b/src/render.rs @@ -2,9 +2,14 @@ use crate::auth::{CurrentUser, Theme}; use crate::social::{ AccountView, MediaView, NotificationView, PostView, QuotePreview, TimelineEventKind, }; -use axum::http::StatusCode; +use axum::http::{StatusCode, Uri}; use chrono::{DateTime, NaiveDateTime, Utc}; +const YOUTUBE_VIDEO_ID_LEN: usize = 11; +// Keep link-heavy posts compact in timelines while still showing useful context. +const MAX_YOUTUBE_PREVIEWS_PER_POST: usize = 3; +const YOUTUBE_PREVIEW_TITLE: &str = "YouTube video"; + #[derive(Debug, Clone)] pub struct LayoutContext { pub anonymous_mode_enabled: bool, @@ -32,6 +37,7 @@ impl Default for LayoutContext { pub struct PostRenderOptions { pub show_timestamp: bool, pub clickable_card: bool, + pub blur_nsfw_media: bool, } impl PostRenderOptions { @@ -39,6 +45,7 @@ impl PostRenderOptions { Self { show_timestamp: false, clickable_card: true, + blur_nsfw_media: true, } } @@ -46,6 +53,7 @@ impl PostRenderOptions { Self { show_timestamp: true, clickable_card: true, + blur_nsfw_media: true, } } } @@ -115,22 +123,14 @@ pub fn layout_with_context( let left_rail = left_rail(user, &auth_nav); let side_panel = dashboard_panel(user, context); let theme = user.map_or(Theme::Light, |user| user.theme).as_str(); - let footer_onion = context - .tor_onion_address - .as_deref() - .map_or_else(String::new, |onion| { - format!( - r#" Onion: {}"#, - html_escape::encode_text(onion) - ) - }); + let header_tor = tor_header_indicator(context.tor_onion_address.as_deref()); format!( r#" - + @@ -139,9 +139,10 @@ pub fn layout_with_context( - + +
JavaScript is disabled. RustPost will use standard links and forms.
{}
{}
{}
-
{} alpha{}
+
{} alpha
"#, html_escape::encode_double_quoted_attribute(theme), @@ -151,15 +152,52 @@ pub fn layout_with_context( CSS, html_escape::encode_text(&brand_mark.to_string()), html_escape::encode_text(site_name), + header_tor, auth_nav, left_rail, body, side_panel, html_escape::encode_text(site_name), - footer_onion ) } +fn tor_header_indicator(onion: Option<&str>) -> String { + onion.map_or_else(String::new, |onion| { + let attr_onion = html_escape::encode_double_quoted_attribute(onion); + let onion_url = format!("http://{onion}"); + let attr_onion_url = html_escape::encode_double_quoted_attribute(&onion_url); + let short = short_onion_address(onion); + format!( + r#"
Tor:{}
"#, + attr_onion_url, + attr_onion, + attr_onion, + html_escape::encode_text(&short), + attr_onion, + ) + }) +} + +fn short_onion_address(onion: &str) -> String { + let (service_id, suffix_label) = onion + .strip_suffix(".onion") + .map_or((onion, ""), |service_id| (service_id, ".onion")); + let prefix: String = service_id.chars().take(6).collect(); + let suffix = service_id + .chars() + .rev() + .take(3) + .collect::>() + .into_iter() + .rev() + .collect::(); + if prefix.is_empty() || suffix.is_empty() || service_id.len() <= prefix.len() + suffix.len() { + onion.to_owned() + } else { + format!("{prefix}...{suffix}{suffix_label}") + } +} + fn nav_link(href: &str, label: &str, icon: &str) -> String { format!( r#"{}{}"#, @@ -237,13 +275,29 @@ pub fn login_form(message: Option<&str>, min_password_length: usize) -> String { ) } -pub fn register_form(message: Option<&str>, min_password_length: usize) -> String { +pub fn register_form( + message: Option<&str>, + min_password_length: usize, + captcha: Option<&crate::registration_captcha::RegistrationCaptchaChallenge>, +) -> String { let notice = message.map_or_else(String::new, |message| notice("error", message)); let hint = password_requirement_hint(min_password_length); let password_attrs = password_length_attrs(min_password_length, "password-requirement"); let confirm_attrs = password_length_attrs(min_password_length, "confirm-password-requirement"); + let captcha_html = captcha.map_or_else(String::new, register_captcha_fields); format!( - r#"

Create account

{notice}

{hint}

{hint}

"# + r#"

Create account

{notice}

{hint}

{hint}

{captcha_html}
"# + ) +} + +fn register_captcha_fields( + captcha: &crate::registration_captcha::RegistrationCaptchaChallenge, +) -> String { + format!( + r#"
Registration CAPTCHACAPTCHA challenge image

Enter the characters shown in the image. The challenge expires in {} minutes. If it is hard to read, reload this page for a new challenge.

"#, + html_escape::encode_double_quoted_attribute(&captcha.token), + html_escape::encode_double_quoted_attribute(&captcha.image_data_uri), + captcha.expires_minutes, ) } @@ -266,7 +320,9 @@ fn password_requirement_hint(min_password_length: usize) -> String { } } -const CLIENT_SCRIPT: &str = r#"function cardInteractiveTarget(target) { +const CLIENT_SCRIPT: &str = r#"document.documentElement.classList.add("js-enabled"); + +function cardInteractiveTarget(target) { if (!(target instanceof Element)) { return null; } @@ -409,6 +465,56 @@ document.querySelectorAll("textarea[data-character-limit]").forEach((textarea) = textarea.addEventListener("input", () => updateComposerCount(textarea)); }); +function mediaSummary(input) { + if (!input.files || input.files.length === 0) { + return ""; + } + if (input.files.length === 1) { + return input.files[0].name || "1 media file selected"; + } + return `${input.files.length} media files selected`; +} + +function updateComposerMedia(input) { + const form = input.closest("form"); + if (!form) { + return; + } + const hasMedia = !!(input.files && input.files.length > 0); + const selection = form.querySelector("[data-composer-media-selection]"); + const summary = form.querySelector("[data-composer-media-summary]"); + const nsfw = form.querySelector("[data-composer-nsfw]"); + if (summary) { + summary.textContent = mediaSummary(input); + } + if (selection) { + selection.hidden = !hasMedia; + } + if (nsfw && !hasMedia) { + nsfw.checked = false; + } +} + +document.querySelectorAll("input[type=file][data-composer-media]").forEach((input) => { + updateComposerMedia(input); + input.addEventListener("change", () => updateComposerMedia(input)); +}); + +document.addEventListener("click", (event) => { + const button = event.target.closest("[data-composer-clear-media]"); + if (!button) { + return; + } + const form = button.closest("form"); + const input = form ? form.querySelector("input[type=file][data-composer-media]") : null; + if (!input) { + return; + } + input.value = ""; + updateComposerMedia(input); + input.focus(); +}); + function resetSubmittingForms() { document.querySelectorAll('form[data-submitting="true"]').forEach((form) => { delete form.dataset.submitting; @@ -539,6 +645,7 @@ document.addEventListener("submit", async (event) => { } form.reset(); form.querySelectorAll("textarea[data-character-limit]").forEach(updateComposerCount); + form.querySelectorAll("input[type=file][data-composer-media]").forEach(updateComposerMedia); } } catch (_err) { form.submit(); @@ -567,6 +674,39 @@ document.addEventListener("submit", (event) => { if (submitter) { submitter.disabled = true; } +}); + +async function copyTextToClipboard(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text); + return; + } + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.top = "-1000px"; + document.body.append(textarea); + textarea.select(); + document.execCommand("copy"); + textarea.remove(); +} + +document.addEventListener("click", async (event) => { + const button = event.target.closest("[data-copy-text]"); + if (!button) { + return; + } + try { + await copyTextToClipboard(button.getAttribute("data-copy-text") || ""); + const original = button.getAttribute("data-copy-label") || button.textContent; + button.textContent = button.getAttribute("data-copied-label") || "Copied"; + window.setTimeout(() => { + button.textContent = original; + }, 1600); + } catch (_err) { + button.textContent = "Copy failed"; + } });"#; pub fn client_script() -> &'static str { @@ -579,12 +719,23 @@ pub fn composer(csrf: Option<&str>, parent: Option, max_text_chars: usize) }); let csrf = csrf.unwrap_or_default(); let input_id = parent.map_or_else(|| "post-text".to_owned(), |id| format!("reply-text-{id}")); + let media_id = parent.map_or_else(|| "post-media".to_owned(), |id| format!("reply-media-{id}")); + let nsfw_id = parent.map_or_else(|| "post-nsfw".to_owned(), |id| format!("reply-nsfw-{id}")); + let placeholder = if parent.is_some() { + "Write a reply..." + } else { + "What's happening?" + }; format!( r#"

{}

{} remaining
{} - -
+
+ + + +
+
"#, if parent.is_some() { "Reply" @@ -598,7 +749,13 @@ pub fn composer(csrf: Option<&str>, parent: Option, max_text_chars: usize) html_escape::encode_double_quoted_attribute(&input_id), html_escape::encode_double_quoted_attribute(&input_id), max_text_chars, - max_text_chars + max_text_chars, + html_escape::encode_double_quoted_attribute(placeholder), + html_escape::encode_double_quoted_attribute(&media_id), + html_escape::encode_double_quoted_attribute(&media_id), + icon_svg("paperclip"), + html_escape::encode_double_quoted_attribute(&nsfw_id), + html_escape::encode_double_quoted_attribute(&nsfw_id), ) } @@ -608,7 +765,7 @@ pub fn quote_composer(csrf: &str, quote: &QuotePreview, max_text_chars: usize) - r#"

Quote post

{max_text_chars} remaining
{preview}
- +
"#, quote.id, @@ -705,6 +862,7 @@ pub fn search_page( posts: &[PostView], user: Option<&CurrentUser>, csrf: Option<&str>, + blur_nsfw_media: bool, ) -> String { let form = search_form(site_name, query); let state = if query.is_empty() { @@ -715,7 +873,7 @@ pub fn search_page( } else if users.is_empty() && posts.is_empty() { empty_state("No results found", &format!(r#"No matches for "{query}"."#)) } else { - search_results(query, users, posts, user, csrf) + search_results(query, users, posts, user, csrf, blur_nsfw_media) }; format!("{form}{state}") } @@ -734,6 +892,7 @@ fn search_results( posts: &[PostView], user: Option<&CurrentUser>, csrf: Option<&str>, + blur_nsfw_media: bool, ) -> String { let total = users.len() + posts.len(); let plural = if total == 1 { "result" } else { "results" }; @@ -748,7 +907,15 @@ fn search_results( } else { format!( "{posts_heading}{}", - posts_with_options(posts, user, csrf, PostRenderOptions::timeline()) + posts_with_options( + posts, + user, + csrf, + PostRenderOptions { + blur_nsfw_media, + ..PostRenderOptions::timeline() + } + ) ) }; format!( @@ -835,7 +1002,50 @@ pub fn posts(posts: &[PostView], user: Option<&CurrentUser>, csrf: Option<&str>) posts_with_options(posts, user, csrf, PostRenderOptions::timeline()) } +pub fn posts_with_nsfw_blur( + posts: &[PostView], + user: Option<&CurrentUser>, + csrf: Option<&str>, + blur_nsfw_media: bool, +) -> String { + posts_with_options( + posts, + user, + csrf, + PostRenderOptions { + blur_nsfw_media, + ..PostRenderOptions::timeline() + }, + ) +} + pub fn thread_posts(posts: &[PostView], user: Option<&CurrentUser>, csrf: Option<&str>) -> String { + thread_posts_with_options(posts, user, csrf, PostRenderOptions::thread()) +} + +pub fn thread_posts_with_nsfw_blur( + posts: &[PostView], + user: Option<&CurrentUser>, + csrf: Option<&str>, + blur_nsfw_media: bool, +) -> String { + thread_posts_with_options( + posts, + user, + csrf, + PostRenderOptions { + blur_nsfw_media, + ..PostRenderOptions::thread() + }, + ) +} + +fn thread_posts_with_options( + posts: &[PostView], + user: Option<&CurrentUser>, + csrf: Option<&str>, + options: PostRenderOptions, +) -> String { if posts.is_empty() { return empty_state( "No posts yet", @@ -848,11 +1058,11 @@ pub fn thread_posts(posts: &[PostView], user: Option<&CurrentUser>, csrf: Option .iter() .enumerate() .map(|(index, post)| { - let mut options = PostRenderOptions::thread(); + let mut card_options = options; if index == 0 { - options.clickable_card = false; + card_options.clickable_card = false; } - post_card_with_options(post, user, csrf, options) + post_card_with_options(post, user, csrf, card_options) }) .collect::>() .join("") @@ -885,10 +1095,44 @@ pub fn post_card(post: &PostView, user: Option<&CurrentUser>, csrf: Option<&str> post_card_with_options(post, user, csrf, PostRenderOptions::timeline()) } +pub fn post_card_with_nsfw_blur( + post: &PostView, + user: Option<&CurrentUser>, + csrf: Option<&str>, + blur_nsfw_media: bool, +) -> String { + post_card_with_options( + post, + user, + csrf, + PostRenderOptions { + blur_nsfw_media, + ..PostRenderOptions::timeline() + }, + ) +} + pub fn thread_post_card(post: &PostView, user: Option<&CurrentUser>, csrf: Option<&str>) -> String { post_card_with_options(post, user, csrf, PostRenderOptions::thread()) } +pub fn thread_post_card_with_nsfw_blur( + post: &PostView, + user: Option<&CurrentUser>, + csrf: Option<&str>, + blur_nsfw_media: bool, +) -> String { + post_card_with_options( + post, + user, + csrf, + PostRenderOptions { + blur_nsfw_media, + ..PostRenderOptions::thread() + }, + ) +} + // Rendering a post card stays centralized because the markup, counts, media, // and action controls must remain consistent between timelines and threads. #[expect( @@ -954,10 +1198,12 @@ fn post_card_with_options( }, ); let text = linkify(&post.text); + let youtube_previews = render_youtube_previews(&post.text); let media = post .media .iter() - .map(render_media) + .enumerate() + .map(|(index, media)| render_media(media, post.id, index, options.blur_nsfw_media)) .collect::>() .join(""); let controls = if let (Some(user), Some(csrf)) = (user, csrf) { @@ -966,9 +1212,14 @@ fn post_card_with_options( } else { String::new() }; + let nsfw = if user.is_admin && !post.media.is_empty() { + admin_nsfw_form(post, csrf) + } else { + String::new() + }; let reply_link = icon_link(&format!("/posts/{}#reply", post.id), "Reply", "reply"); format!( - r#"
{}{}{}{}{}
"#, + r#"
{}{}{}{}{}{}
"#, icon_action_form( &format!("/posts/{}/like", post.id), csrf, @@ -1006,6 +1257,7 @@ fn post_card_with_options( post.viewer_bookmarked ), delete, + nsfw, ) } else { String::new() @@ -1024,9 +1276,14 @@ fn post_card_with_options( String::new() }; let card_attrs = if options.clickable_card { + format!(r#" data-card-href="/posts/{}""#, post.id) + } else { + String::new() + }; + let permalink = if options.clickable_card { format!( - r#" data-card-href="/posts/{}" tabindex="0" aria-label="Open post {}""#, - post.id, post.id + r#"Open post"#, + post.id ) } else { String::new() @@ -1044,7 +1301,7 @@ fn post_card_with_options( .as_ref() .map_or_else(String::new, quote_preview_card); format!( - r#"
{}{}
{}
{}
{}
{}
{}{}
{} likes{} reposts{} replies
{}
"#, + r#"
{}{}
{}
{}
{}
{}
{}{}{}
{} likes{} reposts{} replies{}
{}
"#, post_class, post.id, post.id, @@ -1056,11 +1313,13 @@ fn post_card_with_options( author, timestamp, text, + youtube_previews, media, quote, post.like_count, post.repost_count, post.reply_count, + permalink, controls ) } @@ -1074,7 +1333,7 @@ fn repost_action_with_quote( ) -> String { let menu_id = format!("quote-menu-{}", action.trim_matches('/').replace('/', "-")); format!( - r#""#, + r#""#, html_escape::encode_double_quoted_attribute(action), html_escape::encode_double_quoted_attribute(csrf), if active { " active" } else { "" }, @@ -1086,7 +1345,9 @@ fn repost_action_with_quote( html_escape::encode_text(label), html_escape::encode_double_quoted_attribute(&menu_id), html_escape::encode_double_quoted_attribute(quote_href), - html_escape::encode_double_quoted_attribute(quote_href) + icon_svg("quote-post"), + html_escape::encode_double_quoted_attribute(quote_href), + icon_svg("quote-post") ) } @@ -1133,6 +1394,21 @@ fn disabled_icon_button(label: &str, icon: &str) -> String { ) } +fn admin_nsfw_form(post: &PostView, csrf: &str) -> String { + let flagged = post.media.iter().any(|media| media.is_nsfw); + let (value, label) = if flagged { + ("false", "Unmark NSFW") + } else { + ("true", "Mark NSFW") + }; + format!( + r#"
"#, + post.id, + html_escape::encode_double_quoted_attribute(csrf), + html_escape::encode_text(label), + ) +} + fn quote_preview_card(quote: &QuotePreview) -> String { if quote.unavailable { return r#""# @@ -1195,24 +1471,194 @@ fn icon_svg(icon: &str) -> &'static str { "repost" => { r#""# } + "quote-post" => { + r#""# + } "bookmark" => { r#""# } "trash" => { r#""# } + "paperclip" => { + r#""# + } _ => r#""#, } } -fn render_media(media: &MediaView) -> String { +fn render_media(media: &MediaView, post_id: i64, index: usize, blur_nsfw_media: bool) -> String { let path = html_escape::encode_double_quoted_attribute(&media.public_path); let alt = html_escape::encode_double_quoted_attribute(&media.alt_text); - if media.media_kind == "video" { + let item = if media.media_kind == "video" { format!(r#""#) } else { format!(r#"{alt}"#) + }; + if !media.is_nsfw || !blur_nsfw_media { + return item; } + let toggle_id = format!("nsfw-media-{post_id}-{index}"); + format!( + r#"
{item}NSFW
Open media
"# + ) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct YoutubePreview { + video_id: String, + href: String, + display_url: String, +} + +fn render_youtube_previews(text: &str) -> String { + let previews = youtube_previews_for_text(text); + if previews.is_empty() { + return String::new(); + } + let cards = previews + .iter() + .map(render_youtube_preview_card) + .collect::>() + .join(""); + format!(r#""#) +} + +fn render_youtube_preview_card(preview: &YoutubePreview) -> String { + let href = html_escape::encode_double_quoted_attribute(&preview.href); + let display_url = html_escape::encode_text(&preview.display_url); + let thumbnail = format!("https://i.ytimg.com/vi/{}/hqdefault.jpg", preview.video_id); + format!( + r#"YouTube{}{display_url}"#, + html_escape::encode_double_quoted_attribute(&thumbnail), + html_escape::encode_text(YOUTUBE_PREVIEW_TITLE), + ) +} + +fn youtube_previews_for_text(text: &str) -> Vec { + let mut previews = Vec::new(); + for word in text.split_whitespace() { + if previews.len() >= MAX_YOUTUBE_PREVIEWS_PER_POST { + break; + } + if let Some(candidate) = youtube_candidate_from_word(word) + && let Some(preview) = youtube_preview_from_url(candidate) + { + previews.push(preview); + } + } + previews +} + +fn youtube_candidate_from_word(word: &str) -> Option<&str> { + let candidate = word + .trim_start_matches(is_url_leading_trim_char) + .trim_end_matches(is_url_trailing_trim_char); + (!candidate.is_empty()).then_some(candidate) +} + +fn is_url_leading_trim_char(ch: char) -> bool { + matches!(ch, '<' | '(' | '[' | '{' | '"' | '\'') +} + +fn is_url_trailing_trim_char(ch: char) -> bool { + matches!( + ch, + '.' | ',' | '!' | '?' | ':' | ';' | ')' | ']' | '}' | '>' | '"' | '\'' + ) +} + +fn youtube_preview_from_url(candidate: &str) -> Option { + let href = youtube_absolute_http_url(candidate)?; + let uri = href.parse::().ok()?; + if !matches!(uri.scheme_str(), Some("http" | "https")) { + return None; + } + let host = uri.host()?.to_ascii_lowercase(); + let video_id = youtube_video_id_from_parts(&host, uri.path(), uri.query())?; + Some(YoutubePreview { + video_id, + href, + display_url: candidate.to_owned(), + }) +} + +fn youtube_absolute_http_url(candidate: &str) -> Option { + if has_http_scheme(candidate) { + return Some(candidate.to_owned()); + } + let (host, _path) = candidate.split_once('/')?; + is_supported_youtube_host(host).then(|| format!("https://{candidate}")) +} + +fn has_http_scheme(candidate: &str) -> bool { + candidate + .get(..7) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("http://")) + || candidate + .get(..8) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("https://")) +} + +fn youtube_video_id_from_parts(host: &str, path: &str, query: Option<&str>) -> Option { + if is_youtu_be_host(host) { + return single_path_segment(path).and_then(valid_youtube_video_id); + } + if !is_youtube_host(host) { + return None; + } + if path == "/watch" { + return query.and_then(youtube_video_id_from_query); + } + path_segment_after_prefix(path, "/shorts/") + .or_else(|| path_segment_after_prefix(path, "/embed/")) + .and_then(valid_youtube_video_id) +} + +fn youtube_video_id_from_query(query: &str) -> Option { + let mut video_id = None; + for pair in query.split('&') { + let (key, value) = pair.split_once('=').unwrap_or((pair, "")); + if key != "v" { + continue; + } + let valid = valid_youtube_video_id(value)?; + if video_id.replace(valid).is_some() { + return None; + } + } + video_id +} + +fn single_path_segment(path: &str) -> Option<&str> { + let value = path.strip_prefix('/')?; + (!value.is_empty() && !value.contains('/')).then_some(value) +} + +fn path_segment_after_prefix<'a>(path: &'a str, prefix: &str) -> Option<&'a str> { + let value = path.strip_prefix(prefix)?; + (!value.is_empty() && !value.contains('/')).then_some(value) +} + +fn valid_youtube_video_id(value: &str) -> Option { + (value.len() == YOUTUBE_VIDEO_ID_LEN + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))) + .then(|| value.to_owned()) +} + +fn is_supported_youtube_host(host: &str) -> bool { + let host = host.to_ascii_lowercase(); + is_youtube_host(&host) || is_youtu_be_host(&host) +} + +fn is_youtube_host(host: &str) -> bool { + matches!(host, "youtube.com" | "www.youtube.com" | "m.youtube.com") +} + +fn is_youtu_be_host(host: &str) -> bool { + host == "youtu.be" } pub fn linkify(text: &str) -> String { @@ -1335,7 +1781,7 @@ fn notification_row(notification: &NotificationView) -> String { let target = notification_target(notification); let target_attrs = target.as_ref().map_or_else(String::new, |href| { format!( - r#" data-card-href="{}" tabindex="0""#, + r#" data-card-href="{}""#, html_escape::encode_double_quoted_attribute(href) ) }); @@ -1519,34 +1965,35 @@ pub fn error_page(status: StatusCode, message: &str) -> String { } const CSS: &str = r#" -:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color-scheme:light;line-height:1.5;--bg:#f5f6f1;--surface:#fff;--surface-subtle:#fbfcfa;--surface-muted:#f4f5f2;--header-bg:rgba(255,255,255,.96);--text:#202124;--text-strong:#172017;--muted:#667064;--muted-strong:#59625a;--border:#dfe4dc;--border-strong:#b9c2b8;--link:#1f5f8b;--link-strong:#24445f;--brand:#163b2f;--brand-hover:#235544;--brand-text:#fff;--hover:#eef3f0;--focus:#93c5fd;--shadow:rgba(20,35,30,.04);--reply-border:#c8d8d0;--avatar-bg:#eef3f0;--danger:#8a3d2d;--danger-strong:#6f2f22;--danger-bg:#fff8f5;--danger-border:#e6b8a8;--success-bg:#f4fbf5;--success-border:#add7b4;--media-bg:#f6f7f4} +:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color-scheme:light;line-height:1.5;--bg:#f5f6f1;--surface:#fff;--surface-subtle:#fbfcfa;--surface-muted:#f4f5f2;--header-bg:rgba(255,255,255,.96);--text:#202124;--text-strong:#172017;--muted:#667064;--muted-strong:#59625a;--border:#dfe4dc;--border-strong:#b9c2b8;--link:#1f5f8b;--link-strong:#24445f;--brand:#163b2f;--brand-hover:#235544;--brand-text:#fff;--hover:#eef3f0;--focus:#93c5fd;--shadow:rgba(20,35,30,.04);--reply-border:#c8d8d0;--avatar-bg:#eef3f0;--danger:#8a3d2d;--danger-strong:#6f2f22;--danger-bg:#fff8f5;--danger-border:#e6b8a8;--success-bg:#f4fbf5;--success-border:#add7b4;--media-bg:#f6f7f4;--shell-side:240px;--shell-primary:640px;--shell-gap:1.25rem;--shell-max:1160px;--header-padding-y:.8rem;--header-brand-size:2rem;--hairline:1px;--rail-sticky-top:calc(var(--header-brand-size) + var(--header-padding-y) + var(--header-padding-y) + var(--shell-gap) + var(--hairline))} :root[data-theme="dark"]{color-scheme:dark;--bg:#111827;--surface:#182231;--surface-subtle:#1d2939;--surface-muted:#233044;--header-bg:rgba(17,24,39,.96);--text:#eef4fb;--text-strong:#f8fafc;--muted:#c3cfdd;--muted-strong:#d4deea;--border:#344256;--border-strong:#596b83;--link:#8fc7ff;--link-strong:#badcff;--brand:#4f8fc7;--brand-hover:#6aa8df;--brand-text:#06111f;--hover:#243349;--focus:#fbbf24;--shadow:rgba(0,0,0,.26);--reply-border:#4f6680;--avatar-bg:#243349;--danger:#ffb4a2;--danger-strong:#ffd2c7;--danger-bg:#3a2020;--danger-border:#8f4d43;--success-bg:#163321;--success-border:#4c8a61;--media-bg:#0f172a} *{box-sizing:border-box}body{margin:0;min-width:320px;color:var(--text);background:var(--bg)}a{color:var(--link);text-decoration:none}a:hover{text-decoration:underline} .site-header{position:sticky;top:0;z-index:10;background:var(--header-bg);border-bottom:1px solid var(--border);backdrop-filter:blur(8px)} -.header-inner{max-width:1180px;margin:0 auto;padding:.8rem 1rem;display:flex;align-items:center;justify-content:space-between;gap:1rem} -.brand{display:flex;align-items:center;gap:.55rem;font-weight:800;color:var(--text-strong)}.brand-mark{display:inline-grid;place-items:center;width:2rem;height:2rem;border-radius:7px;background:var(--brand);color:var(--brand-text)} +.header-inner{max-width:var(--shell-max);margin:0 auto;padding:var(--header-padding-y) 1rem;display:flex;align-items:center;justify-content:space-between;gap:1rem} +.header-brand-row{display:flex;align-items:center;gap:.75rem;min-width:0;max-width:100%}.brand{display:flex;align-items:center;gap:.55rem;font-weight:800;color:var(--text-strong);min-width:0}.brand span:last-child{overflow-wrap:anywhere}.brand-mark{display:inline-grid;place-items:center;width:var(--header-brand-size);height:var(--header-brand-size);border-radius:7px;background:var(--brand);color:var(--brand-text);flex:0 0 auto} +.tor-indicator{display:inline-flex;align-items:center;gap:.28rem;min-width:0;max-width:min(28rem,100%);flex:1 1 14rem;min-height:2rem;border-left:1px solid var(--border);padding:.05rem 0 .05rem .7rem;color:var(--muted-strong);font-size:.86rem;line-height:1;white-space:nowrap;overflow:hidden}.tor-label{flex:0 0 auto;color:var(--muted);font-weight:800}.tor-address-link{display:inline-flex;align-items:center;min-width:0;max-width:100%;flex:1 1 auto;min-height:1.9rem;padding:0 .1rem;color:var(--link-strong);font-weight:750}.tor-address-link:hover{color:var(--text-strong);text-decoration:underline}.tor-summary-text{display:block;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-variant-numeric:tabular-nums}.tor-copy-button{display:none;flex:0 0 auto;min-width:2.55rem;min-height:1.9rem;border:1px solid transparent;border-radius:6px;background:transparent;color:var(--muted-strong);padding:.18rem .35rem;font-size:.82rem;font-weight:800;line-height:1;box-shadow:none}.js-enabled .tor-copy-button{display:inline-flex;align-items:center;justify-content:center}.tor-copy-button:hover{background:var(--hover);color:var(--link-strong)} nav{display:flex;gap:.35rem;align-items:center;flex-wrap:wrap;justify-content:flex-end}nav a,nav button,.button-link{display:inline-flex;align-items:center;gap:.35rem;min-height:2.15rem;border-radius:7px;padding:.42rem .65rem;color:var(--link-strong);border:1px solid transparent;background:transparent} nav a:hover,nav button:hover,.button-link:hover{background:var(--hover);text-decoration:none}nav form,.actions form{display:inline} nav svg{width:1.05rem;height:1.05rem;fill:currentColor;flex:0 0 auto} .nav-badge{display:inline-grid;place-items:center;min-width:1.25rem;height:1.25rem;border-radius:999px;padding:0 .35rem;background:var(--brand);color:var(--brand-text);font-size:.78rem;font-weight:800;line-height:1} -main{padding:1.25rem}.app-shell{width:min(100%,1180px);margin:0 auto;display:grid;grid-template-columns:220px minmax(0,640px) 260px;gap:1.25rem;align-items:start;justify-content:center}.primary-column{min-width:0;width:100%}.left-rail,.right-rail{min-width:0;position:sticky;top:5rem;display:grid;gap:.75rem}.side-rail-card,.rail-nav{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:.85rem;color:var(--muted-strong);box-shadow:0 1px 2px var(--shadow)}.side-rail-card h2{margin:.1rem 0 .6rem;font-size:1rem;color:var(--text)}.rail-nav{display:grid;gap:.25rem}.rail-nav a,.rail-nav button{width:100%;justify-content:flex-start}.rail-nav form{display:block}.mobile-nav{display:none}.dashboard-list{display:grid;grid-template-columns:auto minmax(0,1fr);gap:.45rem .75rem;margin:.25rem 0 .85rem}.dashboard-list dt{font-weight:800;color:var(--text)}.dashboard-list dd{margin:0;overflow-wrap:anywhere}.dashboard-account{color:var(--text)}.dashboard-account:hover{text-decoration:none}.dashboard-actions{display:flex;flex-wrap:wrap;gap:.4rem}.site-footer{max-width:1180px;margin:0 auto;padding:1rem;color:var(--muted);font-size:.9rem}.footer-onion{display:block;margin-top:.25rem;overflow-wrap:anywhere} +main{padding:var(--shell-gap)}.app-shell{width:min(100%,var(--shell-max));margin:0 auto;display:grid;grid-template-columns:var(--shell-side) minmax(0,var(--shell-primary)) var(--shell-side);gap:var(--shell-gap);align-items:start;justify-content:center}.primary-column{min-width:0;width:100%}.left-rail,.right-rail{min-width:0;position:sticky;top:var(--rail-sticky-top);display:grid;gap:.75rem;align-items:start}.side-rail-card,.rail-nav{background:var(--surface);border:1px solid var(--border);border-radius:8px;color:var(--muted-strong);box-shadow:0 1px 2px var(--shadow)}.side-rail-card{padding:.85rem}.side-rail-card h2{margin:.1rem 0 .6rem;font-size:1rem;color:var(--text)}.rail-nav{display:grid;grid-template-columns:minmax(0,1fr);gap:.2rem;width:100%;padding:.35rem;justify-content:stretch}.rail-nav a,.rail-nav button{width:100%;min-height:2.35rem;justify-content:flex-start;padding:.5rem .65rem}.rail-nav form{display:block}.mobile-nav{display:none}.dashboard-list{display:grid;grid-template-columns:auto minmax(0,1fr);gap:.45rem .75rem;margin:.25rem 0 .85rem}.dashboard-list dt{font-weight:800;color:var(--text)}.dashboard-list dd{margin:0;overflow-wrap:anywhere}.dashboard-account{color:var(--text)}.dashboard-account:hover{text-decoration:none}.dashboard-actions{display:flex;flex-wrap:wrap;gap:.4rem}.site-footer{max-width:var(--shell-max);margin:0 auto;padding:1rem;color:var(--muted);font-size:.9rem} .page-header,.post,.composer,.panel,.empty-state,.notice{background:var(--surface);border:1px solid var(--border);border-radius:8px;margin:0 0 .7rem;padding:.85rem;box-shadow:0 1px 2px var(--shadow)} .page-header h1,.section-heading h1,.panel h1{margin:0;font-size:1.45rem;line-height:1.2}.panel h1+table,.panel h1+form,.panel h1+p,.panel h1+dl{margin-top:.85rem}.page-header p,.muted,.empty-state p{color:var(--muted);margin:.35rem 0 0}.section-heading{display:flex;justify-content:space-between;gap:1rem;align-items:baseline;margin-bottom:.8rem} -.notifications-hero{background:var(--surface);border:1px solid var(--border);border-radius:8px;margin:0 0 .7rem;padding:1rem;box-shadow:0 1px 2px var(--shadow);display:flex;align-items:center;justify-content:space-between;gap:1rem}.notifications-hero h1{margin:0;font-size:1.55rem;line-height:1.15}.notifications-hero p:not(.eyebrow){margin:.35rem 0 0;color:var(--muted-strong)}.caught-up-pill{display:inline-flex;align-items:center;min-height:2rem;border:1px solid var(--success-border);border-radius:999px;background:var(--success-bg);color:var(--text-strong);padding:.32rem .75rem;font-weight:800}.caught-up{padding:.75rem .85rem}.caught-up p{margin:0}.notifications-list{display:grid;gap:.5rem}.notification-group{margin:.8rem .15rem .2rem;color:var(--muted);font-size:.82rem;text-transform:uppercase;letter-spacing:.08em}.notification-row{position:relative;display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:.75rem;align-items:start;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:.8rem;box-shadow:0 1px 2px var(--shadow)}.notification-row.unread{border-color:var(--border-strong);background:var(--surface-subtle)}.notification-row[data-card-href]{cursor:pointer}.notification-row[data-card-href]:hover{border-color:var(--border-strong);background:var(--hover)}.notification-kind{display:grid;place-items:center;width:2rem;height:2rem;border-radius:7px;background:var(--surface-muted);color:var(--link-strong);font-weight:900;font-size:.8rem}.notification-row.unread .notification-kind{background:var(--brand);color:var(--brand-text)}.notification-body{min-width:0}.notification-line{margin:0;overflow-wrap:anywhere}.notification-meta{margin:.35rem 0 0;color:var(--muted);font-size:.88rem}.notification-preview{display:block;margin:.5rem 0 0;border:1px solid var(--border);border-radius:7px;padding:.55rem .65rem;background:var(--surface-subtle);color:var(--muted-strong);overflow-wrap:anywhere}.notification-preview:hover{background:var(--surface);text-decoration:none}.notification-preview.unavailable{border-style:dashed}.unread-dot{width:.65rem;height:.65rem;border-radius:999px;background:var(--brand);margin-top:.7rem} +.notifications-hero{background:var(--surface);border:1px solid var(--border);border-radius:8px;margin:0 0 .7rem;padding:1rem;box-shadow:0 1px 2px var(--shadow);display:flex;align-items:center;justify-content:space-between;gap:1rem}.notifications-hero h1{margin:0;font-size:1.55rem;line-height:1.15}.notifications-hero p:not(.eyebrow){margin:.35rem 0 0;color:var(--muted-strong)}.caught-up-pill{display:inline-flex;align-items:center;min-height:2rem;border:1px solid var(--success-border);border-radius:999px;background:var(--success-bg);color:var(--text-strong);padding:.32rem .75rem;font-weight:800}.caught-up{padding:.75rem .85rem}.caught-up p{margin:0}.notifications-list{display:grid;gap:.5rem}.notification-group{margin:.8rem .15rem .2rem;color:var(--muted);font-size:.82rem;text-transform:uppercase;letter-spacing:.08em}.notification-row{position:relative;display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:.75rem;align-items:start;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:.8rem;box-shadow:0 1px 2px var(--shadow)}.notification-row.unread{border-color:var(--border-strong);background:var(--surface-subtle)}.js-enabled .notification-row[data-card-href]{cursor:pointer}.js-enabled .notification-row[data-card-href]:hover{border-color:var(--border-strong);background:var(--hover)}.notification-kind{display:grid;place-items:center;width:2rem;height:2rem;border-radius:7px;background:var(--surface-muted);color:var(--link-strong);font-weight:900;font-size:.8rem}.notification-row.unread .notification-kind{background:var(--brand);color:var(--brand-text)}.notification-body{min-width:0}.notification-line{margin:0;overflow-wrap:anywhere}.notification-meta{margin:.35rem 0 0;color:var(--muted);font-size:.88rem}.notification-preview{display:block;margin:.5rem 0 0;border:1px solid var(--border);border-radius:7px;padding:.55rem .65rem;background:var(--surface-subtle);color:var(--muted-strong);overflow-wrap:anywhere}.notification-preview:hover{background:var(--surface);text-decoration:none}.notification-preview.unavailable{border-style:dashed}.unread-dot{width:.65rem;height:.65rem;border-radius:999px;background:var(--brand);margin-top:.7rem} label{display:block;font-weight:700;margin:.85rem 0 .35rem}input,textarea,button,select{font:inherit}input[type=text],input[type=search],input[type=password],input[type=url],input:not([type]),textarea,select{width:100%;padding:.72rem .8rem;border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);color:var(--text)}textarea{resize:vertical;min-height:7rem}::placeholder{color:var(--muted)} input[type=checkbox]{accent-color:var(--brand)}.check-row,.theme-toggle{display:flex;align-items:center;gap:.55rem;font-weight:700;color:var(--text)}.theme-toggle{padding:.65rem .75rem;border:1px solid var(--border);border-radius:8px;background:var(--surface-subtle)}.theme-toggle input{width:auto} -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:var(--surface);color:var(--link-strong);border-color:var(--border);min-width:4.5rem}.auth-submit{margin-top:1.15rem}.auth-form{margin-top:.35rem}.auth-form .field-help{margin:.15rem 0 .4rem;color:var(--muted-strong)} +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{display:none;background:var(--surface);color:var(--link-strong);border-color:var(--border);min-width:4.5rem}.js-enabled .password-toggle{display:inline-block}.auth-submit{margin-top:1.15rem}.auth-form{margin-top:.35rem}.auth-form .field-help{margin:.15rem 0 .4rem;color:var(--muted-strong)} .search-panel h1{margin-bottom:.75rem}.search-form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.55rem;align-items:center}.search-form input{min-width:0}.search-results{display:grid;gap:.65rem}.section-title{margin:.2rem 0 .65rem;font-size:1.05rem;color:var(--text)}.search-users{margin-bottom:0}.search-users .section-title{margin-top:0}.search-account{grid-template-columns:auto minmax(0,1fr)} input:focus,textarea:focus,select:focus,button:focus-visible,a:focus-visible{outline:3px solid var(--focus);outline-offset:2px}button,.primary{border:1px solid var(--brand);background:var(--brand);color:var(--brand-text);border-radius:7px;padding:.5rem .8rem;cursor:pointer;font-weight:700}button:hover,.primary:hover{background:var(--brand-hover);text-decoration:none} -nav button,.rail-nav button{border-color:transparent;background:transparent;color:var(--link-strong);padding:.42rem .65rem}.rail-nav button:hover,.mobile-nav button:hover{background:var(--hover);color:var(--link-strong)} -.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:var(--link-strong);font-weight:700}.file-control input{max-width:15rem} +nav button{border-color:transparent;background:transparent;color:var(--link-strong);padding:.42rem .65rem}.rail-nav button{border-color:transparent;background:transparent;color:var(--link-strong);padding:.5rem .65rem}.rail-nav button:hover,.mobile-nav button:hover{background:var(--hover);color:var(--link-strong)} +.composer-surface{border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);overflow:hidden}.composer-surface textarea{border:0;border-radius:0;background:transparent;min-height:7rem;resize:vertical}.composer-surface textarea:focus{outline:0;box-shadow:inset 0 0 0 3px var(--focus)}.composer-footer{display:flex;align-items:center;justify-content:space-between;gap:.55rem;border-top:1px solid var(--border);padding:.42rem .5rem;background:var(--surface-subtle)}.composer-file-control{position:relative;display:inline-flex;align-items:center;gap:.45rem;max-width:100%;margin:0;color:var(--link-strong);font-weight:800}.composer-file-input{max-width:100%;color:var(--muted-strong)}.composer-file-input::file-selector-button{border:1px solid var(--border);border-radius:7px;background:var(--surface);color:var(--link-strong);padding:.34rem .58rem;font-weight:800;cursor:pointer}.composer-file-input::file-selector-button:hover{background:var(--hover);color:var(--text-strong)}.composer-file-button{display:none}.js-enabled .composer-file-input{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.js-enabled .composer-file-button{display:inline-flex;align-items:center;gap:.38rem;min-height:2rem;border:1px solid var(--border);border-radius:7px;background:var(--surface);color:var(--link-strong);padding:.3rem .55rem;cursor:pointer}.composer-file-button svg{width:1rem;height:1rem;fill:currentColor;flex:0 0 auto}.js-enabled .composer-file-control:hover .composer-file-button{background:var(--hover);color:var(--text-strong)}.js-enabled .composer-file-input:focus-visible+.composer-file-button{outline:3px solid var(--focus);outline-offset:2px}.composer-media-selection[hidden]{display:none}.composer-media-selection{display:flex;align-items:center;gap:.7rem;flex-wrap:wrap;border-top:1px solid var(--border);padding:.5rem;background:var(--surface)}.composer-media-summary{font-weight:800;color:var(--muted-strong);overflow-wrap:anywhere}.composer-nsfw{margin:0}.composer-clear-media{background:var(--surface);color:var(--link-strong);border-color:var(--border);padding:.32rem .55rem}.composer-clear-media:hover{background:var(--hover);color:var(--text-strong)}.composer-tools{display:flex;align-items:center;justify-content:space-between;gap:.75rem;margin-top:.85rem} .thread-nav{display:flex;margin:0 0 .45rem .85rem}.thread-back{width:2rem;height:2rem;display:inline-flex;align-items:center;justify-content:center;border-radius:999px;color:var(--link-strong)}.thread-back svg{width:1.2rem;height:1.2rem;fill:currentColor}.thread-back:hover{background:var(--hover);text-decoration:none} -.timeline{display:grid;gap:.65rem}.post{overflow:hidden;position:relative}.post[data-card-href]{cursor:pointer}.post[data-card-href]:hover{border-color:var(--border-strong)}.post[data-card-href]:focus-visible{outline:3px solid var(--focus);outline-offset:2px}.reply-post{margin-left:1.1rem;border-left:4px solid var(--reply-border);background:var(--surface-subtle)}.reply-post::before{content:"";position:absolute;left:-1.1rem;top:1.25rem;width:1.1rem;border-top:2px solid var(--reply-border)}.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 var(--border);background:var(--avatar-bg);flex:0 0 auto;margin:0}.post-avatar.placeholder{display:inline-grid;place-items:center;color:var(--muted-strong);font-weight:800}.author-name{font-weight:800;color:var(--text-strong)}.username,.post-time,.counts{color:var(--muted);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 var(--border);margin-top:.5rem;background:var(--media-bg)}.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:inline-flex;gap:.25rem;flex-wrap:wrap;align-items:center;margin-top:.5rem;max-width:100%}.icon-button{width:2.2rem;height:2.2rem;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--border);border-radius:7px;background:var(--surface);color:var(--link-strong);padding:0}.icon-button svg{width:1.05rem;height:1.05rem;fill:currentColor}.icon-button:hover,.icon-button.active{background:var(--hover);color:var(--text-strong);text-decoration:none}.icon-button.disabled,.icon-button:disabled{color:var(--muted);background:var(--surface-muted);border-color:var(--border);cursor:not-allowed;opacity:.75}.icon-button.disabled:hover,.icon-button:disabled:hover{background:var(--surface-muted);color:var(--muted)}.repost-control{position:relative;display:inline-flex;align-items:center;gap:.25rem}.repost-menu{position:absolute;z-index:8;left:0;top:calc(100% + .25rem);min-width:8.5rem;padding:.3rem;border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);box-shadow:0 6px 18px var(--shadow)}.repost-menu a,.quote-fallback{display:inline-flex;align-items:center;min-height:2rem;border-radius:6px;padding:.32rem .55rem;color:var(--link-strong);font-weight:700}.repost-menu a{width:100%}.repost-menu a:hover,.quote-fallback:hover{background:var(--hover);text-decoration:none}.quote-fallback{font-size:.88rem}.quote-preview{display:block;margin:.6rem 0 .25rem;border:1px solid var(--border);border-radius:7px;background:var(--surface-subtle);overflow:hidden}.quote-preview p{margin:.65rem;color:var(--muted-strong)}.quote-link{display:grid;gap:.2rem;padding:.6rem;color:var(--text)}.quote-link:hover{background:var(--hover);text-decoration:none}.quote-author{font-weight:800}.quote-text{white-space:pre-wrap;overflow-wrap:anywhere}.quote-time{color:var(--muted);font-size:.86rem}.follow-button{min-width:6.6rem}.follow-button.active{background:var(--hover);color:var(--text-strong);border-color:var(--border-strong)}.profile-actions{margin-top:0}.profile-secondary button{background:var(--surface);color:var(--danger);border-color:var(--danger-border);padding:.32rem .5rem;min-height:1.85rem;font-size:.86rem}.profile-secondary button:hover{background:var(--danger-bg);color:var(--danger-strong)}.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:var(--muted-strong);font-size:.9rem;font-weight:800;margin-bottom:.35rem}.unavailable{color:var(--muted)}.empty-state{text-align:center;padding:2rem 1rem}.empty-state h2{margin:0;font-size:1.2rem}.notice.error,.error-panel{border-color:var(--danger-border);background:var(--danger-bg)}.notice.success{border-color:var(--success-border);background:var(--success-bg)}.eyebrow{text-transform:uppercase;letter-spacing:.08em;font-weight:800;color:var(--muted);font-size:.78rem} -.profile-banner{width:100%;max-height:220px;object-fit:cover;border-radius:8px;border:1px solid var(--border);background:var(--surface-muted)}.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:999px;border:3px solid var(--surface);background:var(--avatar-bg);flex:0 0 auto}.profile-meta{color:var(--muted-strong);margin:.45rem 0 0}.settings-profile-editor{padding:0;overflow:hidden}.settings-editor-bar{display:flex;justify-content:space-between;align-items:center;gap:1rem;padding:.85rem;border-bottom:1px solid var(--border)}.settings-editor-bar h1{margin:0}.settings-editor-bar .primary{flex:0 0 auto}.settings-profile-form{padding:0 .85rem .85rem}.settings-profile-media{margin:0 -.85rem .85rem}.settings-banner-wrap{background:var(--media-bg)}.settings-banner-preview{display:block;width:100%;height:220px;object-fit:cover;background:linear-gradient(135deg,var(--surface-muted),var(--hover));border:0;border-radius:0}.settings-banner-preview.placeholder::before{content:"";display:block;width:100%;height:100%}.settings-picture-row{display:grid;grid-template-columns:auto minmax(0,1fr);gap:1rem;align-items:end;padding:0 .85rem .85rem;margin-top:-48px}.settings-picture-preview{width:112px;height:112px;object-fit:cover;border-radius:999px;border:5px solid var(--surface);background:var(--avatar-bg);box-shadow:0 1px 4px var(--shadow)}.settings-picture-preview.placeholder{display:block}.settings-media-controls{display:grid;gap:.5rem;align-content:end;padding-top:3.25rem}.media-control-row{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap}.settings-fields{display:grid;gap:.25rem}.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.7rem}.deep-settings-panel{padding:0;overflow:hidden}.deep-settings-form{padding:.85rem;display:grid;gap:.85rem}.deep-settings-group{border:1px solid var(--border);border-radius:8px;padding:.8rem;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.7rem}.deep-settings-group legend{font-weight:800;padding:0 .35rem}.deep-settings-field{display:grid;gap:.25rem;align-content:start}.deep-settings-field label{font-weight:800}.deep-settings-field input,.deep-settings-field select{min-width:0}.field-help{font-size:.88rem}.deep-settings-confirm .settings-item-list li{display:block}.compact-panel h2,.danger-panel h2{margin:0 0 .65rem;font-size:1.1rem}.inline-settings-form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.55rem;align-items:center;margin:.2rem 0 .75rem}.inline-settings-form input{min-width:0}.settings-password-form button[type=submit]{margin-top:.9rem}.settings-item-list{list-style:none;margin:.25rem 0 0;padding:0;display:grid;gap:.45rem}.settings-item-list li{display:flex;justify-content:space-between;align-items:center;gap:.75rem;border:1px solid var(--border);border-radius:7px;padding:.55rem .65rem;background:var(--surface-subtle)}.settings-item-list form{flex:0 0 auto}.settings-item-list button{padding:.32rem .55rem;background:var(--surface);color:var(--link-strong);border-color:var(--border)}.compact-empty{border:1px dashed var(--border);border-radius:7px;padding:.75rem;background:var(--surface-subtle);color:var(--muted-strong)}.compact-empty p{margin:.25rem 0 0}.danger-panel{border-color:var(--danger-border);background:var(--danger-bg)}.danger,.danger-link{border-color:var(--danger-border);background:var(--danger);color:var(--brand-text)}.danger:hover,.danger-link:hover{background:var(--danger-strong);color:var(--brand-text);text-decoration:none}.delete-account-panel p{max-width:62ch}.favicon-preview{width:32px;height:32px;object-fit:contain;border:1px solid var(--border);border-radius:6px;background:var(--surface)}.account-list{display:grid;gap:.65rem}.account-row{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:.75rem;align-items:center;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:.85rem}.account-row p{margin:.3rem 0 0;color:var(--muted-strong);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 var(--border);text-align:left;padding:.55rem;vertical-align:top}pre{white-space:pre-wrap;overflow:auto;max-width:100%} -@media (max-width:1100px){.app-shell{grid-template-columns:200px minmax(0,640px)}.right-rail{display:none}} +.timeline{display:grid;gap:.65rem}.post{overflow:hidden;position:relative}.js-enabled .post[data-card-href]{cursor:pointer}.js-enabled .post[data-card-href]:hover{border-color:var(--border-strong)}.reply-post{margin-left:1.1rem;border-left:4px solid var(--reply-border);background:var(--surface-subtle)}.reply-post::before{content:"";position:absolute;left:-1.1rem;top:1.25rem;width:1.1rem;border-top:2px solid var(--reply-border)}.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 var(--border);background:var(--avatar-bg);flex:0 0 auto;margin:0}.post-avatar.placeholder{display:inline-grid;place-items:center;color:var(--muted-strong);font-weight:800}.author-name{font-weight:800;color:var(--text-strong)}.username,.post-time,.counts{color:var(--muted);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 var(--border);margin-top:.5rem;background:var(--media-bg)}.post img.post-avatar{display:block;margin:0;border-radius:999px}.youtube-previews{display:grid;gap:.45rem;margin:.35rem 0 .55rem}.youtube-preview-card{display:grid;grid-template-columns:minmax(5.5rem,7.5rem) minmax(0,1fr);gap:.65rem;align-items:center;min-height:4.5rem;border:1px solid var(--border);border-radius:7px;background:var(--surface-subtle);color:var(--text);overflow:hidden}.youtube-preview-card:hover{border-color:var(--border-strong);background:var(--hover);text-decoration:none}.youtube-thumbnail-frame{position:relative;display:block;width:100%;aspect-ratio:16/9;overflow:hidden;background:var(--media-bg)}.post img.youtube-thumbnail{width:100%;height:100%;object-fit:cover;margin:0;border:0;border-radius:0}.youtube-play{position:absolute;left:50%;top:50%;width:2rem;height:2rem;border-radius:999px;background:rgba(0,0,0,.68);transform:translate(-50%,-50%)}.youtube-play::before{content:"";position:absolute;left:.78rem;top:.55rem;border-top:.45rem solid transparent;border-bottom:.45rem solid transparent;border-left:.65rem solid #fff}.youtube-preview-body{display:grid;gap:.08rem;min-width:0;padding:.45rem .55rem .45rem 0}.youtube-preview-source{color:var(--muted);font-size:.78rem;font-weight:900;text-transform:uppercase}.youtube-preview-title{color:var(--text-strong);font-weight:850;overflow-wrap:anywhere}.youtube-preview-url{color:var(--muted-strong);font-size:.86rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.nsfw-media{position:relative;margin-top:.5rem}.nsfw-media .post img,.nsfw-media .post video{margin-top:0}.nsfw-media-frame{position:relative;display:block;overflow:hidden;border-radius:8px}.nsfw-media-frame img,.nsfw-media-frame video{margin-top:0;filter:blur(24px);transform:scale(1.02)}.nsfw-toggle:checked+.nsfw-media-frame img,.nsfw-toggle:checked+.nsfw-media-frame video{filter:none;transform:none}.nsfw-badge{position:absolute;left:.55rem;bottom:.55rem;border-radius:999px;padding:.18rem .5rem;background:rgba(0,0,0,.72);color:#fff;font-size:.78rem;font-weight:900;letter-spacing:.03em}.nsfw-show{position:absolute;right:.55rem;bottom:.55rem;margin:0;border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);color:var(--text-strong);padding:.32rem .65rem;font-weight:900;box-shadow:0 1px 2px var(--shadow);cursor:pointer}.nsfw-show:hover{background:var(--hover)}.nsfw-toggle:focus-visible~.nsfw-show{outline:3px solid var(--focus);outline-offset:2px}.nsfw-toggle:checked~.nsfw-show,.nsfw-toggle:checked+.nsfw-media-frame .nsfw-badge{display:none}.nsfw-open{display:block;margin:.35rem 0 0;font-size:.9rem;font-weight:700} +.counts{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.3rem;min-height:1.4rem}.post-permalink{font-weight:700;color:var(--link-strong)}.js-enabled .post-permalink{display:none}.actions{display:inline-flex;gap:.25rem;flex-wrap:wrap;align-items:center;margin-top:.5rem;max-width:100%}.icon-button{width:2.2rem;height:2.2rem;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--border);border-radius:7px;background:var(--surface);color:var(--link-strong);padding:0}.icon-button svg{width:1.05rem;height:1.05rem;fill:currentColor}.icon-button:hover,.icon-button.active{background:var(--hover);color:var(--text-strong);text-decoration:none}.icon-button.disabled,.icon-button:disabled{color:var(--muted);background:var(--surface-muted);border-color:var(--border);cursor:not-allowed;opacity:.75}.icon-button.disabled:hover,.icon-button:disabled:hover{background:var(--surface-muted);color:var(--muted)}.admin-nsfw-button{min-height:2.2rem;padding:.3rem .55rem;background:var(--surface);color:var(--link-strong);border-color:var(--border);font-size:.86rem}.admin-nsfw-button:hover{background:var(--hover);color:var(--text-strong)}.repost-control{position:relative;display:inline-flex;align-items:center;gap:.25rem}.repost-menu{position:absolute;z-index:8;left:0;top:calc(100% + .25rem);min-width:8.5rem;padding:.3rem;border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);box-shadow:0 6px 18px var(--shadow)}.repost-menu a{display:inline-flex;align-items:center;gap:.35rem;width:100%;min-height:2rem;border-radius:6px;padding:.32rem .55rem;color:var(--link-strong);font-weight:700}.repost-menu a svg{width:1rem;height:1rem;fill:currentColor;flex:0 0 auto}.repost-menu a:hover,.quote-fallback:hover{background:var(--hover);text-decoration:none}.quote-preview{display:block;margin:.6rem 0 .25rem;border:1px solid var(--border);border-radius:7px;background:var(--surface-subtle);overflow:hidden}.quote-preview p{margin:.65rem;color:var(--muted-strong)}.quote-link{display:grid;gap:.2rem;padding:.6rem;color:var(--text)}.quote-link:hover{background:var(--hover);text-decoration:none}.quote-author{font-weight:800}.quote-text{white-space:pre-wrap;overflow-wrap:anywhere}.quote-time{color:var(--muted);font-size:.86rem}.follow-button{min-width:6.6rem}.follow-button.active{background:var(--hover);color:var(--text-strong);border-color:var(--border-strong)}.profile-actions{margin-top:0}.profile-secondary button{background:var(--surface);color:var(--danger);border-color:var(--danger-border);padding:.32rem .5rem;min-height:1.85rem;font-size:.86rem}.profile-secondary button:hover{background:var(--danger-bg);color:var(--danger-strong)}.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:var(--muted-strong);font-size:.9rem;font-weight:800;margin-bottom:.35rem}.unavailable{color:var(--muted)}.empty-state{text-align:center;padding:2rem 1rem}.empty-state h2{margin:0;font-size:1.2rem}.notice.error,.error-panel{border-color:var(--danger-border);background:var(--danger-bg)}.notice.success{border-color:var(--success-border);background:var(--success-bg)}.eyebrow{text-transform:uppercase;letter-spacing:.08em;font-weight:800;color:var(--muted);font-size:.78rem}.noscript-banner{max-width:1100px;margin:.7rem auto 0;padding:.65rem .85rem;border:1px solid var(--border);border-radius:8px;background:var(--surface-subtle);color:var(--muted-strong)}.js-enabled .noscript-banner{display:none} +.profile-banner{width:100%;max-height:220px;object-fit:cover;border-radius:8px;border:1px solid var(--border);background:var(--surface-muted)}.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:999px;border:3px solid var(--surface);background:var(--avatar-bg);flex:0 0 auto}.profile-meta{color:var(--muted-strong);margin:.45rem 0 0}.settings-profile-editor{padding:0;overflow:hidden}.settings-editor-bar{display:flex;justify-content:space-between;align-items:center;gap:1rem;padding:.85rem;border-bottom:1px solid var(--border)}.settings-editor-bar h1{margin:0}.settings-editor-bar .primary{flex:0 0 auto}.settings-profile-form{padding:0 .85rem .85rem}.settings-profile-media{margin:0 -.85rem .85rem}.settings-banner-wrap{background:var(--media-bg)}.settings-banner-preview{display:block;width:100%;height:220px;object-fit:cover;background:linear-gradient(135deg,var(--surface-muted),var(--hover));border:0;border-radius:0}.settings-banner-preview.placeholder::before{content:"";display:block;width:100%;height:100%}.settings-picture-row{display:grid;grid-template-columns:auto minmax(0,1fr);gap:1rem;align-items:end;padding:0 .85rem .85rem;margin-top:-48px}.settings-picture-preview{width:112px;height:112px;object-fit:cover;border-radius:999px;border:5px solid var(--surface);background:var(--avatar-bg);box-shadow:0 1px 4px var(--shadow)}.settings-picture-preview.placeholder{display:block}.settings-media-controls{display:grid;gap:.5rem;align-content:end;padding-top:3.25rem}.media-control-row{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap}.settings-fields{display:grid;gap:.25rem}.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.7rem}.deep-settings-panel{padding:0;overflow:hidden}.deep-settings-form{padding:.85rem;display:grid;gap:.85rem}.deep-settings-group{border:1px solid var(--border);border-radius:8px;padding:.8rem;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.7rem}.deep-settings-group legend{font-weight:800;padding:0 .35rem}.deep-settings-field{display:grid;gap:.25rem;align-content:start}.deep-settings-field label{font-weight:800}.deep-settings-field input,.deep-settings-field select{min-width:0}.field-help{font-size:.88rem}.deep-settings-confirm .settings-item-list li{display:block}.compact-panel h2,.danger-panel h2{margin:0 0 .65rem;font-size:1.1rem}.inline-settings-form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.55rem;align-items:center;margin:.2rem 0 .75rem}.inline-settings-form input{min-width:0}.settings-password-form button[type=submit]{margin-top:.9rem}.settings-item-list{list-style:none;margin:.25rem 0 0;padding:0;display:grid;gap:.45rem}.settings-item-list li{display:flex;justify-content:space-between;align-items:center;gap:.75rem;border:1px solid var(--border);border-radius:7px;padding:.55rem .65rem;background:var(--surface-subtle)}.settings-item-list form{flex:0 0 auto}.settings-item-list button{padding:.32rem .55rem;background:var(--surface);color:var(--link-strong);border-color:var(--border)}.compact-empty{border:1px dashed var(--border);border-radius:7px;padding:.75rem;background:var(--surface-subtle);color:var(--muted-strong)}.compact-empty p{margin:.25rem 0 0}.danger-panel{border-color:var(--danger-border);background:var(--danger-bg)}.danger,.danger-link{border-color:var(--danger-border);background:var(--danger);color:var(--brand-text)}.danger:hover,.danger-link:hover{background:var(--danger-strong);color:var(--brand-text);text-decoration:none}.delete-account-panel p{max-width:62ch}.favicon-preview{width:32px;height:32px;object-fit:contain;border:1px solid var(--border);border-radius:6px;background:var(--surface)}.admin-user-search{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto;gap:.65rem;align-items:end;margin-top:.75rem}.admin-user-search label{margin-top:0}.admin-user-search-actions{display:flex;gap:.4rem;align-items:center;margin-bottom:.05rem}.admin-user-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.75rem;border:1px solid var(--border);border-radius:8px;padding:.75rem;margin-top:.65rem;background:var(--surface-subtle)}.admin-user-heading{overflow-wrap:anywhere}.admin-user-statuses,.admin-user-matches{display:flex;flex-wrap:wrap;gap:.35rem;margin-top:.45rem}.admin-user-pill,.admin-user-match{display:inline-flex;align-items:center;min-height:1.55rem;border:1px solid var(--border);border-radius:999px;padding:.15rem .5rem;background:var(--surface);font-size:.82rem;font-weight:800;color:var(--muted-strong)}.admin-user-match{border-color:var(--success-border);background:var(--success-bg);color:var(--text)}.admin-user-meta{margin:.6rem 0 0}.admin-post-preview{margin:.55rem 0 0;color:var(--muted-strong);overflow-wrap:anywhere}.admin-user-actions{display:flex;align-items:flex-start}.admin-users-empty{margin-top:.75rem}.account-list{display:grid;gap:.65rem}.account-row{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:.75rem;align-items:center;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:.85rem}.account-row p{margin:.3rem 0 0;color:var(--muted-strong);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 var(--border);text-align:left;padding:.55rem;vertical-align:top}pre{white-space:pre-wrap;overflow:auto;max-width:100%} +@media (max-width:1100px){.app-shell{--shell-side:220px;--shell-max:880px;grid-template-columns:var(--shell-side) minmax(0,var(--shell-primary))}.right-rail{display:none}} @media (max-width:820px){.app-shell{grid-template-columns:minmax(0,680px)}.left-rail,.right-rail{display:none}.mobile-nav{display:flex}} -@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}.mobile-nav{width:100%}.search-form,.inline-settings-form,.settings-grid,.deep-settings-group{grid-template-columns:1fr}.search-form button,.inline-settings-form button{width:100%}.composer-tools,.post-header,.profile-heading,.profile-title-row,.account-row,.settings-editor-bar,.notifications-hero{align-items:stretch;grid-template-columns:1fr;flex-direction:column}.settings-banner-preview{height:150px}.settings-picture-row{grid-template-columns:1fr;margin-top:-38px;gap:.5rem}.settings-picture-preview{width:92px;height:92px}.settings-media-controls{padding-top:0}.media-control-row{align-items:flex-start}.settings-item-list li{align-items:stretch;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,.notifications-hero h1{font-size:1.25rem}.notification-row{grid-template-columns:auto minmax(0,1fr);gap:.6rem}.unread-dot{position:absolute;right:.75rem;top:.75rem;margin:0}.notification-preview{padding:.5rem}} +@media (max-width:600px){main{padding:.75rem}.header-inner{align-items:flex-start;flex-direction:column}.header-brand-row{align-items:center;width:100%;gap:.55rem}.tor-indicator{max-width:calc(100% - 7rem);margin-left:auto}.tor-details{left:auto;right:0;max-width:calc(100vw - 1.5rem)}.site-header{position:static}nav{justify-content:flex-start}.mobile-nav{width:100%}.search-form,.inline-settings-form,.settings-grid,.deep-settings-group,.admin-user-search,.admin-user-row{grid-template-columns:1fr}.search-form button,.inline-settings-form button{width:100%}.composer-tools,.post-header,.profile-heading,.profile-title-row,.account-row,.settings-editor-bar,.notifications-hero{align-items:stretch;grid-template-columns:1fr;flex-direction:column}.composer-footer,.composer-media-selection{align-items:flex-start;flex-direction:column}.composer-file-input{max-width:100%}.settings-banner-preview{height:150px}.settings-picture-row{grid-template-columns:1fr;margin-top:-38px;gap:.5rem}.settings-picture-preview{width:92px;height:92px}.settings-media-controls{padding-top:0}.media-control-row{align-items:flex-start}.settings-item-list li{align-items:stretch;flex-direction:column}.admin-user-search-actions,.admin-user-actions{align-items:stretch;flex-direction:column}.admin-user-search-actions button,.admin-user-search-actions .button-link,.admin-user-actions button{width:100%;justify-content:center}.panel dl:not(.dashboard-list){grid-template-columns:1fr}table{display:block;max-width:100%;overflow-x:auto}.author-block{align-items:flex-start}.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,.notifications-hero h1{font-size:1.25rem}.notification-row{grid-template-columns:auto minmax(0,1fr);gap:.6rem}.unread-dot{position:absolute;right:.75rem;top:.75rem;margin:0}.notification-preview{padding:.5rem}} "#; #[cfg(test)] @@ -1580,15 +2027,31 @@ mod tests { is_admin: false, is_suspended: false, theme: Theme::Dark, + nsfw_blur_enabled: true, }; let body = layout(Some(&user), "Home Feed", "

body

", "My Microblog"); assert!(body.contains(r#""#)); } + #[test] + fn layout_includes_no_javascript_status_and_styles() { + let body = layout(None, "Home Feed", "

body

", "My Microblog"); + + assert!(body.contains(r#"class="noscript-banner" role="status""#)); + assert!(body.contains(".js-enabled .noscript-banner")); + assert!(body.contains(".js-enabled .post-permalink")); + assert!(body.contains("display:none")); + assert!( + client_script().contains(r#"document.documentElement.classList.add("js-enabled")"#) + ); + assert!(body.contains("JavaScript is disabled.")); + assert!(body.contains("RustPost will use standard links and forms.")); + } + #[test] fn register_form_uses_configured_password_length() { - let body = register_form(None, 5); + let body = register_form(None, 5, None); assert!(body.contains(r#"minlength="5" required"#)); assert!(body.contains("Password must be at least 5 characters.")); @@ -1608,13 +2071,30 @@ mod tests { #[test] fn password_fields_allow_empty_when_minimum_is_zero() { - let body = register_form(None, 0); + let body = register_form(None, 0, None); assert!(!body.contains("minlength=")); assert!(!body.contains(r#"autocomplete="new-password" required"#)); assert!(body.contains("No minimum password length is currently required.")); } + #[test] + fn register_form_renders_optional_captcha_fields() { + let captcha = crate::registration_captcha::RegistrationCaptchaChallenge { + token: "token-1".to_owned(), + image_data_uri: "data:image/png;base64,abc".to_owned(), + expires_minutes: 10, + answer: "ABCDE".to_owned(), + }; + + let body = register_form(None, 10, Some(&captcha)); + + assert!(body.contains("Registration CAPTCHA")); + assert!(body.contains(r#"name="captcha_token" value="token-1""#)); + assert!(body.contains(r#"id="captcha_answer" name="captcha_answer""#)); + assert!(body.contains("The challenge expires in 10 minutes.")); + } + #[test] fn signed_in_left_rail_contains_navigation_only() { let user = CurrentUser { @@ -1624,6 +2104,7 @@ mod tests { is_admin: false, is_suspended: false, theme: Theme::Light, + nsfw_blur_enabled: true, }; let body = layout_with_csrf( Some(&user), @@ -1659,6 +2140,7 @@ mod tests { is_admin: false, is_suspended: false, theme: Theme::Light, + nsfw_blur_enabled: true, }; let body = layout_with_context( Some(&user), @@ -1706,6 +2188,7 @@ mod tests { ); assert!(!without_tor.contains("examplehiddenservice.onion")); assert!(!without_tor.contains("Onion: ")); + assert!(!without_tor.contains("tor-header-indicator")); let with_tor = layout_with_context( None, @@ -1719,17 +2202,79 @@ mod tests { }, ); assert!(with_tor.contains("examplehiddenservice.onion")); - assert!(with_tor.contains("footer-onion")); + assert!(with_tor.contains("tor-header-indicator")); + assert!(with_tor.contains("tor-summary-text")); + assert!(with_tor.contains(r#"Tor:"#)); + assert!(with_tor.contains(r#"href="http://examplehiddenservice.onion""#)); + assert!(with_tor.contains(r#"title="Open Tor mirror: examplehiddenservice.onion""#)); + assert!(with_tor.contains(r#"aria-label="Open Tor mirror at examplehiddenservice.onion""#)); + assert!(with_tor.contains("exampl...ice.onion")); + assert!(with_tor.contains(r#"data-copy-text="examplehiddenservice.onion""#)); + assert!(with_tor.contains(r#"aria-label="Copy Tor onion address""#)); + assert!(!with_tor.contains("footer-onion")); + assert!(!with_tor.contains("")); + assert!(!with_tor.contains("Onion: ")); } #[test] - fn composer_has_live_remaining_counter_without_placeholder() { - let body = composer(Some("csrf"), Some(10), 512); - assert!(body.contains("512 remaining")); + fn short_onion_address_preserves_short_values() { + assert_eq!( + short_onion_address("abc.onion"), + "abc.onion", + "short or unusual values should remain readable" + ); + assert_eq!( + short_onion_address("abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion"), + "abcdef...vwx.onion" + ); + } + + #[test] + fn composer_has_live_remaining_counter_and_contextual_placeholder() { + let post = composer(Some("csrf"), None, 512); + assert!(post.contains("512 remaining")); + assert!(post.contains("data-character-limit=\"512\"")); + assert!(post.contains("maxlength=\"512\"")); + assert!(post.contains("What is happening?")); + assert!(post.contains(r#"placeholder="What's happening?""#)); + assert!(post.contains(r#"
"#)); + assert!(post.contains(r#"id="post-media" name="media" type="file""#)); + assert!(post.contains(r#"aria-label="Attach media" data-composer-media"#)); + assert!( + post.contains( + r#"class="composer-media-selection" data-composer-media-selection hidden"# + ) + ); + assert!(post.contains(r#"id="post-nsfw" name="nsfw" type="checkbox""#)); + assert!(post.contains(r#"data-composer-clear-media"#)); + assert!(!post.contains(r#"class="file-control""#)); + + let reply = composer(Some("csrf"), Some(10), 512); + assert!(reply.contains(r#"id="reply-text-10""#)); + assert!(reply.contains(r#"id="reply-media-10" name="media" type="file""#)); + assert!(reply.contains(r#"id="reply-nsfw-10" name="nsfw" type="checkbox""#)); + assert!(reply.contains(r#"placeholder="Write a reply...""#)); + } + + #[test] + fn quote_composer_has_contextual_placeholder() { + let quote = QuotePreview { + id: 42, + username: Some("ada".to_owned()), + display_name: Some("Ada".to_owned()), + anonymous_label: None, + text: "quoted post".to_owned(), + created_at: "2026-05-18 10:30".to_owned(), + unavailable: false, + }; + let body = quote_composer("csrf", "e, 512); + + assert!(body.contains(r#"id="quote-text""#)); + assert!(body.contains(r#"placeholder="Add your thoughts...""#)); assert!(body.contains("data-character-limit=\"512\"")); assert!(body.contains("maxlength=\"512\"")); - assert!(body.contains("What is happening?")); - assert!(!body.contains("placeholder=")); } #[test] @@ -1738,8 +2283,8 @@ mod tests { let body = post_card(&post, None, None); assert!(body.contains(r#"data-card-href="/posts/42""#)); - assert!(body.contains(r#"tabindex="0""#)); - assert!(!body.contains(r#">Open post"#)); + assert!(!body.contains(r#"tabindex="0""#)); + 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""#)); @@ -1771,9 +2316,11 @@ mod tests { .expect("root card"); assert!(!root_card.contains(r#"data-card-href="/posts/42""#)); assert!(!root_card.contains(r#"tabindex="0""#)); + assert!(!root_card.contains(r#"href="/posts/42">Open post"#)); assert!(root_card.contains(r#"2026-05-18 10:30"#)); assert!(!root_card.contains(r#"class="post-time" href="/posts/42""#)); assert!(body.contains(r#"data-card-href="/posts/43""#)); + assert!(body.contains(r#"href="/posts/43">Open post"#)); } #[test] @@ -1810,7 +2357,7 @@ mod tests { #[test] fn search_page_preserves_and_escapes_query() { - let body = search_page("RustPost", r#" "query""#, &[], &[], None, None); + let body = search_page("RustPost", r#" "query""#, &[], &[], None, None, true); assert!(body.contains(r#"value="<rust> "query"""#)); assert!(body.contains("No results found")); @@ -1832,7 +2379,7 @@ mod tests { viewer_following: false, }; - let body = search_page("RustPost", "ada", &[account], &[post], None, None); + let body = search_page("RustPost", "ada", &[account], &[post], None, None, true); assert!(body.contains(r#"2 results for "ada""#)); assert!(body.contains(r#"id="search-users-title">People"#)); @@ -1849,6 +2396,7 @@ mod tests { is_admin: false, is_suspended: false, theme: Theme::Light, + nsfw_blur_enabled: true, }; let mut post = test_post(); post.user_id = Some(2); @@ -1882,6 +2430,7 @@ mod tests { is_admin: false, is_suspended: false, theme: Theme::Light, + nsfw_blur_enabled: true, }; let mut post = test_post(); post.user_id = Some(2); @@ -1892,8 +2441,13 @@ mod tests { assert!(body.contains("data-repost-menu-button")); assert!(body.contains(r#"aria-haspopup="menu""#)); assert!(body.contains(r#"role="menu""#)); - assert!(body.contains(r#"href="/posts/42/quote">Quote post"#)); - assert!(body.contains(r#"class="quote-fallback" href="/posts/42/quote">Quote"#)); + assert!(body.contains(r#"role="menuitem" href="/posts/42/quote">"#)); + assert!(body.contains(r#"Quote post"#)); + assert!(body.contains( + r#"class="icon-button quote-fallback" href="/posts/42/quote" aria-label="Quote post" title="Quote post""# + )); + assert!(body.contains(r#"Quote post"#)); + assert!(!body.contains(r#">Quote"#)); } #[test] @@ -1936,6 +2490,159 @@ mod tests { assert!(body.contains("Quoted post is no longer available.")); } + #[test] + fn nsfw_media_renders_blurred_with_accessible_reveal_control() { + let mut post = test_post(); + post.media = vec![MediaView { + public_path: "/uploads/images/flagged.webp".to_owned(), + mime_type: "image/webp".to_owned(), + media_kind: "image".to_owned(), + alt_text: "Flagged image".to_owned(), + is_nsfw: true, + }]; + + let body = post_card(&post, None, None); + + assert!(body.contains(r#"data-testid="nsfw-media""#)); + assert!(body.contains(r#"aria-label="Show NSFW media""#)); + assert!(body.contains(">ShowOpen media"#)); + } + + #[test] + fn nsfw_media_blur_can_be_disabled_for_rendering() { + let mut post = test_post(); + post.media = vec![MediaView { + public_path: "/uploads/images/flagged.webp".to_owned(), + mime_type: "image/webp".to_owned(), + media_kind: "image".to_owned(), + alt_text: "Flagged image".to_owned(), + is_nsfw: true, + }]; + + let body = post_card_with_nsfw_blur(&post, None, None, false); + + assert!(!body.contains(r#"data-testid="nsfw-media""#)); + assert!(body.contains(r#"alert(1) https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1" + .to_owned(); + + let body = post_card(&post, None, None); + + assert!(body.contains("unsafe <script>alert(1)</script>")); + assert!(!body.contains("")); + assert!(body.contains(r#"href="https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1""#)); + assert!(body.contains( + r#"https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1"# + )); + } + + #[test] + fn posts_without_youtube_links_do_not_render_preview_cards() { + let mut post = test_post(); + post.text = "hello https://example.com/watch?v=dQw4w9WgXcQ #rust".to_owned(); + + let body = post_card(&post, None, None); + + assert!(!body.contains("youtube-preview-card")); + assert!(body.contains( + r#"
hello https://example.com/watch?v=dQw4w9WgXcQ #rust
"# + )); + } + + #[test] + fn multiple_youtube_links_render_up_to_documented_cap() { + let mut post = test_post(); + post.text = "https://youtu.be/dQw4w9WgXcQ https://youtube.com/shorts/aaaaaaaaaaa https://youtube.com/embed/bbbbbbbbbbb https://youtube.com/watch?v=ccccccccccc".to_owned(); + + let body = post_card(&post, None, None); + + assert_eq!( + body.matches(r#"data-testid="youtube-preview-card""#) + .count(), + 3 + ); + assert!(body.contains("https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg")); + assert!(body.contains("https://i.ytimg.com/vi/aaaaaaaaaaa/hqdefault.jpg")); + assert!(body.contains("https://i.ytimg.com/vi/bbbbbbbbbbb/hqdefault.jpg")); + assert!(!body.contains("https://i.ytimg.com/vi/ccccccccccc/hqdefault.jpg")); + } + + #[test] + fn no_javascript_youtube_preview_shows_thumbnail_and_link_card() { + let mut post = test_post(); + post.text = "watch https://youtu.be/dQw4w9WgXcQ".to_owned(); + + let body = post_card(&post, None, None); + + assert!(body.contains( + r#""# + )); + assert!(body.contains(r#"YouTube video"#)); + assert!(!body.contains("body

", "RustPost"); + + assert!(body.contains("img-src 'self' data: https://i.ytimg.com")); + } + fn test_post() -> PostView { PostView { event_id: "p:42".to_owned(), diff --git a/src/server.rs b/src/server.rs index 2d60d0c..398698f 100644 --- a/src/server.rs +++ b/src/server.rs @@ -7,6 +7,7 @@ use axum::Router; use axum::extract::connect_info::ConnectInfo; use axum::extract::{DefaultBodyLimit, Form, Multipart, Path, Query, State}; use axum::http::{HeaderMap, HeaderValue, StatusCode, Uri, header}; +use axum::middleware; use axum::response::{Html, IntoResponse as _, Redirect, Response}; use axum::routing::{get, post}; use rusqlite::{OptionalExtension as _, params}; @@ -19,6 +20,7 @@ use crate::config::Settings; use crate::db::SqlitePool; use crate::errors::{AppError, AppResult}; use crate::ffmpeg::FfmpegStatus; +use crate::registration_captcha::RegistrationCaptchaStore; use crate::runtime::RuntimePaths; use crate::{account, admin, backup, csrf, favicon, media, rate_limit, render, social}; @@ -31,6 +33,7 @@ pub struct AppState { pub paths: RuntimePaths, pub ffmpeg: FfmpegStatus, pub tor: crate::tor::TorStatus, + pub registration_captcha: RegistrationCaptchaStore, } impl AppState { @@ -48,6 +51,7 @@ impl AppState { paths, ffmpeg, tor, + registration_captcha: RegistrationCaptchaStore::default(), }) } } @@ -100,6 +104,7 @@ pub fn router(state: Arc) -> Router { .route("/admin/users", get(admin_users)) .route("/admin/users/{id}/suspend", post(admin_suspend)) .route("/admin/posts/{id}/delete", post(admin_delete_post)) + .route("/admin/posts/{id}/nsfw", post(admin_toggle_post_nsfw)) .route("/admin/health", get(admin_health)) .route("/admin/media", get(admin_media)) .route( @@ -129,6 +134,9 @@ pub fn router(state: Arc) -> Router { ServeDir::new(state.paths.uploads_thumbs.clone()), ) .layer(DefaultBodyLimit::max(upload_body_limit)) + .layer(middleware::from_fn( + crate::compression::response_compression, + )) .layer(TraceLayer::new_for_http()) .with_state(state) } @@ -167,6 +175,8 @@ struct RegisterForm { username: String, password: String, confirm_password: Option, + captcha_token: Option, + captcha_answer: Option, } #[derive(Deserialize)] @@ -196,6 +206,12 @@ struct SearchQuery { q: Option, } +#[derive(Deserialize)] +struct AdminUsersQuery { + user_q: Option, + post_q: Option, +} + #[derive(Deserialize)] struct SettingsQuery { saved: Option, @@ -237,6 +253,7 @@ struct ParsedProfileUpdate { theme: Theme, delete_profile_picture: bool, delete_banner: bool, + nsfw_blur_enabled: bool, profile_picture_media_id: Option, banner_media_id: Option, } @@ -250,6 +267,13 @@ struct ParsedPostCreate { text: String, parent_post_id: Option, media_ids: Vec, + is_nsfw: bool, +} + +#[derive(Deserialize)] +struct AdminNsfwForm { + csrf: String, + nsfw: String, } struct DeletePreview { @@ -311,7 +335,12 @@ async fn home(State(state): State>, headers: HeaderMap) -> AppResu "{}{}{}", render::page_header("Home Feed", "All posts"), composer, - render::posts(&posts, user.as_ref(), csrf.as_deref()) + render::posts_with_nsfw_blur( + &posts, + user.as_ref(), + csrf.as_deref(), + blur_nsfw_media(&state, user.as_ref()), + ) ); Ok(Html( page_layout(&state, user.as_ref(), csrf.as_deref(), "Home Feed", &body).await?, @@ -332,7 +361,10 @@ async fn layout_context( }; Ok(render::LayoutContext { anonymous_mode_enabled: state.settings.accounts.anonymous_mode_enabled, - tor_onion_address: state.tor.onion_address(), + tor_onion_address: state.tor.onion_address().or_else(|| { + (!state.settings.tor.display_onion_address.is_empty()) + .then(|| state.settings.tor.display_onion_address.clone()) + }), follower_count: counts.map(|(followers, _following)| followers), following_count: counts.map(|(_followers, following)| following), notification_unread_count, @@ -358,6 +390,14 @@ async fn page_layout( )) } +fn blur_nsfw_media(state: &AppState, user: Option<&CurrentUser>) -> bool { + let global_blur = Settings::load(&state.paths.settings_path) + .map_or(state.settings.media.nsfw_blur_enabled, |settings| { + settings.media.nsfw_blur_enabled + }); + global_blur && user.is_none_or(|user| user.nsfw_blur_enabled) +} + async fn login_form(State(state): State>) -> Html { let body = render::login_form(None, state.settings.accounts.min_password_length); let context = layout_context(&state, None).await.unwrap_or_default(); @@ -418,7 +458,7 @@ async fn register_form(State(state): State>) -> AppResult AppResult { - let body = render::register_form(Some(message), state.settings.accounts.min_password_length); + let body = register_form_body(state, Some(message)).await?; Ok(( status, Html(page_layout(state, None, None, "Register", &body).await?), @@ -518,6 +569,19 @@ async fn register_form_response( .into_response()) } +async fn register_form_body(state: &AppState, message: Option<&str>) -> AppResult { + let captcha = if state.settings.accounts.registration_captcha_enabled { + Some(state.registration_captcha.create_challenge().await?) + } else { + None + }; + Ok(render::register_form( + message, + state.settings.accounts.min_password_length, + captcha.as_ref(), + )) +} + async fn logout( State(state): State>, headers: HeaderMap, @@ -566,6 +630,9 @@ async fn create_post( if user.is_some() { validate_csrf(&state.pool, &headers, &form.csrf_token).await?; } + if form.is_nsfw { + media::set_media_nsfw(&state.pool, &form.media_ids, true).await?; + } let (scope, actor, max_events, window_secs) = if user.is_none() { ( rate_limit::Scope::AnonymousPost, @@ -617,16 +684,18 @@ async fn create_post( parent_post_id: form.parent_post_id, redirect, html: if form.parent_post_id.is_some() { - render::thread_post_card( + render::thread_post_card_with_nsfw_blur( post, user.as_ref(), form_csrf(&state, &headers).await.as_deref(), + blur_nsfw_media(&state, user.as_ref()), ) } else { - render::post_card( + render::post_card_with_nsfw_blur( post, user.as_ref(), form_csrf(&state, &headers).await.as_deref(), + blur_nsfw_media(&state, user.as_ref()), ) }, }) @@ -645,6 +714,7 @@ async fn parse_post_create( text: String::new(), parent_post_id: None, media_ids: Vec::new(), + is_nsfw: false, }; while let Some(field) = multipart .next_field() @@ -684,6 +754,13 @@ async fn parse_post_create( ) })?); } + "nsfw" => { + form.is_nsfw = true; + let _ignored = field + .text() + .await + .map_err(|err| AppError::BadRequest(err.to_string()))?; + } "media" if field .file_name() @@ -747,7 +824,12 @@ async fn thread( let body = format!( "{}{}{}", render::thread_back_control(), - render::thread_posts(&posts, user.as_ref(), csrf.as_deref()), + render::thread_posts_with_nsfw_blur( + &posts, + user.as_ref(), + csrf.as_deref(), + blur_nsfw_media(&state, user.as_ref()), + ), composer ); Ok(Html( @@ -1052,7 +1134,12 @@ async fn profile( location_line, html_escape::encode_text(bio.as_str()), website_link, - render::posts(&posts, user.as_ref(), csrf.as_deref()) + render::posts_with_nsfw_blur( + &posts, + user.as_ref(), + csrf.as_deref(), + blur_nsfw_media(&state, user.as_ref()), + ) ); Ok(Html( page_layout( @@ -1269,6 +1356,49 @@ fn settings_query_notice(saved: Option<&str>) -> Option<(&'static str, &'static } } +struct SettingsProfile { + display_name: String, + bio: String, + location: String, + website: String, + theme: String, + nsfw_blur_enabled: bool, + picture_path: Option, + banner_path: Option, +} + +async fn settings_profile(pool: &SqlitePool, user_id: i64) -> AppResult { + pool.call(move |conn| { + conn.query_row( + r#" + SELECT u.display_name, u.bio, u.location, u.website, u.theme, u.nsfw_blur_enabled, + pic.public_path AS profile_picture_path, + banner.public_path AS banner_path + FROM users u + LEFT JOIN media pic ON pic.id = u.profile_picture_media_id + LEFT JOIN media banner ON banner.id = u.banner_media_id + WHERE u.id = ? + "#, + [user_id], + |row| { + Ok(SettingsProfile { + display_name: row.get(0)?, + bio: row.get(1)?, + location: row.get(2)?, + website: row.get(3)?, + theme: row.get(4)?, + nsfw_blur_enabled: row.get::<_, i64>(5)? != 0, + picture_path: row.get(6)?, + banner_path: row.get(7)?, + }) + }, + ) + .map_err(Into::into) + }) + .await + .map_err(Into::into) +} + fn settings_profile_media( picture_path: Option<&str>, banner_path: Option<&str>, @@ -1444,41 +1574,16 @@ async fn settings_page( csrf: &str, notice: Option<(&str, &str)>, ) -> AppResult { - let user_id = user.id; - let profile = state - .pool - .call(move |conn| { - conn.query_row( - r#" - SELECT u.display_name, u.bio, u.location, u.website, u.theme, - pic.public_path AS profile_picture_path, - banner.public_path AS banner_path - FROM users u - LEFT JOIN media pic ON pic.id = u.profile_picture_media_id - LEFT JOIN media banner ON banner.id = u.banner_media_id - WHERE u.id = ? - "#, - [user_id], - |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, String>(3)?, - row.get::<_, String>(4)?, - row.get::<_, Option>(5)?, - row.get::<_, Option>(6)?, - )) - }, - ) - .map_err(Into::into) - }) - .await?; - let (display_name, bio, location, website, theme, picture_path, banner_path) = profile; + let profile = settings_profile(&state.pool, user.id).await?; let blocked = social::blocked_users(&state.pool, user.id).await?; let muted = social::muted_users(&state.pool, user.id).await?; let muted_words = social::muted_words(&state.pool, user.id).await?; - let dark_checked = if Theme::from(theme.as_str()) == Theme::Dark { + let dark_checked = if Theme::from(profile.theme.as_str()) == Theme::Dark { + " checked" + } else { + "" + }; + let nsfw_checked = if profile.nsfw_blur_enabled { " checked" } else { "" @@ -1502,20 +1607,21 @@ async fn settings_page( "confirm-new-password-requirement", ); let profile_media = settings_profile_media( - picture_path.as_deref(), - banner_path.as_deref(), + profile.picture_path.as_deref(), + profile.banner_path.as_deref(), state.settings.accounts.allow_profile_pictures, state.settings.accounts.allow_profile_banners, ); let body = format!( - r#"{notice_html}

Account settings

Profile, privacy, and account controls.

{}

Blocked users

{}

Muted users

{}

Muted words

{}

Change password

{}

{}

Delete account

This permanently removes your profile, posts, media, sessions, and account relationships.

Start delete account flow

"#, + r#"{notice_html}

Account settings

Profile, privacy, and account controls.

{}

Blocked users

{}

Muted users

{}

Muted words

{}

Change password

{}

{}

Delete account

This permanently removes your profile, posts, media, sessions, and account relationships.

Start delete account flow

"#, html_escape::encode_double_quoted_attribute(&csrf), profile_media, dark_checked, - html_escape::encode_double_quoted_attribute(display_name.as_str()), - html_escape::encode_text(bio.as_str()), - html_escape::encode_double_quoted_attribute(location.as_str()), - html_escape::encode_double_quoted_attribute(website.as_str()), + nsfw_checked, + html_escape::encode_double_quoted_attribute(profile.display_name.as_str()), + html_escape::encode_text(profile.bio.as_str()), + html_escape::encode_double_quoted_attribute(profile.location.as_str()), + html_escape::encode_double_quoted_attribute(profile.website.as_str()), settings_user_list( &blocked, "/unblock", @@ -1558,12 +1664,13 @@ async fn settings_update( let location = form.location.trim().to_owned(); let website = form.website.trim().to_owned(); let theme = form.theme.as_str().to_owned(); + let nsfw_blur_enabled = i64::from(form.nsfw_blur_enabled); state .pool .call(move |conn| { conn.execute( - "UPDATE users SET display_name = ?, bio = ?, location = ?, website = ?, theme = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", - params![display_name, bio, location, website, theme, user.id], + "UPDATE users SET display_name = ?, bio = ?, location = ?, website = ?, theme = ?, nsfw_blur_enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + params![display_name, bio, location, website, theme, nsfw_blur_enabled, user.id], )?; Ok(()) }) @@ -1910,6 +2017,7 @@ async fn parse_profile_update( theme: Theme::Light, delete_profile_picture: false, delete_banner: false, + nsfw_blur_enabled: false, profile_picture_media_id: None, banner_media_id: None, }; @@ -1955,6 +2063,9 @@ async fn parse_profile_update( "dark_mode" => { form.theme = Theme::Dark; } + "nsfw_blur_enabled" => { + form.nsfw_blur_enabled = true; + } "delete_profile_picture" => { form.delete_profile_picture = true; } @@ -2292,7 +2403,12 @@ async fn bookmarks( let body = format!( "{}{}", render::page_header("Bookmarks", "Posts you saved for later."), - render::posts(&posts, Some(&user), csrf.as_deref()) + render::posts_with_nsfw_blur( + &posts, + Some(&user), + csrf.as_deref(), + blur_nsfw_media(&state, Some(&user)), + ) ); Ok(Html( page_layout(&state, Some(&user), csrf.as_deref(), "Bookmarks", &body).await?, @@ -2361,6 +2477,7 @@ async fn search( &posts, user.as_ref(), csrf.as_deref(), + blur_nsfw_media(&state, user.as_ref()), ); Ok(Html( page_layout(&state, user.as_ref(), csrf.as_deref(), "Search", &body).await?, @@ -2542,35 +2659,27 @@ async fn admin_health( async fn admin_users( State(state): State>, headers: HeaderMap, + Query(query): Query, ) -> AppResult> { let user = require_admin(&state, &headers).await?; let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); - let rows = admin::users(&state.pool).await?; - let list = rows - .into_iter() - .map(|(id, username, is_admin, suspended)| { - format!( - r#"{}{}{}{}{}"#, - id, - html_escape::encode_text(&username), - is_admin, - suspended, - small_form( - &format!("/admin/users/{id}/suspend"), - &csrf, - if suspended { "Unsuspend" } else { "Suspend" }, - if suspended { - "Unsuspend this account" - } else { - "Suspend this account" - }, - ) - ) - }) - .collect::>() - .join(""); + let user_query = query.user_q.unwrap_or_default(); + let post_query = query.post_q.unwrap_or_default(); + let search = admin::AdminUserSearch::new(&user_query, &post_query); + let malformed_quotes = search.post_search.malformed_quotes; + let has_filter = search.has_filter(); + let rows = admin::users(&state.pool, search).await?; + let quote_notice = if malformed_quotes { + r#"

Post search had unmatched quotes, so it was treated as plain keyword search.

"# + } else { + "" + }; + let list = admin_user_rows(&rows, &csrf, has_filter); let body = format!( - r#"

Users

{}
IDUsernameAdminSuspendedAction
"#, + r#"

Users

{}{}
"#, + html_escape::encode_double_quoted_attribute(&user_query), + html_escape::encode_double_quoted_attribute(&post_query), + quote_notice, list ); Ok(Html( @@ -2578,6 +2687,137 @@ async fn admin_users( )) } +fn admin_user_rows(rows: &[admin::AdminUserInvestigation], csrf: &str, searched: bool) -> String { + if rows.is_empty() { + let message = if searched { + "No users matched those filters." + } else { + "No users found." + }; + return format!( + r#"
{}

Try a different username, handle, display name, or post keyword.

"#, + html_escape::encode_text(message) + ); + } + + rows.iter() + .map(|row| admin_user_row(row, csrf, searched)) + .collect::>() + .join("") +} + +fn admin_user_row(row: &admin::AdminUserInvestigation, csrf: &str, searched: bool) -> String { + let display_name = if row.display_name.trim().is_empty() { + row.username.as_str() + } else { + row.display_name.as_str() + }; + let statuses = [ + if row.is_admin { "Admin" } else { "Member" }, + if row.is_suspended { + "Suspended" + } else { + "Active" + }, + if row.is_deleted { + "Deleted" + } else { + "Not deleted" + }, + ] + .iter() + .map(|status| { + format!( + r#"{}"#, + html_escape::encode_text(status) + ) + }) + .collect::>() + .join(""); + let match_labels = admin_user_match_labels(row, searched); + let preview = row + .post_match_preview + .as_ref() + .map_or_else(String::new, |text| { + format!( + r#"

Post match preview: {}

"#, + html_escape::encode_text(&short_preview(text)) + ) + }); + let action = small_form( + &format!("/admin/users/{}/suspend", row.id), + csrf, + if row.is_suspended { + "Unsuspend" + } else { + "Suspend" + }, + if row.is_suspended { + "Unsuspend this account" + } else { + "Suspend this account" + }, + ); + format!( + r#"
{} @{} #{}
{}
{}
Created
{}
Updated
{}
Last session
{}
Last post
{}
Total posts
{}
Uploaded media
{}
Reports on posts
{}
Moderation actions
{}
Matching posts
{}
{}
"#, + html_escape::encode_double_quoted_attribute(&row.username), + html_escape::encode_text(display_name), + html_escape::encode_text(&row.username), + row.id, + statuses, + match_labels, + html_escape::encode_text(&row.created_at), + html_escape::encode_text(&row.updated_at), + html_escape::encode_text(row.last_session_at.as_deref().unwrap_or("No session")), + html_escape::encode_text(row.last_post_at.as_deref().unwrap_or("No posts")), + row.total_posts, + row.uploaded_media_count, + row.reports_on_posts_count, + row.moderation_action_count, + row.matching_post_count, + preview, + action + ) +} + +fn admin_user_match_labels(row: &admin::AdminUserInvestigation, searched: bool) -> String { + if !searched { + return String::new(); + } + + let mut labels = Vec::new(); + if row.matched_name { + labels.push("Matched name".to_owned()); + } + if row.matching_post_count > 0 { + labels.push(format!("Matched post content: {}", row.matching_post_count)); + } + if labels.is_empty() { + return String::new(); + } + let labels = labels + .iter() + .map(|label| { + format!( + r#"{}"#, + html_escape::encode_text(label) + ) + }) + .collect::>() + .join(""); + format!(r#"
{labels}
"#) +} + +fn short_preview(text: &str) -> String { + const LIMIT: usize = 140; + let trimmed = text.trim(); + let mut preview = trimmed.chars().take(LIMIT).collect::(); + if trimmed.chars().count() > LIMIT { + preview.push_str("..."); + } + preview +} + async fn admin_suspend( State(state): State>, headers: HeaderMap, @@ -2613,6 +2853,39 @@ async fn admin_delete_post( Ok(Redirect::to("/admin").into_response()) } +async fn admin_toggle_post_nsfw( + State(state): State>, + headers: HeaderMap, + Path(id): Path, + Form(form): Form, +) -> AppResult { + let user = require_admin(&state, &headers).await?; + validate_csrf(&state.pool, &headers, &form.csrf).await?; + let is_nsfw = match form.nsfw.as_str() { + "true" => true, + "false" => false, + _ => return Err(AppError::BadRequest("invalid NSFW setting".to_owned())), + }; + let changed = social::set_post_media_nsfw(&state.pool, id, is_nsfw) + .await + .map_err(|err| AppError::BadRequest(err.to_string()))?; + if changed == 0 { + return Err(AppError::BadRequest("post has no media".to_owned())); + } + admin::audit( + &state.pool, + user.id, + if is_nsfw { + "mark_post_nsfw" + } else { + "unmark_post_nsfw" + }, + &format!("post:{id}"), + ) + .await?; + Ok(redirect_to_post_anchor(&headers, id, false).into_response()) +} + async fn admin_media( State(state): State>, headers: HeaderMap, @@ -3104,12 +3377,16 @@ fn user_actor(user_id: i64) -> String { #[cfg(test)] mod tests { use super::*; + use flate2::read::GzDecoder; + use std::io::Read as _; use std::path::PathBuf; use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; struct TestServer { base_url: String, data_dir: PathBuf, + pool: SqlitePool, + registration_captcha: RegistrationCaptchaStore, _task: tokio::task::JoinHandle<()>, _temp: tempfile::TempDir, } @@ -3117,6 +3394,7 @@ mod tests { struct TestResponse { status: u16, headers: Vec<(String, String)>, + body_bytes: Vec, body: String, } @@ -3213,6 +3491,244 @@ mod tests { ); } + #[tokio::test] + async fn non_admin_cannot_access_admin_users() { + let server = spawn_test_server_with_admin().await; + let member_cookie = register_test_user(&server, "member").await; + + let response = get_with_cookie(&server, "/admin/users", &member_cookie).await; + + assert_eq!(response.status, 403); + } + + #[tokio::test] + async fn admin_users_page_shows_expanded_user_details() { + let server = spawn_test_server_with_admin().await; + let admin_cookie = admin_session_cookie(&server).await; + let settings = Settings::default(); + let alice = auth::register_user( + &server.pool, + &settings, + "alice", + "very secure password", + false, + ) + .await + .expect("alice"); + let reporter = auth::register_user( + &server.pool, + &settings, + "reporter", + "very secure password", + false, + ) + .await + .expect("reporter"); + let post = social::create_post( + &server.pool, + &settings, + Some(alice), + "expanded admin detail post", + None, + &[], + ) + .await + .expect("post"); + server + .pool + .call(move |conn| { + conn.execute( + "UPDATE users SET display_name = 'Alice Admin' WHERE id = ?", + [alice], + )?; + conn.execute( + "INSERT INTO media (owner_user_id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len) VALUES (?, 'alice.png', '/tmp/alice.png', '/uploads/images/alice.png', 'image/png', 'image', 12)", + [alice], + )?; + conn.execute( + "INSERT INTO reports (reporter_user_id, post_id, reason) VALUES (?, ?, 'spam')", + params![reporter, post], + )?; + Ok(()) + }) + .await + .expect("seed details"); + + let response = get_with_cookie(&server, "/admin/users", &admin_cookie).await; + + assert_eq!(response.status, 200); + assert!(response.body.contains("Alice Admin")); + assert!(response.body.contains("@alice")); + assert!(response.body.contains("Created")); + assert!(response.body.contains("Last post")); + assert!(response.body.contains("Total posts")); + assert!(response.body.contains("Uploaded media")); + assert!(response.body.contains("Reports on posts")); + assert!(response.body.contains("Moderation actions")); + assert!(response.body.contains(">1")); + } + + #[tokio::test] + async fn admin_users_username_search_returns_expected_users() { + let server = spawn_test_server_with_admin().await; + let admin_cookie = admin_session_cookie(&server).await; + let settings = Settings::default(); + auth::register_user( + &server.pool, + &settings, + "alice", + "very secure password", + false, + ) + .await + .expect("alice"); + auth::register_user( + &server.pool, + &settings, + "bob", + "very secure password", + false, + ) + .await + .expect("bob"); + + let response = get_with_cookie(&server, "/admin/users?user_q=ali", &admin_cookie).await; + + assert_eq!(response.status, 200); + assert!(response.body.contains("@alice")); + assert!(!response.body.contains("@bob")); + assert!(response.body.contains("Matched name")); + } + + #[tokio::test] + async fn admin_users_post_keyword_search_returns_matching_accounts() { + let server = spawn_test_server_with_admin().await; + let admin_cookie = admin_session_cookie(&server).await; + let settings = Settings::default(); + let alice = auth::register_user( + &server.pool, + &settings, + "alice", + "very secure password", + false, + ) + .await + .expect("alice"); + let bob = auth::register_user( + &server.pool, + &settings, + "bob", + "very secure password", + false, + ) + .await + .expect("bob"); + social::create_post( + &server.pool, + &settings, + Some(alice), + "admin keyword needle