From ae629afbed5ab559cb45309bd1724dd5fa153ef2 Mon Sep 17 00:00:00 2001 From: csd113 Date: Fri, 5 Jun 2026 22:34:07 -0700 Subject: [PATCH 01/21] Harden RustPost for v1.0.0-rc.1 Prepare the initial release candidate by fixing verified release-blocking and release-embarrassing issues without changing routes, schema, visual identity, or privacy semantics. Release packaging and operator experience: - add the documented primary rustpost binary while retaining rustpost-cli - bump the crate and lockfile version to 1.0.0-rc.1 - update first-run, first-admin, startup, backup, and restore command guidance to use rustpost - correct generated settings wording from byte limits to character limits - refresh release verification copy and remove stale Tor reachability claims Security, privacy, and session hardening: - allow existing passwords to authenticate after an operator raises the configured minimum password length - validate profile website URLs as absolute HTTP/HTTPS URLs - suppress unsafe legacy website values instead of rendering executable links Media and posting hardening: - enforce configured per-post image and video attachment limits - reject missing or unsupported attachment records during post creation - calculate multipart body limits from the largest valid configured media mix Tests and validation: - add regression coverage for password-policy login compatibility, unsafe profile URLs, upload body limits, and per-type media limits - pass cargo fmt --all --check - pass strict workspace Clippy with all targets and features - pass cargo test --workspace --all-features with 310 tests - validate fresh install/admin/CAPTCHA/browser flows and backup/restore with and without controlled Tor key material - confirm Playwright artifacts remain ignored --- CHANGELOG.md | 11 ++- Cargo.lock | 2 +- Cargo.toml | 6 +- README.md | 21 +++-- src/admin.rs | 2 +- src/bin/rustpost.rs | 12 +++ src/cli.rs | 4 +- src/config.rs | 6 +- src/server.rs | 191 ++++++++++++++++++++++++++++++++++++++------ src/social.rs | 110 +++++++++++++++++++++++++ src/terminal.rs | 16 ++-- 11 files changed, 327 insertions(+), 54 deletions(-) create mode 100644 src/bin/rustpost.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6febc85..89f0d0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,15 @@ # Changelog -## Unreleased - First-Release Database Baseline +## v1.0.0-rc.1 - First-Release Hardening + +### Release Blockers Fixed +- Added the documented primary `rustpost` binary alongside `rustpost-cli` for release builds. +- Bumped the crate version to `1.0.0-rc.1` and refreshed `Cargo.lock`. +- Updated first-run command hints to use the primary `rustpost` binary. +- Fixed login so existing stored passwords continue to work after an operator raises the configured minimum password length. +- Rejected unsafe profile website URL schemes server-side and stopped rendering unsafe legacy profile website values as links. +- Enforced configured per-post image and video attachment limits and raised the multipart body limit to cover valid configured media mixes. +- Corrected generated settings copy for username, display name, and bio limits from bytes to characters. ### Database Lineage - Squashed the pre-release internal migration chain into a clean first-release SQLite schema baseline at database schema version `1`. diff --git a/Cargo.lock b/Cargo.lock index 0682771..e6a5c56 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3224,7 +3224,7 @@ dependencies = [ [[package]] name = "rustpost" -version = "0.2.0" +version = "1.0.0-rc.1" dependencies = [ "anyhow", "argon2", diff --git a/Cargo.toml b/Cargo.toml index 45635d5..c7ac895 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpost" -version = "0.2.0" +version = "1.0.0-rc.1" edition = "2024" license = "MIT" description = "A single-binary self-hosted microblogging app" @@ -10,6 +10,10 @@ keywords = ["microblog", "self-hosted", "sqlite", "axum"] categories = ["web-programming::http-server"] autobins = false +[[bin]] +name = "rustpost" +path = "src/bin/rustpost.rs" + [[bin]] name = "rustpost-cli" path = "src/bin/rustpost-cli.rs" diff --git a/README.md b/README.md index 7c86fb7..9dba18b 100644 --- a/README.md +++ b/README.md @@ -386,16 +386,16 @@ Profile pictures and banners follow the same media pipeline as post uploads. ```sh # Create a backup (SQLite DB snapshot + settings + media/assets) -rustpost-cli backup +rustpost backup # Include Tor onion-service keys -rustpost-cli backup --include-tor-keys +rustpost backup --include-tor-keys # Restore into a fresh data directory -rustpost-cli restore rustpost-20260526T....tar +rustpost restore rustpost-20260526T....tar # Restore including Tor keys -rustpost-cli restore rustpost-20260526T....tar --include-tor-keys +rustpost restore rustpost-20260526T....tar --include-tor-keys ``` Backups are also available from **Admin → Backups**. The page supports manual backup creation, admin-only downloads, restore from uploaded `.tar` archives, automatic backup settings, safe retention controls, recent archive history, and no-JS form flows. @@ -504,22 +504,21 @@ Each archive contains `rustpost`, `rustpost-cli`, `README.md`, `LICENSE`, and op ## ✅ Release Verification -*Last sweep: **May 18, 2026*** +*Last sweep: **June 6, 2026***
Verified locally - Fresh `--data-dir` boot creates `settings.toml`, `db/rustpost.sqlite3`, upload roots, temp upload staging, backup/log dirs, and Tor state dirs. -- `rustpost-cli check` passes on a fresh data directory with `tor.enabled = false`. +- `rustpost check` passes on a fresh data directory with `tor.enabled = false`. - Clearnet serving on `127.0.0.1:8080` loads `/home`. -- Registration, login, post creation, replies, repost rendering, likes, bookmarks, follows, notifications, admin health, and CSRF-protected logout all work through live HTTP requests. +- Registration with CAPTCHA, login, post creation, replies, quote reposts, repost rendering, likes, bookmarks, followers/following pages, notifications, admin health, and CSRF-protected logout all work through live HTTP/browser flows. - Anonymous posting is disabled by default — anonymous users cannot see the composer and anonymous post attempts are rejected. - Non-admin users cannot access admin health; anonymous users cannot access authenticated pages. - `ffmpeg` 8.1.1 detected with WebP and VP9 support. Image, profile picture/banner, and small video uploads live-tested; WebP and WebM outputs produced; admin media/health pages reported conversion state correctly. -- Normal backups include DB, settings, and media; exclude Tor keys. `--include-tor-keys` includes them only when explicitly requested. Restore into a fresh data directory completed and `check` passed. +- Normal backups include DB, settings, and media; exclude Tor keys. `--include-tor-keys` includes them only when explicitly requested. Restores into fresh data directories completed and `check` passed with and without controlled test Tor key material. - Backup archive names include subsecond precision — no same-second overwrite collisions. -- `tor_only = false` live-tested: clearnet bound quickly, Arti produced a real `.onion` hostname, Tor Browser reached `/home` through the onion. -- `tor_only = true` live-tested: no clearnet listener exposed, loopback-only binding confirmed, real `.onion` hostname produced and reached via Tor Browser. +- Tor health/status fields render in admin health with Tor disabled in the current local sweep. - Both `rustpost` and `rustpost-cli` pass `check` on a fresh data directory.
@@ -527,7 +526,7 @@ Each archive contains `rustpost`, `rustpost-cli`, `README.md`, `LICENSE`, and op
Partially verified / environment-dependent -- Live Tor reachability depends on Tor network access and descriptor publication time. The May 2026 sweep succeeded with a temporary onion identity; future release checks should repeat with a non-temporary identity if a stable onion address is required. +- Live Tor reachability depends on Tor network access and descriptor publication time. The June 2026 sweep did not repeat real onion reachability testing. - Tor private key material was not rendered in admin health or normal logs during the sweep. Operational text may reference key paths or the `--include-tor-keys` flag but does not print key contents.
diff --git a/src/admin.rs b/src/admin.rs index 963393a..53b7ffd 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -1208,7 +1208,7 @@ pub async fn ensure_first_boot_admin_hint(pool: &SqlitePool) -> anyhow::Result<( let count = admin_count(pool).await?; if count == 0 { tracing::warn!( - "no admin account exists; run `rustpost-cli create-admin-interactive` or `rustpost-cli create-admin `" + "no admin account exists; run `rustpost create-admin-interactive` or `rustpost create-admin `" ); } Ok(()) diff --git a/src/bin/rustpost.rs b/src/bin/rustpost.rs new file mode 100644 index 0000000..5cfd75d --- /dev/null +++ b/src/bin/rustpost.rs @@ -0,0 +1,12 @@ +#![allow(clippy::multiple_crate_versions)] + +use std::io::Write as _; + +#[tokio::main] +async fn main() { + if let Err(error) = rustpost::cli::run().await { + let mut stderr = std::io::stderr().lock(); + let _write_result = stderr.write_all(rustpost::terminal::render_error(&error).as_bytes()); + std::process::exit(1); + } +} diff --git a/src/cli.rs b/src/cli.rs index 7f2f790..4f4d3d9 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -14,7 +14,7 @@ use crate::{admin, backup, config, db, demo_seed, logging, runtime, server, term #[derive(Debug, Parser)] #[command( about = "Single-binary self-hosted microblog", - after_help = "Common first run:\n rustpost-cli init\n rustpost-cli create-admin-interactive\n rustpost-cli serve" + after_help = "Common first run:\n rustpost init\n rustpost create-admin-interactive\n rustpost serve" )] struct Cli { /// Path to settings.toml. Defaults to /settings.toml. @@ -231,7 +231,7 @@ async fn create_admin_command( terminal::row( "Next command", format!( - "rustpost-cli --data-dir {} serve", + "rustpost --data-dir {} serve", database.paths.data_dir.display() ), ), diff --git a/src/config.rs b/src/config.rs index 6a64428..2b1ac7a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -354,13 +354,13 @@ anonymous_mode_enabled = {anonymous_mode_enabled} # Minimum account password length. Recommended default is 10. min_password_length = {min_password_length} -# Maximum username length in bytes. Usernames are also format-validated. +# Maximum username length in characters. Usernames are also format-validated. max_username_len = {max_username_len} -# Maximum display-name length in bytes. +# Maximum display-name length in characters. max_display_name_len = {max_display_name_len} -# Maximum profile bio length in bytes. +# Maximum profile bio length in characters. max_bio_len = {max_bio_len} # Allow users to upload profile banner images. diff --git a/src/server.rs b/src/server.rs index b6e777c..7779b82 100644 --- a/src/server.rs +++ b/src/server.rs @@ -160,13 +160,39 @@ pub fn router(state: Arc) -> Router { } fn upload_body_limit(settings: &Settings) -> usize { - let limit = settings.media.max_video_size.saturating_add(1024 * 1024); + let limit = upload_payload_limit(settings).saturating_add(1024 * 1024); match usize::try_from(limit) { Ok(limit) => limit, Err(_overflow) => usize::MAX, } } +fn upload_payload_limit(settings: &Settings) -> u64 { + let max_media = settings.posts.max_media_per_post; + if settings.media.max_video_size >= settings.media.max_image_size { + let videos = settings.posts.max_videos_per_post.min(max_media); + let images = settings + .posts + .max_images_per_post + .min(max_media.saturating_sub(videos)); + return attachment_bytes(videos, settings.media.max_video_size) + .saturating_add(attachment_bytes(images, settings.media.max_image_size)); + } + let images = settings.posts.max_images_per_post.min(max_media); + let videos = settings + .posts + .max_videos_per_post + .min(max_media.saturating_sub(images)); + attachment_bytes(images, settings.media.max_image_size) + .saturating_add(attachment_bytes(videos, settings.media.max_video_size)) +} + +fn attachment_bytes(count: usize, max_size: u64) -> u64 { + u64::try_from(count) + .unwrap_or(u64::MAX) + .saturating_mul(max_size) +} + async fn client_script() -> Response { ( [( @@ -508,10 +534,6 @@ async fn login( ) .await .map_err(|err| AppError::RateLimited(err.to_string()))?; - if let Err(err) = crate::validation::validate_password(&form.password, &state.settings) { - rate_limit::record(&state.pool, rate_limit::Scope::FailedLogin, &actor).await?; - return auth_form_response(&state, StatusCode::BAD_REQUEST, &err.to_string()).await; - } let session = match auth::login(&state.pool, &form.username, &form.password).await? { Ok(session) => session, Err(failure) => { @@ -1599,15 +1621,7 @@ async fn profile( ) }, ); - let website_link = if website.trim().is_empty() { - String::new() - } else { - format!( - r#"

{}

"#, - html_escape::encode_double_quoted_attribute(website.as_str()), - html_escape::encode_text(website.as_str()) - ) - }; + let website_link = render_profile_website_link(&website); let location_line = if location.trim().is_empty() { String::new() } else { @@ -2109,6 +2123,45 @@ fn validate_profile_location(location: &str) -> AppResult<()> { Ok(()) } +fn validate_profile_website(website: &str) -> AppResult<()> { + let website = website.trim(); + if website.is_empty() { + return Ok(()); + } + if website.chars().count() > 2_048 { + return Err(AppError::BadRequest("website URL is too long".to_owned())); + } + if website.chars().any(char::is_control) { + return Err(AppError::BadRequest( + "website URL contains unsupported control characters".to_owned(), + )); + } + if !is_safe_profile_website_url(website) { + return Err(AppError::BadRequest( + "website URL must start with http:// or https://".to_owned(), + )); + } + Ok(()) +} + +fn is_safe_profile_website_url(website: &str) -> bool { + website.parse::().is_ok_and(|uri| { + matches!(uri.scheme_str(), Some("http" | "https")) && uri.authority().is_some() + }) +} + +fn render_profile_website_link(website: &str) -> String { + let website = website.trim(); + if website.is_empty() || !is_safe_profile_website_url(website) { + return String::new(); + } + format!( + r#"

{}

"#, + html_escape::encode_double_quoted_attribute(website), + html_escape::encode_text(website) + ) +} + async fn mute( State(state): State>, headers: HeaderMap, @@ -2266,6 +2319,7 @@ async fn settings_update( validate_csrf(&state.pool, &headers, &form.csrf_token).await?; crate::validation::validate_profile_text(&form.display_name, &form.bio, &state.settings)?; validate_profile_location(&form.location)?; + validate_profile_website(&form.website)?; let display_name = form.display_name.trim().to_owned(); let bio = form.bio.trim().to_owned(); let location = form.location.trim().to_owned(); @@ -4664,6 +4718,21 @@ mod tests { assert_eq!(hashes[CSRF_TOKEN_HISTORY_LIMIT - 2], "token-30"); } + #[test] + fn upload_body_limit_covers_configured_media_mix() { + let settings = Settings::default(); + let remaining_media = + u64::try_from(settings.posts.max_media_per_post.saturating_sub(1)).unwrap_or(u64::MAX); + let expected_payload = settings.media.max_video_size.saturating_add( + settings + .media + .max_image_size + .saturating_mul(remaining_media), + ); + + assert!(u64::try_from(upload_body_limit(&settings)).unwrap_or(0) > expected_payload); + } + #[test] fn compact_text_collapses_whitespace_and_truncates() { let compact = compact_text( @@ -5033,18 +5102,6 @@ mod tests { .contains(r#"aria-describedby="confirm-password-requirement""#) ); - let short_login = request( - &server.base_url, - "POST", - "/login", - &[("content-type", "application/x-www-form-urlencoded")], - b"username=alice&password=short".to_vec(), - ) - .await; - assert_eq!(short_login.status, 400); - assert!(short_login.body.contains("

Log in

")); - assert!(short_login.body.contains("password is too short")); - let short_register = request( &server.base_url, "POST", @@ -5058,6 +5115,28 @@ mod tests { assert!(short_register.body.contains("password is too short")); } + #[tokio::test] + async fn login_allows_existing_password_after_policy_increase() { + let server = spawn_test_server().await; + let mut permissive_settings = Settings::default(); + permissive_settings.accounts.min_password_length = 0; + auth::register_user(&server.pool, &permissive_settings, "shorty", "short", false) + .await + .expect("register short password user"); + + let login = request( + &server.base_url, + "POST", + "/login", + &[("content-type", "application/x-www-form-urlencoded")], + b"username=shorty&password=short".to_vec(), + ) + .await; + + assert_eq!(login.status, 303); + assert!(header_value(&login, "set-cookie").is_some()); + } + #[tokio::test] async fn duplicate_registration_renders_register_form_message() { let server = spawn_test_server().await; @@ -6939,6 +7018,66 @@ mod tests { ); } + #[tokio::test] + async fn profile_website_rejects_unsafe_schemes_and_hides_legacy_values() { + let server = spawn_test_server().await; + let cookie = register_test_user(&server, "alice").await; + + let unsafe_saved = save_profile_settings( + &server, + &cookie, + &[ + ("display_name", "Alice"), + ("bio", ""), + ("location", ""), + ("website", "javascript:alert(1)"), + ], + ) + .await; + assert_eq!(unsafe_saved.status, 400); + assert!( + unsafe_saved + .body + .contains("website URL must start with http:// or https://") + ); + let (_, _, _, _, _, _, website) = user_settings_state(&server, "alice").await; + assert_eq!(website, ""); + + server + .pool + .call(|conn| { + conn.execute( + "UPDATE users SET website = 'javascript:alert(1)' WHERE normalized_username = 'alice'", + [], + )?; + Ok(()) + }) + .await + .expect("write legacy unsafe website"); + + let profile = get_with_cookie(&server, "/users/alice", &cookie).await; + assert_eq!(profile.status, 200); + assert!(!profile.body.contains("javascript:alert")); + assert!(!profile.body.contains(r#"href="javascript:"#)); + + let safe_saved = save_profile_settings( + &server, + &cookie, + &[ + ("display_name", "Alice"), + ("bio", ""), + ("location", ""), + ("website", "https://example.test/profile"), + ], + ) + .await; + assert_eq!(safe_saved.status, 303); + let profile = get_with_cookie(&server, "/users/alice", &cookie).await; + assert!(profile.body.contains( + r#"

https://example.test/profile

"# + )); + } + #[tokio::test] async fn profile_tabs_apply_relationship_and_suspended_visibility() { let server = spawn_test_server().await; diff --git a/src/social.rs b/src/social.rs index be711e6..2c96a2b 100644 --- a/src/social.rs +++ b/src/social.rs @@ -241,6 +241,8 @@ pub async fn create_post( if media_ids.len() > settings.posts.max_media_per_post { anyhow::bail!("too many media attachments"); } + let max_images_per_post = settings.posts.max_images_per_post; + let max_videos_per_post = settings.posts.max_videos_per_post; let text = clean_post_text(text, settings.posts.max_text_chars, media_ids.len())?; let youtube_embeds = crate::youtube::metadata_for_text(&text).await; let allow_mentions = settings.posts.allow_mentions; @@ -262,6 +264,13 @@ pub async fn create_post( } else { None }; + let (image_count, video_count) = media_kind_counts_tx(&tx, &media_ids)?; + if image_count > max_images_per_post { + anyhow::bail!("too many image attachments"); + } + if video_count > max_videos_per_post { + anyhow::bail!("too many video attachments"); + } let anonymous_label = user_id.is_none().then_some("Anonymous"); tx.execute( "INSERT INTO posts (user_id, anonymous_label, text, parent_post_id, root_post_id) VALUES (?, ?, ?, ?, ?)", @@ -296,6 +305,30 @@ pub async fn create_post( .await } +fn media_kind_counts_tx( + tx: &rusqlite::Transaction<'_>, + media_ids: &[i64], +) -> anyhow::Result<(usize, usize)> { + let mut image_count = 0usize; + let mut video_count = 0usize; + for media_id in media_ids { + let media_kind = tx + .query_row( + "SELECT media_kind FROM media WHERE id = ?", + [media_id], + |row| row.get::<_, String>(0), + ) + .optional()?; + match media_kind.as_deref() { + Some("image") => image_count += 1, + Some("video") => video_count += 1, + Some(_) => anyhow::bail!("media attachment has unsupported kind"), + None => anyhow::bail!("media attachment not found"), + } + } + Ok((image_count, video_count)) +} + pub async fn edit_post( pool: &SqlitePool, settings: &Settings, @@ -2792,6 +2825,53 @@ mod tests { mute(&pool, alice, bob).await.expect("mute"); } + #[tokio::test] + async fn create_post_enforces_image_and_video_attachment_limits() { + let (pool, mut settings, alice, _bob) = fixture().await; + settings.posts.max_media_per_post = 4; + settings.posts.max_images_per_post = 1; + settings.posts.max_videos_per_post = 1; + let first_image = insert_test_media_kind(&pool, alice, "image").await; + let second_image = insert_test_media_kind(&pool, alice, "image").await; + let first_video = insert_test_media_kind(&pool, alice, "video").await; + let second_video = insert_test_media_kind(&pool, alice, "video").await; + + let too_many_images = create_post( + &pool, + &settings, + Some(alice), + "image limit", + None, + &[first_image, second_image], + ) + .await + .expect_err("image limit"); + assert_eq!(too_many_images.to_string(), "too many image attachments"); + + let too_many_videos = create_post( + &pool, + &settings, + Some(alice), + "video limit", + None, + &[first_video, second_video], + ) + .await + .expect_err("video limit"); + assert_eq!(too_many_videos.to_string(), "too many video attachments"); + + create_post( + &pool, + &settings, + Some(alice), + "mixed media within limits", + None, + &[first_image, first_video], + ) + .await + .expect("mixed media post"); + } + #[tokio::test] async fn youtube_embeds_are_normalized_and_stored_on_create() { let (pool, settings, alice, _bob) = fixture().await; @@ -3623,6 +3703,36 @@ mod tests { .expect("attach media"); } + async fn insert_test_media_kind( + pool: &SqlitePool, + owner_user_id: i64, + media_kind: &str, + ) -> i64 { + let media_kind = media_kind.to_owned(); + let id = uuid::Uuid::new_v4().simple().to_string(); + let extension = if media_kind == "video" { + "webm" + } else { + "webp" + }; + let mime_type = if media_kind == "video" { + "video/webm" + } else { + "image/webp" + }; + let stored_path = format!("/tmp/rustpost-test-{id}.{extension}"); + let public_path = format!("/uploads/{media_kind}s/rustpost-test-{id}.{extension}"); + 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 (?, 'media', ?, ?, ?, ?, 1)", + params![owner_user_id, stored_path, public_path, mime_type, media_kind], + )?; + Ok(conn.last_insert_rowid()) + }) + .await + .expect("test media") + } + async fn notification_count( pool: &SqlitePool, user_id: i64, diff --git a/src/terminal.rs b/src/terminal.rs index b426fb5..fbe41f4 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -157,7 +157,7 @@ pub fn render_check( push_section( &mut out, "Next commands", - &[row("Start server", "rustpost-cli serve")], + &[row("Start server", "rustpost serve")], ); out.push_str(RULE); out.push('\n'); @@ -187,14 +187,14 @@ pub fn render_init(paths: &runtime::RuntimePaths, settings_path: &Path) -> Strin row( "Create admin", format!( - "rustpost-cli --data-dir {} create-admin-interactive", + "rustpost --data-dir {} create-admin-interactive", display(paths.data_dir.as_path()) ), ), row( "Start server", format!( - "rustpost-cli --data-dir {} serve", + "rustpost --data-dir {} serve", display(paths.data_dir.as_path()) ), ), @@ -246,14 +246,14 @@ pub fn render_first_admin_non_interactive(paths: &runtime::RuntimePaths) -> Stri row( "Create admin", format!( - "rustpost-cli --data-dir {} create-admin-interactive", + "rustpost --data-dir {} create-admin-interactive", display(paths.data_dir.as_path()) ), ), row( "Alternative", format!( - "rustpost-cli --data-dir {} create-admin ", + "rustpost --data-dir {} create-admin ", display(paths.data_dir.as_path()) ), ), @@ -342,7 +342,7 @@ fn next_command_rows(input: &StartupDashboard<'_>) -> Vec { rows.push(row( "Create admin", format!( - "rustpost-cli --data-dir {} create-admin-interactive", + "rustpost --data-dir {} create-admin-interactive", display(input.paths.data_dir.as_path()) ), )); @@ -350,7 +350,7 @@ fn next_command_rows(input: &StartupDashboard<'_>) -> Vec { rows.push(row( "Check config", format!( - "rustpost-cli --data-dir {} check", + "rustpost --data-dir {} check", display(input.paths.data_dir.as_path()) ), )); @@ -556,7 +556,7 @@ mod tests { assert!(output.contains(&logs_dir)); assert!(output.contains(&backups_dir)); assert!(output.contains(&format!( - "rustpost-cli --data-dir {data_dir} create-admin-interactive" + "rustpost --data-dir {data_dir} create-admin-interactive" ))); assert!(output.contains("FFmpeg")); assert!(output.contains("[WARN] unavailable")); From 4a4c07a814e8aa3f4bcc28360bd2aa37dfe7f167 Mon Sep 17 00:00:00 2001 From: csd113 Date: Fri, 5 Jun 2026 23:25:09 -0700 Subject: [PATCH 02/21] Harden Tor release status and backup handling --- README.md | 3 ++- src/backup.rs | 38 +++++++++++++++++++++++++--- src/cli.rs | 31 ++++++++++++++++++++--- src/config.rs | 52 +++++++++++++++++++------------------- src/runtime.rs | 21 ++++++++++++++++ src/server.rs | 22 ++++++++++++---- src/tor.rs | 68 ++++++++++++++++++++++++++++++++++++++++++-------- 7 files changed, 184 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 9dba18b..392ce2c 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ rustpost backup # Create a backup archive rustpost backup --include-tor-keys # Backup including Tor private keys rustpost restore # Restore from a backup rustpost restore --include-tor-keys # Restore including Tor keys -rustpost print-onion-address # Print the current .onion hostname +rustpost print-onion-address # Print an active address, or fail clearly if unavailable ``` --- @@ -360,6 +360,7 @@ rustpost restore archive.tar # rejects Tor key paths unless flag giv rustpost restore archive.tar --include-tor-keys ``` +On Unix, the backup directory is mode `0700` and created backup archives are mode `0600`. Restore path validation rejects: absolute paths, traversal sequences, symlinks/hardlinks, duplicate entries, duplicate separators, Windows drive prefixes, backslash paths, encoded traversal or slash markers, and slash-like Unicode bypass characters. --- diff --git a/src/backup.rs b/src/backup.rs index 851ca4e..b619bce 100644 --- a/src/backup.rs +++ b/src/backup.rs @@ -383,10 +383,7 @@ fn create_backup_inner( let manifest = build_manifest(db_schema_version, include_tor_keys, &directories, &files); let manifest_toml = toml::to_string(&manifest)?; let archive_path = unique_archive_path(&paths.backups_dir, kind); - let file = OpenOptions::new() - .write(true) - .create_new(true) - .open(&archive_path) + let file = create_private_file(&archive_path) .with_context(|| format!("failed to create backup archive {}", archive_path.display()))?; let mut builder = Builder::new(file); append_bytes( @@ -406,6 +403,22 @@ fn create_backup_inner( Ok(archive_path) } +#[cfg(unix)] +fn create_private_file(path: &Path) -> io::Result { + use std::os::unix::fs::OpenOptionsExt as _; + + OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) +} + +#[cfg(not(unix))] +fn create_private_file(path: &Path) -> io::Result { + OpenOptions::new().write(true).create_new(true).open(path) +} + fn append_runtime_dir( files: &mut Vec, directories: &mut BTreeSet, @@ -1304,6 +1317,23 @@ mod tests { assert!(names.iter().any(|name| name == "tor/onion-service/secret")); } + #[cfg(unix)] + #[test] + fn backup_archive_is_private() { + use std::os::unix::fs::PermissionsExt as _; + + let (_temp, paths) = test_paths(); + + let archive = create_backup(&paths, true).expect("backup"); + let mode = fs::metadata(archive) + .expect("archive metadata") + .permissions() + .mode() + & 0o777; + + assert_eq!(mode, 0o600); + } + #[test] fn backup_normalizes_valid_pre_release_schema_without_touching_live_database() { let (_temp, paths) = test_paths(); diff --git a/src/cli.rs b/src/cli.rs index 4f4d3d9..613ff17 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -3,6 +3,7 @@ use std::net::SocketAddr; use std::path::PathBuf; #[cfg(unix)] use std::process::Command as ProcessCommand; +use std::time::Duration; use anyhow::Context as _; use clap::{Parser, Subcommand}; @@ -172,10 +173,10 @@ async fn run_command( } => restore_command(&paths, &archive, include_tor_keys), Command::PrintOnionAddress => { let status = tor::validate_startup(&settings.tor); - stdout_line(format_args!( - "{}", - status.onion_address().unwrap_or_default() - ))?; + let onion = status.onion_address().context( + "onion address unavailable: use the running site's public header or admin health", + )?; + stdout_line(format_args!("{onion}"))?; Ok(()) } Command::Serve => serve(paths, settings_path, settings).await, @@ -522,6 +523,7 @@ fn spawn_onion_forwarding( if !started.running() { return; } + spawn_tor_status_sync(tor_status.clone(), started, shutdown_rx.clone()); let addr = match listener.local_addr() { Ok(addr) => addr, Err(error) => { @@ -542,6 +544,27 @@ fn spawn_onion_forwarding( }); } +fn spawn_tor_status_sync( + visible: tor::TorStatus, + running: tor::TorStatus, + mut shutdown_rx: watch::Receiver, +) { + tokio::spawn(async move { + loop { + tokio::select! { + () = tokio::time::sleep(Duration::from_secs(1)) => { + visible.replace_snapshot_with(&running); + } + changed = shutdown_rx.changed() => { + if changed.is_err() || *shutdown_rx.borrow() { + break; + } + } + } + } + }); +} + struct StartupPrintContext<'a> { paths: &'a runtime::RuntimePaths, settings_path: &'a std::path::Path, diff --git a/src/config.rs b/src/config.rs index 2b1ac7a..7f2591e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -267,6 +267,11 @@ impl Settings { if self.tor.tor_only && !self.tor.enabled { anyhow::bail!("tor.tor_only requires tor.enabled"); } + if self.tor.include_tor_keys_in_backups_by_default { + anyhow::bail!( + "tor.include_tor_keys_in_backups_by_default must remain false; use an explicit include-Tor-keys backup option" + ); + } validate_onion_service_name(&self.tor.onion_service_name)?; validate_display_onion_address(&self.tor.display_onion_address)?; validate_post_edit_window(self.posts.post_edit_window_seconds)?; @@ -467,8 +472,8 @@ 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. +# Deprecated compatibility setting. Must remain blank; the public UI only +# shows the active address reported by the running Arti onion service. display_onion_address = {display_onion_address} # Seconds to wait for Tor bootstrap during Tor-only startup. @@ -477,8 +482,8 @@ bootstrap_timeout_secs = {bootstrap_timeout_secs} # Maximum concurrent onion streams accepted by the service. max_concurrent_streams = {max_concurrent_streams} -# SECURITY: Include onion service keys when backups are created by default. -# Keep false unless backups are encrypted and access-controlled. +# Deprecated compatibility setting. Must remain false; Tor keys are included +# only with an explicit backup option. include_tor_keys_in_backups_by_default = {include_tor_keys_in_backups_by_default} @@ -656,22 +661,9 @@ 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(()) + anyhow::bail!( + "tor.display_onion_address must remain blank; RustPost only displays the active Arti onion address" + ) } const fn default_true() -> bool { @@ -834,18 +826,24 @@ mod tests { } #[test] - fn validates_display_onion_address() { + fn rejects_configured_display_onion_address() { let mut settings = Settings::default(); settings.tor.display_onion_address = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion".to_owned(); - settings.validate().expect("valid display onion address"); + let error = settings.validate().expect_err("display address must fail"); + assert!(error.to_string().contains("only displays the active Arti")); + } - settings.tor.display_onion_address = "examplehiddenservice.onion".to_owned(); - assert!(settings.validate().is_err()); + #[test] + fn rejects_implicit_tor_key_backups() { + let mut settings = Settings::default(); + settings.tor.include_tor_keys_in_backups_by_default = true; - settings.tor.display_onion_address = - "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWX.onion".to_owned(); - assert!(settings.validate().is_err()); + let error = settings + .validate() + .expect_err("implicit Tor key backup must fail"); + + assert!(error.to_string().contains("explicit include-Tor-keys")); } #[test] diff --git a/src/runtime.rs b/src/runtime.rs index cfab75b..5f54a00 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -92,6 +92,7 @@ impl RuntimePaths { })?; } self.migrate_legacy_database_layout()?; + restrict_dir(&self.backups_dir)?; restrict_dir(&self.tor_dir)?; restrict_dir(&self.tor_onion_service_dir)?; Ok(()) @@ -270,6 +271,26 @@ mod tests { } } + #[cfg(unix)] + #[test] + fn ensure_restricts_sensitive_runtime_directories() { + use std::os::unix::fs::PermissionsExt as _; + + let temp = tempfile::tempdir().expect("temp dir"); + let paths = RuntimePaths::from_data_dir(temp.path().join("data")); + + paths.ensure().expect("ensure paths"); + + for path in [ + &paths.backups_dir, + &paths.tor_dir, + &paths.tor_onion_service_dir, + ] { + let mode = fs::metadata(path).expect("metadata").permissions().mode() & 0o777; + assert_eq!(mode, 0o700, "{} should be private", path.display()); + } + } + #[test] fn ensure_migrates_legacy_database_when_new_database_is_absent() { let temp = tempfile::tempdir().expect("temp dir"); diff --git a/src/server.rs b/src/server.rs index 7779b82..4a8233f 100644 --- a/src/server.rs +++ b/src/server.rs @@ -469,10 +469,7 @@ async fn layout_context( }; Ok(render::LayoutContext { anonymous_mode_enabled: state.settings.accounts.anonymous_mode_enabled, - 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()) - }), + tor_onion_address: state.tor.onion_address(), follower_count: counts.map(|(followers, _following)| followers), following_count: counts.map(|(_followers, following)| following), notification_unread_count, @@ -3507,7 +3504,7 @@ async fn admin_health( .bootstrap_status() .unwrap_or_else(|| "unavailable".to_owned()); let body = format!( - r#"

Site health

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

Recent media jobs

{}
"#, + r#"

Site health

DB path
{}
DB schema version
{}
DB diagnostics
{}
Upload path
{}
Media path
{}
Logs path
{}
Backup path
{}
ffmpeg
{}
WebP support
{}
VP9 support
{}
Tor
{}
Tor enabled
{}
Tor service active
{}
Tor bootstrap
{}
Tor error
{}
Onion address
{}
Anonymous mode
{}
Registration
{}

Recent media jobs

{}
"#, html_escape::encode_text(&state.paths.database_path.display().to_string()), html_escape::encode_text(&schema_version), html_escape::encode_text(&schema_summary), @@ -5072,6 +5069,21 @@ mod tests { assert!(!response.body.contains("Authentication required")); } + #[tokio::test] + async fn public_layout_does_not_render_configured_onion_fallback() { + let mut settings = Settings::default(); + settings.tor.enabled = true; + settings.tor.display_onion_address = + "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion".to_owned(); + let server = spawn_test_server_with_settings(settings).await; + + let response = request(&server.base_url, "GET", "/", &[], Vec::new()).await; + + assert_eq!(response.status, 200); + assert!(!response.body.contains("tor-header-indicator")); + assert!(!response.body.contains(".onion")); + } + #[tokio::test] async fn auth_forms_show_password_minimum_and_short_passwords_return_forms() { let server = spawn_test_server().await; diff --git a/src/tor.rs b/src/tor.rs index 9f98b02..833d21f 100644 --- a/src/tor.rs +++ b/src/tor.rs @@ -141,20 +141,30 @@ impl TorStatus { } pub(crate) fn replace_with(&self, other: &Self) { - if let (Ok(mut snapshot), Ok(other_snapshot)) = (self.inner.write(), other.inner.read()) { - *snapshot = other_snapshot.clone(); - } + self.replace_snapshot_with(other); if let (Ok(mut runtime), Ok(other_runtime)) = (self.runtime.write(), other.runtime.read()) { runtime.clone_from(&other_runtime); } } + pub(crate) fn replace_snapshot_with(&self, other: &Self) { + if let (Ok(mut snapshot), Ok(other_snapshot)) = (self.inner.write(), other.inner.read()) { + *snapshot = other_snapshot.clone(); + } + } + fn record_error(&self, message: impl Into) { if let Ok(mut snapshot) = self.inner.write() { snapshot.error = Some(message.into()); } } + fn record_reachable(&self) { + if let Ok(mut snapshot) = self.inner.write() { + "reachable".clone_into(&mut snapshot.state); + } + } + fn snapshot(&self) -> TorStatusSnapshot { self.inner .read() @@ -281,7 +291,7 @@ async fn start_inner( enabled: true, running: true, onion_address: onion_address.clone(), - state: "running".to_owned(), + state: "service active; external reachability unverified".to_owned(), bootstrap_status, error: None, })); @@ -296,10 +306,13 @@ async fn start_inner( while let Some(request) = stream_requests.next().await { let task_status = task_status.clone(); tokio::spawn(async move { - if let Err(error) = forward_stream_request(request, onion_target).await { - let message = error.to_string(); - task_status.record_error(message.clone()); - warn!(error = %message, "Tor onion stream forwarding failed"); + match forward_stream_request(request, onion_target).await { + Ok(()) => task_status.record_reachable(), + Err(error) => { + let message = format!("{error:#}"); + task_status.record_error(message.clone()); + warn!(error = %message, "Tor onion stream forwarding failed"); + } } }); } @@ -314,7 +327,7 @@ async fn start_inner( } info!( onion = onion_address.as_deref().unwrap_or("unavailable"), - "Arti onion service running" + "Arti onion service active; external reachability unverified" ); Ok(TorStatus { inner, @@ -363,10 +376,12 @@ fn is_clean_stream_close(error: &io::Error) -> bool { matches!( error.kind(), io::ErrorKind::BrokenPipe + | io::ErrorKind::ConnectionAborted | io::ErrorKind::ConnectionReset | io::ErrorKind::NotConnected + | io::ErrorKind::TimedOut | io::ErrorKind::UnexpectedEof - ) + ) || error.to_string() == "Received an END cell with reason MISC" } fn create_private_dir(path: &Path) -> anyhow::Result<()> { @@ -476,16 +491,49 @@ mod tests { assert!(!rendered.contains("secret")); } + #[test] + fn status_snapshot_sync_copies_later_runtime_errors() { + let visible = TorStatus::starting(); + let running = TorStatus::new(TorStatusSnapshot { + enabled: true, + running: true, + onion_address: Some("example.onion".to_owned()), + state: "service active; external reachability unverified".to_owned(), + bootstrap_status: Some("ready".to_owned()), + error: None, + }); + + visible.replace_with(&running); + running.record_error("forwarding failed"); + visible.replace_snapshot_with(&running); + + assert_eq!(visible.error().as_deref(), Some("forwarding failed")); + } + + #[test] + fn successful_stream_marks_status_reachable() { + let status = TorStatus::starting(); + + status.record_reachable(); + + assert_eq!(status.state(), "reachable"); + } + #[test] fn common_http_stream_close_errors_are_clean() { for kind in [ io::ErrorKind::BrokenPipe, + io::ErrorKind::ConnectionAborted, io::ErrorKind::ConnectionReset, io::ErrorKind::NotConnected, + io::ErrorKind::TimedOut, io::ErrorKind::UnexpectedEof, ] { let error = io::Error::from(kind); assert!(is_clean_stream_close(&error)); } + assert!(is_clean_stream_close(&io::Error::other( + "Received an END cell with reason MISC" + ))); } } From a3534986228714717172f7ca31f80ee41582cc76 Mon Sep 17 00:00:00 2001 From: csd113 Date: Sat, 6 Jun 2026 08:29:11 -0700 Subject: [PATCH 03/21] Harden auth, media, and post privacy boundaries --- src/auth.rs | 126 ++++++- src/media.rs | 300 +++++++++++++++- src/server.rs | 131 ++++++- src/social.rs | 286 ++++++++++++++- tests/playwright/backup_restore.spec.mjs | 427 ----------------------- 5 files changed, 800 insertions(+), 470 deletions(-) delete mode 100644 tests/playwright/backup_restore.spec.mjs diff --git a/src/auth.rs b/src/auth.rs index c8b5eaa..8846800 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -189,6 +189,7 @@ pub async fn change_password( current_password: &str, new_password: &str, confirm_new_password: &str, + current_session_token: Option<&str>, ) -> anyhow::Result<()> { if new_password != confirm_new_password { anyhow::bail!("new passwords do not match"); @@ -198,11 +199,25 @@ pub async fn change_password( anyhow::bail!("current password is incorrect"); } let hash = hash_password(new_password)?; + let current_session_hash = current_session_token.map(hash_token); pool.call(move |conn| { - conn.execute( + let tx = conn.transaction()?; + tx.execute( "UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND is_deleted = 0", params![hash, user_id], )?; + if let Some(current_session_hash) = current_session_hash { + tx.execute( + "UPDATE sessions SET revoked_at = CURRENT_TIMESTAMP WHERE user_id = ? AND token_hash != ? AND revoked_at IS NULL", + params![user_id, current_session_hash], + )?; + } else { + tx.execute( + "UPDATE sessions SET revoked_at = CURRENT_TIMESTAMP WHERE user_id = ? AND revoked_at IS NULL", + [user_id], + )?; + } + tx.commit()?; Ok(()) }) .await @@ -222,7 +237,9 @@ pub async fn current_user( 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 + WHERE s.token_hash = ? AND s.revoked_at IS NULL + AND datetime(s.expires_at) > CURRENT_TIMESTAMP + AND u.is_deleted = 0 "#, [token_hash], |row| { @@ -250,7 +267,7 @@ pub async fn csrf_hashes_for_cookie( let token_hash = hash_token(token); pool.call(move |conn| { conn.query_row( - "SELECT csrf_token_hash, previous_csrf_token_hash FROM sessions WHERE token_hash = ? AND revoked_at IS NULL", + "SELECT csrf_token_hash, previous_csrf_token_hash FROM sessions WHERE token_hash = ? AND revoked_at IS NULL AND datetime(expires_at) > CURRENT_TIMESTAMP", [token_hash], |row| { let current = row.get::<_, String>(0)?; @@ -386,6 +403,7 @@ mod tests { "very secure password", "much better password", "much better password", + None, ) .await .expect("password change"); @@ -404,6 +422,62 @@ mod tests { ); } + #[tokio::test] + async fn password_change_revokes_other_sessions_but_keeps_current_session() { + let temp = tempfile::tempdir().expect("temp dir"); + let pool = crate::db::connect(&temp.path().join("test.sqlite3")) + .await + .expect("connect"); + crate::db::migrate(&pool).await.expect("migrate"); + let settings = Settings::default(); + let user_id = register_user(&pool, &settings, "Alice", "very secure password", false) + .await + .expect("user"); + let current = create_session(&pool, user_id) + .await + .expect("current session"); + let other = create_session(&pool, user_id).await.expect("other session"); + + change_password( + &pool, + &settings, + user_id, + "very secure password", + "much better password", + "much better password", + Some(¤t.token), + ) + .await + .expect("password change"); + + let mut current_headers = HeaderMap::new(); + current_headers.insert( + axum::http::header::COOKIE, + format!("rustpost_session={}", current.token) + .parse() + .expect("current cookie"), + ); + assert!( + current_user(&pool, ¤t_headers) + .await + .expect("current user") + .is_some() + ); + let mut other_headers = HeaderMap::new(); + other_headers.insert( + axum::http::header::COOKIE, + format!("rustpost_session={}", other.token) + .parse() + .expect("other cookie"), + ); + assert!( + current_user(&pool, &other_headers) + .await + .expect("other user") + .is_none() + ); + } + #[tokio::test] async fn password_change_rejects_wrong_current_password() { let temp = tempfile::tempdir().expect("temp dir"); @@ -423,6 +497,7 @@ mod tests { "wrong password", "much better password", "much better password", + None, ) .await; @@ -457,6 +532,7 @@ mod tests { "very secure password", "much better password", "different password", + None, ) .await; @@ -465,4 +541,48 @@ mod tests { "new passwords do not match" ); } + + #[tokio::test] + async fn expired_rfc3339_session_is_rejected() { + let temp = tempfile::tempdir().expect("temp dir"); + let pool = crate::db::connect(&temp.path().join("test.sqlite3")) + .await + .expect("connect"); + crate::db::migrate(&pool).await.expect("migrate"); + let settings = Settings::default(); + let user_id = register_user(&pool, &settings, "Alice", "very secure password", false) + .await + .expect("user"); + let session = create_session(&pool, user_id).await.expect("session"); + let token_hash = hash_token(&session.token); + pool.call(move |conn| { + conn.execute( + "UPDATE sessions SET expires_at = datetime('now', '-1 minute') || 'Z' WHERE token_hash = ?", + [token_hash], + )?; + Ok(()) + }) + .await + .expect("expire session"); + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::header::COOKIE, + format!("rustpost_session={}", session.token) + .parse() + .expect("cookie"), + ); + + assert!( + current_user(&pool, &headers) + .await + .expect("current user") + .is_none() + ); + assert!( + csrf_hashes_for_cookie(&pool, &session.token) + .await + .expect("csrf lookup") + .is_none() + ); + } } diff --git a/src/media.rs b/src/media.rs index 91c53ca..5559ab3 100644 --- a/src/media.rs +++ b/src/media.rs @@ -846,6 +846,7 @@ fn has_parent_component(path: &Path) -> bool { pub async fn set_profile_media( pool: &SqlitePool, + paths: &RuntimePaths, user_id: i64, slot: ProfileMediaSlot, media_id: i64, @@ -862,13 +863,16 @@ pub async fn set_profile_media( }) .await?; if media_kind.as_deref() != Some("image") { - delete_media(pool, media_id).await?; + delete_media(pool, paths, media_id).await?; anyhow::bail!("profile media must be an image"); } let select_sql = format!("SELECT {} FROM users WHERE id = ?", slot.column()); let previous: Option = pool .call(move |conn| Ok(conn.query_row(&select_sql, [user_id], |row| row.get(0))?)) .await?; + if let Some(previous) = previous.filter(|previous| *previous != media_id) { + validate_media_deletion_paths(pool, paths, previous).await?; + } let update_sql = format!( "UPDATE users SET {} = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", slot.column() @@ -879,13 +883,14 @@ pub async fn set_profile_media( }) .await?; if let Some(previous) = previous.filter(|previous| *previous != media_id) { - delete_media(pool, previous).await?; + delete_media(pool, paths, previous).await?; } Ok(()) } pub async fn clear_profile_media( pool: &SqlitePool, + paths: &RuntimePaths, user_id: i64, slot: ProfileMediaSlot, ) -> anyhow::Result<()> { @@ -893,6 +898,9 @@ pub async fn clear_profile_media( let previous: Option = pool .call(move |conn| Ok(conn.query_row(&select_sql, [user_id], |row| row.get(0))?)) .await?; + if let Some(previous) = previous { + validate_media_deletion_paths(pool, paths, previous).await?; + } let update_sql = format!( "UPDATE users SET {} = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?", slot.column() @@ -903,12 +911,17 @@ pub async fn clear_profile_media( }) .await?; if let Some(previous) = previous { - delete_media(pool, previous).await?; + delete_media(pool, paths, previous).await?; } Ok(()) } -pub async fn delete_media(pool: &SqlitePool, media_id: i64) -> anyhow::Result<()> { +pub async fn delete_media( + pool: &SqlitePool, + paths: &RuntimePaths, + media_id: i64, +) -> anyhow::Result<()> { + validate_media_deletion_paths(pool, paths, media_id).await?; let paths_to_remove = pool .call(move |conn| { let tx = conn.transaction()?; @@ -959,6 +972,178 @@ pub async fn delete_media(pool: &SqlitePool, media_id: i64) -> anyhow::Result<() Ok(()) } +async fn validate_media_deletion_paths( + pool: &SqlitePool, + paths: &RuntimePaths, + media_id: i64, +) -> anyhow::Result<()> { + let media_paths: Option<(Option, String, Option)> = pool + .call(move |conn| { + conn.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() + .map_err(Into::into) + }) + .await?; + let Some((original_path, stored_path, thumbnail_path)) = media_paths else { + return Ok(()); + }; + for path in [original_path, Some(stored_path), thumbnail_path] + .into_iter() + .flatten() + .filter(|path| !path.is_empty()) + { + validate_media_deletion_path(paths, Path::new(&path)).await?; + } + Ok(()) +} + +async fn validate_media_deletion_path(paths: &RuntimePaths, path: &Path) -> anyhow::Result<()> { + if !path.is_absolute() || has_parent_component(path) { + anyhow::bail!("refusing to delete unsafe media path {}", path.display()); + } + let allowed_roots = [ + &paths.uploads_originals, + &paths.uploads_images, + &paths.uploads_videos, + &paths.uploads_thumbs, + ]; + let Some(root) = allowed_roots + .into_iter() + .find(|root| path.starts_with(root)) + else { + anyhow::bail!( + "refusing to delete media path outside upload directories: {}", + path.display() + ); + }; + let metadata = match tokio::fs::symlink_metadata(path).await { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + }; + if !metadata.file_type().is_file() { + anyhow::bail!("refusing to delete non-file media path {}", path.display()); + } + let canonical_root = tokio::fs::canonicalize(root).await?; + let canonical_path = tokio::fs::canonicalize(path).await?; + if !canonical_path.starts_with(canonical_root) { + anyhow::bail!( + "refusing to delete media path outside upload directories: {}", + path.display() + ); + } + Ok(()) +} + +pub async fn delete_post_media( + pool: &SqlitePool, + paths: &RuntimePaths, + post_id: i64, +) -> anyhow::Result<()> { + let media_ids = post_media_ids(pool, post_id).await?; + for media_id in &media_ids { + validate_media_deletion_paths(pool, paths, *media_id).await?; + } + let paths_to_remove = pool + .call(move |conn| { + let tx = conn.transaction()?; + tx.execute("DELETE FROM post_media WHERE post_id = ?", [post_id])?; + let mut paths = BTreeSet::new(); + for media_id in media_ids { + let referenced: bool = tx.query_row( + r#" + SELECT EXISTS( + SELECT 1 FROM post_media WHERE media_id = ? + UNION ALL + SELECT 1 FROM users + WHERE profile_picture_media_id = ? OR banner_media_id = ? + ) + "#, + params![media_id, media_id, media_id], + |row| row.get(0), + )?; + if referenced { + continue; + } + 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 { + continue; + }; + 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])?; + 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?; + remove_media_files(paths_to_remove, post_id, "post media cleanup").await; + Ok(()) +} + +pub async fn validate_post_media_deletion( + pool: &SqlitePool, + paths: &RuntimePaths, + post_id: i64, +) -> anyhow::Result<()> { + for media_id in post_media_ids(pool, post_id).await? { + validate_media_deletion_paths(pool, paths, media_id).await?; + } + Ok(()) +} + +async fn post_media_ids(pool: &SqlitePool, post_id: i64) -> anyhow::Result> { + pool.call(move |conn| { + let mut stmt = + conn.prepare("SELECT media_id FROM post_media WHERE post_id = ? ORDER BY position")?; + let ids = stmt + .query_map([post_id], |row| row.get(0))? + .collect::, _>>()?; + Ok(ids) + }) + .await +} + +async fn remove_media_files(paths: Vec, subject_id: i64, operation: &'static str) { + for path in paths { + if let Err(error) = tokio::fs::remove_file(&path).await + && error.kind() != std::io::ErrorKind::NotFound + { + tracing::warn!( + subject_id, + path = %path.display(), + error = %error, + operation, + "failed to remove unreferenced media file" + ); + } + } +} + fn promote_canonical_references( tx: &rusqlite::Transaction<'_>, media_id: i64, @@ -1407,9 +1592,13 @@ mod tests { .await; let second = insert_duplicate_row(&pool, user_id, first, &shared).await; - delete_media(&pool, second).await.expect("delete duplicate"); + delete_media(&pool, &paths, second) + .await + .expect("delete duplicate"); assert!(shared.exists()); - delete_media(&pool, first).await.expect("delete canonical"); + delete_media(&pool, &paths, first) + .await + .expect("delete canonical"); assert!(!shared.exists()); } @@ -1434,7 +1623,9 @@ mod tests { .await; let second = insert_duplicate_row(&pool, user_id, first, &shared).await; - delete_media(&pool, first).await.expect("delete canonical"); + delete_media(&pool, &paths, first) + .await + .expect("delete canonical"); assert!(shared.exists()); let promoted = media_row(&pool, second).await; @@ -1588,7 +1779,9 @@ mod tests { #[tokio::test] async fn profile_media_replacement_deletes_previous_file() { let temp = tempfile::tempdir().expect("temp dir"); - let pool = crate::db::connect(&temp.path().join("test.sqlite3")) + 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"); @@ -1597,9 +1790,9 @@ mod tests { crate::auth::register_user(&pool, &settings, "alice", "very secure password", false) .await .expect("user"); - let first = temp.path().join("first.webp"); - let first_thumb = temp.path().join("first-thumb.webp"); - let second = temp.path().join("second.webp"); + let first = paths.uploads_images.join("first.webp"); + let first_thumb = paths.uploads_thumbs.join("first-thumb.webp"); + let second = paths.uploads_images.join("second.webp"); tokio::fs::write(&first, b"first").await.expect("first"); tokio::fs::write(&first_thumb, b"first-thumb") .await @@ -1609,10 +1802,10 @@ mod tests { let second_id = insert_test_media(&pool, user_id, &second, "image").await; set_test_thumbnail(&pool, first_id, &first_thumb).await; - set_profile_media(&pool, user_id, ProfileMediaSlot::Picture, first_id) + set_profile_media(&pool, &paths, user_id, ProfileMediaSlot::Picture, first_id) .await .expect("set first"); - set_profile_media(&pool, user_id, ProfileMediaSlot::Picture, second_id) + set_profile_media(&pool, &paths, user_id, ProfileMediaSlot::Picture, second_id) .await .expect("set second"); @@ -1624,7 +1817,9 @@ mod tests { #[tokio::test] async fn profile_media_rejects_non_image_and_removes_upload() { let temp = tempfile::tempdir().expect("temp dir"); - let pool = crate::db::connect(&temp.path().join("test.sqlite3")) + 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"); @@ -1633,18 +1828,91 @@ mod tests { crate::auth::register_user(&pool, &settings, "alice", "very secure password", false) .await .expect("user"); - let video = temp.path().join("video.webm"); + let video = paths.uploads_videos.join("video.webm"); tokio::fs::write(&video, b"video").await.expect("video"); let media_id = insert_test_media(&pool, user_id, &video, "video").await; assert!( - set_profile_media(&pool, user_id, ProfileMediaSlot::Picture, media_id) + set_profile_media(&pool, &paths, user_id, ProfileMediaSlot::Picture, media_id) .await .is_err() ); assert!(!video.exists()); } + #[tokio::test] + async fn media_deletion_rejects_restored_paths_outside_upload_directories() { + let (temp, paths, pool, _settings, _ffmpeg, user_id) = media_fixture().await; + let outside = temp.path().join("outside.webp"); + tokio::fs::write(&outside, b"private") + .await + .expect("outside"); + let media_id = insert_test_media(&pool, user_id, &outside, "image").await; + + let error = delete_media(&pool, &paths, media_id) + .await + .expect_err("outside path must be rejected"); + + assert!(error.to_string().contains("outside upload directories")); + assert!(outside.exists()); + let row_exists: bool = pool + .call(move |conn| { + conn.query_row( + "SELECT EXISTS(SELECT 1 FROM media WHERE id = ?)", + [media_id], + |row| row.get(0), + ) + .map_err(Into::into) + }) + .await + .expect("media row"); + assert!(row_exists); + } + + #[tokio::test] + async fn deleted_post_media_is_removed_only_after_its_last_reference() { + let (_temp, paths, pool, _settings, _ffmpeg, user_id) = media_fixture().await; + let stored = paths.uploads_images.join("post.webp"); + tokio::fs::write(&stored, b"post") + .await + .expect("post media"); + let media_id = insert_test_media(&pool, user_id, &stored, "image").await; + let (first_post, second_post) = pool + .call(move |conn| { + conn.execute( + "INSERT INTO posts (user_id, text) VALUES (?, 'first')", + [user_id], + )?; + let first = conn.last_insert_rowid(); + conn.execute( + "INSERT INTO posts (user_id, text) VALUES (?, 'second')", + [user_id], + )?; + let second = conn.last_insert_rowid(); + conn.execute( + "INSERT INTO post_media (post_id, media_id, position) VALUES (?, ?, 0)", + params![first, media_id], + )?; + conn.execute( + "INSERT INTO post_media (post_id, media_id, position) VALUES (?, ?, 0)", + params![second, media_id], + )?; + Ok((first, second)) + }) + .await + .expect("posts"); + + delete_post_media(&pool, &paths, first_post) + .await + .expect("first cleanup"); + assert!(stored.exists()); + + delete_post_media(&pool, &paths, second_post) + .await + .expect("second cleanup"); + assert!(!stored.exists()); + } + #[cfg(unix)] #[tokio::test] async fn profile_picture_thumbnail_generation_creates_webp_metadata() { diff --git a/src/server.rs b/src/server.rs index 4a8233f..ad6c925 100644 --- a/src/server.rs +++ b/src/server.rs @@ -519,8 +519,10 @@ async fn login_form(State(state): State>) -> Html { async fn login( State(state): State>, ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, Form(form): Form, ) -> AppResult { + reject_cross_site_form_post(&headers)?; let actor = ip_actor(addr); rate_limit::ensure_under_limit( &state.pool, @@ -568,8 +570,10 @@ async fn register_form(State(state): State>) -> AppResult>, ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, Form(form): Form, ) -> AppResult { + reject_cross_site_form_post(&headers)?; if !state.settings.accounts.registration_enabled { return Err(AppError::Forbidden); } @@ -736,6 +740,7 @@ async fn onboarding_update( if let Some(media_id) = form.profile_picture_media_id { media::set_profile_media( &state.pool, + &state.paths, user.id, media::ProfileMediaSlot::Picture, media_id, @@ -755,7 +760,7 @@ async fn onboarding_update( async fn cleanup_onboarding_uploads(state: &AppState, form: &ParsedOnboardingUpdate) { if let Some(media_id) = form.profile_picture_media_id - && let Err(error) = media::delete_media(&state.pool, media_id).await + && let Err(error) = media::delete_media(&state.pool, &state.paths, media_id).await { tracing::warn!( media_id, @@ -1321,7 +1326,9 @@ async fn delete_post( } Err(err) => return Err(err), }; + media::validate_post_media_deletion(&state.pool, &state.paths, id).await?; social::delete_post(&state.pool, user.id, id, user.is_admin).await?; + media::delete_post_media(&state.pool, &state.paths, id).await?; let fallback = if let Some(parent_id) = preview.parent_post_id { format!("/posts/{parent_id}#post-{parent_id}") } else { @@ -2344,14 +2351,27 @@ async fn settings_update( }) .await?; if form.delete_profile_picture { - media::clear_profile_media(&state.pool, user.id, media::ProfileMediaSlot::Picture).await?; + media::clear_profile_media( + &state.pool, + &state.paths, + user.id, + media::ProfileMediaSlot::Picture, + ) + .await?; } if form.delete_banner { - media::clear_profile_media(&state.pool, user.id, media::ProfileMediaSlot::Banner).await?; + media::clear_profile_media( + &state.pool, + &state.paths, + user.id, + media::ProfileMediaSlot::Banner, + ) + .await?; } if let Some(media_id) = form.profile_picture_media_id { media::set_profile_media( &state.pool, + &state.paths, user.id, media::ProfileMediaSlot::Picture, media_id, @@ -2361,6 +2381,7 @@ async fn settings_update( if let Some(media_id) = form.banner_media_id { media::set_profile_media( &state.pool, + &state.paths, user.id, media::ProfileMediaSlot::Banner, media_id, @@ -2412,6 +2433,7 @@ async fn change_password( ) -> AppResult { let user = require_active_user(&state, &headers).await?; validate_csrf(&state.pool, &headers, &form.csrf).await?; + let current_session_token = auth::session_cookie(&headers); match auth::change_password( &state.pool, &state.settings, @@ -2419,6 +2441,7 @@ async fn change_password( &form.current_password, &form.new_password, &form.confirm_new_password, + current_session_token.as_deref(), ) .await { @@ -3206,6 +3229,36 @@ fn referer_target(headers: &HeaderMap) -> Option { } } +fn reject_cross_site_form_post(headers: &HeaderMap) -> AppResult<()> { + if headers + .get("sec-fetch-site") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.eq_ignore_ascii_case("cross-site")) + { + return Err(AppError::Forbidden); + } + let Some(origin) = headers + .get(header::ORIGIN) + .and_then(|value| value.to_str().ok()) + else { + return Ok(()); + }; + let Some(host) = headers + .get(header::HOST) + .and_then(|value| value.to_str().ok()) + else { + return Err(AppError::Forbidden); + }; + let origin_host = origin.parse::().ok().and_then(|uri| { + uri.authority() + .map(|authority| authority.as_str().to_owned()) + }); + if origin_host.is_none_or(|origin_host| !origin_host.eq_ignore_ascii_case(host)) { + return Err(AppError::Forbidden); + } + Ok(()) +} + async fn bookmarks( State(state): State>, headers: HeaderMap, @@ -3723,7 +3776,9 @@ async fn admin_delete_post( ) -> AppResult { let user = require_admin(&state, &headers).await?; validate_csrf(&state.pool, &headers, &form.csrf).await?; + media::validate_post_media_deletion(&state.pool, &state.paths, id).await?; social::delete_post(&state.pool, user.id, id, true).await?; + media::delete_post_media(&state.pool, &state.paths, id).await?; admin::audit(&state.pool, user.id, "delete_post", &format!("post:{id}")).await?; Ok(Redirect::to("/admin").into_response()) } @@ -4589,7 +4644,7 @@ async fn require_active_user(state: &AppState, headers: &HeaderMap) -> AppResult } async fn require_admin(state: &AppState, headers: &HeaderMap) -> AppResult { - let user = require_user(state, headers).await?; + let user = require_active_user(state, headers).await?; if !user.is_admin { return Err(AppError::Forbidden); } @@ -4611,7 +4666,7 @@ async fn form_csrf(state: &AppState, headers: &HeaderMap) -> Option { let token_hash = token_hash.clone(); move |conn| { conn.query_row( - "SELECT csrf_token_hash, previous_csrf_token_hash FROM sessions WHERE token_hash = ? AND revoked_at IS NULL", + "SELECT csrf_token_hash, previous_csrf_token_hash FROM sessions WHERE token_hash = ? AND revoked_at IS NULL AND datetime(expires_at) > CURRENT_TIMESTAMP", [token_hash], |row| Ok((row.get(0)?, row.get(1)?)), ) @@ -4817,6 +4872,72 @@ mod tests { assert_eq!(response.status, 403); } + #[tokio::test] + async fn cross_site_login_and_registration_posts_are_rejected() { + let server = spawn_test_server_with_admin().await; + + let login = request( + &server.base_url, + "POST", + "/login", + &[ + ("content-type", "application/x-www-form-urlencoded"), + ("origin", "https://attacker.example"), + ("sec-fetch-site", "cross-site"), + ], + b"username=siteowner&password=very%20secure%20password".to_vec(), + ) + .await; + let registration = request( + &server.base_url, + "POST", + "/register", + &[ + ("content-type", "application/x-www-form-urlencoded"), + ("origin", "https://attacker.example"), + ("sec-fetch-site", "cross-site"), + ], + b"username=forced&password=very%20secure%20password&confirm_password=very%20secure%20password".to_vec(), + ) + .await; + + assert_eq!(login.status, 403); + assert_eq!(registration.status, 403); + let forced_accounts: i64 = server + .pool + .call(|conn| { + Ok(conn.query_row( + "SELECT COUNT(*) FROM users WHERE normalized_username = 'forced'", + [], + |row| row.get(0), + )?) + }) + .await + .expect("forced account count"); + assert_eq!(forced_accounts, 0); + } + + #[tokio::test] + async fn suspended_admin_session_cannot_access_admin_routes() { + let server = spawn_test_server_with_admin().await; + let admin_cookie = admin_session_cookie(&server).await; + server + .pool + .call(|conn| { + conn.execute( + "UPDATE users SET is_suspended = 1 WHERE normalized_username = 'siteowner'", + [], + )?; + Ok(()) + }) + .await + .expect("suspend admin"); + + let response = get_with_cookie(&server, "/admin/users", &admin_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; diff --git a/src/social.rs b/src/social.rs index 2c96a2b..a37342e 100644 --- a/src/social.rs +++ b/src/social.rs @@ -250,6 +250,9 @@ pub async fn create_post( pool.call(move |conn| { let tx = conn.transaction()?; let root_post_id = if let Some(parent_id) = parent_post_id { + if let Some(actor_id) = user_id { + ensure_post_interaction_accessible_tx(&tx, actor_id, parent_id)?; + } let root = tx .query_row( "SELECT COALESCE(root_post_id, id) FROM posts WHERE id = ? AND is_deleted = 0", @@ -264,7 +267,7 @@ pub async fn create_post( } else { None }; - let (image_count, video_count) = media_kind_counts_tx(&tx, &media_ids)?; + let (image_count, video_count) = media_kind_counts_tx(&tx, user_id, &media_ids)?; if image_count > max_images_per_post { anyhow::bail!("too many image attachments"); } @@ -307,6 +310,7 @@ pub async fn create_post( fn media_kind_counts_tx( tx: &rusqlite::Transaction<'_>, + owner_user_id: Option, media_ids: &[i64], ) -> anyhow::Result<(usize, usize)> { let mut image_count = 0usize; @@ -314,8 +318,8 @@ fn media_kind_counts_tx( for media_id in media_ids { let media_kind = tx .query_row( - "SELECT media_kind FROM media WHERE id = ?", - [media_id], + "SELECT media_kind FROM media WHERE id = ? AND owner_user_id IS ?", + params![media_id, owner_user_id], |row| row.get::<_, String>(0), ) .optional()?; @@ -564,9 +568,13 @@ pub async fn post_thread( }; let root_id = root; let mut sql = base_post_query(); - sql.push_str(" AND (p.id = ? OR p.root_post_id = ?) ORDER BY p.id ASC LIMIT 200"); + sql.push_str(" AND (p.id = ? OR p.root_post_id = ?)"); + append_viewer_filters(&mut sql, "p.user_id", viewer_id); + sql.push_str(" ORDER BY p.id ASC LIMIT 200"); + let mut bindings = vec![root_id, root_id]; + push_viewer_filter_bindings(&mut bindings, viewer_id); let rows = pool - .call(move |conn| query_post_rows(conn, &sql, params![root_id, root_id])) + .call(move |conn| query_post_rows(conn, &sql, params_from_iter(bindings))) .await?; rows_to_posts(pool, rows, viewer_id).await } @@ -574,6 +582,7 @@ pub async fn post_thread( pub async fn repost(pool: &SqlitePool, user_id: i64, post_id: i64) -> anyhow::Result { pool.call(move |conn| { let tx = conn.transaction()?; + ensure_post_interaction_accessible_tx(&tx, user_id, post_id)?; let owner: Option = tx .query_row( "SELECT user_id FROM posts WHERE id = ? AND is_deleted = 0", @@ -729,6 +738,9 @@ pub async fn follow(pool: &SqlitePool, follower_id: i64, followed_id: i64) -> an if !target_available { anyhow::bail!("account cannot be followed"); } + if blocks_exist_tx(&tx, follower_id, followed_id)? { + anyhow::bail!("account cannot be followed"); + } let changed = tx.execute( "INSERT OR IGNORE INTO follows (follower_id, followed_id) VALUES (?, ?)", params![follower_id, followed_id], @@ -1165,6 +1177,7 @@ fn clean_muted_word(term: &str) -> anyhow::Result { pub async fn like(pool: &SqlitePool, user_id: i64, post_id: i64) -> anyhow::Result<()> { pool.call(move |conn| { let tx = conn.transaction()?; + ensure_post_interaction_accessible_tx(&tx, user_id, post_id)?; let owner_exists = tx .query_row( "SELECT 1 FROM posts WHERE id = ? AND is_deleted = 0", @@ -1202,15 +1215,68 @@ pub async fn unlike(pool: &SqlitePool, user_id: i64, post_id: i64) -> anyhow::Re pub async fn bookmark(pool: &SqlitePool, user_id: i64, post_id: i64) -> anyhow::Result<()> { pool.call(move |conn| { - conn.execute( + let tx = conn.transaction()?; + ensure_post_interaction_accessible_tx(&tx, user_id, post_id)?; + tx.execute( "INSERT OR IGNORE INTO bookmarks (user_id, post_id) VALUES (?, ?)", params![user_id, post_id], )?; + tx.commit()?; Ok(()) }) .await } +fn ensure_post_interaction_accessible_tx( + tx: &rusqlite::Transaction<'_>, + user_id: i64, + post_id: i64, +) -> anyhow::Result<()> { + let owner = tx + .query_row( + r#" + SELECT p.user_id + FROM posts p + LEFT JOIN users u ON u.id = p.user_id + WHERE p.id = ? AND p.is_deleted = 0 + AND (p.user_id IS NULL OR (u.is_deleted = 0 AND u.is_suspended = 0)) + "#, + [post_id], + |row| row.get::<_, Option>(0), + ) + .optional()?; + let Some(owner) = owner else { + anyhow::bail!("post not found"); + }; + if let Some(owner) = owner + && owner != user_id + && blocks_exist_tx(tx, user_id, owner)? + { + anyhow::bail!("post not found"); + } + Ok(()) +} + +fn blocks_exist_tx( + tx: &rusqlite::Transaction<'_>, + left_id: i64, + right_id: i64, +) -> anyhow::Result { + Ok(tx + .query_row( + r#" + SELECT 1 FROM blocks + WHERE (blocker_id = ? AND blocked_id = ?) + OR (blocker_id = ? AND blocked_id = ?) + LIMIT 1 + "#, + params![left_id, right_id, right_id, left_id], + |_| Ok(()), + ) + .optional()? + .is_some()) +} + pub async fn unbookmark(pool: &SqlitePool, user_id: i64, post_id: i64) -> anyhow::Result<()> { pool.call(move |conn| { conn.execute( @@ -1358,22 +1424,44 @@ pub async fn search( FROM users u LEFT JOIN media pic ON pic.id = u.profile_picture_media_id WHERE u.is_deleted = 0 + AND u.is_suspended = 0 + AND ( + ? < 0 OR ( + u.id NOT IN (SELECT blocked_id FROM blocks WHERE blocker_id = ?) + AND u.id NOT IN (SELECT muted_id FROM mutes WHERE muter_id = ?) + AND NOT EXISTS ( + SELECT 1 FROM blocks + WHERE blocker_id = u.id AND blocked_id = ? + ) + ) + ) AND (u.normalized_username LIKE ? OR u.display_name LIKE ?) ORDER BY lower(u.username) LIMIT 20 "#, )?; let rows = stmt - .query_map(params![viewer_id, username_query, display_query], |row| { - Ok(AccountView { - id: row.get(0)?, - username: row.get(1)?, - display_name: row.get(2)?, - bio: row.get(3)?, - profile_picture_path: row.get(4)?, - viewer_following: row.get::<_, i64>(5)? != 0, - }) - })? + .query_map( + params![ + viewer_id, + viewer_id, + viewer_id, + viewer_id, + viewer_id, + username_query, + display_query + ], + |row| { + Ok(AccountView { + id: row.get(0)?, + username: row.get(1)?, + display_name: row.get(2)?, + bio: row.get(3)?, + profile_picture_path: row.get(4)?, + viewer_following: row.get::<_, i64>(5)? != 0, + }) + }, + )? .collect::, _>>()?; Ok(rows) }) @@ -1388,7 +1476,7 @@ pub async fn search( query_post_rows( conn, &post_sql, - params![fts_query, viewer_id, viewer_id, viewer_id], + params![fts_query, viewer_id, viewer_id, viewer_id, viewer_id], ) } else { query_post_rows(conn, &post_sql, params![fts_query]) @@ -1876,6 +1964,7 @@ fn base_post_query() -> String { LEFT JOIN users u ON u.id = p.user_id LEFT JOIN media pic ON pic.id = u.profile_picture_media_id WHERE p.is_deleted = 0 + AND (p.user_id IS NULL OR (u.is_deleted = 0 AND u.is_suspended = 0)) "#.to_owned() } @@ -1900,7 +1989,8 @@ fn base_repost_query() -> String { LEFT JOIN posts p ON p.id = r.post_id LEFT JOIN users u ON u.id = p.user_id LEFT JOIN media pic ON pic.id = u.profile_picture_media_id - WHERE ru.is_deleted = 0 + WHERE ru.is_deleted = 0 AND ru.is_suspended = 0 + AND (p.user_id IS NULL OR (u.is_deleted = 0 AND u.is_suspended = 0)) "#.to_owned() } @@ -2071,6 +2161,9 @@ fn append_viewer_filters(sql: &mut String, user_column: &str, viewer_id: Option< sql.push_str(&format!( " AND ({user_column} IS NULL OR {user_column} NOT IN (SELECT muted_id FROM mutes WHERE muter_id = ?))" )); + sql.push_str(&format!( + " AND ({user_column} IS NULL OR NOT EXISTS (SELECT 1 FROM blocks WHERE blocker_id = {user_column} AND blocked_id = ?))" + )); sql.push_str( " AND NOT EXISTS (SELECT 1 FROM muted_words mw WHERE mw.user_id = ? AND instr(lower(p.text), mw.normalized_term) > 0)", ); @@ -2082,6 +2175,7 @@ fn push_viewer_filter_bindings(bindings: &mut Vec, viewer_id: Option) bindings.push(id); bindings.push(id); bindings.push(id); + bindings.push(id); } } @@ -2239,6 +2333,7 @@ async fn quote_preview_for_post( FROM posts q LEFT JOIN users u ON u.id = q.user_id WHERE q.id = ? AND q.is_deleted = 0 + AND (q.user_id IS NULL OR (u.is_deleted = 0 AND u.is_suspended = 0)) "# .to_owned(); if viewer_id.is_some() { @@ -2298,7 +2393,13 @@ fn ensure_quote_target_accessible_tx( ) -> anyhow::Result<()> { let owner: Option = tx .query_row( - "SELECT user_id FROM posts WHERE id = ? AND is_deleted = 0", + r#" + SELECT p.user_id + FROM posts p + JOIN users u ON u.id = p.user_id + WHERE p.id = ? AND p.is_deleted = 0 + AND u.is_deleted = 0 AND u.is_suspended = 0 + "#, [post_id], |row| row.get(0), ) @@ -2872,6 +2973,25 @@ mod tests { .expect("mixed media post"); } + #[tokio::test] + async fn create_post_rejects_media_owned_by_another_user() { + let (pool, settings, alice, bob) = fixture().await; + let bob_media = insert_test_media_kind(&pool, bob, "image").await; + + let error = create_post( + &pool, + &settings, + Some(alice), + "stolen media", + None, + &[bob_media], + ) + .await + .expect_err("other user's media must be rejected"); + + assert_eq!(error.to_string(), "media attachment not found"); + } + #[tokio::test] async fn youtube_embeds_are_normalized_and_stored_on_create() { let (pool, settings, alice, _bob) = fixture().await; @@ -3347,6 +3467,134 @@ mod tests { ); } + #[tokio::test] + async fn blocked_users_cannot_interact_with_blocker_content_or_account() { + let (pool, settings, alice, bob) = fixture().await; + let original = create_post(&pool, &settings, Some(alice), "original", None, &[]) + .await + .expect("original"); + block(&pool, alice, bob).await.expect("block"); + + assert!(follow(&pool, bob, alice).await.is_err()); + assert!(like(&pool, bob, original).await.is_err()); + assert!(bookmark(&pool, bob, original).await.is_err()); + assert!(repost(&pool, bob, original).await.is_err()); + assert!( + create_post( + &pool, + &settings, + Some(bob), + "blocked reply", + Some(original), + &[], + ) + .await + .is_err() + ); + + let (follows, likes, bookmarks, reposts, replies): (i64, i64, i64, i64, i64) = pool + .call(move |conn| { + Ok(( + conn.query_row("SELECT COUNT(*) FROM follows", [], |row| row.get(0))?, + conn.query_row("SELECT COUNT(*) FROM likes", [], |row| row.get(0))?, + conn.query_row("SELECT COUNT(*) FROM bookmarks", [], |row| row.get(0))?, + conn.query_row("SELECT COUNT(*) FROM reposts", [], |row| row.get(0))?, + conn.query_row( + "SELECT COUNT(*) FROM posts WHERE parent_post_id = ?", + [original], + |row| row.get(0), + )?, + )) + }) + .await + .expect("interaction counts"); + assert_eq!( + (follows, likes, bookmarks, reposts, replies), + (0, 0, 0, 0, 0) + ); + } + + #[tokio::test] + async fn blocked_users_cannot_recover_blocker_posts_from_feeds_threads_or_search() { + let (pool, settings, alice, bob) = fixture().await; + let original = create_post( + &pool, + &settings, + Some(alice), + "blocked visibility target", + None, + &[], + ) + .await + .expect("original"); + block(&pool, alice, bob).await.expect("block"); + + let timeline = timeline(&pool, Some(bob), "local", None) + .await + .expect("timeline"); + let thread = post_thread(&pool, Some(bob), original) + .await + .expect("thread"); + let (users, posts) = search(&pool, Some(bob), "alice blocked visibility") + .await + .expect("search"); + + assert!(timeline.iter().all(|post| post.user_id != Some(alice))); + assert!(thread.is_empty()); + assert!(users.iter().all(|user| user.id != alice)); + assert!(posts.iter().all(|post| post.user_id != Some(alice))); + } + + #[tokio::test] + async fn suspended_user_posts_are_hidden_and_cannot_receive_interactions() { + let (pool, settings, alice, bob) = fixture().await; + let original = create_post( + &pool, + &settings, + Some(alice), + "suspended visibility target", + None, + &[], + ) + .await + .expect("original"); + pool.call(move |conn| { + conn.execute("UPDATE users SET is_suspended = 1 WHERE id = ?", [alice])?; + Ok(()) + }) + .await + .expect("suspend user"); + + let timeline = timeline(&pool, Some(bob), "local", None) + .await + .expect("timeline"); + let thread = post_thread(&pool, Some(bob), original) + .await + .expect("thread"); + let (users, posts) = search(&pool, Some(bob), "suspended visibility") + .await + .expect("search"); + + assert!(timeline.iter().all(|post| post.user_id != Some(alice))); + assert!(thread.is_empty()); + assert!(users.iter().all(|user| user.id != alice)); + assert!(posts.iter().all(|post| post.user_id != Some(alice))); + assert!(like(&pool, bob, original).await.is_err()); + assert!(repost(&pool, bob, original).await.is_err()); + assert!( + create_post( + &pool, + &settings, + Some(bob), + "reply to suspended post", + Some(original), + &[], + ) + .await + .is_err() + ); + } + #[tokio::test] async fn compact_post_avatar_prefers_profile_thumbnail() { let (pool, settings, alice, _) = fixture().await; diff --git a/tests/playwright/backup_restore.spec.mjs b/tests/playwright/backup_restore.spec.mjs deleted file mode 100644 index 21c5217..0000000 --- a/tests/playwright/backup_restore.spec.mjs +++ /dev/null @@ -1,427 +0,0 @@ -import { test, expect } from "@playwright/test"; -import { spawn } from "node:child_process"; -import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; -import { existsSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import net from "node:net"; - -const repoRoot = process.cwd(); -const adminUsername = "siteowner"; -const adminPassword = "very secure password"; -const tinyPng = Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", - "base64", -); - -test.describe.configure({ mode: "serial" }); - -function commandOutput(command, args, code, stdout, stderr) { - return [`${command} ${args.join(" ")} exited with ${code}`, stdout.trim(), stderr.trim()] - .filter(Boolean) - .join("\n"); -} - -function runCommand(command, args) { - return new Promise((resolve, reject) => { - const child = spawn(command, args, { cwd: repoRoot }); - let stdout = ""; - let stderr = ""; - child.stdout.on("data", (chunk) => { - stdout += chunk; - }); - child.stderr.on("data", (chunk) => { - stderr += chunk; - }); - child.on("error", reject); - child.on("close", (code) => { - if (code === 0) { - resolve({ stdout, stderr }); - } else { - reject(new Error(commandOutput(command, args, code, stdout, stderr))); - } - }); - }); -} - -function freePort() { - return new Promise((resolve, reject) => { - const listener = net.createServer(); - listener.on("error", reject); - listener.listen(0, "127.0.0.1", () => { - const address = listener.address(); - listener.close(() => resolve(address.port)); - }); - }); -} - -async function configureDataDir(dataDir, port, replacements = []) { - await runCommand("cargo", [ - "run", - "--quiet", - "--bin", - "rustpost-cli", - "--", - "--data-dir", - dataDir, - "init", - ]); - const settingsPath = path.join(dataDir, "settings.toml"); - let settings = await readFile(settingsPath, "utf8"); - settings = settings - .replace("port = 8080", `port = ${port}`) - .replace("create_admin_on_first_boot = true", "create_admin_on_first_boot = false") - .replace("account_creations_per_ip_per_day = 3", "account_creations_per_ip_per_day = 50"); - for (const [from, to] of replacements) { - settings = settings.replace(from, to); - } - await writeFile(settingsPath, settings); - await runCommand("cargo", [ - "run", - "--quiet", - "--bin", - "rustpost-cli", - "--", - "--data-dir", - dataDir, - "create-admin", - adminUsername, - adminPassword, - ]); -} - -function startServer(dataDir) { - const child = spawn( - "cargo", - ["run", "--quiet", "--bin", "rustpost-cli", "--", "--data-dir", dataDir, "serve"], - { - cwd: repoRoot, - detached: process.platform !== "win32", - stdio: ["ignore", "pipe", "pipe"], - }, - ); - child.stdout.resume(); - child.stderr.resume(); - return child; -} - -async function waitForServer(baseUrl, child) { - const deadline = Date.now() + 90_000; - while (Date.now() < deadline) { - expect(child.exitCode).toBeNull(); - try { - const response = await fetch(`${baseUrl}/home`, { redirect: "manual" }); - if (response.status < 500) { - return; - } - } catch (_error) { - // Retry while cargo builds and the server binds its listener. - } - await new Promise((resolve) => setTimeout(resolve, 250)); - } - throw new Error("RustPost server did not become ready in time"); -} - -async function stopProcess(child) { - if (!child || child.exitCode !== null) { - return; - } - try { - if (process.platform === "win32") { - child.kill("SIGTERM"); - } else { - process.kill(-child.pid, "SIGTERM"); - } - } catch (error) { - if (error.code === "ESRCH") { - return; - } - throw error; - } - await new Promise((resolve) => { - const timeout = setTimeout(resolve, 5_000); - child.once("close", () => { - clearTimeout(timeout); - resolve(); - }); - }); -} - -async function withRuntime(testPrefix, callback, replacements = []) { - const dataDir = await mkdtemp(path.join(tmpdir(), testPrefix)); - const port = await freePort(); - const baseUrl = `http://127.0.0.1:${port}`; - let server; - await configureDataDir(dataDir, port, replacements); - try { - server = startServer(dataDir); - await waitForServer(baseUrl, server); - await callback({ dataDir, baseUrl, server }); - } finally { - await stopProcess(server); - await rm(dataDir, { recursive: true, force: true }); - } -} - -async function login(page, baseUrl) { - await page.goto(`${baseUrl}/login`); - await page.fill("#username", adminUsername); - await page.fill("#password", adminPassword); - await Promise.all([page.waitForURL("**/home"), page.locator("button.auth-submit").click()]); -} - -async function createPost(page, baseUrl, text, withMedia = false) { - await page.goto(`${baseUrl}/home`); - await page.fill("#post-text", text); - if (withMedia) { - await page.setInputFiles("#post-media", { - name: "backup.png", - mimeType: "image/png", - buffer: tinyPng, - }); - } - await Promise.all([ - page.waitForURL(/\/home#post-\d+$/), - page.locator('form[action="/posts"] button[type="submit"]').click(), - ]); -} - -async function latestBackup(dataDir, prefix = "rustpost-") { - const backups = (await readdir(path.join(dataDir, "backups"))) - .filter((name) => name.startsWith(prefix) && name.endsWith(".tar")) - .sort(); - expect(backups.length).toBeGreaterThan(0); - return path.join(dataDir, "backups", backups.at(-1)); -} - -async function waitForAutomaticBackup(dataDir) { - const deadline = Date.now() + 90_000; - while (Date.now() < deadline) { - const backups = (await readdir(path.join(dataDir, "backups"))).filter((name) => - name.startsWith("rustpost-auto-") && name.endsWith(".tar"), - ); - if (backups.length > 0) { - return backups; - } - await new Promise((resolve) => setTimeout(resolve, 500)); - } - throw new Error("automatic backup was not created"); -} - -test("manual backup can be created and downloaded", async ({ page }) => { - await withRuntime("rustpost-backup-manual-", async ({ baseUrl }) => { - await login(page, baseUrl); - await createPost(page, baseUrl, "manual backup includes posts and media", true); - await page.goto(`${baseUrl}/admin/backups`); - await expect(page.getByRole("heading", { name: "Backups", exact: true })).toBeVisible(); - await page.getByRole("button", { name: "Create backup" }).click(); - await expect(page.getByText("Backup created:")).toBeVisible(); - const download = await Promise.all([ - page.waitForEvent("download"), - page.getByRole("link", { name: "Download" }).first().click(), - ]).then(([download]) => download); - expect(download.suggestedFilename()).toMatch(/^rustpost-.*\.tar$/); - }); -}); - -test("fresh runtime restore preserves users posts settings media and admin access", async ({ page }) => { - const sourceDir = await mkdtemp(path.join(tmpdir(), "rustpost-backup-source-")); - const sourcePort = await freePort(); - const sourceUrl = `http://127.0.0.1:${sourcePort}`; - let sourceServer; - let restoredServer; - const restoredDir = await mkdtemp(path.join(tmpdir(), "rustpost-backup-restored-")); - try { - await configureDataDir(sourceDir, sourcePort, [ - ["name = \"RustPost\"", "name = \"Restored RustPost\""], - ["anonymous_mode_enabled = false", "anonymous_mode_enabled = true"], - ["nsfw_blur_enabled = true", "nsfw_blur_enabled = false"], - ]); - sourceServer = startServer(sourceDir); - await waitForServer(sourceUrl, sourceServer); - await login(page, sourceUrl); - await createPost(page, sourceUrl, "fresh restore keeps this media post", true); - await runCommand("cargo", [ - "run", - "--quiet", - "--bin", - "rustpost-cli", - "--", - "--data-dir", - sourceDir, - "backup", - ]); - const archive = await latestBackup(sourceDir); - await stopProcess(sourceServer); - sourceServer = null; - - const restoredPort = await freePort(); - await configureDataDir(restoredDir, restoredPort); - await runCommand("cargo", [ - "run", - "--quiet", - "--bin", - "rustpost-cli", - "--", - "--data-dir", - restoredDir, - "restore", - archive, - ]); - restoredServer = startServer(restoredDir); - const restoredUrl = sourceUrl; - await waitForServer(restoredUrl, restoredServer); - await login(page, restoredUrl); - await expect(page.getByText("fresh restore keeps this media post")).toBeVisible(); - await expect(page.locator('img[src^="/uploads/"], video[src^="/uploads/"]').first()).toBeVisible(); - await page.goto(`${restoredUrl}/admin/health`); - await expect(page.getByText("Anonymous mode")).toBeVisible(); - await expect( - page.locator("dt", { hasText: "Anonymous mode" }).locator("xpath=following-sibling::dd[1]"), - ).toHaveText("true"); - } finally { - await stopProcess(sourceServer); - await stopProcess(restoredServer); - await rm(sourceDir, { recursive: true, force: true }); - await rm(restoredDir, { recursive: true, force: true }); - } -}); - -test("admin restore upload replaces populated runtime after restart", async ({ page }) => { - await withRuntime("rustpost-backup-populated-", async ({ dataDir, baseUrl, server }) => { - await login(page, baseUrl); - await createPost(page, baseUrl, "state captured before restore", true); - await runCommand("cargo", [ - "run", - "--quiet", - "--bin", - "rustpost-cli", - "--", - "--data-dir", - dataDir, - "backup", - ]); - const archive = await latestBackup(dataDir); - await createPost(page, baseUrl, "state that should disappear after restore"); - await page.goto(`${baseUrl}/admin/backups`); - await page.setInputFiles("#backup-upload", archive); - await page.fill("#restore-confirm", "RESTORE"); - await page.getByRole("button", { name: "Restore backup" }).click(); - await expect(page.getByText("Restore completed.")).toBeVisible(); - await stopProcess(server); - const restarted = startServer(dataDir); - try { - await waitForServer(baseUrl, restarted); - await login(page, baseUrl); - await expect(page.getByText("state captured before restore")).toBeVisible(); - await expect(page.getByText("state that should disappear after restore")).toHaveCount(0); - } finally { - await stopProcess(restarted); - } - }); -}); - -test("scheduler creates automatic backup and retention prunes old automatic archives", async ({ page }) => { - test.setTimeout(130_000); - await withRuntime( - "rustpost-backup-scheduler-", - async ({ dataDir, baseUrl }) => { - await login(page, baseUrl); - await createPost(page, baseUrl, "scheduler backup state"); - await waitForAutomaticBackup(dataDir); - await writeFile(path.join(dataDir, "backups", "rustpost-auto-20000101T000000000000Z.tar"), "old"); - await writeFile(path.join(dataDir, "backups", "rustpost-auto-20000101T000001000000Z.tar"), "older"); - await page.goto(`${baseUrl}/admin/backups`); - await page.getByLabel("Keep newest automatic backups").fill("1"); - await page.getByRole("button", { name: "Save backup settings" }).click(); - await expect(page.getByText("Backup settings saved.")).toBeVisible(); - const deadline = Date.now() + 90_000; - while (Date.now() < deadline) { - const oldExists = - existsSync(path.join(dataDir, "backups", "rustpost-auto-20000101T000000000000Z.tar")) || - existsSync(path.join(dataDir, "backups", "rustpost-auto-20000101T000001000000Z.tar")); - if (!oldExists) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 500)); - } - throw new Error("automatic retention did not prune old archives"); - }, - [ - ["automatic_enabled = false", "automatic_enabled = true"], - ["automatic_interval_minutes = 1440", "automatic_interval_minutes = 1"], - ["retention_keep_last = 10", "retention_keep_last = 2"], - ], - ); -}); - -test("malicious restore upload is rejected without replacing live state", async ({ page }) => { - await withRuntime("rustpost-backup-malicious-", async ({ dataDir, baseUrl }) => { - const malicious = path.join(dataDir, "tmp", "malicious.tar"); - await writeFile(malicious, maliciousTraversalTar()); - await login(page, baseUrl); - await createPost(page, baseUrl, "live state survives malicious restore"); - await page.goto(`${baseUrl}/admin/backups`); - await page.setInputFiles("#backup-upload", malicious); - await page.fill("#restore-confirm", "RESTORE"); - await page.getByRole("button", { name: "Restore backup" }).click(); - await expect(page.getByText("Restore failed")).toBeVisible(); - await page.goto(`${baseUrl}/home`); - await expect(page.getByText("live state survives malicious restore")).toBeVisible(); - }); -}); - -test("backup page works without JavaScript in Firefox", async ({ browserName, browser }) => { - test.skip(browserName !== "firefox", "Firefox no-JS coverage"); - const context = await browser.newContext({ javaScriptEnabled: false }); - const page = await context.newPage(); - page.setDefaultTimeout(20_000); - await withRuntime("rustpost-backup-no-js-", async ({ baseUrl }) => { - await login(page, baseUrl); - await page.goto(`${baseUrl}/admin/backups`); - await expect(page.getByRole("heading", { name: "Backups", exact: true })).toBeVisible(); - await expect(page.getByRole("button", { name: "Create backup" })).toBeVisible(); - await expect(page.getByRole("button", { name: "Restore backup" })).toBeVisible(); - }); - await context.close(); -}); - -test("backup admin page has a WebKit smoke path", async ({ browserName, page }) => { - test.skip(browserName !== "webkit", "WebKit admin smoke"); - await withRuntime("rustpost-backup-webkit-", async ({ baseUrl }) => { - await login(page, baseUrl); - await page.goto(`${baseUrl}/admin`); - await page.getByRole("link", { name: "Backups" }).click(); - await expect(page.getByRole("heading", { name: "Backups", exact: true })).toBeVisible(); - }); -}); - -function maliciousTraversalTar() { - const chunks = [ - tarEntry("manifest.toml", "0", Buffer.from("format_version = 1\n")), - tarEntry("../settings.toml", "0", Buffer.from("owned")), - Buffer.alloc(1024), - ]; - return Buffer.concat(chunks); -} - -function tarEntry(name, typeflag, body) { - const header = Buffer.alloc(512, 0); - header.write(name, 0, "utf8"); - header.write("0000600\0", 100, "ascii"); - header.write("0000000\0", 108, "ascii"); - header.write("0000000\0", 116, "ascii"); - header.write(body.length.toString(8).padStart(11, "0") + "\0", 124, "ascii"); - header.write("00000000000\0", 136, "ascii"); - header.fill(" ", 148, 156); - header.write(typeflag, 156, "ascii"); - header.write("ustar\0", 257, "ascii"); - header.write("00", 263, "ascii"); - let checksum = 0; - for (const byte of header) { - checksum += byte; - } - header.write(checksum.toString(8).padStart(6, "0") + "\0 ", 148, "ascii"); - const padding = Buffer.alloc((512 - (body.length % 512)) % 512, 0); - return Buffer.concat([header, body, padding]); -} From a46581cd0ef47e4f699e9c990560e5b36a5fb179 Mon Sep 17 00:00:00 2001 From: csd113 Date: Sat, 6 Jun 2026 09:02:05 -0700 Subject: [PATCH 04/21] Document release audit exception and Playwright hygiene --- CHANGELOG.md | 6 ++++++ README.md | 16 ++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89f0d0d..89a0b63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ - Enforced configured per-post image and video attachment limits and raised the multipart body limit to cover valid configured media mixes. - Corrected generated settings copy for username, display name, and bio limits from bytes to characters. +### Release Validation +- Updated the ignored local Playwright release harness so it no longer configures the rejected `tor.display_onion_address`; synthetic local Tor-header assertions were removed while opt-in real-onion coverage remains. +- Documented the accepted v1.0.0 `cargo audit` exception for transitive Arti `rsa 0.9.10` / `RUSTSEC-2023-0071`, for which no fixed upgrade is currently available. +- Recorded unmaintained transitive Arti dependencies as upstream maintenance caveats rather than RustPost application vulnerabilities. +- Kept Playwright files and generated artifacts ignored and out of the repository. + ### Database Lineage - Squashed the pre-release internal migration chain into a clean first-release SQLite schema baseline at database schema version `1`. - Fresh databases are now initialized directly from the baseline instead of replaying alpha development migrations. diff --git a/README.md b/README.md index 392ce2c..c309964 100644 --- a/README.md +++ b/README.md @@ -507,6 +507,22 @@ Each archive contains `rustpost`, `rustpost-cli`, `README.md`, `LICENSE`, and op *Last sweep: **June 6, 2026*** +Release validation distinguishes the required Rust gates from the dependency-advisory review: + +```sh +cargo fmt --all --check +cargo build --release --bins +cargo clippy --workspace --all-targets --all-features -- -D warnings -D clippy::all -D clippy::pedantic -D clippy::nursery -D clippy::cargo +cargo test --workspace --all-features +cargo audit +``` + +The format, release build, strict Clippy, and test commands must pass. `cargo audit` is reviewed separately and is **not fully clean** for v1.0.0 because of this documented upstream exception: + +> **Accepted v1.0.0 upstream audit exception:** `RUSTSEC-2023-0071` affects transitive `rsa 0.9.10` through the Arti 0.42.0 dependency family. The advisory reports a potential key-recovery timing side channel, and no fixed upgrade is currently available. This is an upstream dependency risk, not a verified RustPost application vulnerability. Re-evaluate it when updating Arti or before the next release. + +`cargo audit` also reports unmaintained transitive `bincode 2.0.1` (`RUSTSEC-2025-0141`) and `paste 1.0.15` (`RUSTSEC-2024-0436`) dependencies inherited through the Arti dependency family. These are tracked as upstream maintenance caveats, not RustPost application vulnerabilities. +
Verified locally From 8ce772ff3fd66008e98c8ea180008e4f60522a39 Mon Sep 17 00:00:00 2001 From: csd113 Date: Sat, 6 Jun 2026 09:21:21 -0700 Subject: [PATCH 05/21] Disable textarea resize handles --- README.md | 2 +- src/render.rs | 4 ++-- src/runtime.rs | 2 ++ 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c309964..22ad127 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ rustpost-data/ ``` > **Note:** All runtime paths are derived from `--data-dir` (or the executable location as fallback). RustPost does not rely on the current working directory. -> Runtime data is local operator state. Databases, uploads, logs, backups, Tor key material, and temporary upload files under `rustpost-data/` should not be committed to git. +> Runtime data is local operator state. Databases, uploads, logs, backups, Tor key material, and temporary upload files under `rustpost-data/` should not be committed to git. On Unix, RustPost restricts the runtime data directory to mode `0700`. > Existing data directories that still contain `app.sqlite3` at the data-dir root are migrated to `db/rustpost.sqlite3` on startup. If both old and new database files exist, RustPost stops with a conflict error and does not overwrite either file. ### Create Your First Admin diff --git a/src/render.rs b/src/render.rs index bdf9c84..7655c60 100644 --- a/src/render.rs +++ b/src/render.rs @@ -2869,13 +2869,13 @@ main{padding:var(--shell-gap)}.app-shell{width:min(100%,var(--shell-max));margin .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} .character-counter{display:inline-block;flex:0 0 auto;min-width:8.5rem;text-align:right;white-space:nowrap;font-variant-numeric:tabular-nums}.character-counter-normal{color:var(--muted)}.character-counter-warning{color:var(--warning)}.character-counter-danger{color:var(--danger)} .notifications-hero{background:var(--surface);border:1px solid var(--border);border-radius:8px;margin:0 0 var(--section-gap);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}.notification-group{margin:var(--section-gap) .15rem 0;color:var(--muted);font-size:.82rem;text-transform:uppercase;letter-spacing:.08em}.notification-group:first-child{margin-top:0}.notification-row{position:relative;display:grid;grid-template-columns:auto minmax(0,1fr) auto 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],.js-enabled .notification-row[data-card-form]{cursor:pointer}.js-enabled .notification-row[data-card-href]:hover,.js-enabled .notification-row[data-card-form]: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-counts{color:var(--muted-strong);font-weight:800}.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}.notification-actors{margin:.45rem 0 0}.notification-actors summary{display:inline-flex;align-items:center;min-height:1.7rem;color:var(--link-strong);font-weight:800;cursor:pointer}.notification-actors ul{list-style:none;margin:.25rem 0 0;padding:0;display:flex;flex-wrap:wrap;gap:.35rem}.notification-actors li{border:1px solid var(--border);border-radius:999px;background:var(--surface-subtle);padding:.16rem .5rem;font-size:.88rem}.notification-open-form{display:flex;align-items:flex-start}.notification-open{min-height:2rem;padding:.3rem .55rem;background:var(--surface);border-color:var(--border)}.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)} +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:none;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{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:var(--section-gap)}.section-title{margin:.2rem 0 var(--section-gap);font-size:1.05rem;color:var(--text)}.search-results>.section-title{margin:0}.search-users{margin: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}button:disabled,.primary:disabled,button:disabled:hover,.primary:disabled:hover{border-color:var(--border);background:var(--surface-muted);color:var(--muted);cursor:not-allowed;opacity:1} 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{position:relative;border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);overflow:visible}.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)}.mention-menu[hidden]{display:none}.mention-menu{position:absolute;z-index:9;left:.55rem;right:.55rem;top:3.1rem;max-height:12rem;overflow:auto;border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);box-shadow:0 8px 24px var(--shadow);padding:.25rem}.mention-option{display:grid;width:100%;grid-template-columns:minmax(0,1fr) auto;gap:.65rem;align-items:center;border:0;border-radius:6px;background:transparent;color:var(--text);padding:.42rem .5rem;text-align:left}.mention-option:hover,.mention-option[aria-selected="true"]{background:var(--hover);color:var(--text-strong)}.mention-name{font-weight:800;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mention-handle{color:var(--muted);font-size:.9rem}.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} +.composer-surface{position:relative;border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);overflow:visible}.composer-surface textarea{border:0;border-radius:0;background:transparent;min-height:7rem;resize:none}.composer-surface textarea:focus{outline:0;box-shadow:inset 0 0 0 3px var(--focus)}.mention-menu[hidden]{display:none}.mention-menu{position:absolute;z-index:9;left:.55rem;right:.55rem;top:3.1rem;max-height:12rem;overflow:auto;border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);box-shadow:0 8px 24px var(--shadow);padding:.25rem}.mention-option{display:grid;width:100%;grid-template-columns:minmax(0,1fr) auto;gap:.65rem;align-items:center;border:0;border-radius:6px;background:transparent;color:var(--text);padding:.42rem .5rem;text-align:left}.mention-option:hover,.mention-option[aria-selected="true"]{background:var(--hover);color:var(--text-strong)}.mention-name{font-weight:800;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mention-handle{color:var(--muted);font-size:.9rem}.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 var(--section-gap) .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,.notifications-list,.account-list{display:grid;gap:var(--card-gap)}.timeline+.composer{margin-top:var(--section-gap)}.pinned-timeline{gap:var(--section-gap);margin-bottom:var(--section-gap)}.pinned-timeline>.section-title{margin:0}.post{margin:0;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:.5rem;margin:.35rem 0 .55rem}.youtube-preview-card{display:grid;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)}.youtube-preview-main{display:grid;grid-template-columns:minmax(5.5rem,7.5rem) minmax(0,1fr);gap:.65rem;align-items:center;min-height:4.5rem;color:inherit}.youtube-preview-main: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);box-shadow:0 1px 4px rgba(0,0,0,.35);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}.youtube-preview-actions{display:flex;justify-content:flex-end;border-top:1px solid var(--border);padding:.32rem .55rem}.youtube-open-link{color:var(--muted-strong);font-size:.84rem;font-weight:800}.youtube-player-frame{width:100%;aspect-ratio:16/9;background:#000}.youtube-preview-playing .youtube-preview-main{display:none}.youtube-iframe{display:block;width:100%;height:100%;border:0;background:#000}.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} .counts{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.3rem;min-height:1.4rem}.edited-marker{font-weight:800;color:var(--muted-strong)}.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}.profile-tabs{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:.35rem;margin:0 0 var(--section-gap);border-bottom:1px solid var(--border)}.profile-tabs a{display:flex;align-items:center;justify-content:center;min-height:2.6rem;border-radius:7px 7px 0 0;color:var(--muted-strong);font-weight:850}.profile-tabs a:hover{background:var(--hover);color:var(--text-strong);text-decoration:none}.profile-tabs a.active{color:var(--text-strong);background:var(--surface);box-shadow:inset 0 -3px 0 var(--brand)}.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)} diff --git a/src/runtime.rs b/src/runtime.rs index 5f54a00..6927386 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -92,6 +92,7 @@ impl RuntimePaths { })?; } self.migrate_legacy_database_layout()?; + restrict_dir(&self.data_dir)?; restrict_dir(&self.backups_dir)?; restrict_dir(&self.tor_dir)?; restrict_dir(&self.tor_onion_service_dir)?; @@ -282,6 +283,7 @@ mod tests { paths.ensure().expect("ensure paths"); for path in [ + &paths.data_dir, &paths.backups_dir, &paths.tor_dir, &paths.tor_onion_service_dir, From 1f6c58d08b7b3837ec36a08e0fcd378c6eebe9cd Mon Sep 17 00:00:00 2001 From: csd113 Date: Sat, 6 Jun 2026 10:27:34 -0700 Subject: [PATCH 06/21] Rename binary to rustpost-cli and release 1.0.0 Standardize the project on a single CLI binary named `rustpost-cli` and prepare the 1.0.0 release. Deleted the legacy `rustpost` binary and `[[bin]]` entry, updated Cargo.toml version to 1.0.0, and adjusted docs, help text, startup hints, and admin/terminal messages to reference `rustpost-cli`. Remove the deprecated PrintOnionAddress CLI command and its docs, and add a guard to reject restoring Tor keys unless the restore explicitly opts in (with a unit test). Also update the changelog (release text and DB baseline bump), and tweak the release workflow SHA256 generation command to write checksums from the dist directory. --- .github/workflows/release.yml | 2 +- CHANGELOG.md | 16 +++--- Cargo.lock | 2 +- Cargo.toml | 6 +-- README.md | 97 +++++++++++++++++++++-------------- src/admin.rs | 2 +- src/backup.rs | 6 +++ src/bin/rustpost.rs | 12 ----- src/cli.rs | 18 ++----- src/terminal.rs | 16 +++--- 10 files changed, 89 insertions(+), 88 deletions(-) delete mode 100644 src/bin/rustpost.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ec2b9aa..f06a38e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,7 +58,7 @@ jobs: if [ -f NOTICE ]; then cp NOTICE "$staging/"; fi if [ -f NOTICES ]; then cp NOTICES "$staging/"; fi tar -czf "dist/${{ matrix.archive }}" -C "dist/${{ matrix.name }}" rustpost - shasum -a 256 "dist/${{ matrix.archive }}" > "dist/${{ matrix.archive }}.sha256" + (cd dist && shasum -a 256 "${{ matrix.archive }}" > "${{ matrix.archive }}.sha256") - name: Upload release assets uses: softprops/action-gh-release@v2 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 89a0b63..808a72a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,12 @@ # Changelog -## v1.0.0-rc.1 - First-Release Hardening +## v1.0.0 - First Release ### Release Blockers Fixed -- Added the documented primary `rustpost` binary alongside `rustpost-cli` for release builds. -- Bumped the crate version to `1.0.0-rc.1` and refreshed `Cargo.lock`. -- Updated first-run command hints to use the primary `rustpost` binary. +- Standardized the release artifact, install docs, CI, and operator commands on the single `rustpost-cli` binary. +- Bumped the crate version to `1.0.0` and refreshed `Cargo.lock`. +- Updated first-run command hints to use the canonical `rustpost-cli` binary. +- Removed the nonfunctional standalone `print-onion-address` command; active onion addresses are reported by the running service. - Fixed login so existing stored passwords continue to work after an operator raises the configured minimum password length. - Rejected unsafe profile website URL schemes server-side and stopped rendering unsafe legacy profile website values as links. - Enforced configured per-post image and video attachment limits and raised the multipart body limit to cover valid configured media mixes. @@ -16,15 +17,16 @@ - Documented the accepted v1.0.0 `cargo audit` exception for transitive Arti `rsa 0.9.10` / `RUSTSEC-2023-0071`, for which no fixed upgrade is currently available. - Recorded unmaintained transitive Arti dependencies as upstream maintenance caveats rather than RustPost application vulnerabilities. - Kept Playwright files and generated artifacts ignored and out of the repository. +- Audited the release build, clean temporary-runtime first-run flow, restart persistence, backup/restore behavior, runtime permissions, and operator documentation before tagging. ### Database Lineage -- Squashed the pre-release internal migration chain into a clean first-release SQLite schema baseline at database schema version `1`. +- Squashed the pre-release internal migration chain into a clean first-release SQLite schema baseline at database schema version `2`. - Fresh databases are now initialized directly from the baseline instead of replaying alpha development migrations. -- Existing current alpha databases that structurally match the baseline are marked as baseline version `1` without destructive changes or data loss. +- Existing current alpha databases that structurally match the baseline are marked as baseline version `2` without destructive changes or data loss. - Incomplete, unknown, or structurally unsafe pre-release databases now fail closed with administrator guidance to back up/export/recreate/restore instead of attempting blind migration. - Added stricter schema diagnostics for required tables, columns, indexes, and triggers so startup, backups, restores, and admin health can report incompatible or corrupt database structure clearly. - `check` now reports database schema status without creating or migrating the database during diagnostics. -- Future post-release database changes should use normal forward migrations after baseline version `1`; released migration history must not be squashed or rewritten after the first public release. +- Future post-release database changes should use normal forward migrations after baseline version `2`; released migration history must not be squashed or rewritten after the first public release. ## v0.1.6 - Pinned Profiles diff --git a/Cargo.lock b/Cargo.lock index e6a5c56..e319437 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3224,7 +3224,7 @@ dependencies = [ [[package]] name = "rustpost" -version = "1.0.0-rc.1" +version = "1.0.0" dependencies = [ "anyhow", "argon2", diff --git a/Cargo.toml b/Cargo.toml index c7ac895..239051a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpost" -version = "1.0.0-rc.1" +version = "1.0.0" edition = "2024" license = "MIT" description = "A single-binary self-hosted microblogging app" @@ -10,10 +10,6 @@ keywords = ["microblog", "self-hosted", "sqlite", "axum"] categories = ["web-programming::http-server"] autobins = false -[[bin]] -name = "rustpost" -path = "src/bin/rustpost.rs" - [[bin]] name = "rustpost-cli" path = "src/bin/rustpost-cli.rs" diff --git a/README.md b/README.md index 22ad127..dd024f0 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ [![CI](https://img.shields.io/github/actions/workflow/status/csd113/RustPost/ci.yml?branch=main&style=flat-square&label=CI&logo=github)](https://github.com/csd113/RustPost/actions) [![Rust](https://img.shields.io/badge/rust-1.90%2B-orange?style=flat-square&logo=rust)](https://www.rust-lang.org/) [![SQLite](https://img.shields.io/badge/database-SQLite-blue?style=flat-square&logo=sqlite)](https://www.sqlite.org/) -[![Tor Ready](https://img.shields.io/badge/Tor-Arti%200.42.0-7D4698?style=flat-square&logo=torproject)](https://www.torproject.org/) +[![Embedded Arti](https://img.shields.io/badge/embedded%20Arti-0.42.0-7D4698?style=flat-square&logo=torproject)](https://www.torproject.org/) [![License](https://img.shields.io/badge/license-see%20LICENSE-green?style=flat-square)](./LICENSE) [**Getting Started**](#-getting-started) · [**Configuration**](#-configuration) · [**CLI Reference**](#-cli-reference) · [**Security**](#-security-model) · [**Tor / Arti**](#-tor--arti) @@ -31,7 +31,7 @@ RustPost is a **single-binary, self-hosted microblogging platform** written in R > **Design philosophy:** Small, auditable modules over framework magic. Security-sensitive behavior centralized. No JavaScript bundler. No cloud dependency. No federation surface. -This repository is a **production-oriented project** — not a toy, but not overengineered. The goal is a system you can read end to end, deploy in minutes, and trust. +This repository is intended for operators who want a small, understandable self-hosted service. Operators remain responsible for deployment hardening, monitoring, backups, upgrades, and deciding whether RustPost is appropriate for their environment. --- @@ -106,27 +106,43 @@ The screenshots above were captured from a local generated demo instance with fi - **Rust 1.90+** — install via [rustup](https://rustup.rs/) - **ffmpeg** *(optional)* — enables image and video conversion. RustPost boots and runs without it. -### Build +### Build from source ```sh cargo build --release ``` -This produces two binaries in `target/release/`: +This produces the release binary in `target/release/`: | Binary | Purpose | |---|---| -| `rustpost` | Primary server + CLI | -| `rustpost-cli` | Identical CLI surface — useful for running admin commands while the server is managed separately | +| `rustpost-cli` | Server and administration CLI | + +### Install a release archive + +Download the archive and matching `.sha256` file for your platform from the GitHub release, verify the checksum, then extract it. Unix archives contain `rustpost/rustpost-cli`; the Windows archive contains `rustpost/rustpost-cli.exe`. + +Example Unix user-local install: + +```sh +# Linux checksum verification +sha256sum -c rustpost-linux-x86_64.tar.gz.sha256 +# macOS checksum verification uses: shasum -a 256 -c .sha256 +tar -xzf rustpost-linux-x86_64.tar.gz +install -m 0755 rustpost/rustpost-cli "$HOME/.local/bin/rustpost-cli" +rustpost-cli --version +``` + +Use an explicit `--data-dir` for installed deployments. Without it, RustPost places `rustpost-data` beside the executable. ### First Run ```sh # Linux / macOS -./target/release/rustpost --data-dir ./rustpost-data serve +./target/release/rustpost-cli --data-dir ./rustpost-data serve # Windows (PowerShell) -.\target\release\rustpost.exe --data-dir .\rustpost-data serve +.\target\release\rustpost-cli.exe --data-dir .\rustpost-data serve ``` > If no subcommand is provided, `serve` is assumed. @@ -145,6 +161,7 @@ rustpost-data/ │ ├── images/ │ ├── videos/ │ └── thumbs/ +├── assets/ ├── tmp/ │ └── uploads/ ← interrupted upload staging only ├── backups/ @@ -159,18 +176,18 @@ rustpost-data/ ### Create Your First Admin -When `serve` starts, RustPost prints the data directory, settings path, database path, upload/media paths, log path, backup path, bind address, and whether an admin account exists. If `admin.create_admin_on_first_boot` is enabled and no admin exists, interactive startup enters a `Create admin account` step before the server begins accepting requests. If stdin is not interactive, RustPost prints the bootstrap commands instead of waiting for input. +If `admin.create_admin_on_first_boot` is enabled and no admin exists, interactive `serve` startup enters a `Create admin account` step before the server begins accepting requests. If stdin is not interactive, RustPost prints the bootstrap commands instead of waiting for input. Startup then prints the data directory, settings path, database path, upload/media paths, log path, backup path, bind address, local URL, and whether an admin account exists. You can also create the first admin explicitly. The preferred local setup path hides the password while you type: ```sh -./target/release/rustpost --data-dir ./rustpost-data create-admin-interactive +./target/release/rustpost-cli --data-dir ./rustpost-data create-admin-interactive ``` -For scripted deployments, the non-interactive command is still available. Be aware that command-line arguments can be visible to other local processes on some systems: +For scripted deployments, the non-interactive command is still available. After setting `RUSTPOST_ADMIN_PASSWORD` from the deployment's secret source, run the following. Be aware that command-line arguments can be visible to other local processes on some systems: ```sh -./target/release/rustpost --data-dir ./rustpost-data create-admin alice s3cr3tpassword +./target/release/rustpost-cli --data-dir ./rustpost-data create-admin alice "$RUSTPOST_ADMIN_PASSWORD" ``` Then open [http://127.0.0.1:8080](http://127.0.0.1:8080) and log in. @@ -188,18 +205,17 @@ Replies remain attached to their parent thread. They render inside the thread vi ## 🖥 CLI Reference ```sh -rustpost init # Initialize data directory -rustpost check # Validate config, data directory, and DB schema status -rustpost create-admin # Create an admin account -rustpost create-admin-interactive # Create an admin with hidden password prompts -rustpost reset-admin-password # Reset an admin's password -rustpost seed-demo # Seed a guarded local demo instance under target/debug/rustpost-demo -rustpost serve # Start the HTTP server (default) -rustpost backup # Create a backup archive -rustpost backup --include-tor-keys # Backup including Tor private keys -rustpost restore # Restore from a backup -rustpost restore --include-tor-keys # Restore including Tor keys -rustpost print-onion-address # Print an active address, or fail clearly if unavailable +rustpost-cli init # Initialize data directory +rustpost-cli check # Validate config, data directory, and DB schema status +rustpost-cli create-admin # Create an admin account +rustpost-cli create-admin-interactive # Create an admin with hidden password prompts +rustpost-cli reset-admin-password # Reset an admin's password +rustpost-cli seed-demo # Seed a guarded local demo instance under target/debug/rustpost-demo +rustpost-cli serve # Start the HTTP server (default) +rustpost-cli backup # Create a backup archive +rustpost-cli backup --include-tor-keys # Backup including Tor private keys +rustpost-cli restore # Restore from a backup +rustpost-cli restore --include-tor-keys # Restore including Tor keys ``` --- @@ -217,7 +233,7 @@ The visible site name is configured in `settings.toml`: 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. +Changing `site.name` updates the rendered browser title, header brand, footer, and user-facing site copy. The executable remains `rustpost-cli`; package names, cookie names, and data paths remain `rustpost` for compatibility. ### Account creation @@ -321,6 +337,9 @@ anonymous_posts_per_ip_per_hour = 10 RustPost embeds [Arti](https://gitlab.torproject.org/tpo/core/arti) (the Rust Tor implementation) directly in the binary. No external `tor` daemon required. +Embedded Arti provides an onion-service transport option, not an anonymity or security guarantee. Real onion reachability depends on Tor network access, bootstrap, descriptor publication, and client routing; verify reachability from a separate Tor client before relying on it. +The active onion address is shown by the running server in its startup/status output, public header, and admin health page; it is not derived from configuration alone. + **Behavior by config:** | `tor.enabled` | `tor_only` | Behavior | @@ -329,7 +348,7 @@ RustPost embeds [Arti](https://gitlab.torproject.org/tpo/core/arti) (the Rust To | `true` | `false` | Clearnet binds immediately. Arti onion service starts in background. If Tor fails, clearnet keeps running and admin health reports the error. | | `true` | `true` | Only a loopback listener is bound for Arti forwarding. Startup **fails** if Arti/onion startup fails. | -**Arti crate versions (Arti 2.3.0 / 2026-05-07):** +**Pinned Arti crate versions:** ``` arti-client = 0.42.0 # bootstraps the embedded Tor client and onion service @@ -340,7 +359,7 @@ tor-rtcompat = 0.42.0 # Tokio-compatible Arti runtime rustls = 0.23 # ring crypto provider required by Arti's rustls stack ``` -> **Dependency note:** `cargo tree -i libsqlite3-sys` should show exactly one version. RustPost uses `rusqlite` rather than `sqlx 0.9` (alpha) specifically to keep the `libsqlite3-sys` dependency unified with the Arti family. +> **Dependency note:** `cargo tree -i libsqlite3-sys` should show exactly one version. RustPost uses `rusqlite` specifically to keep the `libsqlite3-sys` dependency unified with the Arti family. **Tor data layout:** @@ -354,10 +373,10 @@ rustpost-data/tor/ **Backups and Tor keys:** ```sh -rustpost backup # excludes Tor keys (safe default) -rustpost backup --include-tor-keys # opt-in to include keys -rustpost restore archive.tar # rejects Tor key paths unless flag given -rustpost restore archive.tar --include-tor-keys +rustpost-cli backup # excludes Tor keys (safe default) +rustpost-cli backup --include-tor-keys # opt-in to include keys +rustpost-cli restore archive.tar # rejects Tor key paths unless flag given +rustpost-cli restore archive.tar --include-tor-keys ``` On Unix, the backup directory is mode `0700` and created backup archives are mode `0600`. @@ -387,16 +406,16 @@ Profile pictures and banners follow the same media pipeline as post uploads. ```sh # Create a backup (SQLite DB snapshot + settings + media/assets) -rustpost backup +rustpost-cli backup # Include Tor onion-service keys -rustpost backup --include-tor-keys +rustpost-cli backup --include-tor-keys # Restore into a fresh data directory -rustpost restore rustpost-20260526T....tar +rustpost-cli restore rustpost-20260526T....tar # Restore including Tor keys -rustpost restore rustpost-20260526T....tar --include-tor-keys +rustpost-cli restore rustpost-20260526T....tar --include-tor-keys ``` Backups are also available from **Admin → Backups**. The page supports manual backup creation, admin-only downloads, restore from uploaded `.tar` archives, automatic backup settings, safe retention controls, recent archive history, and no-JS form flows. @@ -448,7 +467,7 @@ Run the app locally with a disposable data directory: ```sh cargo build --workspace --all-features -./target/debug/rustpost --data-dir /tmp/rustpost-ui serve +./target/debug/rustpost-cli --data-dir /tmp/rustpost-ui serve ``` Then open [http://127.0.0.1:8080](http://127.0.0.1:8080). The server initializes the data directory on first boot. @@ -475,7 +494,7 @@ rustpost-macos-aarch64.tar.gz rustpost-windows-x86_64.zip ``` -Each archive contains `rustpost`, `rustpost-cli`, `README.md`, `LICENSE`, and optional notice files. A `.sha256` checksum is generated for each archive. Runtime data (databases, uploads, backups, logs, Tor keys) is never included. +Each archive contains `rustpost-cli` (`rustpost-cli.exe` on Windows), `README.md`, `LICENSE`, and optional notice files. A `.sha256` checksum is generated for each archive. Runtime data (databases, uploads, backups, logs, Tor keys) is never included. --- @@ -527,7 +546,7 @@ The format, release build, strict Clippy, and test commands must pass. `cargo au Verified locally - Fresh `--data-dir` boot creates `settings.toml`, `db/rustpost.sqlite3`, upload roots, temp upload staging, backup/log dirs, and Tor state dirs. -- `rustpost check` passes on a fresh data directory with `tor.enabled = false`. +- `rustpost-cli check` passes on a fresh data directory with `tor.enabled = false`. - Clearnet serving on `127.0.0.1:8080` loads `/home`. - Registration with CAPTCHA, login, post creation, replies, quote reposts, repost rendering, likes, bookmarks, followers/following pages, notifications, admin health, and CSRF-protected logout all work through live HTTP/browser flows. - Anonymous posting is disabled by default — anonymous users cannot see the composer and anonymous post attempts are rejected. @@ -536,7 +555,7 @@ The format, release build, strict Clippy, and test commands must pass. `cargo au - Normal backups include DB, settings, and media; exclude Tor keys. `--include-tor-keys` includes them only when explicitly requested. Restores into fresh data directories completed and `check` passed with and without controlled test Tor key material. - Backup archive names include subsecond precision — no same-second overwrite collisions. - Tor health/status fields render in admin health with Tor disabled in the current local sweep. -- Both `rustpost` and `rustpost-cli` pass `check` on a fresh data directory. +- `rustpost-cli --version`, `rustpost-cli --help`, and `rustpost-cli check` report the final release CLI and pass on a fresh data directory.
diff --git a/src/admin.rs b/src/admin.rs index 53b7ffd..963393a 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -1208,7 +1208,7 @@ pub async fn ensure_first_boot_admin_hint(pool: &SqlitePool) -> anyhow::Result<( let count = admin_count(pool).await?; if count == 0 { tracing::warn!( - "no admin account exists; run `rustpost create-admin-interactive` or `rustpost create-admin `" + "no admin account exists; run `rustpost-cli create-admin-interactive` or `rustpost-cli create-admin `" ); } Ok(()) diff --git a/src/backup.rs b/src/backup.rs index b619bce..ba51bbe 100644 --- a/src/backup.rs +++ b/src/backup.rs @@ -1037,6 +1037,9 @@ fn validate_archive_path_text( } fn validate_allowed_archive_path(path: &str, include_tor_keys: bool) -> anyhow::Result<()> { + if !include_tor_keys && (path == "tor" || path.starts_with("tor/")) { + anyhow::bail!("archive contains Tor keys but restore did not opt in"); + } if path == MANIFEST_PATH || path == "settings.toml" || path == "db" @@ -1288,6 +1291,9 @@ mod tests { assert!(validate_archive_path(Path::new("uploads/∕/x"), false).is_err()); assert!(validate_archive_path(Path::new("tor/onion-service/key"), false).is_err()); assert!(validate_archive_path(Path::new("tor/onion-service/key"), true).is_ok()); + let tor_error = + validate_allowed_archive_path("tor/onion-service", false).expect_err("Tor opt-in"); + assert!(tor_error.to_string().contains("restore did not opt in")); } #[test] diff --git a/src/bin/rustpost.rs b/src/bin/rustpost.rs deleted file mode 100644 index 5cfd75d..0000000 --- a/src/bin/rustpost.rs +++ /dev/null @@ -1,12 +0,0 @@ -#![allow(clippy::multiple_crate_versions)] - -use std::io::Write as _; - -#[tokio::main] -async fn main() { - if let Err(error) = rustpost::cli::run().await { - let mut stderr = std::io::stderr().lock(); - let _write_result = stderr.write_all(rustpost::terminal::render_error(&error).as_bytes()); - std::process::exit(1); - } -} diff --git a/src/cli.rs b/src/cli.rs index 613ff17..103cda2 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -14,8 +14,10 @@ use crate::{admin, backup, config, db, demo_seed, logging, runtime, server, term #[derive(Debug, Parser)] #[command( + name = "rustpost-cli", + version, about = "Single-binary self-hosted microblog", - after_help = "Common first run:\n rustpost init\n rustpost create-admin-interactive\n rustpost serve" + after_help = "Common first run:\n rustpost-cli init\n rustpost-cli create-admin-interactive\n rustpost-cli serve" )] struct Cli { /// Path to settings.toml. Defaults to /settings.toml. @@ -82,10 +84,6 @@ enum Command { include_tor_keys: bool, }, - /// Print only the configured onion address when one is available. - #[command(about = "Print the onion address when one is available")] - PrintOnionAddress, - /// Start the `RustPost` web server. #[command(about = "Start the RustPost web server")] Serve, @@ -171,14 +169,6 @@ async fn run_command( archive, include_tor_keys, } => restore_command(&paths, &archive, include_tor_keys), - Command::PrintOnionAddress => { - let status = tor::validate_startup(&settings.tor); - let onion = status.onion_address().context( - "onion address unavailable: use the running site's public header or admin health", - )?; - stdout_line(format_args!("{onion}"))?; - Ok(()) - } Command::Serve => serve(paths, settings_path, settings).await, } } @@ -232,7 +222,7 @@ async fn create_admin_command( terminal::row( "Next command", format!( - "rustpost --data-dir {} serve", + "rustpost-cli --data-dir {} serve", database.paths.data_dir.display() ), ), diff --git a/src/terminal.rs b/src/terminal.rs index fbe41f4..b426fb5 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -157,7 +157,7 @@ pub fn render_check( push_section( &mut out, "Next commands", - &[row("Start server", "rustpost serve")], + &[row("Start server", "rustpost-cli serve")], ); out.push_str(RULE); out.push('\n'); @@ -187,14 +187,14 @@ pub fn render_init(paths: &runtime::RuntimePaths, settings_path: &Path) -> Strin row( "Create admin", format!( - "rustpost --data-dir {} create-admin-interactive", + "rustpost-cli --data-dir {} create-admin-interactive", display(paths.data_dir.as_path()) ), ), row( "Start server", format!( - "rustpost --data-dir {} serve", + "rustpost-cli --data-dir {} serve", display(paths.data_dir.as_path()) ), ), @@ -246,14 +246,14 @@ pub fn render_first_admin_non_interactive(paths: &runtime::RuntimePaths) -> Stri row( "Create admin", format!( - "rustpost --data-dir {} create-admin-interactive", + "rustpost-cli --data-dir {} create-admin-interactive", display(paths.data_dir.as_path()) ), ), row( "Alternative", format!( - "rustpost --data-dir {} create-admin ", + "rustpost-cli --data-dir {} create-admin ", display(paths.data_dir.as_path()) ), ), @@ -342,7 +342,7 @@ fn next_command_rows(input: &StartupDashboard<'_>) -> Vec { rows.push(row( "Create admin", format!( - "rustpost --data-dir {} create-admin-interactive", + "rustpost-cli --data-dir {} create-admin-interactive", display(input.paths.data_dir.as_path()) ), )); @@ -350,7 +350,7 @@ fn next_command_rows(input: &StartupDashboard<'_>) -> Vec { rows.push(row( "Check config", format!( - "rustpost --data-dir {} check", + "rustpost-cli --data-dir {} check", display(input.paths.data_dir.as_path()) ), )); @@ -556,7 +556,7 @@ mod tests { assert!(output.contains(&logs_dir)); assert!(output.contains(&backups_dir)); assert!(output.contains(&format!( - "rustpost --data-dir {data_dir} create-admin-interactive" + "rustpost-cli --data-dir {data_dir} create-admin-interactive" ))); assert!(output.contains("FFmpeg")); assert!(output.contains("[WARN] unavailable")); From d1ee6e8d3825da574d438250934030e3ca396279 Mon Sep 17 00:00:00 2001 From: csd113 Date: Sat, 6 Jun 2026 23:37:36 -0700 Subject: [PATCH 07/21] Upgrade Arti and Tor crates to 0.43.0 --- Cargo.lock | 295 ++++++++++++++++++++++++++++++----------------------- Cargo.toml | 10 +- src/tor.rs | 1 - 3 files changed, 172 insertions(+), 134 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e319437..8cb2597 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -160,9 +160,9 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "arti-client" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16739ee6ad4ee11073a8d12c34a911ea72977c5ce4abbb21a1f7cf5955f22a01" +checksum = "7ffc89b08e6a475a83d70e52f0ede139a433eb46e986a6cee6eeaa1b73297bf1" dependencies = [ "async-trait", "cfg-if", @@ -178,7 +178,7 @@ dependencies = [ "libc", "once_cell", "postage", - "rand 0.9.4", + "rand 0.10.1", "safelog", "serde", "tempfile", @@ -576,6 +576,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.45" @@ -1427,35 +1438,15 @@ dependencies = [ "walkdir", ] -[[package]] -name = "fslock" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04412b8935272e3a9bae6f48c7bfff74c2911f60525404edfdd28e49884c3bfb" -dependencies = [ - "libc", - "winapi", -] - -[[package]] -name = "fslock-arti-fork" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b21bd626aaab7b904b20bef6d9e06298914a0c8d9fb8b010483766b2e532791" -dependencies = [ - "libc", - "winapi", -] - [[package]] name = "fslock-guard" -version = "0.6.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75f62cb7d296f7d1fabdce7291281d9f0147b06a6e79afae05e1230eab667d85" +checksum = "e77b8a36ee17f9079b53db7281de9b16a8fb2bc97a7cd1e41a2998f3a837af7a" dependencies = [ - "fslock-arti-fork", + "libc", "thiserror 2.0.18", - "winapi", + "windows-sys 0.61.2", ] [[package]] @@ -1611,6 +1602,7 @@ dependencies = [ "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", "wasm-bindgen", @@ -2939,6 +2931,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -2959,6 +2962,16 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_chacha" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb" +dependencies = [ + "ppv-lite86", + "rand_core 0.10.1", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -2977,15 +2990,21 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_jitter" -version = "0.5.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b16df48f071248e67b8fc5e866d9448d45c08ad8b672baaaf796e2f15e606ff0" +checksum = "3fdcd80e68f0a8f9ca5ec7cfd02fd5fbb8fbe6ef4e9b90ea2f48bb929b74f88e" dependencies = [ "libc", - "rand_core 0.9.5", - "winapi", + "rand_core 0.10.1", + "windows-sys 0.59.0", ] [[package]] @@ -3066,6 +3085,15 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reseeding_rng" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35d8fa137e1f0bbc1139893fcf4fff5f099d76658e6da2b10fadd04f0cadc2d4" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "retry-error" version = "0.12.0" @@ -4130,9 +4158,9 @@ checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tor-async-utils" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ee2d3e0d74d393dea1c73e5e3ca6a0f0c7920c21b4c72ae9820bc85ba86db2b" +checksum = "27fc83e7539ac5f717b722456df991ca8819782d04f7ae2ee28eefacca030eef" dependencies = [ "derive-deftly", "educe", @@ -4146,9 +4174,9 @@ dependencies = [ [[package]] name = "tor-basic-utils" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a20878eacdfbcd1fed2ee27092e66ccee2df871e0a6b92b6658efd59f739230" +checksum = "7bd01b9947f57a99193547f3a40a0bd179e9a332ead855c07e2fb702a1517f74" dependencies = [ "derive_more", "getrandom 0.4.2", @@ -4156,8 +4184,8 @@ dependencies = [ "itertools", "libc", "paste", - "rand 0.9.4", - "rand_chacha 0.9.0", + "rand 0.10.1", + "rand_chacha 0.10.0", "serde", "slab", "smallvec", @@ -4168,9 +4196,9 @@ dependencies = [ [[package]] name = "tor-bytes" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "428748cf8cf3f2bd1a4cc30bab33512b1a7d3f8b1f2abd02e39cbcbbebc05aed" +checksum = "de6a7af55881d599cd48c841916520318d5711df17514001aafd5f796f61e337" dependencies = [ "bytes", "derive-deftly", @@ -4186,9 +4214,9 @@ dependencies = [ [[package]] name = "tor-cell" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9efd607ea595674678f73ebf77c245caf04e1d229eae8caf58d2989ff6c63959" +checksum = "dad292658d194b145aaec268ffb91566b7d96c98971eea4980c1bf951d7d082b" dependencies = [ "amplify", "bitflags 2.12.1", @@ -4199,7 +4227,7 @@ dependencies = [ "educe", "itertools", "paste", - "rand 0.9.4", + "rand 0.10.1", "smallvec", "thiserror 2.0.18", "tor-basic-utils", @@ -4217,9 +4245,9 @@ dependencies = [ [[package]] name = "tor-cert" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6baf9c4f05d2c90e46fcb404039c2fe9628c2b56ef0101bb7bcb5f8e8125c8b1" +checksum = "474e732207b1be44f9ae4ddf295fba3644cd3c09494d131846ff9c9a624ab263" dependencies = [ "caret", "derive_builder_fork_arti", @@ -4235,9 +4263,9 @@ dependencies = [ [[package]] name = "tor-chanmgr" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22ec7e9542c0100f974b0953f3c497755044d46a07fec5e23c1738fb5c5036c9" +checksum = "b2bb088804f54de320ee97dd2a48ae319f0df9d9d0e37eef050d65488194a768" dependencies = [ "async-trait", "base64ct", @@ -4251,7 +4279,7 @@ dependencies = [ "oneshot-fused-workaround", "percent-encoding", "postage", - "rand 0.9.4", + "rand 0.10.1", "safelog", "serde", "serde_with", @@ -4278,9 +4306,9 @@ dependencies = [ [[package]] name = "tor-checkable" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b54e6c8c206b5fc1d49bfb000a19d3feb1b69650eb99bd51d68964882827527" +checksum = "9505791bac25b31b8de88c0e2b02e1690e10ff1308583c6f61c320bab2677959" dependencies = [ "humantime", "signature", @@ -4291,9 +4319,9 @@ dependencies = [ [[package]] name = "tor-circmgr" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2402d71b92ff5221497713f7fdb124bd3861213382f7dee5cd93dd4c98ef5abf" +checksum = "7e986c7c4e1faae8a412b575ce66ff087050525a371db1034f0b24d70b459a2e" dependencies = [ "amplify", "async-trait", @@ -4310,7 +4338,7 @@ dependencies = [ "once_cell", "oneshot-fused-workaround", "pin-project", - "rand 0.9.4", + "rand 0.10.1", "retry-error", "safelog", "serde", @@ -4341,9 +4369,9 @@ dependencies = [ [[package]] name = "tor-config" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b63f57706747de9221c97785c6dca52231af9c798cbcc49ac5c8bf1a70a69ea" +checksum = "224f85aa02b514c384ea5ec32f1b924233bea6b553195fd83da7f1e50a2e0d1b" dependencies = [ "amplify", "cfg-if", @@ -4375,9 +4403,9 @@ dependencies = [ [[package]] name = "tor-config-path" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6010b61d49d2fa3f02957c237a8c70bf37f100e9408473965e67ce7d834e8d9" +checksum = "4232a56a8ce40a47ad6b42c9c7d9e6f4485e6e6a43b3149304c35bc295034107" dependencies = [ "directories", "serde", @@ -4389,9 +4417,9 @@ dependencies = [ [[package]] name = "tor-consdiff" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "499d69ee59f762ed7c4a68d87af2f1bc8a03735d43649217598b339e3031631f" +checksum = "d5fcd0c631a66b45dca4421b8a06309dd02ae5d6aea5daa55399f68638b2abf3" dependencies = [ "derive_more", "digest 0.10.7", @@ -4406,9 +4434,9 @@ dependencies = [ [[package]] name = "tor-dirclient" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d30d7a9a7b17ea5828dc171046f3a29b8e10b5c20a1ade257f744386f25aa4" +checksum = "bf637d6a5d8c886f23ca2df50edfaa56443f23294428f037206fb3a0d07ba57e" dependencies = [ "async-compression", "base64ct", @@ -4435,9 +4463,9 @@ dependencies = [ [[package]] name = "tor-dircommon" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4988c5af5d57fd6590ae7a50015366ebd54fbf2411247ff5a2346119493a078" +checksum = "35e412167d54c3618c3261dc77f1a31fe20c518f59aca5abfdec5f600b6a9b1f" dependencies = [ "base64ct", "derive-deftly", @@ -4456,9 +4484,9 @@ dependencies = [ [[package]] name = "tor-dirmgr" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adeefa259f7d03ceb29bfbb631b786361fcba6234676d2f8ff71ae1820748f04" +checksum = "9b984d2b0f0a2764a1b24d7c4c203ef83600b071c0c5af36e20d4f63e80ce562" dependencies = [ "async-trait", "base64ct", @@ -4468,7 +4496,7 @@ dependencies = [ "educe", "event-listener", "fs-mistrust", - "fslock", + "fslock-guard", "futures", "hex", "humantime", @@ -4478,7 +4506,7 @@ dependencies = [ "oneshot-fused-workaround", "paste", "postage", - "rand 0.9.4", + "rand 0.10.1", "rusqlite", "safelog", "scopeguard", @@ -4513,9 +4541,9 @@ dependencies = [ [[package]] name = "tor-error" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd6728477fffe0d1ce98410635b1c690221e3d1d596e436aafa46d6a255d6466" +checksum = "1409b1a9085502c7797405a01fe3f3292a9827c9ed6a3f7c34fbb222572db5ac" dependencies = [ "derive_more", "futures", @@ -4531,9 +4559,9 @@ dependencies = [ [[package]] name = "tor-general-addr" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "163dd5f727f4e98975dbb9f4ddc3e6129f0008985686334d5c5ff86b4733d112" +checksum = "09f17e219140e587e169f71f53e528ca394c39e83593c9fbe9e9366cce7655e1" dependencies = [ "derive_more", "thiserror 2.0.18", @@ -4542,9 +4570,9 @@ dependencies = [ [[package]] name = "tor-guardmgr" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4cc40821c4b68f376a443ddd8530314efa1ebd38a1434b3ddb0c8555d2be972" +checksum = "d56c43d7afa526449c6e6460d373a02624a24c78637ddd7c68e2ac1879ae587c" dependencies = [ "amplify", "base64ct", @@ -4561,7 +4589,7 @@ dependencies = [ "oneshot-fused-workaround", "pin-project", "postage", - "rand 0.9.4", + "rand 0.10.1", "safelog", "serde", "strum", @@ -4586,9 +4614,9 @@ dependencies = [ [[package]] name = "tor-hsclient" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc8df5e4ff97bc4086993c0943687f35f4565b0f767029c75ed46780a584f1eb" +checksum = "1ff8ba0afebc5f76b42240f76b2bfb92aa5862c19ff184492c59c654750ee275" dependencies = [ "async-trait", "derive-deftly", @@ -4599,7 +4627,7 @@ dependencies = [ "itertools", "oneshot-fused-workaround", "postage", - "rand 0.9.4", + "rand 0.10.1", "retry-error", "safelog", "slotmap-careful", @@ -4631,9 +4659,9 @@ dependencies = [ [[package]] name = "tor-hscrypto" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35f1db46a8bd6307f104dcc0a11f56a5b05f5823cc9d11e1748402daa940d3c7" +checksum = "3761c1828e74818cba4eb661d1eb79d4f663b05e8e7d3bc6e35d754968282fc0" dependencies = [ "cipher", "data-encoding", @@ -4644,7 +4672,7 @@ dependencies = [ "humantime", "itertools", "paste", - "rand 0.9.4", + "rand 0.10.1", "safelog", "serde", "signature", @@ -4664,9 +4692,9 @@ dependencies = [ [[package]] name = "tor-hsservice" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8aba207bceef6973f0607e92ee5606a001422c52a2c85ee95bf3964d8099c4ee" +checksum = "5fa87ed8d1ccfe8447b4e65bf0e2dfcab6a8ac9d2eadf7b3f37c102e1c18e9bb" dependencies = [ "amplify", "async-trait", @@ -4687,8 +4715,8 @@ dependencies = [ "once_cell", "oneshot-fused-workaround", "postage", - "rand 0.9.4", - "rand_core 0.9.5", + "rand 0.10.1", + "rand_core 0.10.1", "retry-error", "safelog", "serde", @@ -4723,15 +4751,15 @@ dependencies = [ [[package]] name = "tor-key-forge" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d9ae1ee46f439cfeb934bb1815ec460b94c1b6fce51b3b378d5253a1f99ce5b" +checksum = "86c877b95e0679f151743bc2b706320a29e14bd18d9bcb92918df34948e2e9d2" dependencies = [ "derive-deftly", "derive_more", "downcast-rs", "paste", - "rand 0.9.4", + "rand 0.10.1", "rsa", "signature", "ssh-key-fork-arti", @@ -4745,9 +4773,9 @@ dependencies = [ [[package]] name = "tor-keymgr" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0e968a3720ae845d1e4142d86b194cfa34c1e6f1c81eef29a5a285fc897f809" +checksum = "5f59fbec28e31d688822b4c00a6f7fd94539886baccd3ce1b35dc26699c37873" dependencies = [ "amplify", "arrayvec", @@ -4762,7 +4790,7 @@ dependencies = [ "humantime", "inventory", "itertools", - "rand 0.9.4", + "rand 0.10.1", "safelog", "serde", "signature", @@ -4786,9 +4814,9 @@ dependencies = [ [[package]] name = "tor-linkspec" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "048c5fe9e697c76f6326d73255ae9cc107a6b4f161b8b552f25f10157e31f85e" +checksum = "0e1cc8d5469c324d1e0761066d868363d3fdccb723b14cdc983a5aef9b67981b" dependencies = [ "base64ct", "by_address", @@ -4813,9 +4841,9 @@ dependencies = [ [[package]] name = "tor-llcrypto" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01a40374cd6525a416d5131afeb7b2d9ea9ce838f32bd6175032b5a43a507c16" +checksum = "d0015d521f5e30a2f74879fe4c615fc2c3ace822d7cc88c1dfed0ae816b25f15" dependencies = [ "aes", "base64ct", @@ -4831,12 +4859,13 @@ dependencies = [ "getrandom 0.3.4", "getrandom 0.4.2", "hex", - "rand 0.9.4", - "rand_chacha 0.9.0", + "rand 0.10.1", + "rand_chacha 0.10.0", + "rand_core 0.10.1", "rand_core 0.6.4", - "rand_core 0.9.5", "rand_jitter", "rdrand", + "reseeding_rng", "rsa", "safelog", "serde", @@ -4855,9 +4884,9 @@ dependencies = [ [[package]] name = "tor-log-ratelim" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1ef1a9811152abad23de9783d21f72a1a89cd0655a8fae6f61e9b975679fce6" +checksum = "7d16152b21f2e7b309dfbdcbe587c5adba9547d3119f2933b453d6edbce7e8e0" dependencies = [ "futures", "humantime", @@ -4871,9 +4900,9 @@ dependencies = [ [[package]] name = "tor-memquota" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f170000c5fe0bef7f476b0b683a478e34b70f0ed6c2ad69d4b672aa93c101aa5" +checksum = "38ad2950d88e968572b0de6bb5702f5ed42f36555f362fb741c572c62441476e" dependencies = [ "cfg-if", "derive-deftly", @@ -4902,9 +4931,9 @@ dependencies = [ [[package]] name = "tor-memquota-cost" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17475dcc5d5ddf008043c138a38ec70a8448c9509e099fa86be09ab92ee3601e" +checksum = "993ad5bfd39fb629990da40c2b2086284b1481b7d4843652a959db1d9e5ac463" dependencies = [ "derive-deftly", "itertools", @@ -4914,9 +4943,9 @@ dependencies = [ [[package]] name = "tor-netdir" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1351ffe2fff8bad7d5f2b79ec88ea188e159fe94cf705b1e485d8566526a7b5" +checksum = "6700c5e590818d9873a13e06b49b95cf3a39fa8fb3968bd67ec3295cd584b97c" dependencies = [ "async-trait", "bitflags 2.12.1", @@ -4927,7 +4956,7 @@ dependencies = [ "humantime", "itertools", "num_enum", - "rand 0.9.4", + "rand 0.10.1", "serde", "strum", "thiserror 2.0.18", @@ -4947,9 +4976,9 @@ dependencies = [ [[package]] name = "tor-netdoc" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505b69e589e042276da708f7baa44d9f16ec50074a3f54ebb3bcbbcd6c4d9aa" +checksum = "a497ce4c36f59e4c67c352e3b80cd4642f6c5e660bb003de1922be4c7ed03081" dependencies = [ "amplify", "base64ct", @@ -4967,7 +4996,7 @@ dependencies = [ "memchr", "paste", "phf", - "rand 0.9.4", + "rand 0.10.1", "saturating-time", "serde", "serde_with", @@ -4996,16 +5025,15 @@ dependencies = [ [[package]] name = "tor-persist" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d574ac9a23c15d55d77a7c472294e762a4373bf0030e19d192eb523f1393ea60" +checksum = "b6da1a96a1f6418faac1cd1892e029fd265cf50e67e941a4ba2580d92dd32d47" dependencies = [ "amplify", "derive-deftly", "derive_more", "filetime", "fs-mistrust", - "fslock", "fslock-guard", "futures", "itertools", @@ -5026,9 +5054,9 @@ dependencies = [ [[package]] name = "tor-proto" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c97a19e322dab933540e50ab0b987e7f9946394b4e2884976ba4d371562e531" +checksum = "a0a5fa0e1a365fc089a7f140f4142379dd74c91c15103c2f9d11ea711e16029e" dependencies = [ "amplify", "async-trait", @@ -5054,8 +5082,8 @@ dependencies = [ "oneshot-fused-workaround", "pin-project", "postage", - "rand 0.9.4", - "rand_core 0.9.5", + "rand 0.10.1", + "rand_core 0.10.1", "safelog", "slotmap-careful", "smallvec", @@ -5093,9 +5121,9 @@ dependencies = [ [[package]] name = "tor-protover" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2afba54106ccf364d8d422d7e33510e21e8004129574609ba26d2d8a2da1b81" +checksum = "c4f7f4ee15c9bfa0b700ae06e85fa52abddf17a35531cd3d3833412900e8da5c" dependencies = [ "caret", "paste", @@ -5107,11 +5135,11 @@ dependencies = [ [[package]] name = "tor-relay-selection" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ede9f32476abff4c61843bf3eba06ce03751fc62f472e0d9622185a5bfd1b009" +checksum = "5b565b6dec2e3edb7b033203a3662edb7b586e486d53e02af4295e05e7562870" dependencies = [ - "rand 0.9.4", + "rand 0.10.1", "serde", "tor-basic-utils", "tor-linkspec", @@ -5121,15 +5149,17 @@ dependencies = [ [[package]] name = "tor-rtcompat" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4cbf776b2c52614b0ebaec7c2b93782bb5b61233f474ae46331976e38d8bbdc" +checksum = "fcd7a4ff4898044b3ec693c0bf098251accf13c58741c44ed9364ce5faa89287" dependencies = [ + "amplify", "async-trait", "async_executors", "asynchronous-codec", "cfg-if", "coarsetime", + "derive_builder_fork_arti", "derive_more", "dyn-clone", "educe", @@ -5156,9 +5186,9 @@ dependencies = [ [[package]] name = "tor-rtmock" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a733ab3432e24f36b6f6680cc92413c61bb9a2041e09a17ac465cc3f2a788e5" +checksum = "8331e7d57bf7cf157cf3610ff6dbb5a591de5c3b22fa65ea6be18b08ff81477a" dependencies = [ "amplify", "assert_matches", @@ -5186,9 +5216,9 @@ dependencies = [ [[package]] name = "tor-socksproto" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b29d42752715cdf679ff7537f26457e2aabeabb682deec98beb79f01c8ee7bd" +checksum = "55f432eda94343bdadd73995f0b3082d96637dadb0e20c3f9a1bf2349f623d3c" dependencies = [ "amplify", "caret", @@ -5203,9 +5233,9 @@ dependencies = [ [[package]] name = "tor-units" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527324d70bf67aadaa23fb6ebc585aae5ab7df1b8d16f9d361861d2c4c50dcee" +checksum = "8cf033ee8b22b11abd8272272e19ccbbf58c7b3152f8d224aa8d21f358c43d0d" dependencies = [ "derive-deftly", "derive_more", @@ -5820,6 +5850,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" diff --git a/Cargo.toml b/Cargo.toml index 239051a..5e30079 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ path = "src/bin/rustpost-cli.rs" [dependencies] anyhow = "1.0" -arti-client = { version = "=0.42.0", default-features = false, features = ["tokio", "rustls", "onion-service-service"] } +arti-client = { version = "=0.43.0", default-features = false, features = ["tokio", "rustls", "onion-service-service"] } argon2 = "0.5" axum = { version = "0.8.9", features = ["multipart"] } base64 = "0.22" @@ -39,10 +39,10 @@ tempfile = "3.23" thiserror = "2.0" tokio = { version = "1.48", features = ["macros", "rt-multi-thread", "fs", "process", "signal", "net", "time", "io-util", "sync"] } toml = "0.9" -tor-cell = "=0.42.0" -tor-hsservice = "=0.42.0" -tor-proto = { version = "=0.42.0", features = ["tokio"] } -tor-rtcompat = { version = "=0.42.0", default-features = false, features = ["tokio", "rustls"] } +tor-cell = "=0.43.0" +tor-hsservice = "=0.43.0" +tor-proto = { version = "=0.43.0", features = ["tokio"] } +tor-rtcompat = { version = "=0.43.0", default-features = false, features = ["tokio", "rustls"] } tower-http = { version = "0.6", features = ["fs", "trace"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/src/tor.rs b/src/tor.rs index 833d21f..4ca72ac 100644 --- a/src/tor.rs +++ b/src/tor.rs @@ -264,7 +264,6 @@ async fn start_inner( ) })? .context("bootstrap Arti client")?; - let client = Arc::new(client); let bootstrap_status = Some(format!("{:?}", client.bootstrap_status())); let nickname = HsNickname::new(settings.onion_service_name.clone()) From d2f9c7825cc594601411ac2e2f777d4765e4e327 Mon Sep 17 00:00:00 2001 From: csd113 Date: Sat, 6 Jun 2026 23:46:56 -0700 Subject: [PATCH 08/21] Upgrade compatible Rust dependencies --- Cargo.lock | 102 ++++++++++++++++++------------------ Cargo.toml | 51 +++++++++--------- src/backup.rs | 12 ++++- src/media.rs | 12 ++++- src/registration_captcha.rs | 13 +++-- 5 files changed, 104 insertions(+), 86 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8cb2597..2400925 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -435,9 +435,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.12.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bitvec" @@ -480,6 +480,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bs58" version = "0.5.1" @@ -1078,7 +1087,7 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", + "block-buffer 0.10.4", "const-oid 0.9.6", "crypto-common 0.1.7", "subtle", @@ -1090,6 +1099,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ + "block-buffer 0.12.0", "const-oid 0.10.2", "crypto-common 0.2.2", ] @@ -1182,7 +1192,7 @@ dependencies = [ "merlin", "rand_core 0.6.4", "serde", - "sha2", + "sha2 0.10.9", "subtle", "zeroize", ] @@ -1681,9 +1691,9 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashlink" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" dependencies = [ "hashbrown 0.16.1", ] @@ -2044,7 +2054,7 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "inotify-sys", "libc", ] @@ -2154,7 +2164,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "libc", ] @@ -2369,7 +2379,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "inotify", "kqueue", "libc", @@ -2386,7 +2396,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", ] [[package]] @@ -2497,7 +2507,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", ] [[package]] @@ -2564,7 +2574,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "primeorder", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -2576,7 +2586,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "primeorder", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -2590,7 +2600,7 @@ dependencies = [ "elliptic-curve", "primeorder", "rand_core 0.6.4", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -3022,7 +3032,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", ] [[package]] @@ -3151,7 +3161,7 @@ dependencies = [ "pkcs1", "pkcs8", "rand_core 0.6.4", - "sha2", + "sha2 0.10.9", "signature", "spki", "subtle", @@ -3174,7 +3184,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -3208,7 +3218,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys", @@ -3264,6 +3274,7 @@ dependencies = [ "clap", "flate2", "futures-util", + "getrandom 0.4.2", "html-escape", "http-body-util", "infer", @@ -3273,12 +3284,12 @@ dependencies = [ "safelog", "serde", "serde_json", - "sha2", + "sha2 0.11.0", "tar", "tempfile", "thiserror 2.0.18", "tokio", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", "tor-cell", "tor-hsservice", "tor-proto", @@ -3558,6 +3569,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + [[package]] name = "sha3" version = "0.10.9" @@ -3734,7 +3756,7 @@ checksum = "e0cf03c3a7ea88451ff83a129a79451fd9891d44fc76c25e916a11848b81814c" dependencies = [ "base64ct", "pem-rfc7468", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -3750,7 +3772,7 @@ dependencies = [ "rand_core 0.6.4", "rsa", "sec1", - "sha2", + "sha2 0.10.9", "signature", "ssh-cipher-fork-arti", "ssh-encoding-fork-arti", @@ -4052,21 +4074,6 @@ dependencies = [ "toml_edit 0.22.27", ] -[[package]] -name = "toml" -version = "0.9.12+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" -dependencies = [ - "indexmap 2.14.0", - "serde_core", - "serde_spanned 1.1.1", - "toml_datetime 0.7.5+spec-1.1.0", - "toml_parser", - "toml_writer", - "winnow 0.7.15", -] - [[package]] name = "toml" version = "1.1.2+spec-1.1.0" @@ -4091,15 +4098,6 @@ dependencies = [ "serde", ] -[[package]] -name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", -] - [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -4219,7 +4217,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dad292658d194b145aaec268ffb91566b7d96c98971eea4980c1bf951d7d082b" dependencies = [ "amplify", - "bitflags 2.12.1", + "bitflags 2.13.0", "bytes", "caret", "derive-deftly", @@ -4870,7 +4868,7 @@ dependencies = [ "safelog", "serde", "sha1", - "sha2", + "sha2 0.10.9", "sha3 0.10.9", "signature", "subtle", @@ -4948,7 +4946,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6700c5e590818d9873a13e06b49b95cf3a39fa8fb3968bd67ec3295cd584b97c" dependencies = [ "async-trait", - "bitflags 2.12.1", + "bitflags 2.13.0", "derive_more", "digest 0.10.7", "futures", @@ -5266,7 +5264,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "bytes", "futures-core", "futures-util", @@ -5669,7 +5667,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "hashbrown 0.15.5", "indexmap 2.14.0", "semver", @@ -6097,7 +6095,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.12.1", + "bitflags 2.13.0", "indexmap 2.14.0", "log", "serde", diff --git a/Cargo.toml b/Cargo.toml index 5e30079..1bb7cb0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,43 +15,44 @@ name = "rustpost-cli" path = "src/bin/rustpost-cli.rs" [dependencies] -anyhow = "1.0" +anyhow = "1.0.102" arti-client = { version = "=0.43.0", default-features = false, features = ["tokio", "rustls", "onion-service-service"] } -argon2 = "0.5" +argon2 = "0.5.3" axum = { version = "0.8.9", features = ["multipart"] } -base64 = "0.22" +base64 = "0.22.1" 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" +chrono = { version = "0.4.45", features = ["serde"] } +clap = { version = "4.6.1", features = ["derive", "env"] } +futures-util = "0.3.32" +flate2 = "1.1.9" +html-escape = "0.2.13" infer = "0.19" -rand_core = { version = "0.6", features = ["getrandom"] } -safelog = "0.8" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -sha2 = "0.10" -rusqlite = { version = "0.39", features = ["bundled"] } -rustls = { version = "0.23", default-features = false, features = ["ring"] } -tar = "0.4" -tempfile = "3.23" -thiserror = "2.0" -tokio = { version = "1.48", features = ["macros", "rt-multi-thread", "fs", "process", "signal", "net", "time", "io-util", "sync"] } -toml = "0.9" +getrandom = "0.4.2" +rand_core = { version = "0.6.4", features = ["getrandom"] } +safelog = "0.8.2" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" +sha2 = "0.11.0" +rusqlite = { version = "0.39.0", features = ["bundled"] } +rustls = { version = "0.23.40", default-features = false, features = ["ring"] } +tar = "0.4.46" +tempfile = "3.27.0" +thiserror = "2.0.18" +tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread", "fs", "process", "signal", "net", "time", "io-util", "sync"] } +toml = "1.1.2" tor-cell = "=0.43.0" tor-hsservice = "=0.43.0" tor-proto = { version = "=0.43.0", features = ["tokio"] } tor-rtcompat = { version = "=0.43.0", default-features = false, features = ["tokio", "rustls"] } -tower-http = { version = "0.6", features = ["fs", "trace"] } -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tower-http = { version = "0.6.11", features = ["fs", "trace"] } +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } ureq = { version = "3.3", default-features = false, features = ["rustls"] } -uuid = { version = "1.18", features = ["v4", "serde"] } +uuid = { version = "1.23.2", features = ["v4", "serde"] } walkdir = "2.5" [dev-dependencies] -http-body-util = "0.1" +http-body-util = "0.1.3" [profile.release] lto = true diff --git a/src/backup.rs b/src/backup.rs index ba51bbe..68afa48 100644 --- a/src/backup.rs +++ b/src/backup.rs @@ -1205,7 +1205,17 @@ fn hash_file(path: &Path) -> anyhow::Result<(u64, String)> { total = total.saturating_add(u64::try_from(read).unwrap_or(u64::MAX)); hasher.update(&buffer[..read]); } - Ok((total, format!("{:x}", hasher.finalize()))) + Ok((total, hex_lower(hasher.finalize().as_ref()))) +} + +fn hex_lower(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut encoded = String::with_capacity(bytes.len() * 2); + for &byte in bytes { + encoded.push(char::from(HEX[usize::from(byte >> 4)])); + encoded.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + encoded } fn remove_path_if_exists(path: &Path) -> anyhow::Result<()> { diff --git a/src/media.rs b/src/media.rs index 5559ab3..6b2a46a 100644 --- a/src/media.rs +++ b/src/media.rs @@ -762,7 +762,17 @@ async fn hash_file(path: &Path) -> anyhow::Result { fn sha256_hex(data: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(data); - format!("{:x}", hasher.finalize()) + hex_lower(hasher.finalize().as_ref()) +} + +fn hex_lower(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut encoded = String::with_capacity(bytes.len() * 2); + for &byte in bytes { + encoded.push(char::from(HEX[usize::from(byte >> 4)])); + encoded.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + encoded } fn stable_media_basename(original_filename: &str, original_sha256: &str) -> String { diff --git a/src/registration_captcha.rs b/src/registration_captcha.rs index 1b75419..014ef00 100644 --- a/src/registration_captcha.rs +++ b/src/registration_captcha.rs @@ -6,7 +6,6 @@ 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; @@ -62,7 +61,7 @@ struct StoredChallenge { impl RegistrationCaptchaStore { pub async fn create_challenge(&self) -> anyhow::Result { let token = Uuid::new_v4().to_string(); - let answer = challenge_answer(); + 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); @@ -129,23 +128,23 @@ impl RegistrationCaptchaStore { } } -fn challenge_answer() -> String { +fn challenge_answer() -> anyhow::Result { #[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; + return Ok(normalized); } } let mut bytes = [0_u8; CAPTCHA_LENGTH]; - OsRng.fill_bytes(&mut bytes); - bytes + getrandom::fill(&mut bytes).context("failed to generate CAPTCHA randomness")?; + Ok(bytes .iter() .map(|byte| CAPTCHA_CHARS[usize::from(*byte) % CAPTCHA_CHARS.len()] as char) - .collect() + .collect()) } fn captcha_image_data_uri(answer: &str) -> anyhow::Result { From c1d8c483c4945461f291204b8eb10d12cd035b7b Mon Sep 17 00:00:00 2001 From: csd113 Date: Sun, 7 Jun 2026 00:23:55 -0700 Subject: [PATCH 09/21] Upgrade RustPost dependencies to latest compatible releases --- README.md | 41 ++++++++++++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index dd024f0..d20accf 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ [![CI](https://img.shields.io/github/actions/workflow/status/csd113/RustPost/ci.yml?branch=main&style=flat-square&label=CI&logo=github)](https://github.com/csd113/RustPost/actions) [![Rust](https://img.shields.io/badge/rust-1.90%2B-orange?style=flat-square&logo=rust)](https://www.rust-lang.org/) [![SQLite](https://img.shields.io/badge/database-SQLite-blue?style=flat-square&logo=sqlite)](https://www.sqlite.org/) -[![Embedded Arti](https://img.shields.io/badge/embedded%20Arti-0.42.0-7D4698?style=flat-square&logo=torproject)](https://www.torproject.org/) +[![Embedded Arti](https://img.shields.io/badge/embedded%20Arti-0.43.0-7D4698?style=flat-square&logo=torproject)](https://www.torproject.org/) [![License](https://img.shields.io/badge/license-see%20LICENSE-green?style=flat-square)](./LICENSE) [**Getting Started**](#-getting-started) · [**Configuration**](#-configuration) · [**CLI Reference**](#-cli-reference) · [**Security**](#-security-model) · [**Tor / Arti**](#-tor--arti) @@ -348,14 +348,14 @@ The active onion address is shown by the running server in its startup/status ou | `true` | `false` | Clearnet binds immediately. Arti onion service starts in background. If Tor fails, clearnet keeps running and admin health reports the error. | | `true` | `true` | Only a loopback listener is bound for Arti forwarding. Startup **fails** if Arti/onion startup fails. | -**Pinned Arti crate versions:** +**Current pinned Arti/Tor crates in `Cargo.toml`:** ``` -arti-client = 0.42.0 # bootstraps the embedded Tor client and onion service -tor-hsservice = 0.42.0 # onion-service config, handle, and rendezvous streams -tor-proto = 0.42.0 # inspect and accept incoming onion stream requests -tor-cell = 0.42.0 # cell-level protocol handling -tor-rtcompat = 0.42.0 # Tokio-compatible Arti runtime +arti-client = 0.43.0 # bootstraps the embedded Tor client and onion service +tor-hsservice = 0.43.0 # onion-service config, handle, and rendezvous streams +tor-proto = 0.43.0 # inspect and accept incoming onion stream requests +tor-cell = 0.43.0 # cell-level protocol handling +tor-rtcompat = 0.43.0 # Tokio-compatible Arti runtime rustls = 0.23 # ring crypto provider required by Arti's rustls stack ``` @@ -382,6 +382,29 @@ rustpost-cli restore archive.tar --include-tor-keys On Unix, the backup directory is mode `0700` and created backup archives are mode `0600`. Restore path validation rejects: absolute paths, traversal sequences, symlinks/hardlinks, duplicate entries, duplicate separators, Windows drive prefixes, backslash paths, encoded traversal or slash markers, and slash-like Unicode bypass characters. +**Live/local Tor smoke validation:** + +- Start dual mode with a fresh explicit `--data-dir`, then confirm Arti bootstrap and onion descriptor publication in the logs. +- Valid public local smoke endpoints are `/`, `/home`, `/login`, and `/register`. RustPost does not implement `/healthz` or `/readyz`; use the startup/status output and authenticated `/admin/health` page for operational status. +- The active v3 onion hostname must contain 56 characters followed by `.onion`, and the same address must appear in startup/status output and the public Tor pill. +- Confirm the printed loopback Arti forwarder target serves the same local page as the clearnet listener. +- Onion-routed validation requires a reachable SOCKS proxy. Prefer Tor Browser at `127.0.0.1:9150`, then system Tor at `127.0.0.1:9050`: + +```sh +if nc -z 127.0.0.1 9150; then + socks_proxy=127.0.0.1:9150 +elif nc -z 127.0.0.1 9050; then + socks_proxy=127.0.0.1:9050 +else + echo "SOCKS unavailable" +fi + +test -n "${socks_proxy:-}" && + curl --socks5-hostname "$socks_proxy" -fsS "http://<56-character-v3-address>.onion/" +``` + +No available SOCKS proxy is an environment limitation, not a RustPost product failure. The smoke can still validate Arti bootstrap, descriptor publication, UI onion-address consistency, local HTTP, and the loopback Arti forwarder. + --- ## 🎬 Media & FFmpeg @@ -538,7 +561,7 @@ cargo audit The format, release build, strict Clippy, and test commands must pass. `cargo audit` is reviewed separately and is **not fully clean** for v1.0.0 because of this documented upstream exception: -> **Accepted v1.0.0 upstream audit exception:** `RUSTSEC-2023-0071` affects transitive `rsa 0.9.10` through the Arti 0.42.0 dependency family. The advisory reports a potential key-recovery timing side channel, and no fixed upgrade is currently available. This is an upstream dependency risk, not a verified RustPost application vulnerability. Re-evaluate it when updating Arti or before the next release. +> **Accepted v1.0.0 upstream audit exception:** `RUSTSEC-2023-0071` affects transitive `rsa 0.9.10` through the current pinned Arti/Tor dependency family. The advisory reports a potential key-recovery timing side channel, and no fixed upgrade is currently available. This is an upstream dependency risk, not a verified RustPost application vulnerability. Re-evaluate it when updating Arti or before the next release. `cargo audit` also reports unmaintained transitive `bincode 2.0.1` (`RUSTSEC-2025-0141`) and `paste 1.0.15` (`RUSTSEC-2024-0436`) dependencies inherited through the Arti dependency family. These are tracked as upstream maintenance caveats, not RustPost application vulnerabilities. @@ -562,7 +585,7 @@ The format, release build, strict Clippy, and test commands must pass. `cargo au
Partially verified / environment-dependent -- Live Tor reachability depends on Tor network access and descriptor publication time. The June 2026 sweep did not repeat real onion reachability testing. +- Live Tor reachability depends on Tor network access, descriptor publication time, and a separate Tor client. The June 2026 live/local smoke verified embedded Arti bootstrap and descriptor publication; onion-over-SOCKS reachability remained untested because no local SOCKS proxy was available. - Tor private key material was not rendered in admin health or normal logs during the sweep. Operational text may reference key paths or the `--include-tor-keys` flag but does not print key contents.
From c83502ae374a770361eb05d358e3b6849b2bbe55 Mon Sep 17 00:00:00 2001 From: csd113 Date: Thu, 18 Jun 2026 21:24:59 -0700 Subject: [PATCH 10/21] Harden staged upload cleanup --- CHANGELOG.md | 3 + src/media.rs | 221 +++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 201 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 808a72a..c445f1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,12 @@ - Fixed login so existing stored passwords continue to work after an operator raises the configured minimum password length. - Rejected unsafe profile website URL schemes server-side and stopped rendering unsafe legacy profile website values as links. - Enforced configured per-post image and video attachment limits and raised the multipart body limit to cover valid configured media mixes. +- Hardened invalid upload cleanup with a cancellation-safe staged-file guard so rejected malformed, oversized, or staging-error uploads do not leave orphaned files under `tmp/uploads`. - Corrected generated settings copy for username, display name, and bio limits from bytes to characters. ### Release Validation +- Added regression coverage for malformed and oversized rejected upload cleanup, guard-drop cleanup, and successful durable-media handoff. +- Completed a focused invalid-upload cleanup probe and three live disposable stress reruns across public browsing, auth, social, media, admin/diagnostics, and no-JS/Tor-like browser behavior with no pending staged uploads. - Updated the ignored local Playwright release harness so it no longer configures the rejected `tor.display_onion_address`; synthetic local Tor-header assertions were removed while opt-in real-onion coverage remains. - Documented the accepted v1.0.0 `cargo audit` exception for transitive Arti `rsa 0.9.10` / `RUSTSEC-2023-0071`, for which no fixed upgrade is currently available. - Recorded unmaintained transitive Arti dependencies as upstream maintenance caveats rather than RustPost application vulnerabilities. diff --git a/src/media.rs b/src/media.rs index 6b2a46a..8786c2c 100644 --- a/src/media.rs +++ b/src/media.rs @@ -69,20 +69,62 @@ struct UploadContext<'a> { struct StagedUpload { owner_user_id: Option, original_filename: String, - staging: PathBuf, + staging: StagedUploadGuard, bytes: u64, } struct PreparedUpload { owner_user_id: Option, original_filename: String, - staging: PathBuf, + staging: StagedUploadGuard, bytes: u64, mime: String, media_kind: MediaKind, original_sha256: String, } +struct StagedUploadGuard { + path: PathBuf, + armed: bool, +} + +impl StagedUploadGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + fn path(&self) -> &Path { + &self.path + } + + fn disarm(&mut self) { + self.armed = false; + } + + async fn remove(&mut self) { + if remove_staged_upload(&self.path).await { + self.disarm(); + } + } +} + +impl Drop for StagedUploadGuard { + fn drop(&mut self) { + if !self.armed { + return; + } + if let Err(error) = std::fs::remove_file(&self.path) + && error.kind() != std::io::ErrorKind::NotFound + { + tracing::debug!( + path = %self.path.display(), + error = %error, + "failed to remove staged upload during guard drop" + ); + } + } +} + struct NewMediaRecord { owner_user_id: Option, original_filename: String, @@ -140,8 +182,8 @@ async fn save_upload_inner( let original_filename = field.file_name().unwrap_or("upload").to_owned(); reject_path_tricks(&original_filename)?; let id = Uuid::new_v4().simple().to_string(); - let staging = paths.staged_upload_path(&id); - let bytes = write_upload_to_staging(settings, &staging, &mut field).await?; + let staging = StagedUploadGuard::new(paths.staged_upload_path(&id)); + let bytes = write_upload_to_staging(settings, staging.path(), &mut field).await?; let context = UploadContext { pool, settings, @@ -166,11 +208,18 @@ async fn save_staged_upload( upload: StagedUpload, required_image_label: Option<&'static str>, ) -> anyhow::Result { - let prepared = prepare_staged_upload(context.settings, upload).await?; + save_staged_upload_inner(context, upload, required_image_label).await +} + +async fn save_staged_upload_inner( + context: &UploadContext<'_>, + upload: StagedUpload, + required_image_label: Option<&'static str>, +) -> anyhow::Result { + let mut prepared = prepare_staged_upload(context.settings, upload).await?; if let Some(label) = required_image_label && prepared.media_kind != MediaKind::Image { - remove_staged_upload(&prepared.staging).await; anyhow::bail!("{label} must be an image"); } if let Some(media_id) = try_insert_duplicate( @@ -181,7 +230,7 @@ async fn save_staged_upload( ) .await? { - remove_staged_upload(&prepared.staging).await; + prepared.staging.remove().await; return Ok(media_id); } store_new_upload(context, prepared).await @@ -191,19 +240,12 @@ async fn prepare_staged_upload( settings: &Settings, upload: StagedUpload, ) -> anyhow::Result { - let data = tokio::fs::read(&upload.staging).await?; + let data = tokio::fs::read(upload.staging.path()).await?; let Some(kind) = infer::get(&data) else { - 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, upload.bytes) { - Ok(media_kind) => media_kind, - Err(error) => { - remove_staged_upload(&upload.staging).await; - return Err(error); - } - }; + let media_kind = classify(settings, &mime, upload.bytes)?; Ok(PreparedUpload { owner_user_id: upload.owner_user_id, original_filename: upload.original_filename, @@ -252,7 +294,7 @@ async fn try_insert_duplicate( async fn store_new_upload( context: &UploadContext<'_>, - upload: PreparedUpload, + mut 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); @@ -261,7 +303,8 @@ async fn store_new_upload( stem.clone_into(&mut basename); } let original_public_path = public_upload_path(context.paths, &original_path)?; - tokio::fs::rename(&upload.staging, &original_path).await?; + tokio::fs::rename(upload.staging.path(), &original_path).await?; + upload.staging.disarm(); let stored = convert_or_original( context.settings, context.paths, @@ -447,13 +490,24 @@ async fn write_upload_to_staging( settings: &Settings, staging: &Path, field: &mut Field<'_>, +) -> anyhow::Result { + let result = write_upload_to_staging_inner(settings, staging, field).await; + if result.is_err() { + remove_staged_upload(staging).await; + } + result +} + +async fn write_upload_to_staging_inner( + settings: &Settings, + staging: &Path, + field: &mut Field<'_>, ) -> anyhow::Result { let mut file = tokio::fs::File::create(staging).await?; let mut bytes = 0_u64; while let Some(chunk) = field.chunk().await? { bytes += u64::try_from(chunk.len())?; if bytes > settings.media.max_video_size { - remove_staged_upload(staging).await; anyhow::bail!("upload exceeds maximum size"); } file.write_all(&chunk).await?; @@ -550,9 +604,14 @@ async fn generate_profile_picture_thumbnail( Ok(()) } -async fn remove_staged_upload(path: &Path) { - if let Err(error) = tokio::fs::remove_file(path).await { - tracing::debug!(error = %error, "failed to remove rejected staged upload"); +async fn remove_staged_upload(path: &Path) -> bool { + match tokio::fs::remove_file(path).await { + Ok(()) => true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => true, + Err(error) => { + tracing::debug!(error = %error, "failed to remove rejected staged upload"); + false + } } } @@ -1387,6 +1446,105 @@ mod tests { assert!(classify(&settings, "video/mp4", settings.media.max_video_size + 1).is_err()); } + #[tokio::test] + async fn rejected_malformed_upload_removes_staging_file() { + let (_temp, paths, pool, settings, ffmpeg, user_id) = media_fixture().await; + let bytes = b"not a supported media file"; + let staging = paths.staged_upload_path(&Uuid::new_v4().simple().to_string()); + tokio::fs::write(&staging, bytes).await.expect("staging"); + let context = UploadContext { + pool: &pool, + settings: &settings, + paths: &paths, + ffmpeg: &ffmpeg, + }; + + let error = save_staged_upload( + &context, + StagedUpload { + owner_user_id: Some(user_id), + original_filename: "malformed.png".to_owned(), + staging: StagedUploadGuard::new(staging.clone()), + bytes: u64::try_from(bytes.len()).expect("byte len"), + }, + None, + ) + .await + .expect_err("malformed upload must be rejected"); + + assert!(error.to_string().contains("unsupported media type")); + assert!(!staging.exists()); + assert_eq!(media_count(&pool).await, 0); + assert_tmp_uploads_empty(&paths).await; + } + + #[tokio::test] + async fn rejected_oversize_image_removes_staging_file() { + let (_temp, paths, pool, mut settings, ffmpeg, user_id) = media_fixture().await; + let bytes = tiny_png_bytes(); + settings.media.max_image_size = + u64::try_from(bytes.len().saturating_sub(1)).expect("image size"); + let staging = paths.staged_upload_path(&Uuid::new_v4().simple().to_string()); + tokio::fs::write(&staging, &bytes).await.expect("staging"); + let context = UploadContext { + pool: &pool, + settings: &settings, + paths: &paths, + ffmpeg: &ffmpeg, + }; + + let error = save_staged_upload( + &context, + StagedUpload { + owner_user_id: Some(user_id), + original_filename: "large.png".to_owned(), + staging: StagedUploadGuard::new(staging.clone()), + bytes: u64::try_from(bytes.len()).expect("byte len"), + }, + None, + ) + .await + .expect_err("oversize image must be rejected"); + + assert!(error.to_string().contains("image exceeds maximum size")); + assert!(!staging.exists()); + assert_eq!(media_count(&pool).await, 0); + assert_tmp_uploads_empty(&paths).await; + } + + #[test] + fn staged_upload_guard_drop_removes_armed_file() { + let temp = tempfile::tempdir().expect("temp dir"); + let staging = temp.path().join("upload.tmp"); + std::fs::write(&staging, b"partial upload").expect("staging"); + + { + let _guard = StagedUploadGuard::new(staging.clone()); + } + + assert!(!staging.exists()); + } + + #[tokio::test] + async fn successful_upload_disarms_guard_and_keeps_durable_media() { + let (_temp, paths, pool, settings, ffmpeg, user_id) = media_fixture().await; + + let media_id = save_test_upload( + &pool, + &settings, + &paths, + &ffmpeg, + user_id, + "Photo.PNG", + &tiny_png_bytes(), + ) + .await; + + let row = media_row(&pool, media_id).await; + assert!(Path::new(&row.stored_path).exists()); + assert_tmp_uploads_empty(&paths).await; + } + #[tokio::test] async fn exact_same_file_uploaded_twice_reuses_canonical_media() { let (_temp, paths, pool, settings, ffmpeg, user_id) = media_fixture().await; @@ -2081,7 +2239,7 @@ mod tests { StagedUpload { owner_user_id: Some(user_id), original_filename: filename.to_owned(), - staging, + staging: StagedUploadGuard::new(staging), bytes: u64::try_from(bytes.len()).expect("byte len"), }, None, @@ -2115,6 +2273,23 @@ mod tests { .expect("media row") } + async fn media_count(pool: &SqlitePool) -> i64 { + pool.call(|conn| Ok(conn.query_row("SELECT COUNT(*) FROM media", [], |row| row.get(0))?)) + .await + .expect("media count") + } + + async fn assert_tmp_uploads_empty(paths: &RuntimePaths) { + let mut entries = tokio::fs::read_dir(&paths.tmp_uploads) + .await + .expect("tmp uploads dir"); + let mut leftovers = Vec::new(); + while let Some(entry) = entries.next_entry().await.expect("tmp upload entry") { + leftovers.push(entry.path()); + } + assert!(leftovers.is_empty(), "leftover tmp uploads: {leftovers:?}"); + } + struct TestCanonicalMedia<'a> { stored_path: &'a Path, public_path: &'a str, From cce28b116f85b756c767685fdc62822fe39f46d2 Mon Sep 17 00:00:00 2001 From: csd113 Date: Sat, 15 Aug 2026 23:42:17 -0700 Subject: [PATCH 11/21] Refactor project structure and update application behavior --- .github/workflows/ci.yml | 16 +- .github/workflows/release.yml | 8 +- Cargo.lock | 1526 ++++++++++++++++----------------- Cargo.toml | 49 +- README.md | 8 +- src/auth.rs | 7 +- src/ffmpeg.rs | 6 +- src/registration_captcha.rs | 2 +- src/tor.rs | 2 +- 9 files changed, 794 insertions(+), 830 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3dc7c15..f2f801b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,8 +19,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - - name: Install Rust 1.90 - uses: dtolnay/rust-toolchain@1.90.0 + - name: Install Rust 1.91 + uses: dtolnay/rust-toolchain@1.91.0 with: components: rustfmt - name: Check formatting @@ -34,8 +34,8 @@ jobs: uses: actions/checkout@v4 - name: Install Linux build tools run: sudo apt-get update && sudo apt-get install -y --no-install-recommends build-essential pkg-config - - name: Install Rust 1.90 - uses: dtolnay/rust-toolchain@1.90.0 + - name: Install Rust 1.91 + uses: dtolnay/rust-toolchain@1.91.0 with: components: clippy - name: Cache Cargo @@ -53,8 +53,8 @@ jobs: uses: actions/checkout@v4 - name: Install Linux build tools run: sudo apt-get update && sudo apt-get install -y --no-install-recommends build-essential pkg-config - - name: Install Rust 1.90 - uses: dtolnay/rust-toolchain@1.90.0 + - name: Install Rust 1.91 + uses: dtolnay/rust-toolchain@1.91.0 with: components: clippy - name: Cache Cargo @@ -85,8 +85,8 @@ jobs: - name: Install Linux build tools if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y --no-install-recommends build-essential pkg-config - - name: Install Rust 1.90 - uses: dtolnay/rust-toolchain@1.90.0 + - name: Install Rust 1.91 + uses: dtolnay/rust-toolchain@1.91.0 - name: Cache Cargo uses: Swatinem/rust-cache@v2 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f06a38e..6bad4b5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,8 +37,8 @@ jobs: - name: Install Linux build tools if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y --no-install-recommends build-essential pkg-config - - name: Install Rust 1.90 - uses: dtolnay/rust-toolchain@1.90.0 + - name: Install Rust 1.91 + uses: dtolnay/rust-toolchain@1.91.0 with: targets: ${{ matrix.target }} - name: Cache Cargo @@ -72,8 +72,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - - name: Install Rust 1.90 - uses: dtolnay/rust-toolchain@1.90.0 + - name: Install Rust 1.91 + uses: dtolnay/rust-toolchain@1.91.0 with: targets: x86_64-pc-windows-msvc - name: Cache Cargo diff --git a/Cargo.lock b/Cargo.lock index 2400925..84130ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,21 +10,21 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aes" -version = "0.8.4" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" dependencies = [ - "cfg-if", - "cipher", - "cpufeatures 0.2.17", + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.0", "zeroize", ] [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -77,9 +77,9 @@ dependencies = [ [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -136,9 +136,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "argon2" @@ -154,15 +154,15 @@ dependencies = [ [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "arti-client" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ffc89b08e6a475a83d70e52f0ede139a433eb46e986a6cee6eeaa1b73297bf1" +checksum = "18e7e5f663c4a5db15ba9377b98b3fff2190f64c02e608beb1292dd67b30f199" dependencies = [ "async-trait", "cfg-if", @@ -178,11 +178,11 @@ dependencies = [ "libc", "once_cell", "postage", - "rand 0.10.1", + "rand 0.10.2", "safelog", "serde", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "tor-async-utils", "tor-basic-utils", @@ -230,7 +230,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -241,7 +241,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "synstructure", ] @@ -253,7 +253,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -264,9 +264,9 @@ checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" [[package]] name = "async-compression" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" dependencies = [ "compression-codecs", "compression-core", @@ -276,13 +276,13 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -411,6 +411,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -435,15 +441,15 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bitvec" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" dependencies = [ "funty", "radium", @@ -468,7 +474,7 @@ checksum = "e0b121a9fe0df916e362fb3271088d071159cdf11db0e4182d02152850756eff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -482,11 +488,12 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", + "zeroize", ] [[package]] @@ -500,13 +507,13 @@ dependencies = [ [[package]] name = "bstr" -version = "1.12.1" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" dependencies = [ "memchr", "regex-automata", - "serde", + "serde_core", ] [[package]] @@ -523,9 +530,9 @@ checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" [[package]] name = "byteorder" @@ -535,9 +542,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "captcha" @@ -548,21 +555,21 @@ dependencies = [ "base64 0.13.1", "image", "lodepng", - "rand 0.9.4", + "rand 0.9.5", "serde_json", ] [[package]] name = "caret" -version = "0.9.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "beae2cb9f60bc3f21effaaf9c64e51f6627edd54eedc9199ba07f519ef2a2101" +checksum = "23a6f83e57018d15e7615a6f8e5e2c6b6b0226e24a1c562cd705f855a2bc751a" [[package]] name = "cc" -version = "1.2.63" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" dependencies = [ "find-msvc-tools", "shlex", @@ -570,13 +577,13 @@ dependencies = [ [[package]] name = "cfb" -version = "0.7.3" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +checksum = "a347dcabdae9c31b0825fd6a8bed285ec9c2acb89c47827126d52fa4f59cece3" dependencies = [ - "byteorder", "fnv", "uuid", + "web-time", ] [[package]] @@ -587,9 +594,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "chacha20" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -617,15 +624,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common 0.1.7", - "inout", + "inout 0.1.4", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout 0.2.2", "zeroize", ] [[package]] name = "clap" -version = "4.6.1" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -633,9 +651,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -645,14 +663,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -700,15 +718,6 @@ version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "const-oid" version = "0.9.6" @@ -745,6 +754,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -774,18 +789,18 @@ dependencies = [ [[package]] name = "crossbeam-queue" -version = "0.3.12" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crypto-bigint" @@ -820,11 +835,11 @@ dependencies = [ [[package]] name = "ctr" -version = "0.9.2" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" dependencies = [ - "cipher", + "cipher 0.5.2", ] [[package]] @@ -851,7 +866,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -908,7 +923,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -921,7 +936,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -943,7 +958,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core 0.21.3", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -954,14 +969,45 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "data-encoding" -version = "2.11.0" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] [[package]] name = "der" @@ -994,15 +1040,14 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "powerfmt", "serde_core", ] [[package]] name = "derive-deftly" -version = "1.11.2" +version = "1.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c002a60a3baf5d3fa28db4e1c98733aa2bc784aa63b83c011a72ae9335fb2" +checksum = "98e02cd118058400797c74550dca132b57cf2e6af0e9ea0672a83eb186fd655e" dependencies = [ "derive-deftly-macros", "heck", @@ -1010,9 +1055,9 @@ dependencies = [ [[package]] name = "derive-deftly-macros" -version = "1.11.2" +version = "1.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd5850ec9ad2d9ba0aa33fb22c0b0ef4d91e524566be497ac2a6e40b847e67bb" +checksum = "ba91f619216e76a5eb2515783f7ec2e271938b51aadac592f4930517f4626863" dependencies = [ "heck", "indexmap 2.14.0", @@ -1022,7 +1067,7 @@ dependencies = [ "quote", "sha3 0.12.0", "strum", - "syn 2.0.117", + "syn 3.0.3", "unicode-ident", "void", ] @@ -1077,7 +1122,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.117", + "syn 2.0.119", "unicode-xid", ] @@ -1099,7 +1144,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "const-oid 0.10.2", "crypto-common 0.2.2", ] @@ -1136,13 +1181,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -1211,9 +1256,9 @@ dependencies = [ [[package]] name = "either" -version = "1.16.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" [[package]] name = "elliptic-curve" @@ -1253,7 +1298,7 @@ dependencies = [ "num-traits", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1265,14 +1310,14 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "enumset" -version = "1.1.13" +version = "1.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "839c4174b41e75c8f7306110b2c51996a293b8d1d850edd529011841d9fede7d" +checksum = "ccc5801fd11762e24d1e420d01d2ac518f2a2ca4329d4fbb6639f2412b6204e0" dependencies = [ "enumset_derive", ] @@ -1286,7 +1331,7 @@ dependencies = [ "darling 0.21.3", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1307,15 +1352,25 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] +[[package]] +name = "extend" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "311a6d2f1f9d60bff73d2c78a0af97ed27f79672f15c238192a5bbb64db56d00" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -1330,9 +1385,9 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "fdeflate" @@ -1384,9 +1439,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "flate2" @@ -1417,12 +1472,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1434,28 +1483,29 @@ dependencies = [ [[package]] name = "fs-mistrust" -version = "0.14.2" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cfebc7c6bb65d327ded064db65cd260b6c418c27ae790318650cfa2a81bf33f" +checksum = "9b2c81a5a0e7d67644309a15694eb7f414a4d6556c64c1ece1e5969aa609c8a9" dependencies = [ "derive_builder_fork_arti", "dirs", + "extend", "libc", "pwd-grp", "serde", - "thiserror 2.0.18", + "thiserror 2.0.20", "void", "walkdir", ] [[package]] name = "fslock-guard" -version = "0.7.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e77b8a36ee17f9079b53db7281de9b16a8fb2bc97a7cd1e41a2998f3a837af7a" +checksum = "dd682ede578019974b784324792fe72890bb6a3344428173327b60f61c30f4d2" dependencies = [ "libc", - "thiserror 2.0.18", + "thiserror 2.0.20", "windows-sys 0.61.2", ] @@ -1467,9 +1517,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -1482,9 +1532,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -1492,15 +1542,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -1509,19 +1559,19 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -1537,21 +1587,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -1604,30 +1654,27 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", - "wasip2", - "wasip3", "wasm-bindgen", ] [[package]] name = "getset" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" +checksum = "6cf442baaabe4213ce7d1239afc26c039180b6456da2cededa316ae2c8a77a77" dependencies = [ - "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1671,16 +1718,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash 0.1.5", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "foldhash 0.2.0", + "foldhash", ] [[package]] @@ -1691,11 +1729,11 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashlink" -version = "0.11.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown 0.16.1", + "hashbrown 0.15.5", ] [[package]] @@ -1736,18 +1774,15 @@ checksum = "f558a64ac9af88b5ba400d99b579451af0d39c6d360980045b91aac966d705e2" [[package]] name = "html-escape" -version = "0.2.13" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476" -dependencies = [ - "utf8-width", -] +checksum = "c9356095b4b41197bba32173600e1582792cda618f65d12f68e2e77d273413c5" [[package]] name = "http" -version = "1.4.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -1755,9 +1790,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -1765,9 +1800,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -1796,9 +1831,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "humantime" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" [[package]] name = "humantime-serde" @@ -1812,18 +1847,19 @@ dependencies = [ [[package]] name = "hybrid-array" -version = "0.4.12" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" dependencies = [ "typenum", + "zeroize", ] [[package]] name = "hyper" -version = "1.10.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" dependencies = [ "atomic-waker", "bytes", @@ -1880,9 +1916,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -1894,9 +1930,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -1907,9 +1943,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1921,16 +1957,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -1941,15 +1978,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" dependencies = [ "displaydoc", "icu_locale_core", @@ -1960,12 +1997,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -2041,29 +2072,29 @@ dependencies = [ [[package]] name = "infer" -version = "0.19.0" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +checksum = "f4200d433cbd5178df7797c9c2e75b348b728e39631cf14520d1e2fc424201f4" dependencies = [ "cfb", ] [[package]] name = "inotify" -version = "0.11.2" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "inotify-sys", "libc", ] [[package]] name = "inotify-sys" -version = "0.1.5" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" dependencies = [ "libc", ] @@ -2077,6 +2108,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "inventory" version = "0.3.24" @@ -2086,6 +2126,21 @@ dependencies = [ "rustversion", ] +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "iprange" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37209be0ad225457e63814401415e748e2453a5297f9b637338f5fb8afa4ec00" +dependencies = [ + "ipnet", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -2094,9 +2149,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" -version = "0.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" dependencies = [ "either", ] @@ -2107,15 +2162,67 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -2140,9 +2247,9 @@ dependencies = [ [[package]] name = "keccak" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +checksum = "ffd9697dc4a9a62e2da93389f34400b77a28f0287711263cabb203b3ccb9c0e4" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -2150,9 +2257,9 @@ dependencies = [ [[package]] name = "kqueue" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" dependencies = [ "kqueue-sys", "libc", @@ -2164,7 +2271,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "libc", ] @@ -2177,17 +2284,11 @@ dependencies = [ "spin", ] -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libm" @@ -2197,18 +2298,18 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" dependencies = [ "libc", ] [[package]] name = "libsqlite3-sys" -version = "0.37.0" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +checksum = "91632f3b4fb6bd1d72aa3d78f41ffecfcf2b1a6648d8c241dbe7dbfaf4875e15" dependencies = [ "cc", "pkg-config", @@ -2223,9 +2324,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "lock_api" @@ -2250,9 +2351,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "matchers" @@ -2271,15 +2372,15 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmap2" -version = "0.9.10" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", ] @@ -2330,9 +2431,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "log", @@ -2379,7 +2480,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "inotify", "kqueue", "libc", @@ -2396,7 +2497,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", ] [[package]] @@ -2419,9 +2520,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -2438,7 +2539,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.6", + "rand 0.8.7", "smallvec", "zeroize", ] @@ -2451,20 +2552,19 @@ checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] [[package]] name = "num-iter" -version = "0.1.45" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" dependencies = [ - "autocfg", "num-integer", "num-traits", ] @@ -2498,7 +2598,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2507,7 +2607,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", ] [[package]] @@ -2534,9 +2634,9 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "oneshot-fused-workaround" -version = "0.6.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17b52d0e4a06a4c7eb8d2943c0015fa628cf4ccc409429cebc0f5bed6d33a82" +checksum = "56892f7dcba21c5eb4181b8de221620855805891ee55ef960aad7cac3b5d88b2" dependencies = [ "futures", ] @@ -2666,9 +2766,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "phf" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ "phf_macros", "phf_shared", @@ -2677,9 +2777,9 @@ dependencies = [ [[package]] name = "phf_generator" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ "fastrand", "phf_shared", @@ -2687,22 +2787,22 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" dependencies = [ "phf_generator", "phf_shared", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "phf_shared" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" dependencies = [ "siphasher", ] @@ -2724,7 +2824,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2756,9 +2856,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "png" @@ -2773,6 +2873,21 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "postage" version = "0.5.0" @@ -2790,9 +2905,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -2812,16 +2927,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.117", -] - [[package]] name = "primeorder" version = "0.13.6" @@ -2848,36 +2953,14 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.12+spec-1.1.0", -] - -[[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" -dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", - "syn 2.0.117", + "toml_edit 0.25.13+spec-1.1.0", ] [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -2891,14 +2974,14 @@ dependencies = [ "derive-deftly", "libc", "paste", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -2923,9 +3006,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "rand_chacha 0.3.1", "rand_core 0.6.4", @@ -2933,9 +3016,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -2943,12 +3026,12 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", - "getrandom 0.4.2", + "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -3017,13 +3100,20 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "rangemap" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" + [[package]] name = "rdrand" -version = "0.8.3" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d92195228612ac8eed47adbc2ed0f04e513a4ccb98175b6f2bd04d963b533655" +checksum = "84448986e59427c795b929d8dbe12d176275c7d0887ee48098c35b7206f51bae" dependencies = [ - "rand_core 0.6.4", + "libc", + "rand_core 0.10.1", ] [[package]] @@ -3032,7 +3122,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", ] [[package]] @@ -3043,34 +3133,34 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] name = "ref-cast" -version = "1.0.25" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.25" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -3080,9 +3170,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -3091,24 +3181,24 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reseeding_rng" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35d8fa137e1f0bbc1139893fcf4fff5f099d76658e6da2b10fadd04f0cadc2d4" +checksum = "ecfc8e855ab517235426ec1290bfb0745e73fa478b29619eb66a2a81be9d8716" dependencies = [ "rand_core 0.10.1", ] [[package]] name = "retry-error" -version = "0.12.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf6aa271ee564cc5d1df57c5cf7c6ac7a21a4f9f40d2bf1d32bf0a1bb3ddaeb0" +checksum = "d350ad359aca3414f7261cdcc763e37ccc9fa56fe40ca30a9758c28a295bcad0" dependencies = [ "humantime", "web-time", @@ -3168,29 +3258,18 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rsqlite-vfs" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" -dependencies = [ - "hashbrown 0.16.1", - "thiserror 2.0.18", -] - [[package]] name = "rusqlite" -version = "0.39.0" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" +checksum = "3de23c3319433716cf134eed225fe9986bc24f63bed9be9f20c329029e672dc7" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "fallible-iterator", "fallible-streaming-iterator", "hashlink", "libsqlite3-sys", "smallvec", - "sqlite-wasm-rs", "time", ] @@ -3218,7 +3297,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", @@ -3227,9 +3306,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "log", "once_cell", @@ -3242,18 +3321,18 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "zeroize", ] [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" dependencies = [ "ring", "rustls-pki-types", @@ -3268,17 +3347,16 @@ dependencies = [ "argon2", "arti-client", "axum", - "base64 0.22.1", + "base64 0.23.1", "captcha", "chrono", "clap", "flate2", "futures-util", - "getrandom 0.4.2", + "getrandom 0.4.3", "html-escape", "http-body-util", "infer", - "rand_core 0.6.4", "rusqlite", "rustls", "safelog", @@ -3287,9 +3365,9 @@ dependencies = [ "sha2 0.11.0", "tar", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", - "toml 1.1.2+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", "tor-cell", "tor-hsservice", "tor-proto", @@ -3304,9 +3382,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -3316,15 +3394,15 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "safelog" -version = "0.8.2" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97a907e0d82c61b1b06a2030c968eb313dcf432686b77801a26bc4b206f96573" +checksum = "73ef1d6e273fc8ce5cc1376cf498c06d23b786e80e4a7679a643bb0a838b0029" dependencies = [ "derive_more", "educe", "either", "fluid-let", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -3347,9 +3425,9 @@ dependencies = [ [[package]] name = "saturating-time" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63583a1dd0647d1484228529ab4ecaa874048d2956f117362aa5f5826456230" +checksum = "802bdbfcca9a239cb46eeaaedea507e37bb13ba1a673762d2e2ef7a9dac63144" [[package]] name = "schemars" @@ -3365,9 +3443,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ "dyn-clone", "ref-cast", @@ -3403,9 +3481,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -3433,22 +3511,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -3463,9 +3541,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -3517,9 +3595,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.21.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" dependencies = [ "base64 0.22.1", "bs58", @@ -3527,8 +3605,9 @@ dependencies = [ "hex", "indexmap 1.9.3", "indexmap 2.14.0", + "jiff", "schemars 0.9.0", - "schemars 1.2.1", + "schemars 1.2.2", "serde_core", "serde_json", "serde_with_macros", @@ -3537,21 +3616,21 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.21.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -3597,7 +3676,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" dependencies = [ "digest 0.11.3", - "keccak 0.2.0", + "keccak 0.2.1", "sponge-cursor", ] @@ -3649,9 +3728,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "siphasher" @@ -3677,28 +3756,28 @@ dependencies = [ [[package]] name = "slotmap-careful" -version = "0.7.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed92816c1fbb29891a525b92d5fa95757c9dee47044f76c8e06ceb1e052a8d64" +checksum = "ba7bcbd01d9b495f4509e74f660ebaf3315219b2f8a81753667ee7d6d8a199ac" dependencies = [ "paste", "serde", "slotmap", - "thiserror 2.0.18", + "thiserror 2.0.20", "void", ] [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -3706,9 +3785,9 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" [[package]] name = "spki" @@ -3726,25 +3805,13 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" -[[package]] -name = "sqlite-wasm-rs" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" -dependencies = [ - "cc", - "js-sys", - "rsqlite-vfs", - "wasm-bindgen", -] - [[package]] name = "ssh-cipher-fork-arti" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "125c5795103fc93fced42d123c8044180afc55469caa1ab56487c3c5543c898d" dependencies = [ - "cipher", + "cipher 0.4.4", "ssh-encoding-fork-arti", ] @@ -3822,7 +3889,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3844,9 +3911,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -3867,7 +3945,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3908,7 +3986,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -3925,11 +4003,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.20", ] [[package]] @@ -3940,38 +4018,36 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] [[package]] name = "time" -version = "0.3.47" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", - "itoa", - "js-sys", "num-conv", "powerfmt", "serde_core", @@ -3981,15 +4057,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -3997,9 +4073,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "serde_core", @@ -4008,9 +4084,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -4023,9 +4099,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -4039,20 +4115,20 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", @@ -4076,9 +4152,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.2+spec-1.1.0" +version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ "indexmap 2.14.0", "serde_core", @@ -4086,7 +4162,7 @@ dependencies = [ "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] @@ -4123,23 +4199,23 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.12+spec-1.1.0" +version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] @@ -4150,61 +4226,74 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tor-async-utils" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27fc83e7539ac5f717b722456df991ca8819782d04f7ae2ee28eefacca030eef" +checksum = "0969a2e97472807009ea516a0b98541048d59b21afad98e1da9c577cd93d5db9" dependencies = [ + "cfg-if", "derive-deftly", "educe", + "extend", "futures", "oneshot-fused-workaround", "pin-project", "postage", - "thiserror 2.0.18", + "sync_wrapper", + "thiserror 2.0.20", + "tokio", + "tokio-util", + "tor-basic-utils", + "tor-rtcompat", + "tracing", "void", + "web-time-compat", ] [[package]] name = "tor-basic-utils" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bd01b9947f57a99193547f3a40a0bd179e9a332ead855c07e2fb702a1517f74" +checksum = "70c40efc7fdfd91d8e774cccaff5d8218cc6b914a5c65b04ca5719416427c9f8" dependencies = [ + "derive-deftly", "derive_more", - "getrandom 0.4.2", + "educe", + "extend", + "getrandom 0.4.3", "hex", "itertools", "libc", "paste", - "rand 0.10.1", + "rand 0.10.2", "rand_chacha 0.10.0", "serde", "slab", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.20", + "tracing", "weak-table", "web-time-compat", ] [[package]] name = "tor-bytes" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de6a7af55881d599cd48c841916520318d5711df17514001aafd5f796f61e337" +checksum = "c23ad66d11cdb8052cd52f6f54cd14cc8e060fb49284a717246213500e6e911f" dependencies = [ "bytes", "derive-deftly", "digest 0.10.7", "educe", - "getrandom 0.4.2", + "getrandom 0.4.3", "safelog", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-error", "tor-llcrypto", "zeroize", @@ -4212,12 +4301,12 @@ dependencies = [ [[package]] name = "tor-cell" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dad292658d194b145aaec268ffb91566b7d96c98971eea4980c1bf951d7d082b" +checksum = "c8bb1363898e3a5eebc3fd0bff63e00449810d4ad96699b685a8537b92929958" dependencies = [ "amplify", - "bitflags 2.13.0", + "bitflags 2.13.1", "bytes", "caret", "derive-deftly", @@ -4225,9 +4314,9 @@ dependencies = [ "educe", "itertools", "paste", - "rand 0.10.1", + "rand 0.10.2", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-basic-utils", "tor-bytes", "tor-cert", @@ -4243,15 +4332,16 @@ dependencies = [ [[package]] name = "tor-cert" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "474e732207b1be44f9ae4ddf295fba3644cd3c09494d131846ff9c9a624ab263" +checksum = "12a760ed5a0177a49d405c9ff53a17cf0348b3a297f8c228e860e880a878649c" dependencies = [ "caret", "derive_builder_fork_arti", "derive_more", "digest 0.10.7", - "thiserror 2.0.18", + "saturating-time", + "thiserror 2.0.20", "tor-bytes", "tor-checkable", "tor-error", @@ -4261,9 +4351,9 @@ dependencies = [ [[package]] name = "tor-chanmgr" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2bb088804f54de320ee97dd2a48ae319f0df9d9d0e37eef050d65488194a768" +checksum = "4e6be3bca876eda4aa9a47e77f803f2914f4c882f9affde994219146b6a88e8c" dependencies = [ "async-trait", "base64ct", @@ -4277,11 +4367,11 @@ dependencies = [ "oneshot-fused-workaround", "percent-encoding", "postage", - "rand 0.10.1", + "rand 0.10.2", "safelog", "serde", "serde_with", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-async-utils", "tor-basic-utils", "tor-cell", @@ -4304,22 +4394,23 @@ dependencies = [ [[package]] name = "tor-checkable" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9505791bac25b31b8de88c0e2b02e1690e10ff1308583c6f61c320bab2677959" +checksum = "bcdb5c50f28a24a4048a5ff8db5089eb1de204de80dc5ef40e04188ba66c8025" dependencies = [ "humantime", + "itertools", "signature", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-llcrypto", "web-time-compat", ] [[package]] name = "tor-circmgr" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e986c7c4e1faae8a412b575ce66ff087050525a371db1034f0b24d70b459a2e" +checksum = "b2ababd68e4bdbc4da874eef6938fdb8792f387d1722f53574a88e84a08a54af" dependencies = [ "amplify", "async-trait", @@ -4336,11 +4427,11 @@ dependencies = [ "once_cell", "oneshot-fused-workaround", "pin-project", - "rand 0.10.1", + "rand 0.10.2", "retry-error", "safelog", "serde", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-async-utils", "tor-basic-utils", "tor-cell", @@ -4367,9 +4458,9 @@ dependencies = [ [[package]] name = "tor-config" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224f85aa02b514c384ea5ec32f1b924233bea6b553195fd83da7f1e50a2e0d1b" +checksum = "a0ecacf2afa11dc25841514d69f0a770abf3b2c0da792ef47e5bdfdfe07bf67e" dependencies = [ "amplify", "cfg-if", @@ -4377,6 +4468,7 @@ dependencies = [ "derive_builder_fork_arti", "educe", "either", + "extend", "figment", "fs-mistrust", "futures", @@ -4390,8 +4482,8 @@ dependencies = [ "serde-value", "serde_ignored", "strum", - "thiserror 2.0.18", - "toml 1.1.2+spec-1.1.0", + "thiserror 2.0.20", + "toml 1.1.4+spec-1.1.0", "tor-basic-utils", "tor-error", "tor-rtcompat", @@ -4401,30 +4493,30 @@ dependencies = [ [[package]] name = "tor-config-path" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4232a56a8ce40a47ad6b42c9c7d9e6f4485e6e6a43b3149304c35bc295034107" +checksum = "52de46b996c648c3880ae0cc0cf8dd2cf9be4466be65e24df39790bd6e673204" dependencies = [ "directories", "serde", "shellexpand", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-error", "tor-general-addr", ] [[package]] name = "tor-consdiff" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fcd0c631a66b45dca4421b8a06309dd02ae5d6aea5daa55399f68638b2abf3" +checksum = "243ef0da7444be7f8f15ba17e3eb7c9e4359e9f93ddb68abd33984874e39cf6e" dependencies = [ "derive_more", "digest 0.10.7", "hex", "imara-diff", "static_assertions", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-error", "tor-llcrypto", "tor-netdoc", @@ -4432,9 +4524,9 @@ dependencies = [ [[package]] name = "tor-dirclient" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf637d6a5d8c886f23ca2df50edfaa56443f23294428f037206fb3a0d07ba57e" +checksum = "53c62385daf1ec5a8a7132c9da0baf5121169316d2fc3a40886a26a6074fa635" dependencies = [ "async-compression", "base64ct", @@ -4446,7 +4538,7 @@ dependencies = [ "httpdate", "itertools", "memchr", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-circmgr", "tor-error", "tor-hscrypto", @@ -4461,9 +4553,9 @@ dependencies = [ [[package]] name = "tor-dircommon" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35e412167d54c3618c3261dc77f1a31fe20c518f59aca5abfdec5f600b6a9b1f" +checksum = "44132db8353536b8c79b81c9d0887053c1fe4679f0198b5f244d15a542b5bf16" dependencies = [ "base64ct", "derive-deftly", @@ -4482,9 +4574,9 @@ dependencies = [ [[package]] name = "tor-dirmgr" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b984d2b0f0a2764a1b24d7c4c203ef83600b071c0c5af36e20d4f63e80ce562" +checksum = "2b5523974c841cf2a9f85cfae6a1345572ac6e9deda1a43c2acf5301bddf8f83" dependencies = [ "async-trait", "base64ct", @@ -4504,7 +4596,7 @@ dependencies = [ "oneshot-fused-workaround", "paste", "postage", - "rand 0.10.1", + "rand 0.10.2", "rusqlite", "safelog", "scopeguard", @@ -4513,7 +4605,7 @@ dependencies = [ "signature", "static_assertions", "strum", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "tor-async-utils", "tor-basic-utils", @@ -4539,9 +4631,9 @@ dependencies = [ [[package]] name = "tor-error" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1409b1a9085502c7797405a01fe3f3292a9827c9ed6a3f7c34fbb222572db5ac" +checksum = "4ac7221e0878a49f9eaa1adbfcbc15300699c7fc65644432706bef3cb99c5fc3" dependencies = [ "derive_more", "futures", @@ -4549,7 +4641,7 @@ dependencies = [ "retry-error", "static_assertions", "strum", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", "void", "web-time-compat", @@ -4557,20 +4649,20 @@ dependencies = [ [[package]] name = "tor-general-addr" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f17e219140e587e169f71f53e528ca394c39e83593c9fbe9e9366cce7655e1" +checksum = "6b55314b3d95d4145c2d88cee46d4c623b209eceb980ac151c35ab97b2cba9de" dependencies = [ "derive_more", - "thiserror 2.0.18", + "thiserror 2.0.20", "void", ] [[package]] name = "tor-guardmgr" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56c43d7afa526449c6e6460d373a02624a24c78637ddd7c68e2ac1879ae587c" +checksum = "9ddc6ae93e20b4e90174a915fca0be127753664886ae41722425dbd88bc4f500" dependencies = [ "amplify", "base64ct", @@ -4587,11 +4679,11 @@ dependencies = [ "oneshot-fused-workaround", "pin-project", "postage", - "rand 0.10.1", + "rand 0.10.2", "safelog", "serde", "strum", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-async-utils", "tor-basic-utils", "tor-config", @@ -4612,11 +4704,12 @@ dependencies = [ [[package]] name = "tor-hsclient" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ff8ba0afebc5f76b42240f76b2bfb92aa5862c19ff184492c59c654750ee275" +checksum = "00a766e2a933e69f627ab112232634d1c5d92f8a24f694484aaf8b47f98e664e" dependencies = [ "async-trait", + "cfg-if", "derive-deftly", "derive_more", "educe", @@ -4625,12 +4718,12 @@ dependencies = [ "itertools", "oneshot-fused-workaround", "postage", - "rand 0.10.1", + "rand 0.10.2", "retry-error", "safelog", "slotmap-careful", "strum", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-async-utils", "tor-basic-utils", "tor-bytes", @@ -4657,11 +4750,11 @@ dependencies = [ [[package]] name = "tor-hscrypto" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3761c1828e74818cba4eb661d1eb79d4f663b05e8e7d3bc6e35d754968282fc0" +checksum = "7f6438a115a512791dc1309ab8a70b969c89631914d8aa0cfa35cdb20d5d33b7" dependencies = [ - "cipher", + "cipher 0.5.2", "data-encoding", "derive-deftly", "derive_more", @@ -4670,12 +4763,12 @@ dependencies = [ "humantime", "itertools", "paste", - "rand 0.10.1", + "rand 0.10.2", "safelog", "serde", "signature", "subtle", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-basic-utils", "tor-bytes", "tor-error", @@ -4690,9 +4783,9 @@ dependencies = [ [[package]] name = "tor-hsservice" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fa87ed8d1ccfe8447b4e65bf0e2dfcab6a8ac9d2eadf7b3f37c102e1c18e9bb" +checksum = "3291bbf7641b457a16ef803db4d585334e669fa72fe194a276e4c6fc631f858d" dependencies = [ "amplify", "async-trait", @@ -4713,14 +4806,14 @@ dependencies = [ "once_cell", "oneshot-fused-workaround", "postage", - "rand 0.10.1", + "rand 0.10.2", "rand_core 0.10.1", "retry-error", "safelog", "serde", "serde_with", "strum", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-async-utils", "tor-basic-utils", "tor-bytes", @@ -4749,19 +4842,19 @@ dependencies = [ [[package]] name = "tor-key-forge" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86c877b95e0679f151743bc2b706320a29e14bd18d9bcb92918df34948e2e9d2" +checksum = "763406a26bdb2ce8d9b1bd52bc1eb5d808a9c5b4f25ed687972e2ac869be801f" dependencies = [ "derive-deftly", "derive_more", "downcast-rs", "paste", - "rand 0.10.1", + "rand 0.10.2", "rsa", "signature", "ssh-key-fork-arti", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-bytes", "tor-cert", "tor-checkable", @@ -4771,9 +4864,9 @@ dependencies = [ [[package]] name = "tor-keymgr" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f59fbec28e31d688822b4c00a6f7fd94539886baccd3ce1b35dc26699c37873" +checksum = "43f7f56a7b14d090d29e2e3c78b2fea84a6b97ad9b4b9fc13c4e09ec5ac283fa" dependencies = [ "amplify", "arrayvec", @@ -4788,12 +4881,12 @@ dependencies = [ "humantime", "inventory", "itertools", - "rand 0.10.1", + "rand 0.10.2", "safelog", "serde", "signature", "ssh-key-fork-arti", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-basic-utils", "tor-bytes", "tor-config", @@ -4812,9 +4905,9 @@ dependencies = [ [[package]] name = "tor-linkspec" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e1cc8d5469c324d1e0761066d868363d3fdccb723b14cdc983a5aef9b67981b" +checksum = "608f77e1b9acba4304b45d2810e1d41c3899741dbdadf2f6e1e18baea29bbd5b" dependencies = [ "base64ct", "by_address", @@ -4828,7 +4921,7 @@ dependencies = [ "serde", "serde_with", "strum", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-basic-utils", "tor-bytes", "tor-config", @@ -4839,9 +4932,9 @@ dependencies = [ [[package]] name = "tor-llcrypto" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0015d521f5e30a2f74879fe4c615fc2c3ace822d7cc88c1dfed0ae816b25f15" +checksum = "6b7b91d916be50cbbb575c468f6926cc34c3003d0162a644195bf60c00ad0247" dependencies = [ "aes", "base64ct", @@ -4855,9 +4948,9 @@ dependencies = [ "educe", "getrandom 0.2.17", "getrandom 0.3.4", - "getrandom 0.4.2", + "getrandom 0.4.3", "hex", - "rand 0.10.1", + "rand 0.10.2", "rand_chacha 0.10.0", "rand_core 0.10.1", "rand_core 0.6.4", @@ -4872,7 +4965,7 @@ dependencies = [ "sha3 0.10.9", "signature", "subtle", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-error", "tor-memquota-cost", "visibility", @@ -4882,13 +4975,13 @@ dependencies = [ [[package]] name = "tor-log-ratelim" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d16152b21f2e7b309dfbdcbe587c5adba9547d3119f2933b453d6edbce7e8e0" +checksum = "382fbfbe7b7a6e88165bc473362616b2360c5d07d2c66c9362c79b0f6da3d39c" dependencies = [ "futures", "humantime", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-error", "tor-rtcompat", "tracing", @@ -4898,15 +4991,16 @@ dependencies = [ [[package]] name = "tor-memquota" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38ad2950d88e968572b0de6bb5702f5ed42f36555f362fb741c572c62441476e" +checksum = "10a433d849640322ccfa68015e5ae5835471e0959e70d097833f3847f5934239" dependencies = [ "cfg-if", "derive-deftly", "derive_more", "dyn-clone", "educe", + "extend", "futures", "itertools", "paste", @@ -4915,7 +5009,7 @@ dependencies = [ "slotmap-careful", "static_assertions", "sysinfo", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-async-utils", "tor-basic-utils", "tor-config", @@ -4929,9 +5023,9 @@ dependencies = [ [[package]] name = "tor-memquota-cost" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "993ad5bfd39fb629990da40c2b2086284b1481b7d4843652a959db1d9e5ac463" +checksum = "93d6db1779594e2934ea62b83baedb0b2de1f436177d3b07a497b252ca7b522d" dependencies = [ "derive-deftly", "itertools", @@ -4941,12 +5035,12 @@ dependencies = [ [[package]] name = "tor-netdir" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6700c5e590818d9873a13e06b49b95cf3a39fa8fb3968bd67ec3295cd584b97c" +checksum = "4214b180dae5f489253a2495322cf2c83f3bc7a4b246747cc6bf2ad1cba41ca1" dependencies = [ "async-trait", - "bitflags 2.13.0", + "bitflags 2.13.1", "derive_more", "digest 0.10.7", "futures", @@ -4954,10 +5048,10 @@ dependencies = [ "humantime", "itertools", "num_enum", - "rand 0.10.1", + "rand 0.10.2", "serde", "strum", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "tor-basic-utils", "tor-error", @@ -4974,27 +5068,31 @@ dependencies = [ [[package]] name = "tor-netdoc" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a497ce4c36f59e4c67c352e3b80cd4642f6c5e660bb003de1922be4c7ed03081" +checksum = "7f0f1ef6582bab04851c78982f7a43b7f6058498338ec9ff8f6a5e2d34471c95" dependencies = [ "amplify", "base64ct", - "cipher", + "cipher 0.5.2", "derive-deftly", "derive_builder_fork_arti", "derive_more", "digest 0.10.7", "educe", "enumset", + "extend", "hex", "hostname-validator", "humantime", + "ipnet", + "iprange", "itertools", "memchr", "paste", "phf", - "rand 0.10.1", + "rand 0.10.2", + "rangemap", "saturating-time", "serde", "serde_with", @@ -5002,7 +5100,7 @@ dependencies = [ "smallvec", "strum", "subtle", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "tinystr", "tor-basic-utils", @@ -5023,13 +5121,14 @@ dependencies = [ [[package]] name = "tor-persist" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6da1a96a1f6418faac1cd1892e029fd265cf50e67e941a4ba2580d92dd32d47" +checksum = "2718cecfe2ef009ab81f0426ce64be6aa4098c04c13d9cc116b5128376f37c2e" dependencies = [ "amplify", "derive-deftly", "derive_more", + "extend", "filetime", "fs-mistrust", "fslock-guard", @@ -5040,7 +5139,7 @@ dependencies = [ "sanitize-filename", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "tor-async-utils", "tor-basic-utils", @@ -5052,9 +5151,9 @@ dependencies = [ [[package]] name = "tor-proto" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0a5fa0e1a365fc089a7f140f4142379dd74c91c15103c2f9d11ea711e16029e" +checksum = "9098bb3d88ccfcfbf3b34619df8dedf850ee1ddeba8ed70b6e78e64f3349c43e" dependencies = [ "amplify", "async-trait", @@ -5063,7 +5162,7 @@ dependencies = [ "bytes", "caret", "cfg-if", - "cipher", + "cipher 0.5.2", "coarsetime", "derive-deftly", "derive_builder_fork_arti", @@ -5080,7 +5179,7 @@ dependencies = [ "oneshot-fused-workaround", "pin-project", "postage", - "rand 0.10.1", + "rand 0.10.2", "rand_core 0.10.1", "safelog", "slotmap-careful", @@ -5088,8 +5187,7 @@ dependencies = [ "static_assertions", "strum", "subtle", - "sync_wrapper", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-util", "tor-async-utils", @@ -5119,25 +5217,26 @@ dependencies = [ [[package]] name = "tor-protover" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4f7f4ee15c9bfa0b700ae06e85fa52abddf17a35531cd3d3833412900e8da5c" +checksum = "be1e76606ba190039026fe949af420db546058d64de69ebc42a454a0745607b8" dependencies = [ "caret", + "derive-deftly", "paste", "serde_with", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-basic-utils", "tor-bytes", ] [[package]] name = "tor-relay-selection" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b565b6dec2e3edb7b033203a3662edb7b586e486d53e02af4295e05e7562870" +checksum = "465cc59cd3713b1b1dcb3daafd21153509b49a91dfd19711df3143154806bc97" dependencies = [ - "rand 0.10.1", + "rand 0.10.2", "serde", "tor-basic-utils", "tor-linkspec", @@ -5147,9 +5246,9 @@ dependencies = [ [[package]] name = "tor-rtcompat" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcd7a4ff4898044b3ec693c0bf098251accf13c58741c44ed9364ce5faa89287" +checksum = "9c0c4f9845afe4a2be175091a3610cb0e76361a445f4d849d81e5bccced827dd" dependencies = [ "amplify", "async-trait", @@ -5171,7 +5270,7 @@ dependencies = [ "rustls-pki-types", "rustls-webpki", "socket2", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-util", "tor-error", @@ -5184,9 +5283,9 @@ dependencies = [ [[package]] name = "tor-rtmock" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8331e7d57bf7cf157cf3610ff6dbb5a591de5c3b22fa65ea6be18b08ff81477a" +checksum = "b8b042bfc0441618a513568cc7cd7544af146c7e3841857cc6ad39caca85528f" dependencies = [ "amplify", "assert_matches", @@ -5202,7 +5301,7 @@ dependencies = [ "priority-queue", "slotmap-careful", "strum", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-error", "tor-general-addr", "tor-rtcompat", @@ -5214,9 +5313,9 @@ dependencies = [ [[package]] name = "tor-socksproto" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55f432eda94343bdadd73995f0b3082d96637dadb0e20c3f9a1bf2349f623d3c" +checksum = "c52feb363eefbe662c059f39969842dc001bf82dbe81849c703d5051cbd10266" dependencies = [ "amplify", "caret", @@ -5224,21 +5323,21 @@ dependencies = [ "educe", "safelog", "subtle", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-bytes", "tor-error", ] [[package]] name = "tor-units" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cf033ee8b22b11abd8272272e19ccbbf58c7b3152f8d224aa8d21f358c43d0d" +checksum = "f985b939892d57ab7dfe06ad6cf54aa82808305b22b176191c54cbabab5c073d" dependencies = [ "derive-deftly", "derive_more", "serde", - "thiserror 2.0.18", + "thiserror 2.0.20", "tor-memquota", ] @@ -5260,11 +5359,11 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.11" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "bytes", "futures-core", "futures-util", @@ -5316,7 +5415,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5376,7 +5475,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad06847b7afb65c7866a36664b75c40b895e318cea4f71299f013fb22965329d" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5442,11 +5541,11 @@ checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" [[package]] name = "ureq" -version = "3.3.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "log", "percent-encoding", "rustls", @@ -5458,11 +5557,11 @@ dependencies = [ [[package]] name = "ureq-proto" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "http", "httparse", "log", @@ -5480,12 +5579,6 @@ dependencies = [ "serde", ] -[[package]] -name = "utf8-width" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" - [[package]] name = "utf8-zero" version = "0.8.1" @@ -5506,11 +5599,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.2" +version = "1.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -5542,7 +5635,7 @@ checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5569,36 +5662,27 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" -dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] name = "wasix" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1757e0d1f8456693c7e5c6c629bdb54884e032aa0bb53c155f6a39f94440d332" +checksum = "ae86f02046da16a333a9129d31451423e1657737ecdafed4193838a5f54c5cfe" dependencies = [ "wasi", ] [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -5609,9 +5693,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5619,60 +5703,26 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.13.0", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "semver", -] - [[package]] name = "weak-table" version = "0.3.2" @@ -5691,18 +5741,18 @@ dependencies = [ [[package]] name = "web-time-compat" -version = "0.1.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39819265f219f60a92312f2755262dba9fff180a4ec281556863d69fa36adc59" +checksum = "b6ab47578e1cd38415489592da29326341e272bd281f4ddbc0c10b654a425ad7" dependencies = [ "web-time", ] [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ "rustls-pki-types", ] @@ -5791,7 +5841,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5802,7 +5852,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -6024,112 +6074,24 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap 2.14.0", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.13.0", - "indexmap 2.14.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.14.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "wyz" @@ -6164,9 +6126,9 @@ dependencies = [ [[package]] name = "xxhash-rust" -version = "0.8.15" +version = "0.8.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" +checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" [[package]] name = "yoke" @@ -6187,28 +6149,28 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.50" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.50" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -6228,35 +6190,35 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -6265,9 +6227,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" dependencies = [ "serde", "yoke", @@ -6277,23 +6239,23 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "zlib-rs" -version = "0.6.3" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 1bb7cb0..4a14d92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ edition = "2024" license = "MIT" description = "A single-binary self-hosted microblogging app" repository = "https://github.com/csd113/RustPost" -rust-version = "1.90" +rust-version = "1.91" keywords = ["microblog", "self-hosted", "sqlite", "axum"] categories = ["web-programming::http-server"] autobins = false @@ -15,44 +15,43 @@ name = "rustpost-cli" path = "src/bin/rustpost-cli.rs" [dependencies] -anyhow = "1.0.102" -arti-client = { version = "=0.43.0", default-features = false, features = ["tokio", "rustls", "onion-service-service"] } +anyhow = "1.0.104" +arti-client = { version = "=0.45.0", default-features = false, features = ["tokio", "rustls", "onion-service-service"] } argon2 = "0.5.3" axum = { version = "0.8.9", features = ["multipart"] } -base64 = "0.22.1" +base64 = "0.23.1" captcha = { version = "1.0", default-features = false } chrono = { version = "0.4.45", features = ["serde"] } -clap = { version = "4.6.1", features = ["derive", "env"] } -futures-util = "0.3.32" +clap = { version = "4.6.6", features = ["derive", "env"] } +futures-util = "0.3.34" flate2 = "1.1.9" -html-escape = "0.2.13" -infer = "0.19" -getrandom = "0.4.2" -rand_core = { version = "0.6.4", features = ["getrandom"] } -safelog = "0.8.2" -serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.150" +html-escape = "0.2.15" +infer = "0.22" +getrandom = "0.4.3" +safelog = "0.9.1" +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" sha2 = "0.11.0" -rusqlite = { version = "0.39.0", features = ["bundled"] } +rusqlite = { version = "0.36.0", features = ["bundled"] } rustls = { version = "0.23.40", default-features = false, features = ["ring"] } tar = "0.4.46" tempfile = "3.27.0" -thiserror = "2.0.18" -tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread", "fs", "process", "signal", "net", "time", "io-util", "sync"] } -toml = "1.1.2" -tor-cell = "=0.43.0" -tor-hsservice = "=0.43.0" -tor-proto = { version = "=0.43.0", features = ["tokio"] } -tor-rtcompat = { version = "=0.43.0", default-features = false, features = ["tokio", "rustls"] } -tower-http = { version = "0.6.11", features = ["fs", "trace"] } +thiserror = "2.0.20" +tokio = { version = "1.53.1", features = ["macros", "rt-multi-thread", "fs", "process", "signal", "net", "time", "io-util", "sync"] } +toml = "1.1.4" +tor-cell = "=0.45.0" +tor-hsservice = "=0.45.0" +tor-proto = { version = "=0.45.0", features = ["tokio"] } +tor-rtcompat = { version = "=0.45.0", default-features = false, features = ["tokio", "rustls"] } +tower-http = { version = "0.7.0", features = ["fs", "trace"] } tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } -ureq = { version = "3.3", default-features = false, features = ["rustls"] } -uuid = { version = "1.23.2", features = ["v4", "serde"] } +ureq = { version = "3.4.0", default-features = false, features = ["rustls"] } +uuid = { version = "1.24.1", features = ["v4", "serde"] } walkdir = "2.5" [dev-dependencies] -http-body-util = "0.1.3" +http-body-util = "0.1.5" [profile.release] lto = true diff --git a/README.md b/README.md index d20accf..416a9c9 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,9 @@ **A single-binary, self-hosted microblog — yours alone, with no cloud required.** [![CI](https://img.shields.io/github/actions/workflow/status/csd113/RustPost/ci.yml?branch=main&style=flat-square&label=CI&logo=github)](https://github.com/csd113/RustPost/actions) -[![Rust](https://img.shields.io/badge/rust-1.90%2B-orange?style=flat-square&logo=rust)](https://www.rust-lang.org/) +[![Rust](https://img.shields.io/badge/rust-1.91%2B-orange?style=flat-square&logo=rust)](https://www.rust-lang.org/) [![SQLite](https://img.shields.io/badge/database-SQLite-blue?style=flat-square&logo=sqlite)](https://www.sqlite.org/) -[![Embedded Arti](https://img.shields.io/badge/embedded%20Arti-0.43.0-7D4698?style=flat-square&logo=torproject)](https://www.torproject.org/) +[![Embedded Arti](https://img.shields.io/badge/embedded%20Arti-0.45.0-7D4698?style=flat-square&logo=torproject)](https://www.torproject.org/) [![License](https://img.shields.io/badge/license-see%20LICENSE-green?style=flat-square)](./LICENSE) [**Getting Started**](#-getting-started) · [**Configuration**](#-configuration) · [**CLI Reference**](#-cli-reference) · [**Security**](#-security-model) · [**Tor / Arti**](#-tor--arti) @@ -103,7 +103,7 @@ The screenshots above were captured from a local generated demo instance with fi ### Prerequisites -- **Rust 1.90+** — install via [rustup](https://rustup.rs/) +- **Rust 1.91+** — install via [rustup](https://rustup.rs/) - **ffmpeg** *(optional)* — enables image and video conversion. RustPost boots and runs without it. ### Build from source @@ -495,7 +495,7 @@ cargo build --workspace --all-features Then open [http://127.0.0.1:8080](http://127.0.0.1:8080). The server initializes the data directory on first boot. -**CI matrix** (GitHub Actions, Rust 1.90): +**CI matrix** (GitHub Actions, Rust 1.91): | Platform | Arch | |---|---| diff --git a/src/auth.rs b/src/auth.rs index 8846800..2894a0d 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,8 +1,8 @@ +use anyhow::Context as _; use argon2::password_hash::{PasswordHash, PasswordHasher as _, PasswordVerifier as _, SaltString}; use argon2::{Algorithm, Argon2, Params, Version}; use axum::http::HeaderMap; use chrono::{Duration, Utc}; -use rand_core::OsRng; use rusqlite::{OptionalExtension as _, params}; use sha2::{Digest as _, Sha256}; use uuid::Uuid; @@ -325,7 +325,10 @@ fn cookie_value(name: &str, value: &str, secure: bool, max_age: Option) -> } pub fn hash_password(password: &str) -> anyhow::Result { - let salt = SaltString::generate(&mut OsRng); + let mut salt_bytes = [0_u8; 16]; + getrandom::fill(&mut salt_bytes).context("generate password salt")?; + let salt = SaltString::encode_b64(&salt_bytes) + .map_err(|err| anyhow::anyhow!("encode password salt: {err}"))?; let params = Params::new(19_456, 2, 1, None) .map_err(|err| anyhow::anyhow!("invalid argon2 params: {err}"))?; Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params) diff --git a/src/ffmpeg.rs b/src/ffmpeg.rs index e315390..75163b0 100644 --- a/src/ffmpeg.rs +++ b/src/ffmpeg.rs @@ -84,7 +84,7 @@ pub async fn convert_image_to_webp( output: &Path, ) -> anyhow::Result { let args = image_webp_args(settings, input, output); - let result = run_ffmpeg(&settings.ffmpeg_path, &args, Duration::from_secs(120)).await?; + let result = run_ffmpeg(&settings.ffmpeg_path, &args, Duration::from_mins(2)).await?; if result.status.success() { Ok(stderr_summary(&result.stderr)) } else { @@ -99,7 +99,7 @@ pub async fn convert_image_to_webp_thumbnail( size: u16, ) -> anyhow::Result { let args = image_webp_thumbnail_args(settings, input, output, size); - let result = run_ffmpeg(&settings.ffmpeg_path, &args, Duration::from_secs(120)).await?; + let result = run_ffmpeg(&settings.ffmpeg_path, &args, Duration::from_mins(2)).await?; if result.status.success() { Ok(stderr_summary(&result.stderr)) } else { @@ -113,7 +113,7 @@ pub async fn convert_video_to_webm( output: &Path, ) -> anyhow::Result { let args = video_webm_args(settings, input, output); - let result = run_ffmpeg(&settings.ffmpeg_path, &args, Duration::from_secs(300)).await?; + let result = run_ffmpeg(&settings.ffmpeg_path, &args, Duration::from_mins(5)).await?; if result.status.success() { Ok(stderr_summary(&result.stderr)) } else { diff --git a/src/registration_captcha.rs b/src/registration_captcha.rs index 014ef00..bdf4ed5 100644 --- a/src/registration_captcha.rs +++ b/src/registration_captcha.rs @@ -12,7 +12,7 @@ use uuid::Uuid; const CAPTCHA_CHARS: &[u8] = b"123456789ABCDEFGHJKMNPQRSTUVWXYZ"; const CAPTCHA_LENGTH: usize = 5; -const CAPTCHA_TTL: Duration = Duration::from_secs(10 * 60); +const CAPTCHA_TTL: Duration = Duration::from_mins(10); #[cfg(debug_assertions)] const CAPTCHA_E2E_ANSWER_ENV: &str = "RUSTPOST_E2E_CAPTCHA_ANSWER"; diff --git a/src/tor.rs b/src/tor.rs index 4ca72ac..f226305 100644 --- a/src/tor.rs +++ b/src/tor.rs @@ -12,7 +12,7 @@ use safelog::DisplayRedacted as _; use tokio::net::TcpStream; use tor_cell::relaycell::msg::{Connected, End, EndReason}; use tor_hsservice::{HsNickname, RunningOnionService}; -use tor_proto::client::stream::IncomingStreamRequest; +use tor_proto::stream::IncomingStreamRequest; use tracing::{info, warn}; use crate::config::TorSettings; From 60469604e111aade3c8aba00a0c77e12092186d1 Mon Sep 17 00:00:00 2001 From: csd113 Date: Sat, 15 Aug 2026 23:49:44 -0700 Subject: [PATCH 12/21] Update Rust baseline and Arti 0.45 This release raises the Rust baseline to 1.91 and updates the embedded Arti/Tor stack to 0.45.0, including the required onion-stream API migration. It also refreshes dependency versions to their latest compatible releases, keeps rusqlite aligned with the Arti sqlite dependency set, and updates the release docs and verification notes to reflect the new runtime and artifact conventions. --- CHANGELOG.md | 6 ++++++ README.md | 16 ++++++++-------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c445f1d..5e0500a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## v1.0.0 - First Release +### Runtime and Dependency Baseline +- Raised the minimum supported Rust version to `1.91` and updated local, CI, and release-build documentation accordingly. +- Upgraded the embedded Arti and Tor crate family to `0.45.0`, including the onion-stream API migration required by that release. +- Updated direct dependencies to their latest compatible releases, including `base64 0.23`, `tower-http 0.7`, `infer 0.22`, and `ureq 3.4`. +- Kept `rusqlite` at `0.36` so Cargo resolves a single compatible `libsqlite3-sys` version with Arti `0.45.0`. + ### Release Blockers Fixed - Standardized the release artifact, install docs, CI, and operator commands on the single `rustpost-cli` binary. - Bumped the crate version to `1.0.0` and refreshed `Cargo.lock`. diff --git a/README.md b/README.md index 416a9c9..a11f348 100644 --- a/README.md +++ b/README.md @@ -351,11 +351,11 @@ The active onion address is shown by the running server in its startup/status ou **Current pinned Arti/Tor crates in `Cargo.toml`:** ``` -arti-client = 0.43.0 # bootstraps the embedded Tor client and onion service -tor-hsservice = 0.43.0 # onion-service config, handle, and rendezvous streams -tor-proto = 0.43.0 # inspect and accept incoming onion stream requests -tor-cell = 0.43.0 # cell-level protocol handling -tor-rtcompat = 0.43.0 # Tokio-compatible Arti runtime +arti-client = 0.45.0 # bootstraps the embedded Tor client and onion service +tor-hsservice = 0.45.0 # onion-service config, handle, and rendezvous streams +tor-proto = 0.45.0 # inspect and accept incoming onion stream requests +tor-cell = 0.45.0 # cell-level protocol handling +tor-rtcompat = 0.45.0 # Tokio-compatible Arti runtime rustls = 0.23 # ring crypto provider required by Arti's rustls stack ``` @@ -508,7 +508,7 @@ CI runs: format check → Clippy → tests → release build. A separate strict ### Release Artifacts -Tagged releases matching `v*` produce: +For this release, tag `v1.0.0`. Tagged releases matching `v*` produce: ``` rustpost-linux-x86_64.tar.gz @@ -529,7 +529,7 @@ Each archive contains `rustpost-cli` (`rustpost-cli.exe` on Windows), `README.md | `tokio` | Async runtime, filesystem, process, and signals | | `rusqlite` | SQLite access via a dedicated DB worker; WAL, FK, baseline schema plus forward migrations | | `argon2` | Argon2id password hashing | -| `rand_core` + `uuid` | Secure salts and opaque generated tokens/filenames | +| `getrandom` + `uuid` | OS-generated password 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 | @@ -547,7 +547,7 @@ Each archive contains `rustpost-cli` (`rustpost-cli.exe` on Windows), `README.md ## ✅ Release Verification -*Last sweep: **June 6, 2026*** +*Last sweep: **August 15, 2026*** Release validation distinguishes the required Rust gates from the dependency-advisory review: From 7fb290f5133bca3b71af8cd36a71725075d8fbd5 Mon Sep 17 00:00:00 2001 From: csd113 Date: Fri, 28 Aug 2026 00:04:53 -0700 Subject: [PATCH 13/21] Fix FFmpeg timeout test on Windows --- src/ffmpeg.rs | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/ffmpeg.rs b/src/ffmpeg.rs index 75163b0..361dbd2 100644 --- a/src/ffmpeg.rs +++ b/src/ffmpeg.rs @@ -290,12 +290,20 @@ mod tests { #[tokio::test] async fn ffmpeg_timeout_is_reported() { - let result = super::run_ffmpeg( - "sh", - &["-c".to_owned(), "sleep 2".to_owned()], - std::time::Duration::from_millis(50), - ) - .await; + #[cfg(windows)] + let (program, args) = ( + "powershell.exe", + vec![ + "-NoProfile".to_owned(), + "-NonInteractive".to_owned(), + "-Command".to_owned(), + "Start-Sleep -Seconds 2".to_owned(), + ], + ); + #[cfg(not(windows))] + let (program, args) = ("sh", vec!["-c".to_owned(), "sleep 2".to_owned()]); + + let result = super::run_ffmpeg(program, &args, std::time::Duration::from_millis(50)).await; assert!(result.is_err()); assert!( result From efdcd01f9e4875c95f2023434428fb20b937fd32 Mon Sep 17 00:00:00 2001 From: csd113 Date: Tue, 1 Sep 2026 00:18:05 -0700 Subject: [PATCH 14/21] Ignore local Codex guidance file --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index b5a806e..2d78a84 100644 --- a/.gitignore +++ b/.gitignore @@ -73,6 +73,9 @@ uploads/**/* # macOS .DS_Store **/.DS_Store + +# Local Codex guidance (intentionally not published) +/rules.md /tests !/tests/ !/tests/playwright/ From 710b93bbe0e1d423da94aae509f00467009b766e Mon Sep 17 00:00:00 2001 From: csd113 Date: Sun, 20 Sep 2026 10:16:58 -0700 Subject: [PATCH 15/21] Update Cargo.lock --- Cargo.lock | 459 +++++++++++++++++++++++++++++------------------------ 1 file changed, 255 insertions(+), 204 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 84130ca..f45b193 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,13 +10,13 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aes" -version = "0.9.2" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ "cipher 0.5.2", "cpubits", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "zeroize", ] @@ -242,7 +242,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.119", - "synstructure", + "synstructure 0.13.2", ] [[package]] @@ -264,9 +264,9 @@ checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" [[package]] name = "async-compression" -version = "0.4.43" +version = "0.4.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" +checksum = "fb61aea1a7def73ee7c350a184f0e70b32c182344e2e75bf70c9b621b83417fd" dependencies = [ "compression-codecs", "compression-core", @@ -282,7 +282,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] @@ -405,12 +405,6 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - [[package]] name = "base64" version = "0.23.1" @@ -441,9 +435,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "bitvec" @@ -567,9 +561,9 @@ checksum = "23a6f83e57018d15e7615a6f8e5e2c6b6b0226e24a1c562cd705f855a2bc751a" [[package]] name = "cc" -version = "1.4.3" +version = "1.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" dependencies = [ "find-msvc-tools", "shlex", @@ -588,18 +582,18 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "rand_core 0.10.1", ] @@ -641,9 +635,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.6" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ "clap_builder", "clap_derive", @@ -651,9 +645,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.6" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" dependencies = [ "anstream", "anstyle", @@ -663,21 +657,21 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.4" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] name = "clap_lex" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" [[package]] name = "coarsetime" @@ -704,9 +698,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "compression-codecs" -version = "0.4.38" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +checksum = "bef16c47ba2797aa6a909cc37d39911f3a6743811fe7408ac0b0cc0276b656e9" dependencies = [ "compression-core", "flate2", @@ -714,9 +708,9 @@ dependencies = [ [[package]] name = "compression-core" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" [[package]] name = "const-oid" @@ -754,6 +748,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "cpubits" version = "0.1.1" @@ -771,36 +771,36 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" dependencies = [ "cfg-if", ] [[package]] name = "crossbeam-queue" -version = "0.3.13" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.22" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" [[package]] name = "crypto-bigint" @@ -891,12 +891,12 @@ dependencies = [ [[package]] name = "darling" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", + "darling_core 0.24.1", + "darling_macro 0.24.1", ] [[package]] @@ -928,15 +928,15 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" dependencies = [ "ident_case", "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] @@ -963,13 +963,13 @@ dependencies = [ [[package]] name = "darling_macro" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ - "darling_core 0.23.0", + "darling_core 0.24.1", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] @@ -1060,14 +1060,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba91f619216e76a5eb2515783f7ec2e271938b51aadac592f4930517f4626863" dependencies = [ "heck", - "indexmap 2.14.0", + "indexmap 2.14.2", "itertools", "proc-macro-crate", "proc-macro2", "quote", "sha3 0.12.0", "strum", - "syn 3.0.3", + "syn 3.0.6", "unicode-ident", "void", ] @@ -1187,7 +1187,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] @@ -1256,9 +1256,9 @@ dependencies = [ [[package]] name = "either" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "elliptic-curve" @@ -1281,11 +1281,17 @@ dependencies = [ [[package]] name = "encoding_rs" -version = "0.8.35" +version = "0.8.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a" dependencies = [ "cfg-if", + "core_detect", + "multiversion", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", ] [[package]] @@ -1439,18 +1445,18 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.11" +version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.9.1", "zlib-rs", ] @@ -1500,9 +1506,9 @@ dependencies = [ [[package]] name = "fslock-guard" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd682ede578019974b784324792fe72890bb6a3344428173327b60f61c30f4d2" +checksum = "d10ce5036d49f8add4a11d08bc1b1afb0e3cefdd88778ab87fb4202d47b765e1" dependencies = [ "libc", "thiserror 2.0.20", @@ -1571,7 +1577,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] @@ -1847,9 +1853,9 @@ dependencies = [ [[package]] name = "hybrid-array" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ "typenum", "zeroize", @@ -1857,9 +1863,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ "atomic-waker", "bytes", @@ -1984,9 +1990,9 @@ checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.3.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -2060,9 +2066,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", @@ -2085,7 +2091,7 @@ version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "inotify-sys", "libc", ] @@ -2128,9 +2134,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.12.1" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" [[package]] name = "iprange" @@ -2164,9 +2170,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.35" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" dependencies = [ "defmt", "jiff-core", @@ -2181,18 +2187,19 @@ dependencies = [ [[package]] name = "jiff-core" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" dependencies = [ "defmt", + "log", ] [[package]] name = "jiff-static" -version = "0.2.35" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" dependencies = [ "jiff-core", "proc-macro2", @@ -2217,9 +2224,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.104" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" dependencies = [ "cfg-if", "futures-util", @@ -2247,12 +2254,12 @@ dependencies = [ [[package]] name = "keccak" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffd9697dc4a9a62e2da93389f34400b77a28f0287711263cabb203b3ccb9c0e4" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", ] [[package]] @@ -2271,7 +2278,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "libc", ] @@ -2298,9 +2305,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.20" +version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" +checksum = "6480ccc157a1389bb2e4891b24751b0f798ba640d22386f23143fbcc89da195a" dependencies = [ "libc", ] @@ -2351,9 +2358,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "matchers" @@ -2429,11 +2436,21 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "log", @@ -2458,6 +2475,33 @@ dependencies = [ "version_check", ] +[[package]] +name = "multiversion" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c" +dependencies = [ + "multiversion-macros", +] + +[[package]] +name = "multiversion-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83" +dependencies = [ + "proc-macro2", + "quote", + "rustversion", + "syn 3.0.6", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "nom" version = "7.1.3" @@ -2480,7 +2524,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "inotify", "kqueue", "libc", @@ -2497,7 +2541,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", ] [[package]] @@ -2539,7 +2583,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.7", + "rand 0.8.8", "smallvec", "zeroize", ] @@ -2607,7 +2651,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", ] [[package]] @@ -2870,7 +2914,7 @@ dependencies = [ "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -2881,9 +2925,9 @@ checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "portable-atomic-util" -version = "0.2.7" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" dependencies = [ "portable-atomic", ] @@ -2943,7 +2987,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93980406f12d9f8140ed5abe7155acb10bb1e69ea55c88960b9c2f117445ef96" dependencies = [ "equivalent", - "indexmap 2.14.0", + "indexmap 2.14.2", "serde", ] @@ -2953,7 +2997,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.13+spec-1.1.0", + "toml_edit 0.25.15+spec-1.1.0", ] [[package]] @@ -3006,9 +3050,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "rand_chacha 0.3.1", "rand_core 0.6.4", @@ -3122,38 +3166,37 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", ] [[package]] name = "redox_users" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" dependencies = [ - "getrandom 0.2.17", "libredox", "thiserror 2.0.20", ] [[package]] name = "ref-cast" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] @@ -3264,7 +3307,7 @@ version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3de23c3319433716cf134eed225fe9986bc24f63bed9be9f20c329029e672dc7" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -3293,11 +3336,11 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys", @@ -3306,9 +3349,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "log", "once_cell", @@ -3330,9 +3373,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.14" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", @@ -3367,7 +3410,7 @@ dependencies = [ "tempfile", "thiserror 2.0.20", "tokio", - "toml 1.1.4+spec-1.1.0", + "toml 1.1.6+spec-1.1.0", "tor-cell", "tor-hsservice", "tor-proto", @@ -3526,7 +3569,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] @@ -3595,16 +3638,16 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.22.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bs58", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.14.0", + "indexmap 2.14.2", "jiff", "schemars 0.9.0", "schemars 1.2.2", @@ -3616,14 +3659,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.22.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" dependencies = [ - "darling 0.23.0", + "darling 0.24.1", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] @@ -3655,7 +3698,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -3676,7 +3719,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" dependencies = [ "digest 0.11.3", - "keccak 0.2.1", + "keccak 0.2.2", "sponge-cursor", ] @@ -3732,6 +3775,12 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.3" @@ -3769,9 +3818,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" [[package]] name = "socket2" @@ -3922,9 +3971,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.3" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" dependencies = [ "proc-macro2", "quote", @@ -3948,6 +3997,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "sysinfo" version = "0.38.4" @@ -4029,7 +4089,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] @@ -4084,18 +4144,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" @@ -4121,7 +4172,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] @@ -4152,11 +4203,11 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.4+spec-1.1.0" +version = "1.1.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "serde_core", "serde_spanned 1.1.1", "toml_datetime 1.1.1+spec-1.1.0", @@ -4189,7 +4240,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", @@ -4199,11 +4250,11 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.13+spec-1.1.0" +version = "0.25.15+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "winnow 1.0.4", @@ -4306,7 +4357,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8bb1363898e3a5eebc3fd0bff63e00449810d4ad96699b685a8537b92929958" dependencies = [ "amplify", - "bitflags 2.13.1", + "bitflags 2.13.2", "bytes", "caret", "derive-deftly", @@ -4483,7 +4534,7 @@ dependencies = [ "serde_ignored", "strum", "thiserror 2.0.20", - "toml 1.1.4+spec-1.1.0", + "toml 1.1.6+spec-1.1.0", "tor-basic-utils", "tor-error", "tor-rtcompat", @@ -5040,7 +5091,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4214b180dae5f489253a2495322cf2c83f3bc7a4b246747cc6bf2ad1cba41ca1" dependencies = [ "async-trait", - "bitflags 2.13.1", + "bitflags 2.13.2", "derive_more", "digest 0.10.7", "futures", @@ -5359,11 +5410,11 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233" +checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "bytes", "futures-core", "futures-util", @@ -5511,9 +5562,9 @@ checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] name = "unicode-segmentation" @@ -5541,9 +5592,9 @@ checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" [[package]] name = "ureq" -version = "3.4.0" +version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" dependencies = [ "base64 0.23.1", "log", @@ -5557,9 +5608,9 @@ dependencies = [ [[package]] name = "ureq-proto" -version = "0.6.1" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" +checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" dependencies = [ "base64 0.23.1", "http", @@ -5599,9 +5650,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.24.1" +version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -5680,9 +5731,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" dependencies = [ "cfg-if", "once_cell", @@ -5693,9 +5744,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5703,22 +5754,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" dependencies = [ "unicode-ident", ] @@ -6143,30 +6194,30 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", - "synstructure", + "syn 3.0.6", + "synstructure 0.14.0", ] [[package]] name = "zerocopy" -version = "0.8.56" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.56" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" dependencies = [ "proc-macro2", "quote", @@ -6184,14 +6235,14 @@ dependencies = [ [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", - "synstructure", + "syn 3.0.6", + "synstructure 0.14.0", ] [[package]] @@ -6227,9 +6278,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.7" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "serde", "yoke", @@ -6239,20 +6290,20 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.4" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] name = "zlib-rs" -version = "0.6.7" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" [[package]] name = "zmij" From bd0106bc2d3e62c7810c949d74e9b17284a1bf5e Mon Sep 17 00:00:00 2001 From: csd113 Date: Fri, 25 Sep 2026 17:13:05 -0700 Subject: [PATCH 16/21] updates --- Cargo.toml | 2 +- README.md | 13 + src/admin.rs | 2 +- src/auth.rs | 39 +- src/backup.rs | 156 +++++- src/cli.rs | 12 +- src/db.rs | 197 +++++++- src/media.rs | 308 +++++++++++- src/rate_limit.rs | 30 +- src/render.rs | 192 ++++++-- src/runtime.rs | 40 ++ src/server.rs | 976 +++++++++++++++++++++++++++++++++----- src/social.rs | 1155 +++++++++++++++++++++++++++++++++------------ 13 files changed, 2623 insertions(+), 499 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 4a14d92..27d18d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ safelog = "0.9.1" serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" sha2 = "0.11.0" -rusqlite = { version = "0.36.0", features = ["bundled"] } +rusqlite = { version = "0.36.0", features = ["bundled", "functions"] } rustls = { version = "0.23.40", default-features = false, features = ["ring"] } tar = "0.4.46" tempfile = "3.27.0" diff --git a/README.md b/README.md index a11f348..3ad741e 100644 --- a/README.md +++ b/README.md @@ -54,9 +54,22 @@ This repository is intended for operators who want a small, understandable self- | Follows, Blocks & Mutes | Standard social graph primitives | | Likes & Bookmarks | Likes are public; bookmarks are private | | Notifications | In-app notification feed | +| Muted words | Per-user phrase list applied server-side | | Search | Full-text search via SQLite FTS5 + user matching | | Anonymous posting | Supported but **disabled by default** | +### Blocking, muting, and muted words + +RustPost enforces these rules in the database and query layer, not only in the UI. + +| Action | Effect | +|---|---| +| Block | Removes follow relationships in both directions. The blocker and the blocked account cannot follow, reply to, quote, like, repost, bookmark, or mention each other's content, and neither account's posts appear in the other's feeds, threads, search results, mention suggestions, or notifications. The blocked account sees the profile without activity; neither account's display name, bio, or counts are removed from the database. Unblocking restores normal visibility and interactions. | +| Mute | Hides the muted account's posts from the muter's feeds, search, mention suggestions, notifications, and profile activity. Mutes do not notify the muted account. | +| Muted words | Per-user list, editable in Settings. Matching is case-insensitive (Unicode-aware), does not use regular expressions, and matches anywhere in the post text, so muting `cat` also hides `concatenate`. Matching applies to home, profile, media, likes, search, and quote previews, and to notification previews for other people's posts. Your own posts are never hidden by your own muted words. | + +Block and mute rows are deleted when either account is deleted, and deleting a post or account removes the associated media files once no other row references them. + ### Media Pipeline | Feature | Details | |---|---| diff --git a/src/admin.rs b/src/admin.rs index 963393a..831a234 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -1188,7 +1188,7 @@ pub async fn reset_admin_password( password: &str, ) -> anyhow::Result<()> { crate::validation::validate_password(password, settings)?; - let hash = auth::hash_password(password)?; + let hash = auth::hash_password_async(password.to_owned()).await?; let username = username.trim().to_ascii_lowercase(); let changed = pool .call(move |conn| { diff --git a/src/auth.rs b/src/auth.rs index 2894a0d..499bc2e 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -71,7 +71,7 @@ pub async fn register_user( ) -> anyhow::Result { let normalized = normalize_username(username, settings.accounts.max_username_len)?; validate_password(password, settings)?; - let hash = hash_password(password)?; + let hash = hash_password_async(password.to_owned()).await?; let username = username.trim().to_owned(); pool.call(move |conn| { let existing = conn @@ -124,7 +124,7 @@ pub async fn login( if row.2 != 0 || row.3 != 0 { return Ok(Err(LoginFailure::UnavailableAccount)); } - if !verify_password(password, &row.1)? { + if !verify_password_async(password.to_owned(), row.1.clone()).await? { return Ok(Err(LoginFailure::InvalidPassword)); } create_session(pool, row.0).await.map(Ok) @@ -148,6 +148,23 @@ pub async fn create_session(pool: &SqlitePool, user_id: i64) -> anyhow::Result anyhow::Result { + let modifier = format!("-{grace_days} days"); + pool.call(move |conn| { + let changed = conn.execute( + r#" + DELETE FROM sessions + WHERE (revoked_at IS NOT NULL AND datetime(revoked_at) < datetime('now', ?1)) + OR datetime(expires_at) < datetime('now', ?1) + "#, + [modifier], + )?; + Ok(changed) + }) + .await +} + pub async fn revoke_session(pool: &SqlitePool, token: &str) -> anyhow::Result<()> { let token_hash = hash_token(token); pool.call(move |conn| { @@ -179,7 +196,21 @@ pub async fn verify_user_password( let Some(hash) = hash else { return Ok(false); }; - verify_password(password, &hash) + verify_password_async(password.to_owned(), hash).await +} + +/// Argon2id hashing is intentionally CPU and memory heavy; run it on the +/// blocking pool so it cannot stall the async runtime under load. +pub async fn hash_password_async(password: String) -> anyhow::Result { + tokio::task::spawn_blocking(move || hash_password(&password)) + .await + .context("password hashing task failed")? +} + +async fn verify_password_async(password: String, encoded_hash: String) -> anyhow::Result { + tokio::task::spawn_blocking(move || verify_password(&password, &encoded_hash)) + .await + .context("password verification task failed")? } pub async fn change_password( @@ -198,7 +229,7 @@ pub async fn change_password( if !verify_user_password(pool, user_id, current_password).await? { anyhow::bail!("current password is incorrect"); } - let hash = hash_password(new_password)?; + let hash = hash_password_async(new_password.to_owned()).await?; let current_session_hash = current_session_token.map(hash_token); pool.call(move |conn| { let tx = conn.transaction()?; diff --git a/src/backup.rs b/src/backup.rs index 68afa48..22be297 100644 --- a/src/backup.rs +++ b/src/backup.rs @@ -6,7 +6,7 @@ use std::time::{Duration, SystemTime}; use anyhow::Context as _; use chrono::{SecondsFormat, Utc}; -use rusqlite::{Connection, OptionalExtension as _}; +use rusqlite::{Connection, OptionalExtension as _, params}; use serde::{Deserialize, Serialize}; use sha2::{Digest as _, Sha256}; use tar::{Archive, Builder, EntryType, Header}; @@ -109,6 +109,7 @@ pub fn restore_backup( let staged = extract_archive_to_stage(archive_path, staging.path(), include_tor_keys) .with_context(|| "backup archive failed validation")?; validate_staged_backup(staging.path(), &staged, include_tor_keys)?; + rewrite_restored_media_paths(&staging.path().join("db/rustpost.sqlite3"), paths)?; let pre_restore_backup = create_backup_inner(paths, include_tor_keys, BackupKind::PreRestore, false) .with_context(|| "failed to create pre-restore safety backup")?; @@ -720,6 +721,17 @@ fn validate_staged_backup( } require_manifest_file(&manifest_entries, "db/rustpost.sqlite3")?; require_manifest_file(&manifest_entries, "settings.toml")?; + // Require the upload directories even when they are empty. Restore replaces + // the live upload roots, so an archive that silently omits them would wipe + // every media file referenced by the restored database. + for dir in [ + "uploads/originals", + "uploads/images", + "uploads/videos", + "uploads/thumbs", + ] { + require_manifest_directory(&manifest_entries, dir)?; + } for (path, manifest_entry) in manifest_entries { let extracted = staged @@ -802,6 +814,99 @@ fn require_manifest_file( Ok(()) } +fn require_manifest_directory( + manifest_entries: &BTreeMap, + path: &str, +) -> anyhow::Result<()> { + if manifest_entries + .get(path) + .is_none_or(|entry| entry.kind != ManifestEntryKind::Directory) + { + anyhow::bail!("backup manifest is missing required directory {path}"); + } + Ok(()) +} + +/// Rewrites absolute media paths in the staged database so they point at the +/// destination data directory. +/// +/// Media rows store absolute host paths. Restoring to a different data +/// directory would otherwise leave every path pointing at the source machine's +/// filesystem, which breaks media deletion, dedupe, and account deletion even +/// though the files themselves were restored correctly. +fn rewrite_restored_media_paths(database_path: &Path, paths: &RuntimePaths) -> anyhow::Result<()> { + let mut conn = Connection::open(database_path).with_context(|| { + format!( + "failed to open restored database {}", + database_path.display() + ) + })?; + conn.busy_timeout(Duration::from_secs(5))?; + let roots = [ + ("/uploads/originals/", &paths.uploads_originals), + ("/uploads/images/", &paths.uploads_images), + ("/uploads/videos/", &paths.uploads_videos), + ("/uploads/thumbs/", &paths.uploads_thumbs), + ]; + let tx = conn.transaction()?; + let rows = { + let mut stmt = tx.prepare( + "SELECT id, original_path, stored_path, thumbnail_path FROM media ORDER BY id", + )?; + stmt.query_map([], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, String>(2)?, + row.get::<_, Option>(3)?, + )) + })? + .collect::, _>>()? + }; + let mut rewritten = 0usize; + for (id, original_path, stored_path, thumbnail_path) in rows { + let new_original = original_path + .as_deref() + .map(|path| remap_media_path(path, &roots)); + let new_stored = remap_media_path(&stored_path, &roots); + let new_thumbnail = thumbnail_path + .as_deref() + .map(|path| remap_media_path(path, &roots)); + if new_original.as_deref() == original_path.as_deref() + && new_stored == stored_path + && new_thumbnail.as_deref() == thumbnail_path.as_deref() + { + continue; + } + tx.execute( + "UPDATE media SET original_path = ?, stored_path = ?, thumbnail_path = ? WHERE id = ?", + params![new_original, new_stored, new_thumbnail, id], + )?; + rewritten += 1; + } + tx.commit()?; + if rewritten > 0 { + tracing::info!( + rewritten, + "rewrote restored media paths for the destination data directory" + ); + } + Ok(()) +} + +fn remap_media_path(path: &str, roots: &[(&str, &PathBuf)]) -> String { + for (marker, root) in roots { + if let Some(index) = path.find(marker) { + let relative = &path[index + marker.len()..]; + if relative.is_empty() { + return path.to_owned(); + } + return root.join(relative).to_string_lossy().to_string(); + } + } + path.to_owned() +} + fn swap_staged_runtime( paths: &RuntimePaths, staging_dir: &Path, @@ -1289,6 +1394,29 @@ mod tests { (temp, paths) } + #[test] + fn restored_media_paths_are_remapped_to_the_destination() { + let originals = PathBuf::from("/new/data/uploads/originals"); + let roots: [(&str, &PathBuf); 1] = [("/uploads/originals/", &originals)]; + + assert_eq!( + remap_media_path("/old/data/uploads/originals/a.png", &roots), + "/new/data/uploads/originals/a.png" + ); + assert_eq!( + remap_media_path("/new/data/uploads/originals/a.png", &roots), + "/new/data/uploads/originals/a.png" + ); + assert_eq!( + remap_media_path("/unrelated/place/a.png", &roots), + "/unrelated/place/a.png" + ); + assert_eq!( + remap_media_path("/old/data/uploads/originals/", &roots), + "/old/data/uploads/originals/" + ); + } + #[test] fn backup_path_safety() { assert!(validate_archive_path(Path::new("uploads/images/a.webp"), false).is_ok()); @@ -1532,6 +1660,32 @@ mod tests { assert!(error.to_string().contains("already running")); } + #[test] + fn restore_rejects_archives_that_omit_upload_directories() { + let source_temp = tempfile::tempdir().expect("source"); + let source = RuntimePaths::from_data_dir(source_temp.path().join("source")); + source.ensure().expect("source ensure"); + test_db(&source.database_path, CURRENT_SCHEMA_VERSION); + test_settings(&source.settings_path); + let archive = create_backup(&source, false).expect("backup"); + + let staging = tempfile::tempdir().expect("staging"); + let mut staged = + extract_archive_to_stage(&archive, staging.path(), false).expect("extract"); + assert!(staged.entries.remove("uploads/images").is_some()); + staged + .manifest + .components + .retain(|entry| entry.archive_path != "uploads/images"); + + let error = validate_staged_backup(staging.path(), &staged, false) + .expect_err("missing upload directory must be rejected"); + assert!( + error.to_string().contains("uploads/images"), + "unexpected error: {error}" + ); + } + #[test] fn automatic_retention_only_deletes_automatic_backups() { let (_temp, paths) = test_paths(); diff --git a/src/cli.rs b/src/cli.rs index 103cda2..e7e251e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -102,6 +102,9 @@ pub async fn run() -> anyhow::Result<()> { paths = paths .with_tor_data_dir(&settings.tor.data_dir) .with_backup_dir(&settings.backup.backup_dir); + // Keep backup and restore pointed at the settings file the operator + // actually loaded, including an explicit --config path. + paths.settings_path = settings_path.clone(); paths.ensure()?; info!(data_dir = %paths.data_dir.display(), settings = %settings_path.display(), "runtime paths ready"); @@ -338,10 +341,17 @@ async fn serve( }, ) .await?; - let state = server::AppState::new(pool, settings.clone(), paths.clone(), ffmpeg, tor_status); + let state = server::AppState::new( + pool.clone(), + settings.clone(), + paths.clone(), + ffmpeg, + tor_status, + ); let app = server::router(state); let shutdown_rx = shutdown_receiver(); backup::spawn_automatic_scheduler(paths.clone(), settings_path, shutdown_rx.clone()); + server::spawn_maintenance_scheduler(pool, paths.clone(), shutdown_rx.clone()); if settings.tor.tor_only { return serve_tor_only(onion_listener, app, shutdown_rx).await; diff --git a/src/db.rs b/src/db.rs index d256062..baf0cd4 100644 --- a/src/db.rs +++ b/src/db.rs @@ -6,9 +6,12 @@ use std::time::Duration; use anyhow::Context as _; use rusqlite::{Connection, OpenFlags, OptionalExtension as _}; -pub const CURRENT_SCHEMA_VERSION: i64 = 2; +pub const CURRENT_SCHEMA_VERSION: i64 = 3; -const PREVIOUS_RELEASE_SCHEMA_VERSION: i64 = 1; +/// Pre-1.0 release schema version 1 (without the YouTube embed tables). +const RELEASE_V1_SCHEMA_VERSION: i64 = 1; +/// Release 1.0.0 shipped schema version 2 (the YouTube embed tables). +const RELEASE_V2_SCHEMA_VERSION: i64 = 2; const OLD_ALPHA_SCHEMA_VERSION: i64 = 13; const INCOMPATIBLE_DATABASE_HINT: &str = "back up or export the instance, recreate a fresh RustPost data directory, and restore from a known-good backup instead of attempting a blind migration"; @@ -70,9 +73,35 @@ fn open_connection(path: &Path) -> anyhow::Result { conn.busy_timeout(Duration::from_secs(5))?; conn.pragma_update(None, "foreign_keys", "ON")?; conn.pragma_update(None, "journal_mode", "WAL")?; + register_sql_helpers(&conn)?; Ok(conn) } +/// Registers the Rust-backed SQL helpers used by application queries. +/// +/// `rustpost_muted_word_match(text, term)` performs a Unicode-aware, +/// case-insensitive substring test. SQLite's built-in `lower()` only folds +/// ASCII characters, which made muted-word matching miss uppercase accented +/// text. Registering this on the connection keeps the check inside SQL so +/// muted rows are filtered before `LIMIT` is applied. +fn register_sql_helpers(conn: &Connection) -> anyhow::Result<()> { + conn.create_scalar_function( + "rustpost_muted_word_match", + 2, + rusqlite::functions::FunctionFlags::SQLITE_UTF8 + | rusqlite::functions::FunctionFlags::SQLITE_DETERMINISTIC, + |context| { + let text = context.get::>(0)?.unwrap_or_default(); + let term = context.get::>(1)?.unwrap_or_default(); + if term.is_empty() { + return Ok(false); + } + Ok(text.to_lowercase().contains(&term.to_lowercase())) + }, + )?; + Ok(()) +} + pub async fn migrate(pool: &Db) -> anyhow::Result<()> { pool.call(|conn| { let tx = conn.transaction()?; @@ -176,6 +205,7 @@ impl SchemaReport { enum MigrationState { Empty, Baseline, + ReleaseVersion2, ReleaseVersion1, AlphaLatest, Incompatible(String), @@ -217,6 +247,7 @@ fn ensure_release_baseline(conn: &Connection) -> anyhow::Result<()> { match migration_state(conn)? { MigrationState::Empty => initialize_release_baseline(conn)?, MigrationState::Baseline => {} + MigrationState::ReleaseVersion2 => migrate_release_v2_to_current(conn)?, MigrationState::ReleaseVersion1 => migrate_release_v1_to_current(conn)?, MigrationState::AlphaLatest => adopt_latest_alpha_schema(conn)?, MigrationState::Incompatible(reason) => { @@ -233,10 +264,19 @@ fn validate_or_normalize_restorable_schema( ) -> anyhow::Result<()> { match migration_state(conn)? { MigrationState::Baseline => validate_schema(conn), + MigrationState::ReleaseVersion2 => { + if !normalize { + anyhow::bail!( + "database schema version {RELEASE_V2_SCHEMA_VERSION} requires migration before use" + ); + } + migrate_release_v2_to_current(conn)?; + validate_schema(conn) + } MigrationState::ReleaseVersion1 => { if !normalize { anyhow::bail!( - "database schema version {PREVIOUS_RELEASE_SCHEMA_VERSION} requires migration before use" + "database schema version {RELEASE_V1_SCHEMA_VERSION} requires migration before use" ); } migrate_release_v1_to_current(conn)?; @@ -252,7 +292,7 @@ fn validate_or_normalize_restorable_schema( ); } if normalize { - reset_schema_migrations_to(conn, PREVIOUS_RELEASE_SCHEMA_VERSION)?; + reset_schema_migrations_to(conn, RELEASE_V1_SCHEMA_VERSION)?; migrate_release_v1_to_current(conn)?; validate_schema(conn)?; } @@ -302,7 +342,10 @@ fn migration_state(conn: &Connection) -> anyhow::Result { if versions == [CURRENT_SCHEMA_VERSION] { return Ok(MigrationState::Baseline); } - if versions == [PREVIOUS_RELEASE_SCHEMA_VERSION] { + if versions == [RELEASE_V2_SCHEMA_VERSION] { + return Ok(MigrationState::ReleaseVersion2); + } + if versions == [RELEASE_V1_SCHEMA_VERSION] { return Ok(MigrationState::ReleaseVersion1); } if latest == OLD_ALPHA_SCHEMA_VERSION { @@ -338,7 +381,7 @@ fn adopt_latest_alpha_schema(conn: &Connection) -> anyhow::Result<()> { INCOMPATIBLE_DATABASE_HINT ); } - reset_schema_migrations_to(conn, PREVIOUS_RELEASE_SCHEMA_VERSION)?; + reset_schema_migrations_to(conn, RELEASE_V1_SCHEMA_VERSION)?; migrate_release_v1_to_current(conn) } @@ -365,6 +408,14 @@ CREATE TABLE schema_migrations ( fn migrate_release_v1_to_current(conn: &Connection) -> anyhow::Result<()> { conn.execute_batch(YOUTUBE_EMBEDS_SCHEMA)?; + reset_schema_migrations_to(conn, RELEASE_V2_SCHEMA_VERSION)?; + migrate_release_v2_to_current(conn) +} + +/// Adds the release 1.0.x indexes used by thread assembly, media ownership, +/// session revocation, media reference checks, and rate-limit pruning. +fn migrate_release_v2_to_current(conn: &Connection) -> anyhow::Result<()> { + conn.execute_batch(RELEASE_V3_INDEXES)?; reset_schema_migrations(conn) } @@ -421,6 +472,8 @@ fn inspect_schema_objects(conn: &Connection) -> anyhow::Result> { } fn inspect_alpha_schema_objects(conn: &Connection) -> anyhow::Result> { + // Objects introduced after the alpha schema are reported as missing here + // but are created by the migration chain during adoption. Ok(inspect_schema_objects(conn)? .into_iter() .filter(|issue| { @@ -429,6 +482,11 @@ fn inspect_alpha_schema_objects(conn: &Connection) -> anyhow::Result "missing table post_embeds" | "missing index idx_post_embeds_post" | "missing index idx_post_embeds_video" + | "missing index idx_posts_root" + | "missing index idx_media_owner" + | "missing index idx_sessions_user" + | "missing index idx_post_media_media" + | "missing index idx_rate_limit_created" ) }) .collect()) @@ -633,6 +691,16 @@ fn summarize_issues(issues: &[String]) -> String { summary } +/// Schema additions introduced with schema version 3. Applied on top of a +/// version 2 database; every statement is idempotent. +const RELEASE_V3_INDEXES: &str = r#" +CREATE INDEX IF NOT EXISTS idx_posts_root ON posts(root_post_id); +CREATE INDEX IF NOT EXISTS idx_media_owner ON media(owner_user_id); +CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_post_media_media ON post_media(media_id); +CREATE INDEX IF NOT EXISTS idx_rate_limit_created ON rate_limit_events(created_at); +"#; + const YOUTUBE_EMBEDS_SCHEMA: &str = r#" CREATE TABLE IF NOT EXISTS post_embeds ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -661,7 +729,7 @@ CREATE TABLE schema_migrations ( applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); -INSERT INTO schema_migrations (version) VALUES (2); +INSERT INTO schema_migrations (version) VALUES (3); CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -863,6 +931,7 @@ END; CREATE INDEX idx_posts_created ON posts(created_at DESC, id DESC); CREATE INDEX idx_posts_user_created ON posts(user_id, created_at DESC, id DESC); CREATE INDEX idx_posts_parent ON posts(parent_post_id, created_at ASC, id ASC); +CREATE INDEX idx_posts_root ON posts(root_post_id); CREATE INDEX idx_follows_followed ON follows(followed_id, follower_id); CREATE INDEX idx_blocks_blocked ON blocks(blocked_id, blocker_id); CREATE INDEX idx_mutes_muted ON mutes(muted_id, muter_id); @@ -893,6 +962,10 @@ CREATE INDEX idx_notifications_user_id_desc ON notifications(user_id, id DESC); CREATE INDEX idx_notifications_dedupe ON notifications(user_id, actor_user_id, post_id, kind); CREATE INDEX idx_media_is_nsfw ON media(is_nsfw); +CREATE INDEX idx_media_owner ON media(owner_user_id); +CREATE INDEX idx_sessions_user ON sessions(user_id); +CREATE INDEX idx_post_media_media ON post_media(media_id); +CREATE INDEX idx_rate_limit_created ON rate_limit_events(created_at); CREATE INDEX idx_media_original_sha256 ON media(media_kind, original_sha256) @@ -997,6 +1070,31 @@ const REQUIRED_INDEXES: &[RequiredIndex] = &[ name: "idx_posts_parent", unique: false, }, + RequiredIndex { + table: "posts", + name: "idx_posts_root", + unique: false, + }, + RequiredIndex { + table: "media", + name: "idx_media_owner", + unique: false, + }, + RequiredIndex { + table: "sessions", + name: "idx_sessions_user", + unique: false, + }, + RequiredIndex { + table: "post_media", + name: "idx_post_media_media", + unique: false, + }, + RequiredIndex { + table: "rate_limit_events", + name: "idx_rate_limit_created", + unique: false, + }, RequiredIndex { table: "follows", name: "idx_follows_followed", @@ -2326,7 +2424,7 @@ mod tests { pool.call(|conn| { initialize_release_baseline(conn)?; conn.execute("DROP TABLE post_embeds", [])?; - reset_schema_migrations_to(conn, PREVIOUS_RELEASE_SCHEMA_VERSION)?; + reset_schema_migrations_to(conn, RELEASE_V1_SCHEMA_VERSION)?; Ok(()) }) .await @@ -2350,6 +2448,55 @@ mod tests { assert!(report.is_compatible(), "{}", report.summary()); } + #[tokio::test] + async fn release_version_2_databases_gain_release_indexes_without_data_loss() { + let temp = tempfile::tempdir().expect("temp dir"); + let pool = connect(&temp.path().join("test.sqlite3")) + .await + .expect("connect"); + pool.call(|conn| { + initialize_release_baseline(conn)?; + conn.execute( + "INSERT INTO users (username, normalized_username, password_hash, display_name) VALUES ('Alice', 'alice', 'hash', 'Alice')", + [], + )?; + for index in [ + "idx_posts_root", + "idx_media_owner", + "idx_sessions_user", + "idx_post_media_media", + "idx_rate_limit_created", + ] { + conn.execute(&format!("DROP INDEX {index}"), [])?; + } + reset_schema_migrations_to(conn, RELEASE_V2_SCHEMA_VERSION)?; + Ok(()) + }) + .await + .expect("version 2 fixture"); + + migrate(&pool).await.expect("migrate version 2"); + + let (version, users, root_index) = pool + .call(|conn| { + Ok(( + schema_version_from_connection(conn)?, + conn.query_row("SELECT COUNT(*) FROM users", [], |row| row.get::<_, i64>(0))?, + table_indexes(conn, "posts")? + .iter() + .any(|(name, _unique)| name == "idx_posts_root"), + )) + }) + .await + .expect("post-migration state"); + let report = schema_report(&pool).await.expect("schema report"); + + assert_eq!(version, CURRENT_SCHEMA_VERSION); + assert_eq!(users, 1); + assert!(root_index); + assert!(report.is_compatible(), "{}", report.summary()); + } + #[tokio::test] async fn users_get_empty_location_by_default() { let temp = tempfile::tempdir().expect("temp dir"); @@ -2460,6 +2607,36 @@ mod tests { assert_eq!(liked_posts_public, 1); } + #[tokio::test] + async fn alpha_schema_missing_release_objects_is_adopted_and_backfilled() { + let temp = tempfile::tempdir().expect("temp dir"); + let pool = connect(&temp.path().join("test.sqlite3")) + .await + .expect("connect"); + pool.call(|conn| { + install_latest_alpha_fixture(conn)?; + conn.execute("DROP TABLE post_embeds", [])?; + for index in [ + "idx_posts_root", + "idx_media_owner", + "idx_sessions_user", + "idx_post_media_media", + "idx_rate_limit_created", + ] { + conn.execute(&format!("DROP INDEX {index}"), [])?; + } + Ok(()) + }) + .await + .expect("alpha fixture without release objects"); + + migrate(&pool).await.expect("adopt alpha schema"); + let report = schema_report(&pool).await.expect("schema report"); + + assert_eq!(report.version(), Some(CURRENT_SCHEMA_VERSION)); + assert!(report.is_compatible(), "{}", report.summary()); + } + #[tokio::test] async fn latest_alpha_schema_is_marked_as_baseline_without_data_loss() { let temp = tempfile::tempdir().expect("temp dir"); @@ -2649,7 +2826,9 @@ mod tests { let conn = Connection::open(&path).expect("open"); let version = schema_version_from_connection(&conn).expect("version"); - assert!(summary.contains("release baseline schema version 2")); + assert!(summary.contains(&format!( + "release baseline schema version {CURRENT_SCHEMA_VERSION}" + ))); assert!(!summary.contains("version 13")); assert_eq!(version, OLD_ALPHA_SCHEMA_VERSION); } diff --git a/src/media.rs b/src/media.rs index 8786c2c..e4b18a5 100644 --- a/src/media.rs +++ b/src/media.rs @@ -42,6 +42,7 @@ struct StoredMedia { media_kind: String, original_sha256: String, normalized_sha256: Option, + is_nsfw: bool, } #[derive(Debug, Clone)] @@ -240,12 +241,20 @@ async fn prepare_staged_upload( settings: &Settings, upload: StagedUpload, ) -> anyhow::Result { - let data = tokio::fs::read(upload.staging.path()).await?; - let Some(kind) = infer::get(&data) else { + let (kind, original_sha256, prefix) = sniff_and_hash(upload.staging.path()).await?; + let Some(kind) = kind else { anyhow::bail!("unsupported media type"); }; let mime = kind.mime_type().to_owned(); let media_kind = classify(settings, &mime, upload.bytes)?; + if media_kind == MediaKind::Image + && let Some((width, height)) = image_dimensions(&prefix) + { + let pixels = u64::from(width).saturating_mul(u64::from(height)); + if pixels > MAX_IMAGE_PIXELS { + anyhow::bail!("image dimensions exceed the maximum supported size"); + } + } Ok(PreparedUpload { owner_user_id: upload.owner_user_id, original_filename: upload.original_filename, @@ -253,10 +262,137 @@ async fn prepare_staged_upload( bytes: upload.bytes, mime, media_kind, - original_sha256: sha256_hex(&data), + original_sha256, }) } +/// Upper bound used to reject decompression-bomb style images before ffmpeg +/// decodes them. 100 megapixels is far above any realistic profile or post +/// image while keeping a full RGBA decode under ~400 MB. +const MAX_IMAGE_PIXELS: u64 = 100_000_000; + +/// Reads width/height from the headers of the image formats RustPost accepts. +/// Returns `None` for unknown or truncated headers. +fn image_dimensions(data: &[u8]) -> Option<(u32, u32)> { + const PNG_SIGNATURE: &[u8] = b"\x89PNG\r\n\x1a\n"; + if data.starts_with(PNG_SIGNATURE) { + let width = read_be_u32(data, 16)?; + let height = read_be_u32(data, 20)?; + return Some((width, height)); + } + if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") { + let width = read_le_u16(data, 6)?; + let height = read_le_u16(data, 8)?; + return Some((u32::from(width), u32::from(height))); + } + if data.starts_with(&[0xFF, 0xD8]) { + return jpeg_dimensions(data); + } + if data.len() >= 30 && data.starts_with(b"RIFF") && &data[8..12] == b"WEBP" { + return match &data[12..16] { + b"VP8X" => { + let width = read_le_u24(data, 24)? + 1; + let height = read_le_u24(data, 27)? + 1; + Some((width, height)) + } + b"VP8 " => { + let width = u32::from(read_le_u16(data, 26)? & 0x3FFF); + let height = u32::from(read_le_u16(data, 28)? & 0x3FFF); + Some((width, height)) + } + b"VP8L" => { + let bits = read_le_u32(data, 21)?; + let width = (bits & 0x3FFF) + 1; + let height = ((bits >> 14) & 0x3FFF) + 1; + Some((width, height)) + } + _ => None, + }; + } + None +} + +fn read_be_u32(data: &[u8], offset: usize) -> Option { + let bytes = data.get(offset..offset + 4)?; + Some(u32::from_be_bytes(bytes.try_into().ok()?)) +} + +fn read_le_u32(data: &[u8], offset: usize) -> Option { + let bytes = data.get(offset..offset + 4)?; + Some(u32::from_le_bytes(bytes.try_into().ok()?)) +} + +fn read_le_u16(data: &[u8], offset: usize) -> Option { + let bytes = data.get(offset..offset + 2)?; + Some(u16::from_le_bytes(bytes.try_into().ok()?)) +} + +fn read_le_u24(data: &[u8], offset: usize) -> Option { + let bytes = data.get(offset..offset + 3)?; + Some(u32::from(bytes[0]) | (u32::from(bytes[1]) << 8) | (u32::from(bytes[2]) << 16)) +} + +fn jpeg_dimensions(data: &[u8]) -> Option<(u32, u32)> { + let mut index = 2usize; + while index + 9 < data.len() { + if data[index] != 0xFF { + index += 1; + continue; + } + let marker = data[index + 1]; + if matches!(marker, 0xD8 | 0xD9 | 0x01) || (0xD0..=0xD7).contains(&marker) { + index += 2; + continue; + } + let length = usize::from(u16::from_be_bytes([data[index + 2], data[index + 3]])); + if length < 2 { + return None; + } + // Start-of-frame markers (excluding DHT/JPG/DAC at C4/C8/CC). + let is_sof = matches!(marker, 0xC0..=0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF); + if is_sof { + let height = u32::from(u16::from_be_bytes([data[index + 5], data[index + 6]])); + let width = u32::from(u16::from_be_bytes([data[index + 7], data[index + 8]])); + return Some((width, height)); + } + index += 2 + length; + } + None +} + +/// Number of leading bytes inspected for content-based media type detection. +/// +/// The `infer` signatures are all at the start of a file, so a bounded prefix +/// is enough and avoids loading an entire upload (potentially hundreds of +/// megabytes of video) into memory. +const MEDIA_SNIFF_PREFIX_BYTES: usize = 64 * 1024; + +/// Streams a staged upload once, sniffing its magic bytes and hashing it +/// without buffering the whole file. The bounded prefix is also reused for +/// image dimension checks. +async fn sniff_and_hash(path: &Path) -> anyhow::Result<(Option, String, Vec)> { + use tokio::io::AsyncReadExt as _; + + let mut file = tokio::fs::File::open(path).await?; + let mut buffer = vec![0_u8; MEDIA_SNIFF_PREFIX_BYTES]; + let mut prefix = Vec::with_capacity(MEDIA_SNIFF_PREFIX_BYTES); + let mut hasher = Sha256::new(); + loop { + let read = file.read(&mut buffer).await?; + if read == 0 { + break; + } + if prefix.len() < MEDIA_SNIFF_PREFIX_BYTES { + let take = (MEDIA_SNIFF_PREFIX_BYTES - prefix.len()).min(read); + prefix.extend_from_slice(&buffer[..take]); + } + hasher.update(&buffer[..read]); + } + let kind = infer::get(&prefix); + let hash = hex_lower(hasher.finalize().as_ref()); + Ok((kind, hash, prefix)) +} + async fn try_insert_duplicate( context: &UploadContext<'_>, upload: &PreparedUpload, @@ -315,7 +451,13 @@ async fn store_new_upload( &upload.mime, ) .await; - let normalized_sha256 = hash_file(&stored.path).await?; + let normalized_sha256 = match hash_file(&stored.path).await { + Ok(hash) => hash, + Err(error) => { + cleanup_unreferenced_uploads(context.pool, [&original_path, &stored.path]).await; + return Err(error); + } + }; if stored.state == "converted" && !context.settings.media.keep_original_uploads { let _ = tokio::fs::remove_file(&original_path).await; } @@ -328,7 +470,7 @@ async fn store_new_upload( ) .await? { - cleanup_unreferenced_uploads([&original_path, &stored.path]).await; + cleanup_unreferenced_uploads(context.pool, [&original_path, &stored.path]).await; return Ok(media_id); } @@ -367,10 +509,12 @@ async fn store_new_upload( try_insert_duplicate_after_canonical_conflict(context, &upload, &normalized_sha256) .await? { - cleanup_unreferenced_uploads([&cleanup_paths[0], &cleanup_paths[1]]).await; + cleanup_unreferenced_uploads(context.pool, [&cleanup_paths[0], &cleanup_paths[1]]) + .await; return Ok(media_id); } - cleanup_unreferenced_uploads([&cleanup_paths[0], &cleanup_paths[1]]).await; + cleanup_unreferenced_uploads(context.pool, [&cleanup_paths[0], &cleanup_paths[1]]) + .await; return Err(error); } }; @@ -467,7 +611,7 @@ async fn insert_duplicate_media( .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', ?, ?, ?)", + "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, is_nsfw) VALUES (?, ?, ?, ?, ?, ?, ?, 'duplicate', ?, ?, ?, ?)", params![ owner_user_id, original_filename, @@ -479,6 +623,7 @@ async fn insert_duplicate_media( original_sha256, normalized_sha256, canonical.id, + i64::from(canonical.is_nsfw), ], )?; Ok(conn.last_insert_rowid()) @@ -516,6 +661,26 @@ async fn write_upload_to_staging_inner( Ok(bytes) } +pub async fn save_banner_upload( + pool: &SqlitePool, + settings: &Settings, + paths: &RuntimePaths, + ffmpeg: &FfmpegStatus, + owner_user_id: i64, + field: Field<'_>, +) -> anyhow::Result { + save_upload_inner( + pool, + settings, + paths, + ffmpeg, + Some(owner_user_id), + field, + Some("profile banner"), + ) + .await +} + pub async fn save_profile_picture_upload( pool: &SqlitePool, settings: &Settings, @@ -615,9 +780,30 @@ async fn remove_staged_upload(path: &Path) -> bool { } } -async fn cleanup_unreferenced_uploads(paths: [&Path; N]) { +/// Removes upload scratch files that are not referenced by any `media` row. +/// +/// The reference check protects a concurrently committed canonical row from +/// having its file removed by a duplicate-upload cleanup path. +async fn cleanup_unreferenced_uploads(pool: &SqlitePool, paths: [&Path; N]) { let unique_paths = paths.into_iter().collect::>(); for path in unique_paths { + let path_string = path.to_string_lossy().to_string(); + let referenced = pool + .call(move |conn| { + Ok(conn + .query_row( + "SELECT 1 FROM media WHERE original_path = ? OR stored_path = ? OR thumbnail_path = ? LIMIT 1", + params![path_string, path_string, path_string], + |_| Ok(()), + ) + .optional()? + .is_some()) + }) + .await + .unwrap_or(true); + if referenced { + continue; + } if let Err(error) = tokio::fs::remove_file(path).await && error.kind() != std::io::ErrorKind::NotFound { @@ -644,10 +830,10 @@ async fn find_duplicate_media( .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" + "SELECT id, stored_path, public_path, mime_type, media_kind, original_sha256, normalized_sha256, is_nsfw 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" + "SELECT id, stored_path, public_path, mime_type, media_kind, original_sha256, normalized_sha256, is_nsfw 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)?; @@ -661,6 +847,7 @@ async fn find_duplicate_media( media_kind: row.get(4)?, original_sha256: row.get(5)?, normalized_sha256: row.get(6)?, + is_nsfw: row.get::<_, i64>(7)? != 0, }) })? .collect::, _>>()?; @@ -814,10 +1001,22 @@ fn mime_matches_kind(mime_type: &str, media_kind: MediaKind) -> bool { } async fn hash_file(path: &Path) -> anyhow::Result { - let data = tokio::fs::read(path).await?; - Ok(sha256_hex(&data)) + use tokio::io::AsyncReadExt as _; + + let mut file = tokio::fs::File::open(path).await?; + let mut buffer = vec![0_u8; MEDIA_SNIFF_PREFIX_BYTES]; + let mut hasher = Sha256::new(); + loop { + let read = file.read(&mut buffer).await?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(hex_lower(hasher.finalize().as_ref())) } +#[cfg(test)] fn sha256_hex(data: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(data); @@ -875,17 +1074,35 @@ fn stable_media_basename(original_filename: &str, original_sha256: &str) -> Stri format!("{}-{}", sanitized, &original_sha256[..prefix_len]) } +/// Picks an unused output path and claims it atomically. +/// +/// Concurrent uploads of identical bytes derive the same basename, so a plain +/// `exists()` check would let two conversions target the same file. Creating +/// the file with `create_new` claims the name at the filesystem level; callers +/// overwrite or rename over the claimed (empty) file. 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!( + let fallback = dir.join(format!( "{}-{}.{}", basename, Uuid::new_v4().simple(), extension - )) + )); + let candidates = std::iter::once(dir.join(format!("{basename}.{extension}"))) + .chain(std::iter::once(fallback.clone())); + for candidate in candidates { + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&candidate) + { + Ok(_claim) => return candidate, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + // The directory may be missing or read-only; return the intended + // path so the caller surfaces the real I/O error. + Err(_error) => return candidate, + } + } + fallback } fn public_upload_path(paths: &RuntimePaths, path: &Path) -> anyhow::Result { @@ -994,6 +1211,21 @@ pub async fn delete_media( let paths_to_remove = pool .call(move |conn| { let tx = conn.transaction()?; + let referenced: bool = tx.query_row( + r#" + SELECT EXISTS( + SELECT 1 FROM post_media WHERE media_id = ? + UNION ALL + SELECT 1 FROM users + WHERE profile_picture_media_id = ? OR banner_media_id = ? + ) + "#, + params![media_id, media_id, media_id], + |row| row.get(0), + )?; + if referenced { + anyhow::bail!("media is still referenced by a post or profile"); + } let media_paths: Option<(Option, String, Option)> = tx .query_row( "SELECT original_path, stored_path, thumbnail_path FROM media WHERE id = ?", @@ -1319,6 +1551,7 @@ async fn convert_or_original( }; } Err(err) => { + remove_partial_conversion(&out).await; return original_fallback( original, paths, @@ -1344,6 +1577,7 @@ async fn convert_or_original( }; } Err(err) => { + remove_partial_conversion(&out).await; return original_fallback( original, paths, @@ -1361,6 +1595,20 @@ async fn convert_or_original( original_fallback(original, paths, media_kind, original_mime, "original", "") } +/// Removes a partially written conversion output after an ffmpeg failure or +/// timeout. Missing files are fine. +async fn remove_partial_conversion(path: &Path) { + 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 partial media conversion output" + ); + } +} + fn original_fallback( original: &Path, paths: &RuntimePaths, @@ -1437,6 +1685,28 @@ mod tests { assert!(reject_path_tricks("C:\\x.png").is_err()); } + #[test] + fn image_dimension_probe_reads_headers() { + let png = tiny_png_bytes(); + assert_eq!(image_dimensions(&png), Some((1, 1))); + + let mut oversized = png.clone(); + oversized[16..20].copy_from_slice(&40_000_u32.to_be_bytes()); + oversized[20..24].copy_from_slice(&40_000_u32.to_be_bytes()); + let (width, height) = image_dimensions(&oversized).expect("png dimensions"); + assert_eq!((width, height), (40_000, 40_000)); + assert!(u64::from(width) * u64::from(height) > MAX_IMAGE_PIXELS); + + let mut gif = b"GIF89a".to_vec(); + gif.extend_from_slice(&640u16.to_le_bytes()); + gif.extend_from_slice(&480u16.to_le_bytes()); + gif.extend_from_slice(&[0; 8]); + assert_eq!(image_dimensions(&gif), Some((640, 480))); + + assert_eq!(image_dimensions(b"not an image"), None); + assert_eq!(image_dimensions(&[0xFF, 0xD8]), None); + } + #[test] fn size_limits() { let settings = Settings::default(); diff --git a/src/rate_limit.rs b/src/rate_limit.rs index 67a98f7..15c7f35 100644 --- a/src/rate_limit.rs +++ b/src/rate_limit.rs @@ -26,6 +26,11 @@ impl Scope { } } +/// Atomically checks the limit and records the event. +/// +/// The check and the insert happen inside one database transaction so that +/// concurrent requests from the same actor cannot all observe an under-limit +/// state and slip past the limit together. pub async fn check_and_record( pool: &SqlitePool, scope: Scope, @@ -33,8 +38,29 @@ pub async fn check_and_record( max_events: i64, window_secs: i64, ) -> anyhow::Result<()> { - ensure_under_limit(pool, scope, actor, max_events, window_secs).await?; - record(pool, scope, actor).await + if max_events <= 0 { + anyhow::bail!("rate limit exceeded; try again later"); + } + let cutoff = format!("-{window_secs} seconds"); + let actor = actor.to_owned(); + pool.call(move |conn| { + let tx = conn.transaction()?; + let count: i64 = tx.query_row( + "SELECT COUNT(*) FROM rate_limit_events WHERE scope = ? AND actor = ? AND created_at >= datetime('now', ?)", + params![scope.as_str(), actor, cutoff], + |row| row.get(0), + )?; + if count >= max_events { + anyhow::bail!("rate limit exceeded; try again later"); + } + tx.execute( + "INSERT INTO rate_limit_events (scope, actor) VALUES (?, ?)", + params![scope.as_str(), actor], + )?; + tx.commit()?; + Ok(()) + }) + .await } pub async fn ensure_under_limit( diff --git a/src/render.rs b/src/render.rs index 7655c60..6fab22e 100644 --- a/src/render.rs +++ b/src/render.rs @@ -1,3 +1,5 @@ +use std::fmt::Write as _; + use crate::auth::{CurrentUser, Theme}; use crate::social::{ AccountView, MediaView, NotificationGroupView, PostView, ProfileTimelineTab, QuotePreview, @@ -278,6 +280,11 @@ fn notification_nav_link(unread_count: i64) -> String { } } +/// Picks the singular or plural label for a count. +fn plural<'a>(count: i64, singular: &'a str, plural: &'a str) -> &'a str { + if count == 1 { singular } else { plural } +} + fn dashboard_panel(user: Option<&CurrentUser>, context: &LayoutContext) -> String { let posting = if context.anonymous_mode_enabled { "Signed-in and anonymous posting" @@ -302,8 +309,9 @@ fn dashboard_panel(user: Option<&CurrentUser>, context: &LayoutContext) -> Strin user.map_or_else(String::new, |user| { let username = html_escape::encode_double_quoted_attribute(&user.username); let display_name = html_escape::encode_double_quoted_attribute(&user.display_name); + let follower_label = plural(followers, "follower", "followers"); format!( - r#"
Social
{followers} followers
{following} following
"# + r#"
Social
{followers} {follower_label}
{following} following
"# ) }) } @@ -1051,6 +1059,36 @@ function setButtonState(button, active, label) { } } +function clearFormError(form) { + const existing = form.querySelector("[data-form-error]"); + if (existing) { + existing.remove(); + } +} + +function showFormError(form, message) { + clearFormError(form); + const notice = document.createElement("p"); + notice.className = "notice error"; + notice.setAttribute("role", "alert"); + notice.setAttribute("data-form-error", ""); + notice.textContent = message; + form.prepend(notice); +} + +function enhancedErrorMessage(status) { + if (status === 429) { + return "Too many requests. Please wait a moment and try again."; + } + if (status === 400 || status === 422) { + return "That submission was rejected. Check the text and any attached media, then try again."; + } + if (status === 401 || status === 403) { + return "Your session may have expired. Reload the page and sign in again."; + } + return "Something went wrong. Please try again."; +} + document.addEventListener("submit", async (event) => { const form = event.target.closest("form[data-enhance]"); if (!form || !window.fetch) { @@ -1061,6 +1099,7 @@ document.addEventListener("submit", async (event) => { return; } form.dataset.submitting = "true"; + form.setAttribute("aria-busy", "true"); const submitter = event.submitter || form.querySelector("button[type=submit]"); if (submitter) { submitter.disabled = true; @@ -1075,9 +1114,10 @@ document.addEventListener("submit", async (event) => { credentials: "same-origin" }); if (!response.ok) { - HTMLFormElement.prototype.submit.call(form); + showFormError(form, enhancedErrorMessage(response.status)); return; } + clearFormError(form); const data = await response.json(); if (data.kind === "follow") { const followForm = document.querySelector(`[data-follow-user="${data.user_id}"]`); @@ -1093,7 +1133,7 @@ document.addEventListener("submit", async (event) => { } } document.querySelectorAll(`[data-profile-followers="${data.user_id}"]`).forEach((node) => { - node.textContent = `${data.followers} followers`; + node.textContent = `${data.followers} ${data.followers === 1 ? "follower" : "followers"}`; }); document.querySelectorAll(`[data-profile-following="${data.user_id}"]`).forEach((node) => { node.textContent = `${data.following_count} following`; @@ -1103,10 +1143,10 @@ document.addEventListener("submit", async (event) => { const likes = post.querySelector('[data-count="likes"]'); const reposts = post.querySelector('[data-count="reposts"]'); if (likes) { - likes.textContent = `${data.likes} likes`; + likes.textContent = `${data.likes} ${data.likes === 1 ? "like" : "likes"}`; } if (reposts) { - reposts.textContent = `${data.reposts} reposts`; + reposts.textContent = `${data.reposts} ${data.reposts === 1 ? "repost" : "reposts"}`; } const liked = post.querySelector('[data-action-kind="like"]'); const bookmarked = post.querySelector('[data-action-kind="bookmark"]'); @@ -1137,7 +1177,7 @@ document.addEventListener("submit", async (event) => { document.querySelectorAll(`[data-post-id="${data.parent_post_id}"] [data-count="replies"]`).forEach((node) => { const current = Number.parseInt(node.textContent || "0", 10); const next = Number.isFinite(current) ? current + 1 : 1; - node.textContent = `${next} replies`; + node.textContent = `${next} ${next === 1 ? "reply" : "replies"}`; }); } const created = document.getElementById(`post-${data.post_id}`); @@ -1153,9 +1193,15 @@ document.addEventListener("submit", async (event) => { form.querySelectorAll("input[type=file][data-composer-media]").forEach(updateComposerMedia); } } catch (_err) { - form.submit(); + // Do not silently re-POST: the first request may have been committed even + // though the response was lost. + showFormError( + form, + "Network problem. Reload the page to check whether your submission was saved before trying again." + ); } finally { delete form.dataset.submitting; + form.removeAttribute("aria-busy"); if (submitter) { submitter.disabled = false; } @@ -2111,7 +2157,7 @@ fn post_card_with_options( .as_ref() .map_or_else(String::new, quote_preview_card); format!( - r#"
{}{}
{}
{}
{}
{}
{}{}{}
{} likes{} reposts{} replies{}{}
{}
"#, + r#"
{}{}
{}
{}
{}
{}
{}{}{}
{} {}{} {}{} {}{}{}
{}
"#, post_class, post.id, post.id, @@ -2127,8 +2173,11 @@ fn post_card_with_options( media, quote, post.like_count, + plural(post.like_count, "like", "likes"), post.repost_count, + plural(post.repost_count, "repost", "reposts"), post.reply_count, + plural(post.reply_count, "reply", "replies"), edited, permalink, controls @@ -2381,28 +2430,87 @@ fn render_youtube_preview_card(preview: &YoutubeEmbed) -> String { ) } +/// Escapes post text and turns `#tags`, `@mentions`, and plain URLs into links. +/// +/// Whitespace, including newlines, is preserved so multi-line posts render the +/// way they were composed. pub fn linkify(text: &str) -> String { - html_escape::encode_text(text) - .split_whitespace() - .map(|word| { - if let Some(tag) = word.strip_prefix('#').filter(|value| !value.is_empty()) { - format!( - r##"#{}"##, - html_escape::encode_double_quoted_attribute(tag), - html_escape::encode_text(tag) - ) - } else if let Some(name) = word.strip_prefix('@').filter(|value| !value.is_empty()) { - format!( - r#"@{}"#, - html_escape::encode_double_quoted_attribute(name), - html_escape::encode_text(name) - ) - } else { - word.to_owned() - } + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while !rest.is_empty() { + let whitespace_run = rest.chars().next().is_some_and(char::is_whitespace); + let end = rest + .find(|ch: char| ch.is_whitespace() != whitespace_run) + .unwrap_or(rest.len()); + let (chunk, tail) = rest.split_at(end); + if whitespace_run { + out.push_str(&html_escape::encode_text(chunk)); + } else { + out.push_str(&linkify_token(chunk)); + } + rest = tail; + } + out +} + +fn linkify_token(token: &str) -> String { + let leading_len = token + .find(|ch: char| !matches!(ch, '(' | '[' | '{' | '"' | '\'' | '<' | '“' | '‘')) + .unwrap_or(token.len()); + let (leading, remainder) = token.split_at(leading_len); + let core_len = remainder + .trim_end_matches(|ch: char| { + matches!( + ch, + '.' | ',' | '!' | '?' | ';' | ':' | ')' | ']' | '}' | '"' | '\'' | '”' | '’' + ) }) - .collect::>() - .join(" ") + .len(); + let (core, trailing) = remainder.split_at(core_len); + let mut out = html_escape::encode_text(leading).into_owned(); + if let Some(tag) = core + .strip_prefix('#') + .filter(|value| !value.is_empty() && value.chars().any(char::is_alphanumeric)) + { + let _ = write!( + out, + r##"#{}"##, + html_escape::encode_double_quoted_attribute(tag), + html_escape::encode_text(tag) + ); + } else if let Some(name) = core + .strip_prefix('@') + .filter(|value| !value.is_empty() && value.chars().any(char::is_alphanumeric)) + { + let _ = write!( + out, + r#"@{}"#, + html_escape::encode_double_quoted_attribute(name), + html_escape::encode_text(name) + ); + } else if is_linkable_url(core) { + let _ = write!( + out, + r#"{}"#, + html_escape::encode_double_quoted_attribute(core), + html_escape::encode_text(core) + ); + } else { + out.push_str(&html_escape::encode_text(core)); + } + out.push_str(&html_escape::encode_text(trailing)); + out +} + +fn is_linkable_url(value: &str) -> bool { + let Some(rest) = value + .strip_prefix("https://") + .or_else(|| value.strip_prefix("http://")) + else { + return false; + }; + let host = rest.split(['/', '?', '#']).next().unwrap_or_default(); + host.contains('.') && !host.starts_with('.') && !host.ends_with('.') } pub fn empty_state(title: &str, message: &str) -> String { @@ -2823,8 +2931,9 @@ pub fn thread_back_control() -> String { } pub fn notice(kind: &str, message: &str) -> String { + let role = if kind == "error" { "alert" } else { "status" }; format!( - r#"

{}

"#, + r#"

{}

"#, html_escape::encode_double_quoted_attribute(kind), html_escape::encode_text(message) ) @@ -2853,7 +2962,7 @@ 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;--warning:#9a5a00;--danger:#8a3d2d;--danger-strong:#6f2f22;--danger-bg:#fff8f5;--danger-border:#e6b8a8;--success-bg:#f4fbf5;--success-border:#add7b4;--media-bg:#f6f7f4;--card-gap:.5rem;--section-gap:.75rem;--shell-side:240px;--shell-primary:680px;--shell-gap:1.25rem;--shell-max:1220px;--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{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:#2563eb;--shadow:rgba(20,35,30,.04);--reply-border:#c8d8d0;--avatar-bg:#eef3f0;--warning:#9a5a00;--danger:#8a3d2d;--danger-strong:#6f2f22;--danger-bg:#fff8f5;--danger-border:#e6b8a8;--success-bg:#f4fbf5;--success-border:#add7b4;--media-bg:#f6f7f4;--card-gap:.5rem;--section-gap:.75rem;--shell-side:240px;--shell-primary:680px;--shell-gap:1.25rem;--shell-max:1220px;--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;--warning:#f6c36b;--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)} @@ -2883,6 +2992,10 @@ nav button{border-color:transparent;background:transparent;color:var(--link-stro .settings-media-frame{position:relative;min-width:0}.settings-picture-row{display:flex;align-items:flex-end;gap:0}.settings-picture-wrap{display:inline-block;max-width:100%;line-height:0}.settings-picture-preview{display:block}.settings-media-actions{position:absolute;z-index:2;display:flex;gap:.35rem;align-items:center}.settings-banner-actions{top:.55rem;right:.55rem}.settings-picture-actions{left:50%;bottom:.45rem;transform:translateX(-50%)}.settings-media-control{position:relative;display:inline-flex}.settings-media-input,.settings-media-delete-input{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.settings-media-icon-button{display:inline-flex;align-items:center;justify-content:center;width:2rem;height:2rem;margin:0;border:1px solid rgba(255,255,255,.62);border-radius:999px;background:rgba(23,32,23,.56);color:#fff;padding:0;box-shadow:0 1px 4px rgba(0,0,0,.22);cursor:pointer;opacity:.72;transition:opacity .15s ease,background-color .15s ease,border-color .15s ease,transform .15s ease}.settings-media-icon-button svg{width:1rem;height:1rem;fill:currentColor}.settings-media-frame:hover .settings-media-icon-button,.settings-media-frame:focus-within .settings-media-icon-button,.settings-media-icon-button:hover{opacity:1}.settings-media-icon-button:hover{background:rgba(23,32,23,.82);text-decoration:none}.settings-media-input:focus-visible+.settings-media-icon-button,.settings-media-delete-input:focus-visible+.settings-media-icon-button{outline:3px solid var(--focus);outline-offset:2px;opacity:1}.settings-media-delete-input:checked+.settings-media-icon-button,.settings-media-removing .settings-media-remove{background:var(--danger);border-color:var(--danger-border);color:var(--brand-text);opacity:1}.settings-media-has-file .settings-media-change{background:var(--brand);border-color:rgba(255,255,255,.72);color:var(--brand-text);opacity:1}.settings-media-disabled{position:absolute;right:.55rem;bottom:.55rem;max-width:calc(100% - 1.1rem);margin:0;border:1px solid rgba(255,255,255,.5);border-radius:999px;background:rgba(23,32,23,.62);color:#fff;padding:.22rem .55rem;font-size:.82rem;font-weight:800;line-height:1.2;overflow-wrap:anywhere} .notification-row:focus-visible{outline:3px solid var(--focus);outline-offset:2px}.notification-open{min-height:2.25rem;padding:.35rem .6rem}.notification-actors summary{min-height:2.1rem}.post-permalink{font-weight:700;color:var(--link-strong)}.js-enabled .post-permalink{display:inline-flex}.js-enabled .post-permalink:not(:focus-visible){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 .post-permalink:focus-visible{display:inline-flex;align-items:center;position:absolute;right:.85rem;bottom:.85rem;z-index:2;min-height:2.25rem;border:1px solid var(--border-strong);border-radius:999px;background:var(--surface);padding:.3rem .65rem;box-shadow:0 6px 18px var(--shadow);text-decoration:none}.icon-button{width:2.45rem;height:2.45rem}.admin-nsfw-button{min-height:2.45rem;padding:.35rem .6rem}.thread-back{width:2.4rem;height:2.4rem}button,.primary{min-height:2.5rem;padding:.52rem .82rem}input[type=checkbox]{width:1.05rem;height:1.05rem;accent-color:var(--brand)}input[type=file]{max-width:100%;color:var(--muted-strong)}input[type=file]::file-selector-button{min-height:2.15rem;border:1px solid var(--border);border-radius:7px;background:var(--surface);color:var(--link-strong);padding:.34rem .58rem;font-weight:800;cursor:pointer}input[type=file]::file-selector-button:hover{background:var(--hover);color:var(--text-strong)}label:has(>input[type=checkbox]){display:flex;align-items:flex-start;gap:.55rem;min-height:2.4rem;margin:.75rem 0 .35rem}label:has(>input[type=checkbox]) input[type=checkbox]{flex:0 0 auto;margin-top:.22rem}.empty-state{padding:2.2rem 1rem}.danger-zone{border-color:var(--danger-border);background:var(--danger-bg)}.danger-zone h2,.danger-zone h3{color:var(--danger-strong)}.admin-nav-grid{grid-template-columns:repeat(auto-fit,minmax(13rem,1fr))}.admin-nav-card{position:relative;display:grid;align-content:center;min-height:4.2rem;padding-right:2.3rem;color:var(--link-strong);font-weight:850}.admin-nav-card::after{content:"";position:absolute;right:1rem;top:50%;width:.48rem;height:.48rem;border-top:2px solid currentColor;border-right:2px solid currentColor;transform:translateY(-50%) rotate(45deg);opacity:.62}.admin-nav-card:hover{border-color:var(--border-strong);background:var(--hover);text-decoration:none}.admin-nav-card:focus-visible::after,.admin-nav-card:hover::after{opacity:1} .profile-secondary button{min-height:2.4rem}.settings-media-icon-button{width:2.25rem;height:2.25rem}.settings-media-icon-button svg{width:1.08rem;height:1.08rem} +.counts a{color:var(--muted);font-weight:700;text-decoration:none}.counts a:hover{color:var(--link-strong);text-decoration:underline} +.profile-title-row>div{min-width:0}.profile-title-row h1{margin:0;overflow-wrap:anywhere}.profile-bio{margin:.55rem 0 0;overflow-wrap:anywhere}.profile-state-note{color:var(--muted-strong);font-weight:700} +.notice.info{border-color:var(--border-strong);background:var(--surface-subtle)} +@media (prefers-reduced-motion:reduce){*,*::before,*::after{transition-duration:.01ms !important;animation-duration:.01ms !important}} @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}.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,.onboarding-media-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-switch-row{grid-template-columns:1fr;gap:.55rem}.settings-switch-toggle,.settings-switch-control{justify-self:start}.settings-form-actions{justify-content:stretch}.settings-form-actions button,.settings-danger-action .button-link{width:100%;justify-content:center}.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}} @@ -3531,10 +3644,27 @@ mod tests { assert!(!body.contains("youtube-preview-card")); assert!(body.contains( - r#"
hello https://example.com/watch?v=dQw4w9WgXcQ #rust
"# + r#""# )); } + #[test] + fn linkify_preserves_newlines_and_trims_trailing_punctuation() { + assert_eq!( + linkify("first line\nsecond #rust, line"), + "first line\nsecond #rust, line" + ); + assert_eq!(linkify(" spaced \n\ntext "), " spaced \n\ntext "); + assert_eq!( + linkify("see (https://example.test/a)."), + "see (https://example.test/a)." + ); + assert_eq!(linkify("#"), "#"); + assert_eq!(linkify("@"), "@"); + assert_eq!(linkify("no links here"), "no links here"); + assert_eq!(linkify("hello@example.test"), "hello@example.test"); + } + #[test] fn multiple_youtube_links_render_up_to_documented_cap() { let mut post = test_post(); diff --git a/src/runtime.rs b/src/runtime.rs index 6927386..1edc78a 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -192,6 +192,46 @@ impl RuntimePaths { pub fn database_sidecar_path(&self, suffix: &str) -> PathBuf { self.db_dir.join(format!("rustpost.sqlite3-{suffix}")) } + + /// Removes leftover upload staging files and abandoned restore uploads + /// older than `max_age`. Called with a zero age during startup, where no + /// request can be in flight, and with a grace period while running. + pub fn cleanup_stale_temp_files(&self, max_age: std::time::Duration) -> anyhow::Result { + let mut removed = 0usize; + let entries = match fs::read_dir(&self.tmp_dir) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(error) => { + return Err(error).with_context(|| { + format!("failed to read temp directory {}", self.tmp_dir.display()) + }); + } + }; + for entry in entries { + let entry = entry?; + let path = entry.path(); + if !path.is_file() { + continue; + } + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if !(name.ends_with(".upload") || name.starts_with("restore-upload-")) { + continue; + } + let stale = match fs::metadata(&path).and_then(|metadata| metadata.modified()) { + Ok(modified) => modified + .elapsed() + .map(|age| age >= max_age) + .unwrap_or(false), + Err(_error) => false, + }; + if stale && fs::remove_file(&path).is_ok() { + removed += 1; + } + } + Ok(removed) + } } #[cfg(unix)] diff --git a/src/server.rs b/src/server.rs index ad6c925..4f28272 100644 --- a/src/server.rs +++ b/src/server.rs @@ -39,6 +39,14 @@ pub struct AppState { pub ffmpeg: FfmpegStatus, pub tor: crate::tor::TorStatus, pub registration_captcha: RegistrationCaptchaStore, + /// Cached `media.nsfw_blur_enabled` so page rendering does not read and + /// parse `settings.toml` on every request. Updated when an admin saves + /// deep settings. + pub nsfw_blur_default: Arc, + /// Set after an in-process restore swaps the runtime directories. The + /// running process still holds the previous SQLite connection, so writes + /// are refused until the operator restarts RustPost. + pub restart_required: Arc, } impl AppState { @@ -50,6 +58,7 @@ impl AppState { ffmpeg: FfmpegStatus, tor: crate::tor::TorStatus, ) -> Arc { + let nsfw_blur_default = settings.media.nsfw_blur_enabled; Arc::new(Self { pool, settings, @@ -57,10 +66,88 @@ impl AppState { ffmpeg, tor, registration_captcha: RegistrationCaptchaStore::default(), + nsfw_blur_default: Arc::new(std::sync::atomic::AtomicBool::new(nsfw_blur_default)), + restart_required: Arc::new(std::sync::atomic::AtomicBool::new(false)), }) } } +/// Refuses state-changing requests after an in-process restore, because the +/// running process would write them to the replaced (unlinked) database file. +async fn restart_required_guard( + State(state): State>, + request: axum::extract::Request, + next: middleware::Next, +) -> Response { + use axum::http::Method; + + if state + .restart_required + .load(std::sync::atomic::Ordering::Relaxed) + && !matches!( + *request.method(), + Method::GET | Method::HEAD | Method::OPTIONS + ) + { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Html(render::error_page( + StatusCode::SERVICE_UNAVAILABLE, + "A backup was restored. Restart RustPost before making changes so your data is written to the restored database.", + )), + ) + .into_response(); + } + next.run(request).await +} + +/// Periodically prunes expired operational rows and abandoned temp files. +/// +/// Startup performs an aggressive temp cleanup (nothing can be in flight yet); +/// later cycles keep a grace period so active uploads are never removed. +pub fn spawn_maintenance_scheduler( + pool: SqlitePool, + paths: RuntimePaths, + mut shutdown_rx: tokio::sync::watch::Receiver, +) { + tokio::spawn(async move { + // The longest configured rate-limit window is one hour; keep two hours. + const RATE_LIMIT_RETENTION_SECS: i64 = 2 * 60 * 60; + const MAINTENANCE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30 * 60); + let mut first_cycle = true; + loop { + let max_temp_age = if first_cycle { + std::time::Duration::ZERO + } else { + std::time::Duration::from_secs(60 * 60) + }; + match paths.cleanup_stale_temp_files(max_temp_age) { + Ok(0) => {} + Ok(removed) => tracing::info!(removed, "removed stale upload staging files"), + Err(error) => tracing::warn!(error = %error, "temp file cleanup failed"), + } + if let Err(error) = crate::rate_limit::prune_old(&pool, RATE_LIMIT_RETENTION_SECS).await + { + tracing::warn!(error = %error, "rate limit event pruning failed"); + } + match crate::auth::prune_stale_sessions(&pool, 7).await { + Ok(0) => {} + Ok(removed) => tracing::info!(removed, "removed stale sessions"), + Err(error) => tracing::warn!(error = %error, "session pruning failed"), + } + first_cycle = false; + tokio::select! { + changed = shutdown_rx.changed() => { + if changed.is_err() || *shutdown_rx.borrow() { + break; + } + } + () = tokio::time::sleep(MAINTENANCE_INTERVAL) => {} + } + } + }); +} + pub fn router(state: Arc) -> Router { let upload_body_limit = upload_body_limit(&state.settings); Router::new() @@ -152,6 +239,10 @@ pub fn router(state: Arc) -> Router { ServeDir::new(state.paths.uploads_thumbs.clone()), ) .layer(DefaultBodyLimit::max(upload_body_limit)) + .layer(middleware::from_fn_with_state( + state.clone(), + restart_required_guard, + )) .layer(middleware::from_fn( crate::compression::response_compression, )) @@ -280,6 +371,11 @@ struct ProfileQuery { tab: Option, } +#[derive(Deserialize)] +struct AccountListQuery { + after: Option, +} + #[derive(Deserialize)] struct MentionSuggestionsQuery { q: Option, @@ -496,10 +592,9 @@ 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 - }); + let global_blur = state + .nsfw_blur_default + .load(std::sync::atomic::Ordering::Relaxed); global_blur && user.is_none_or(|user| user.nsfw_blur_enabled) } @@ -995,16 +1090,31 @@ async fn create_post( } Err(err) => return Err(err), }; + let outcome = finish_post_create(&state, &headers, addr, user.as_ref(), &form).await; + if outcome.is_err() { + cleanup_post_uploads(&state, &form.media_ids).await; + } + outcome +} + +async fn finish_post_create( + state: &AppState, + headers: &HeaderMap, + addr: SocketAddr, + user: Option<&CurrentUser>, + form: &ParsedPostCreate, +) -> AppResult { if let Some(parent_id) = form.parent_post_id { ensure_parent_post_exists(&state.pool, parent_id).await?; } if user.is_some() { - validate_csrf(&state.pool, &headers, &form.csrf_token).await?; + 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() { + let viewer_id = user.map(|user| user.id); + let (scope, actor, max_events, window_secs) = if viewer_id.is_none() { ( rate_limit::Scope::AnonymousPost, ip_actor(addr), @@ -1014,14 +1124,14 @@ async fn create_post( } else if form.parent_post_id.is_some() { ( rate_limit::Scope::Reply, - user_actor(user.as_ref().map(|u| u.id).unwrap_or_default()), + user_actor(viewer_id.unwrap_or_default()), state.settings.moderation.replies_per_minute, 60, ) } else { ( rate_limit::Scope::Post, - user_actor(user.as_ref().map(|u| u.id).unwrap_or_default()), + user_actor(viewer_id.unwrap_or_default()), state.settings.moderation.posts_per_minute, 60, ) @@ -1032,7 +1142,7 @@ async fn create_post( let post_id = social::create_post( &state.pool, &state.settings, - user.as_ref().map(|u| u.id), + viewer_id, &form.text, form.parent_post_id, &form.media_ids, @@ -1043,8 +1153,8 @@ async fn create_post( || format!("/home#post-{post_id}"), |_| format!("/posts/{post_id}#reply-{post_id}"), ); - if enhanced_request(&headers) { - let posts = social::post_thread(&state.pool, user.as_ref().map(|u| u.id), post_id).await?; + if enhanced_request(headers) { + let posts = social::post_thread(&state.pool, viewer_id, post_id).await?; let post = posts .iter() .find(|post| post.id == post_id) @@ -1057,17 +1167,17 @@ async fn create_post( html: if form.parent_post_id.is_some() { render::thread_post_card_with_controls( post, - user.as_ref(), - form_csrf(&state, &headers).await.as_deref(), - blur_nsfw_media(&state, user.as_ref()), + user, + form_csrf(state, headers).await.as_deref(), + blur_nsfw_media(state, user), state.settings.posts.post_edit_window_seconds, ) } else { render::post_card_with_controls( post, - user.as_ref(), - form_csrf(&state, &headers).await.as_deref(), - blur_nsfw_media(&state, user.as_ref()), + user, + form_csrf(state, headers).await.as_deref(), + blur_nsfw_media(state, user), state.settings.posts.post_edit_window_seconds, ) }, @@ -1077,10 +1187,24 @@ async fn create_post( Ok(Redirect::to(&redirect).into_response()) } +/// Deletes media rows and files that were uploaded for a post that was never +/// created, so rejected or failed submissions do not leave orphaned uploads. +async fn cleanup_post_uploads(state: &AppState, media_ids: &[i64]) { + for media_id in media_ids { + if let Err(error) = media::delete_media(&state.pool, &state.paths, *media_id).await { + tracing::warn!( + media_id, + error = %error, + "failed to clean up media uploaded for a rejected post" + ); + } + } +} + async fn parse_post_create( state: &AppState, user_id: Option, - mut multipart: Multipart, + multipart: Multipart, ) -> AppResult { let mut form = ParsedPostCreate { csrf_token: String::new(), @@ -1089,6 +1213,19 @@ async fn parse_post_create( media_ids: Vec::new(), is_nsfw: false, }; + let outcome = fill_post_create_form(state, user_id, multipart, &mut form).await; + if outcome.is_err() { + cleanup_post_uploads(state, &form.media_ids).await; + } + outcome.map(|()| form) +} + +async fn fill_post_create_form( + state: &AppState, + user_id: Option, + mut multipart: Multipart, + form: &mut ParsedPostCreate, +) -> AppResult<()> { while let Some(field) = multipart .next_field() .await @@ -1139,6 +1276,11 @@ async fn parse_post_create( .file_name() .is_some_and(|name| !name.trim().is_empty()) => { + if form.media_ids.len() >= state.settings.posts.max_media_per_post { + return Err(AppError::BadRequest( + "too many media attachments".to_owned(), + )); + } form.media_ids.push( media::save_upload( &state.pool, @@ -1155,7 +1297,7 @@ async fn parse_post_create( _ => {} } } - Ok(form) + Ok(()) } async fn bad_request_page( @@ -1328,7 +1470,11 @@ async fn delete_post( }; media::validate_post_media_deletion(&state.pool, &state.paths, id).await?; social::delete_post(&state.pool, user.id, id, user.is_admin).await?; - media::delete_post_media(&state.pool, &state.paths, id).await?; + // The post is already deleted; a media cleanup failure should not turn a + // successful deletion into an error page. + if let Err(error) = media::delete_post_media(&state.pool, &state.paths, id).await { + tracing::warn!(post_id = id, error = %error, "post deleted but media cleanup failed"); + } let fallback = if let Some(parent_id) = preview.parent_post_id { format!("/posts/{parent_id}#post-{parent_id}") } else { @@ -1585,6 +1731,7 @@ async fn profile( let viewer_id = user.as_ref().map(|u| u.id); let owner_or_admin = viewer_id == Some(profile_id) || user.as_ref().is_some_and(|viewer| viewer.is_admin); + let relationship = social::profile_relationship(&state.pool, viewer_id, profile_id).await?; let activity_visible = (!is_suspended || owner_or_admin) && social::profile_activity_visible(&state.pool, viewer_id, profile_id).await?; let likes_visible = activity_visible && (liked_posts_public || viewer_id == Some(profile_id)); @@ -1606,7 +1753,7 @@ async fn profile( }); } let (followers, following) = social::follow_counts(&state.pool, profile_id).await?; - let controls = profile_controls(&state, user.as_ref(), csrf.as_deref(), profile_id).await?; + let controls = profile_controls(user.as_ref(), csrf.as_deref(), profile_id, relationship); let picture = picture_path.map_or_else( || r#""#.to_owned(), |path| { @@ -1634,6 +1781,25 @@ async fn profile( html_escape::encode_text(location.as_str()) ) }; + let bio_line = if bio.trim().is_empty() { + String::new() + } else { + format!( + r#"

{}

"#, + html_escape::encode_text(bio.as_str()) + ) + }; + let profile_state_note = + profile_state_note(is_suspended, owner_or_admin, relationship, activity_visible); + let counts = format!( + r#"

{}{}

"#, + html_escape::encode_double_quoted_attribute(&profile_username), + profile_id, + count_label(followers, "follower", "followers"), + html_escape::encode_double_quoted_attribute(&profile_username), + profile_id, + count_label(following, "following", "following"), + ); let pinned = pinned_post.as_ref().map_or_else(String::new, |post| { render::pinned_post_with_controls( post, @@ -1645,14 +1811,17 @@ async fn profile( }); let tabs = render::profile_tabs(&profile_username, active_tab); let timeline = if !activity_visible { - render::posts_with_controls_empty_state( - &posts, - user.as_ref(), - csrf.as_deref(), - blur_nsfw_media(&state, user.as_ref()), - state.settings.posts.post_edit_window_seconds, - profile_tab_empty_state(active_tab), - ) + match profile_state_note { + Some((title, message)) => render::empty_state(title, message), + None => render::posts_with_controls_empty_state( + &posts, + user.as_ref(), + csrf.as_deref(), + blur_nsfw_media(&state, user.as_ref()), + state.settings.posts.post_edit_window_seconds, + profile_tab_empty_state(active_tab), + ), + } } else if active_tab == social::ProfileTimelineTab::Likes && !likes_visible { render::empty_state("This user’s likes are private", "") } else { @@ -1666,18 +1835,15 @@ async fn profile( ) }; let body = format!( - r#"
{}
{}

{}

@{}

{}

{} followers{} following

{}

{}

{}
{}{}{}"#, + r#"
{}
{}

{}

@{}

{}
{}{}{}{}
{}{}{}"#, banner, picture, html_escape::encode_text(display_name.as_str()), html_escape::encode_text(profile_username.as_str()), controls, - profile_id, - followers, - profile_id, - following, + counts, + bio_line, location_line, - html_escape::encode_text(bio.as_str()), website_link, pinned, tabs, @@ -1695,6 +1861,52 @@ async fn profile( )) } +/// A human-readable explanation for why a profile's activity is hidden. +fn profile_state_note( + is_suspended: bool, + owner_or_admin: bool, + relationship: social::ProfileRelationship, + activity_visible: bool, +) -> Option<(&'static str, &'static str)> { + if activity_visible || owner_or_admin { + return None; + } + if relationship.blocked { + return Some(( + "You blocked this account", + "Unblock this account to see their posts and interact again.", + )); + } + if relationship.blocks_viewer { + return Some(( + "Activity unavailable", + "This account's posts and replies are not visible to you.", + )); + } + if relationship.muted { + return Some(( + "You muted this account", + "Their posts stay hidden from your feeds until you unmute them.", + )); + } + if is_suspended { + return Some(( + "Account suspended", + "This account's posts and replies are not visible.", + )); + } + None +} + +/// Renders "1 follower" / "2 followers" without a separate plural forms crate. +fn count_label(count: i64, singular: &str, plural: &str) -> String { + if count == 1 { + format!("{count} {singular}") + } else { + format!("{count} {plural}") + } +} + async fn profile_identity( pool: &SqlitePool, username: &str, @@ -1720,6 +1932,7 @@ async fn profile_followers( State(state): State>, headers: HeaderMap, Path(username): Path, + Query(query): Query, ) -> AppResult> { let user = current(&state, &headers).await?; let csrf = form_csrf(&state, &headers).await; @@ -1728,11 +1941,15 @@ async fn profile_followers( else { return Err(AppError::NotFound); }; - let accounts = - social::followers_accounts(&state.pool, profile_id, user.as_ref().map(|user| user.id)) - .await?; + let (accounts, has_more) = social::followers_accounts( + &state.pool, + profile_id, + user.as_ref().map(|user| user.id), + query.after, + ) + .await?; let body = format!( - "{}{}", + "{}{}{}", render::page_header( &format!("{display_name} followers"), &format!("Users who follow @{profile_username}.") @@ -1740,6 +1957,11 @@ async fn profile_followers( render::account_links_with_empty_state( &accounts, render::EmptyState::new("No followers yet.", "Followers will appear here.",), + ), + account_list_next_link( + &format!("/users/{profile_username}/followers"), + &accounts, + has_more ) ); Ok(Html( @@ -1758,6 +1980,7 @@ async fn profile_following( State(state): State>, headers: HeaderMap, Path(username): Path, + Query(query): Query, ) -> AppResult> { let user = current(&state, &headers).await?; let csrf = form_csrf(&state, &headers).await; @@ -1766,14 +1989,15 @@ async fn profile_following( else { return Err(AppError::NotFound); }; - let accounts = social::following_accounts_for_profile( + let (accounts, has_more) = social::following_accounts_for_profile( &state.pool, profile_id, user.as_ref().map(|user| user.id), + query.after, ) .await?; let body = format!( - "{}{}", + "{}{}{}", render::page_header( &format!("{display_name} following"), &format!("Users @{profile_username} follows.") @@ -1784,6 +2008,11 @@ async fn profile_following( "Not following anyone yet.", "Followed accounts will appear here.", ), + ), + account_list_next_link( + &format!("/users/{profile_username}/following"), + &accounts, + has_more ) ); Ok(Html( @@ -1798,27 +2027,67 @@ async fn profile_following( )) } -async fn profile_controls( - state: &AppState, +/// Renders the "Show more" link for keyset-paginated account lists. +fn account_list_next_link(base: &str, accounts: &[social::AccountView], has_more: bool) -> String { + if !has_more { + return String::new(); + } + let Some(last) = accounts.last() else { + return String::new(); + }; + let cursor = last.username.to_ascii_lowercase(); + let cursor = html_escape::encode_double_quoted_attribute(&cursor); + let base = html_escape::encode_double_quoted_attribute(base); + format!( + r#""# + ) +} + +fn profile_controls( user: Option<&CurrentUser>, csrf: Option<&str>, profile_id: i64, -) -> AppResult { + relationship: social::ProfileRelationship, +) -> String { let (Some(viewer), Some(csrf)) = (user, csrf) else { - return Ok(String::new()); + return String::new(); }; if viewer.id == profile_id { - return Ok( - r#""# - .to_owned(), + return r#""# + .to_owned(); + } + if relationship.blocks_viewer { + return r#"
This account blocked you.
"# + .to_owned(); + } + if relationship.blocked { + return format!( + r#"
You blocked this account.{}
"#, + small_form( + &format!("/users/{profile_id}/unblock"), + csrf, + "Unblock", + "Unblock this account" + ) ); } - let follow_action = render::follow_form( - profile_id, - csrf, - social::is_following(&state.pool, viewer.id, profile_id).await?, - ); - Ok(format!( + let follow_action = render::follow_form(profile_id, csrf, relationship.following); + let mute_action = if relationship.muted { + small_form( + &format!("/users/{profile_id}/unmute"), + csrf, + "Unmute", + "Unmute this account", + ) + } else { + small_form( + &format!("/users/{profile_id}/mute"), + csrf, + "Mute", + "Mute this account", + ) + }; + format!( r#"
{}{}{}
"#, follow_action, small_form( @@ -1827,13 +2096,8 @@ async fn profile_controls( "Block", "Block this account" ), - small_form( - &format!("/users/{profile_id}/mute"), - csrf, - "Mute", - "Mute this account" - ) - )) + mute_action + ) } async fn follow( @@ -2160,7 +2424,7 @@ fn render_profile_website_link(website: &str) -> String { return String::new(); } format!( - r#"

{}

"#, + r#"

{}

"#, html_escape::encode_double_quoted_attribute(website), html_escape::encode_text(website) ) @@ -2320,7 +2584,20 @@ async fn settings_update( ) -> AppResult { let user = require_user(&state, &headers).await?; let form = parse_profile_update(&state, user.id, multipart).await?; - validate_csrf(&state.pool, &headers, &form.csrf_token).await?; + let outcome = apply_profile_update(&state, &headers, &user, &form).await; + if outcome.is_err() { + cleanup_profile_uploads(&state, &form).await; + } + outcome +} + +async fn apply_profile_update( + state: &AppState, + headers: &HeaderMap, + user: &CurrentUser, + form: &ParsedProfileUpdate, +) -> AppResult { + validate_csrf(&state.pool, headers, &form.csrf_token).await?; crate::validation::validate_profile_text(&form.display_name, &form.bio, &state.settings)?; validate_profile_location(&form.location)?; validate_profile_website(&form.website)?; @@ -2331,6 +2608,7 @@ async fn settings_update( let theme = form.theme.as_str().to_owned(); let nsfw_blur_enabled = i64::from(form.nsfw_blur_enabled); let liked_posts_public = i64::from(form.liked_posts_public); + let user_id = user.id; state .pool .call(move |conn| { @@ -2344,7 +2622,7 @@ async fn settings_update( theme, nsfw_blur_enabled, liked_posts_public, - user.id + user_id ], )?; Ok(()) @@ -2682,7 +2960,7 @@ fn render_delete_account_final_warning( ) -> String { let notice = error.map_or_else(String::new, |message| render::notice("error", message)); format!( - r#"{notice}"#, + r#"{notice}"#, html_escape::encode_double_quoted_attribute(csrf), html_escape::encode_double_quoted_attribute(delete_intent) ) @@ -2690,14 +2968,10 @@ fn render_delete_account_final_warning( // Multipart parsing is kept in one place so uploaded profile media and text // fields share one validation path before any database updates happen. -#[expect( - clippy::too_many_lines, - reason = "multipart profile parsing keeps validation and file handling in one transaction-sized flow" -)] async fn parse_profile_update( state: &AppState, user_id: i64, - mut multipart: Multipart, + multipart: Multipart, ) -> AppResult { let mut form = ParsedProfileUpdate { csrf_token: String::new(), @@ -2713,6 +2987,19 @@ async fn parse_profile_update( profile_picture_media_id: None, banner_media_id: None, }; + let outcome = fill_profile_update_form(state, user_id, multipart, &mut form).await; + if outcome.is_err() { + cleanup_profile_uploads(state, &form).await; + } + outcome.map(|()| form) +} + +async fn fill_profile_update_form( + state: &AppState, + user_id: i64, + mut multipart: Multipart, + form: &mut ParsedProfileUpdate, +) -> AppResult<()> { while let Some(field) = multipart .next_field() .await @@ -2798,12 +3085,12 @@ async fn parse_profile_update( continue; } form.banner_media_id = Some( - media::save_upload( + media::save_banner_upload( &state.pool, &state.settings, &state.paths, &state.ffmpeg, - Some(user_id), + user_id, field, ) .await @@ -2816,7 +3103,24 @@ async fn parse_profile_update( _ => {} } } - Ok(form) + Ok(()) +} + +/// Deletes profile media that was uploaded for a settings save that did not +/// complete, so rejected saves do not leave orphaned uploads behind. +async fn cleanup_profile_uploads(state: &AppState, form: &ParsedProfileUpdate) { + for media_id in [form.profile_picture_media_id, form.banner_media_id] + .into_iter() + .flatten() + { + if let Err(error) = media::delete_media(&state.pool, &state.paths, media_id).await { + tracing::warn!( + media_id, + error = %error, + "failed to clean up media uploaded for a rejected settings save" + ); + } + } } async fn ensure_parent_post_exists(pool: &SqlitePool, parent_id: i64) -> AppResult<()> { @@ -3015,9 +3319,22 @@ async fn post_action_response( EXISTS(SELECT 1 FROM bookmarks WHERE user_id = ? AND post_id = p.id), EXISTS(SELECT 1 FROM reposts WHERE user_id = ? AND post_id = p.id) FROM posts p + LEFT JOIN users author ON author.id = p.user_id WHERE p.id = ? AND p.is_deleted = 0 + AND (p.user_id IS NULL OR (author.is_deleted = 0 AND author.is_suspended = 0)) + AND ( + p.user_id IS NULL + OR p.user_id = ? + OR ( + p.user_id NOT IN (SELECT blocked_id FROM blocks WHERE blocker_id = ?) + AND NOT EXISTS (SELECT 1 FROM blocks WHERE blocker_id = p.user_id AND blocked_id = ?) + ) + ) "#, - params![viewer_id, viewer_id, viewer_id, viewer_id, post_id], + params![ + viewer_id, viewer_id, viewer_id, viewer_id, post_id, viewer_id, viewer_id, + viewer_id + ], |row| { Ok(PostActionResponse { kind: "post-action", @@ -3286,14 +3603,17 @@ async fn bookmarks( async fn following( State(state): State>, headers: HeaderMap, + Query(query): Query, ) -> AppResult> { let user = require_user(&state, &headers).await?; let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); - let accounts = social::following_accounts(&state.pool, user.id).await?; + let (accounts, has_more) = + social::following_accounts(&state.pool, user.id, query.after).await?; let body = format!( - "{}{}", + "{}{}{}", render::page_header("Following", "Accounts you follow."), - render::accounts(&accounts, &csrf) + render::accounts(&accounts, &csrf), + account_list_next_link("/following", &accounts, has_more) ); Ok(Html( page_layout(&state, Some(&user), Some(&csrf), "Following", &body).await?, @@ -3778,7 +4098,9 @@ async fn admin_delete_post( validate_csrf(&state.pool, &headers, &form.csrf).await?; media::validate_post_media_deletion(&state.pool, &state.paths, id).await?; social::delete_post(&state.pool, user.id, id, true).await?; - media::delete_post_media(&state.pool, &state.paths, id).await?; + if let Err(error) = media::delete_post_media(&state.pool, &state.paths, id).await { + tracing::warn!(post_id = id, error = %error, "post deleted but media cleanup failed"); + } admin::audit(&state.pool, user.id, "delete_post", &format!("post:{id}")).await?; Ok(Redirect::to("/admin").into_response()) } @@ -3898,6 +4220,10 @@ async fn admin_deep_settings_update( ); return deep_settings_html(&state, &user, &csrf, &body).await; } + state.nsfw_blur_default.store( + updated.media.nsfw_blur_enabled, + std::sync::atomic::Ordering::Relaxed, + ); admin::audit( &state.pool, user.id, @@ -4344,6 +4670,9 @@ async fn admin_restore_backup( let _remove_result = tokio::fs::remove_file(&upload.archive_path).await; match restored { Ok(report) => { + state + .restart_required + .store(true, std::sync::atomic::Ordering::Relaxed); let safety = report .pre_restore_backup .as_ref() @@ -4657,40 +4986,52 @@ async fn validate_csrf(pool: &SqlitePool, headers: &HeaderMap, token: &str) -> A .map_err(|_csrf_err| AppError::Forbidden) } +/// Reads the session's current CSRF token and rotates it for the next form. +/// +/// Two concurrent page loads for one session can both read the same stored +/// hash; only one optimistic update wins. Retrying lets the loser read the new +/// hash and rotate again instead of rendering a form with no token. async fn form_csrf(state: &AppState, headers: &HeaderMap) -> Option { + const ROTATION_ATTEMPTS: usize = 3; let token = auth::session_cookie(headers)?; let token_hash = auth::hash_token(&token); - let (stored_hash, previous_hashes): (String, Option) = state - .pool - .call({ - let token_hash = token_hash.clone(); - move |conn| { - conn.query_row( - "SELECT csrf_token_hash, previous_csrf_token_hash FROM sessions WHERE token_hash = ? AND revoked_at IS NULL AND datetime(expires_at) > CURRENT_TIMESTAMP", - [token_hash], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .optional() - .map_err(Into::into) - } - }) - .await - .ok()??; - let previous_hashes = csrf_history_with(&stored_hash, previous_hashes.as_deref()); - let plain = auth::secure_token(); - let new_hash = auth::hash_token(&plain); - let updated = state - .pool - .call(move |conn| { - let changed = conn.execute( - "UPDATE sessions SET csrf_token_hash = ?, previous_csrf_token_hash = ? WHERE token_hash = ? AND csrf_token_hash = ?", - params![new_hash, previous_hashes, token_hash, stored_hash], - )?; - Ok(changed == 1) - }) - .await - .ok()?; - updated.then_some(plain) + for _attempt in 0..ROTATION_ATTEMPTS { + let (stored_hash, previous_hashes): (String, Option) = state + .pool + .call({ + let token_hash = token_hash.clone(); + move |conn| { + conn.query_row( + "SELECT csrf_token_hash, previous_csrf_token_hash FROM sessions WHERE token_hash = ? AND revoked_at IS NULL AND datetime(expires_at) > CURRENT_TIMESTAMP", + [token_hash], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(Into::into) + } + }) + .await + .ok()??; + let previous_hashes = csrf_history_with(&stored_hash, previous_hashes.as_deref()); + let plain = auth::secure_token(); + let new_hash = auth::hash_token(&plain); + let session_token_hash = token_hash.clone(); + let updated = state + .pool + .call(move |conn| { + let changed = conn.execute( + "UPDATE sessions SET csrf_token_hash = ?, previous_csrf_token_hash = ? WHERE token_hash = ? AND csrf_token_hash = ?", + params![new_hash, previous_hashes, session_token_hash, stored_hash], + )?; + Ok(changed == 1) + }) + .await + .ok()?; + if updated { + return Some(plain); + } + } + None } fn csrf_history_with(current_hash: &str, previous_hashes: Option<&str>) -> String { @@ -6824,7 +7165,7 @@ mod tests { assert!( profile .body - .contains(r#"data-profile-followers="1">1 followers"#) + .contains(r#"data-profile-followers="1">1 follower"#) ); assert!(!profile.body.contains(">Unfollow")); } @@ -7207,7 +7548,7 @@ mod tests { assert_eq!(safe_saved.status, 303); let profile = get_with_cookie(&server, "/users/alice", &cookie).await; assert!(profile.body.contains( - r#"

https://example.test/profile

"# + r#"https://example.test/profile"# )); } @@ -8169,6 +8510,9 @@ mod tests { let logged_out = request(&server.base_url, "GET", "/home", &[], Vec::new()).await; assert!(logged_out.body.contains(r#"data-testid="nsfw-media""#)); + // The running server uses the setting it loaded at startup. Editing + // settings.toml directly takes effect on restart (documented behavior), + // and page rendering no longer re-reads and re-parses the file. let mut settings = Settings::load(&server.data_dir.join("settings.toml")).expect("load"); settings.media.nsfw_blur_enabled = false; std::fs::write( @@ -8176,17 +8520,47 @@ mod tests { toml::to_string(&settings).expect("settings toml"), ) .expect("write settings"); - let unblurred = request(&server.base_url, "GET", "/home", &[], Vec::new()).await; - assert!(!unblurred.body.contains(r#"data-testid="nsfw-media""#)); + let still_blurred = request(&server.base_url, "GET", "/home", &[], Vec::new()).await; + assert!(still_blurred.body.contains(r#"data-testid="nsfw-media""#)); + } - settings.media.nsfw_blur_enabled = true; - std::fs::write( - server.data_dir.join("settings.toml"), - toml::to_string(&settings).expect("settings toml"), + #[tokio::test] + async fn global_nsfw_blur_default_applies_from_loaded_settings() { + let mut settings = Settings::default(); + settings.media.nsfw_blur_enabled = false; + let server = spawn_test_server_with_settings(settings).await; + let cookie = register_test_user(&server, "alice").await; + let home = get_with_cookie(&server, "/home", &cookie).await; + let csrf = csrf_token(&home.body); + let posted = request( + &server.base_url, + "POST", + "/posts", + &[ + ("cookie", &cookie), + ( + "content-type", + "multipart/form-data; boundary=post-boundary", + ), + ], + multipart_body_with_file( + "post-boundary", + &[ + ("csrf", csrf.as_str()), + ("text", "unblurred by default"), + ("nsfw", "true"), + ], + "media", + "photo.png", + "image/png", + &tiny_png_bytes(), + ), ) - .expect("write settings"); - let safe_again = request(&server.base_url, "GET", "/home", &[], Vec::new()).await; - assert!(safe_again.body.contains(r#"data-testid="nsfw-media""#)); + .await; + assert_eq!(posted.status, 303); + + let logged_out = request(&server.base_url, "GET", "/home", &[], Vec::new()).await; + assert!(!logged_out.body.contains(r#"data-testid="nsfw-media""#)); } #[tokio::test] @@ -9033,6 +9407,368 @@ mod tests { assert_eq!(users, 0); } + async fn user_id_for(server: &TestServer, username: &str) -> i64 { + let username = username.to_owned(); + server + .pool + .call(move |conn| { + conn.query_row( + "SELECT id FROM users WHERE normalized_username = ?", + [username], + |row| row.get(0), + ) + .map_err(Into::into) + }) + .await + .expect("user id") + } + + /// End-to-end backup/restore: creates representative data through the HTTP + /// API, snapshots it, restores into a fresh data directory, and verifies + /// the restored database and media files. + #[tokio::test] + async fn backup_restore_round_trips_accounts_graph_muted_words_and_media() { + let server = spawn_test_server().await; + let alice = register_test_user(&server, "alice").await; + let bob = register_test_user(&server, "bob").await; + let _carol = register_test_user(&server, "carol").await; + + create_text_post(&server, &alice, "alice first post").await; + create_text_post(&server, &bob, "bob first post").await; + + // Media upload from bob. + let bob_csrf = csrf_token(&get_with_cookie(&server, "/home", &bob).await.body); + let upload = request( + &server.base_url, + "POST", + "/posts", + &[ + ("cookie", bob.as_str()), + ( + "content-type", + "multipart/form-data; boundary=media-boundary", + ), + ], + multipart_body_with_file( + "media-boundary", + &[("csrf", bob_csrf.as_str()), ("text", "bob photo post")], + "media", + "holiday.png", + "image/png", + &tiny_png_bytes(), + ), + ) + .await; + assert_eq!(upload.status, 303); + + // Alice follows bob; bob blocks carol; alice mutes a word. + let bob_id = user_id_for(&server, "bob").await; + let carol_id = user_id_for(&server, "carol").await; + let alice_csrf = csrf_token(&get_with_cookie(&server, "/home", &alice).await.body); + let followed = post_form_with_cookie( + &server, + &format!("/users/{bob_id}/follow"), + &alice, + &format!("csrf={alice_csrf}"), + ) + .await; + assert_eq!(followed.status, 303); + let bob_settings_csrf = csrf_token(&get_with_cookie(&server, "/settings", &bob).await.body); + let blocked = post_form_with_cookie( + &server, + &format!("/users/{carol_id}/block"), + &bob, + &format!("csrf={bob_settings_csrf}"), + ) + .await; + assert_eq!(blocked.status, 303); + let alice_settings_csrf = + csrf_token(&get_with_cookie(&server, "/settings", &alice).await.body); + let muted = post_form_with_cookie( + &server, + "/settings/muted-words", + &alice, + &format!("csrf={alice_settings_csrf}&term=spoilers"), + ) + .await; + assert_eq!(muted.status, 303); + + let source_paths = RuntimePaths::from_data_dir(server.data_dir.clone()); + let archive = backup::create_backup(&source_paths, false).expect("create backup"); + assert!(archive.is_file()); + + let target_temp = tempfile::tempdir().expect("target temp dir"); + let target_paths = RuntimePaths::from_data_dir(target_temp.path().to_path_buf()); + target_paths.ensure().expect("target paths"); + let report = + backup::restore_backup(&target_paths, &archive, false).expect("restore backup"); + assert!(report.pre_restore_backup.is_some()); + + let restored = + rusqlite::Connection::open(&target_paths.database_path).expect("restored database"); + let count = |table: &str| -> i64 { + restored + .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| { + row.get(0) + }) + .expect("count") + }; + assert_eq!(count("users"), 3); + assert_eq!(count("posts"), 3); + assert_eq!(count("follows"), 1); + assert_eq!(count("blocks"), 1); + assert_eq!(count("muted_words"), 1); + assert_eq!(count("media"), 1); + let (stored_path, public_path): (String, String) = restored + .query_row( + "SELECT stored_path, public_path FROM media LIMIT 1", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .expect("media row"); + // Restore rewrites absolute media paths for the destination data dir. + let target_prefix = target_paths.data_dir.to_string_lossy().to_string(); + assert!( + stored_path.starts_with(&target_prefix), + "restored media path {stored_path} still points outside {target_prefix}" + ); + let file_name = std::path::Path::new(&stored_path) + .file_name() + .expect("media file name"); + assert!( + target_paths.uploads_originals.join(file_name).is_file(), + "restored media file is missing for {public_path}" + ); + assert!(target_paths.settings_path.is_file()); + crate::db::validate_schema(&restored).expect("restored schema validates"); + let restored_post_id: i64 = restored + .query_row("SELECT post_id FROM post_media LIMIT 1", [], |row| { + row.get(0) + }) + .expect("post media"); + drop(restored); + + // Media cleanup must work against the restored paths. + let restored_pool = crate::db::connect(&target_paths.database_path) + .await + .expect("restored pool"); + crate::media::delete_post_media(&restored_pool, &target_paths, restored_post_id) + .await + .expect("delete restored media"); + assert!(!target_paths.uploads_originals.join(file_name).exists()); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_page_loads_each_render_a_csrf_token() { + let server = spawn_test_server().await; + let cookie = register_test_user(&server, "alice").await; + let mut handles = Vec::new(); + for _ in 0..8 { + let base_url = server.base_url.clone(); + let cookie = cookie.clone(); + handles.push(tokio::spawn(async move { + request( + &base_url, + "GET", + "/home", + &[("cookie", &cookie)], + Vec::new(), + ) + .await + })); + } + for handle in handles { + let response = handle.await.expect("task"); + assert_eq!(response.status, 200); + assert!( + response.body.contains(r#"name="csrf""#), + "concurrent page load rendered a form without a CSRF token" + ); + } + } + + #[tokio::test] + async fn oversized_image_headers_are_rejected_before_conversion() { + let server = spawn_test_server().await; + let cookie = register_test_user(&server, "alice").await; + let home = get_with_cookie(&server, "/home", &cookie).await; + let csrf = csrf_token(&home.body); + + let mut oversized = tiny_png_bytes(); + oversized[16..20].copy_from_slice(&40_000_u32.to_be_bytes()); + oversized[20..24].copy_from_slice(&40_000_u32.to_be_bytes()); + let body = multipart_body_with_file( + "post-boundary", + &[("csrf", csrf.as_str()), ("text", "huge image")], + "media", + "huge.png", + "image/png", + &oversized, + ); + let response = request( + &server.base_url, + "POST", + "/posts", + &[ + ("cookie", cookie.as_str()), + ( + "content-type", + "multipart/form-data; boundary=post-boundary", + ), + ], + body, + ) + .await; + assert_eq!(response.status, 400); + assert!(response.body.contains("dimensions")); + let media = media_row_count(&server).await; + assert_eq!(media, 0, "rejected image must not leave a media row"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_post_submissions_each_create_exactly_one_post() { + let mut settings = Settings::default(); + settings.moderation.posts_per_minute = 50; + let server = spawn_test_server_with_settings(settings).await; + let cookie = register_test_user(&server, "alice").await; + let home = get_with_cookie(&server, "/home", &cookie).await; + let csrf = csrf_token(&home.body); + + let mut handles = Vec::new(); + for index in 0..10 { + let base_url = server.base_url.clone(); + let cookie = cookie.clone(); + let csrf = csrf.clone(); + handles.push(tokio::spawn(async move { + request( + &base_url, + "POST", + "/posts", + &[ + ("cookie", cookie.as_str()), + ( + "content-type", + "multipart/form-data; boundary=post-boundary", + ), + ], + multipart_body( + "post-boundary", + &[ + ("csrf", csrf.as_str()), + ("text", &format!("concurrent submission {index}")), + ], + false, + ), + ) + .await + })); + } + for handle in handles { + assert_eq!(handle.await.expect("task").status, 303); + } + + let (count, distinct): (i64, i64) = server + .pool + .call(|conn| { + Ok(( + conn.query_row( + "SELECT COUNT(*) FROM posts WHERE is_deleted = 0", + [], + |row| row.get(0), + )?, + conn.query_row( + "SELECT COUNT(DISTINCT text) FROM posts WHERE is_deleted = 0", + [], + |row| row.get(0), + )?, + )) + }) + .await + .expect("post stats"); + assert_eq!(count, 10); + assert_eq!(distinct, 10); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_identical_media_uploads_keep_files_intact() { + let mut settings = Settings::default(); + settings.moderation.posts_per_minute = 50; + let server = spawn_test_server_with_settings(settings).await; + let cookie = register_test_user(&server, "alice").await; + let home = get_with_cookie(&server, "/home", &cookie).await; + let csrf = csrf_token(&home.body); + + let mut handles = Vec::new(); + for index in 0..6 { + let base_url = server.base_url.clone(); + let cookie = cookie.clone(); + let csrf = csrf.clone(); + handles.push(tokio::spawn(async move { + let body = multipart_body_with_file( + "media-boundary", + &[ + ("csrf", csrf.as_str()), + ("text", &format!("concurrent photo {index}")), + ], + "media", + "same.png", + "image/png", + &tiny_png_bytes(), + ); + request( + &base_url, + "POST", + "/posts", + &[ + ("cookie", cookie.as_str()), + ( + "content-type", + "multipart/form-data; boundary=media-boundary", + ), + ], + body, + ) + .await + })); + } + for handle in handles { + let response = handle.await.expect("task"); + assert_eq!(response.status, 303); + } + + let rows: Vec<(String, Option)> = server + .pool + .call(|conn| { + let mut stmt = conn.prepare("SELECT stored_path, canonical_media_id FROM media")?; + let rows = stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? + .collect::, _>>()?; + Ok(rows) + }) + .await + .expect("media rows"); + assert_eq!(rows.len(), 6); + assert_eq!( + rows.iter() + .filter(|(_, canonical)| canonical.is_none()) + .count(), + 1, + "identical uploads must share one canonical row" + ); + let mut distinct_paths = std::collections::BTreeSet::new(); + for (stored_path, _canonical) in &rows { + let path = std::path::Path::new(stored_path); + assert!(path.is_file(), "missing media file {stored_path}"); + assert_eq!( + std::fs::read(path).expect("read media"), + tiny_png_bytes(), + "media file content changed under concurrent uploads" + ); + distinct_paths.insert(stored_path.clone()); + } + assert_eq!(distinct_paths.len(), 1); + } + fn multipart_body( boundary: &str, fields: &[(&str, &str)], diff --git a/src/social.rs b/src/social.rs index a37342e..ac55132 100644 --- a/src/social.rs +++ b/src/social.rs @@ -454,14 +454,31 @@ pub async fn timeline( if mode != "bookmarks" { posts.extend(repost_events(pool, viewer_id, mode, None).await?); } + sort_events_newest_first(&mut posts); + posts.truncate(40); + Ok(posts) +} + +/// Orders merged post/repost events newest first. +/// +/// Timestamps have second granularity, so the tie-break uses the numeric row +/// id rather than the `p:`/`r:` prefixed event id string (which would order +/// `p:9` before `p:10`). +fn sort_events_newest_first(posts: &mut [PostView]) { posts.sort_by(|left, right| { right .event_created_at .cmp(&left.event_created_at) - .then_with(|| right.event_id.cmp(&left.event_id)) + .then_with(|| event_sort_key(right).cmp(&event_sort_key(left))) }); - posts.truncate(40); - Ok(posts) +} + +fn event_sort_key(post: &PostView) -> i64 { + post.event_id + .rsplit(':') + .next() + .and_then(|value| value.parse().ok()) + .unwrap_or(0) } pub async fn profile_timeline( @@ -490,12 +507,7 @@ pub async fn profile_tab_timeline( if matches!(tab, ProfileTimelineTab::Posts | ProfileTimelineTab::Media) { posts.extend(repost_events(pool, viewer_id, tab.repost_mode(), Some(user_id)).await?); } - posts.sort_by(|left, right| { - right - .event_created_at - .cmp(&left.event_created_at) - .then_with(|| right.event_id.cmp(&left.event_id)) - }); + sort_events_newest_first(&mut posts); posts.truncate(40); Ok(posts) } @@ -536,7 +548,7 @@ pub async fn profile_pinned_post( sql.push_str( " AND p.id = (SELECT pinned_post_id FROM users WHERE id = ? AND is_deleted = 0) AND p.user_id = ?", ); - append_viewer_filters(&mut sql, "p.user_id", viewer_id); + append_viewer_filters(&mut sql, "p.user_id", "p.text", viewer_id); sql.push_str(" LIMIT 1"); let mut bindings = vec![user_id, user_id]; push_viewer_filter_bindings(&mut bindings, viewer_id); @@ -569,14 +581,22 @@ pub async fn post_thread( let root_id = root; let mut sql = base_post_query(); sql.push_str(" AND (p.id = ? OR p.root_post_id = ?)"); - append_viewer_filters(&mut sql, "p.user_id", viewer_id); + append_viewer_filters(&mut sql, "p.user_id", "p.text", viewer_id); sql.push_str(" ORDER BY p.id ASC LIMIT 200"); let mut bindings = vec![root_id, root_id]; push_viewer_filter_bindings(&mut bindings, viewer_id); let rows = pool .call(move |conn| query_post_rows(conn, &sql, params_from_iter(bindings))) .await?; - rows_to_posts(pool, rows, viewer_id).await + let posts = rows_to_posts(pool, rows, viewer_id).await?; + // The requested post can be hidden by a block, mute, suspension, or + // deletion while visible replies remain. Treat that as "not found" rather + // than rendering an orphaned reply branch. + if posts.iter().any(|post| post.id == post_id) { + Ok(posts) + } else { + Ok(Vec::new()) + } } pub async fn repost(pool: &SqlitePool, user_id: i64, post_id: i64) -> anyhow::Result { @@ -703,10 +723,14 @@ pub async fn quote_target_preview( viewer_id: Option, post_id: i64, ) -> anyhow::Result { - let preview = quote_preview_for_post(pool, Some(post_id), viewer_id) - .await? - .filter(|preview| !preview.unavailable); - preview.ok_or_else(|| anyhow::anyhow!("post not found")) + let previews = pool + .call(move |conn| quote_previews(conn, viewer_id, &[post_id])) + .await?; + previews + .get(&post_id) + .filter(|preview| !preview.unavailable) + .cloned() + .ok_or_else(|| anyhow::anyhow!("post not found")) } pub async fn unrepost(pool: &SqlitePool, user_id: i64, post_id: i64) -> anyhow::Result { @@ -806,6 +830,60 @@ pub async fn unfollow( .await } +/// The viewer's relationship to a profile, used to render accurate controls +/// and profile states instead of always showing unused actions. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct ProfileRelationship { + pub following: bool, + pub blocked: bool, + pub muted: bool, + pub blocks_viewer: bool, +} + +impl ProfileRelationship { + #[must_use] + pub const fn has_block(self) -> bool { + self.blocked || self.blocks_viewer + } +} + +pub async fn profile_relationship( + pool: &SqlitePool, + viewer_id: Option, + profile_id: i64, +) -> anyhow::Result { + let Some(viewer_id) = viewer_id else { + return Ok(ProfileRelationship::default()); + }; + if viewer_id == profile_id { + return Ok(ProfileRelationship::default()); + } + pool.call(move |conn| { + Ok(conn.query_row( + r#" + SELECT + EXISTS(SELECT 1 FROM follows WHERE follower_id = ? AND followed_id = ?), + EXISTS(SELECT 1 FROM blocks WHERE blocker_id = ? AND blocked_id = ?), + EXISTS(SELECT 1 FROM mutes WHERE muter_id = ? AND muted_id = ?), + EXISTS(SELECT 1 FROM blocks WHERE blocker_id = ? AND blocked_id = ?) + "#, + params![ + viewer_id, profile_id, viewer_id, profile_id, viewer_id, profile_id, profile_id, + viewer_id + ], + |row| { + Ok(ProfileRelationship { + following: row.get::<_, i64>(0)? != 0, + blocked: row.get::<_, i64>(1)? != 0, + muted: row.get::<_, i64>(2)? != 0, + blocks_viewer: row.get::<_, i64>(3)? != 0, + }) + }, + )?) + }) + .await +} + pub async fn is_following( pool: &SqlitePool, follower_id: i64, @@ -856,37 +934,105 @@ pub async fn instance_counts(pool: &SqlitePool) -> anyhow::Result<(i64, i64)> { .await } -pub async fn following_accounts( +/// Maximum accounts rendered per follower/following page. +pub const ACCOUNT_LIST_PAGE_SIZE: usize = 50; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FollowEdge { + Followers, + Following, +} + +/// Shared follower/following list query with keyset pagination and viewer +/// visibility filtering. Returns the page and whether more accounts follow. +async fn follow_account_list( pool: &SqlitePool, - viewer_id: i64, -) -> anyhow::Result> { - pool.call(move |conn| { - let mut stmt = conn.prepare( + account_id: i64, + viewer_id: Option, + edge: FollowEdge, + after: Option, +) -> anyhow::Result<(Vec, bool)> { + let (edge_column, anchor_column) = match edge { + FollowEdge::Followers => ("follower_id", "followed_id"), + FollowEdge::Following => ("followed_id", "follower_id"), + }; + let mut sql = format!( + r#" + SELECT u.id, u.username, u.display_name, u.bio, + COALESCE(pic.thumbnail_public_path, pic.public_path), + EXISTS( + SELECT 1 FROM follows vf + WHERE vf.follower_id = ? AND vf.followed_id = u.id + ) + FROM follows f + JOIN users u ON u.id = f.{edge_column} + LEFT JOIN media pic ON pic.id = u.profile_picture_media_id + WHERE f.{anchor_column} = ? AND u.is_deleted = 0 AND u.is_suspended = 0 + "# + ); + let mut bindings = vec![ + rusqlite::types::Value::Integer(viewer_id.unwrap_or(0)), + rusqlite::types::Value::Integer(account_id), + ]; + if let Some(cursor) = &after { + sql.push_str(" AND u.normalized_username > ?"); + bindings.push(rusqlite::types::Value::Text(cursor.to_ascii_lowercase())); + } + if viewer_id.is_some() { + sql.push_str( r#" - SELECT u.id, u.username, u.display_name, u.bio, - COALESCE(pic.thumbnail_public_path, pic.public_path) - FROM follows f - JOIN users u ON u.id = f.followed_id - LEFT JOIN media pic ON pic.id = u.profile_picture_media_id - WHERE f.follower_id = ? AND u.is_deleted = 0 - ORDER BY lower(u.username) + AND u.id NOT IN (SELECT blocked_id FROM blocks WHERE blocker_id = ?) + AND u.id NOT IN (SELECT muted_id FROM mutes WHERE muter_id = ?) + AND NOT EXISTS (SELECT 1 FROM blocks WHERE blocker_id = u.id AND blocked_id = ?) "#, - )?; + ); + if let Some(viewer_id) = viewer_id { + bindings.extend(std::iter::repeat_n( + rusqlite::types::Value::Integer(viewer_id), + 3, + )); + } + } + sql.push_str(" ORDER BY u.normalized_username ASC LIMIT ?"); + let limit = i64::try_from(ACCOUNT_LIST_PAGE_SIZE + 1)?; + bindings.push(rusqlite::types::Value::Integer(limit)); + pool.call(move |conn| { + let mut stmt = conn.prepare(&sql)?; let rows = stmt - .query_map([viewer_id], |row| { + .query_map(params_from_iter(bindings), |row| { Ok(AccountView { id: row.get(0)?, username: row.get(1)?, display_name: row.get(2)?, bio: row.get(3)?, profile_picture_path: row.get(4)?, - viewer_following: true, + viewer_following: row.get::<_, i64>(5)? != 0, }) })? .collect::, _>>()?; Ok(rows) }) .await + .map(|mut accounts| { + let has_more = accounts.len() > ACCOUNT_LIST_PAGE_SIZE; + accounts.truncate(ACCOUNT_LIST_PAGE_SIZE); + (accounts, has_more) + }) +} + +pub async fn following_accounts( + pool: &SqlitePool, + viewer_id: i64, + after: Option, +) -> anyhow::Result<(Vec, bool)> { + follow_account_list( + pool, + viewer_id, + Some(viewer_id), + FollowEdge::Following, + after, + ) + .await } pub async fn onboarding_suggestions( @@ -909,21 +1055,27 @@ pub async fn onboarding_suggestions( WHERE u.id != ? AND u.is_deleted = 0 AND u.is_suspended = 0 + AND u.id NOT IN (SELECT blocked_id FROM blocks WHERE blocker_id = ?) + AND u.id NOT IN (SELECT muted_id FROM mutes WHERE muter_id = ?) + AND NOT EXISTS (SELECT 1 FROM blocks WHERE blocker_id = u.id AND blocked_id = ?) ORDER BY lower(u.username), u.id LIMIT ? "#, )?; let rows = stmt - .query_map(params![viewer_id, viewer_id, limit], |row| { - Ok(AccountView { - id: row.get(0)?, - username: row.get(1)?, - display_name: row.get(2)?, - bio: row.get(3)?, - profile_picture_path: row.get(4)?, - viewer_following: row.get::<_, i64>(5)? != 0, - }) - })? + .query_map( + params![viewer_id, viewer_id, viewer_id, viewer_id, viewer_id, limit], + |row| { + Ok(AccountView { + id: row.get(0)?, + username: row.get(1)?, + display_name: row.get(2)?, + bio: row.get(3)?, + profile_picture_path: row.get(4)?, + viewer_following: row.get::<_, i64>(5)? != 0, + }) + }, + )? .collect::, _>>()?; Ok(rows) }) @@ -934,78 +1086,18 @@ pub async fn followers_accounts( pool: &SqlitePool, account_id: i64, viewer_id: Option, -) -> anyhow::Result> { - let viewer_id = viewer_id.unwrap_or(0); - pool.call(move |conn| { - let mut stmt = conn.prepare( - r#" - SELECT u.id, u.username, u.display_name, u.bio, - COALESCE(pic.thumbnail_public_path, pic.public_path), - EXISTS( - SELECT 1 FROM follows vf - WHERE vf.follower_id = ? AND vf.followed_id = u.id - ) - FROM follows f - JOIN users u ON u.id = f.follower_id - LEFT JOIN media pic ON pic.id = u.profile_picture_media_id - WHERE f.followed_id = ? AND u.is_deleted = 0 - ORDER BY lower(u.username) - "#, - )?; - let rows = stmt - .query_map(params![viewer_id, account_id], |row| { - Ok(AccountView { - id: row.get(0)?, - username: row.get(1)?, - display_name: row.get(2)?, - bio: row.get(3)?, - profile_picture_path: row.get(4)?, - viewer_following: row.get::<_, i64>(5)? != 0, - }) - })? - .collect::, _>>()?; - Ok(rows) - }) - .await + after: Option, +) -> anyhow::Result<(Vec, bool)> { + follow_account_list(pool, account_id, viewer_id, FollowEdge::Followers, after).await } pub async fn following_accounts_for_profile( pool: &SqlitePool, account_id: i64, viewer_id: Option, -) -> anyhow::Result> { - let viewer_id = viewer_id.unwrap_or(0); - pool.call(move |conn| { - let mut stmt = conn.prepare( - r#" - SELECT u.id, u.username, u.display_name, u.bio, - COALESCE(pic.thumbnail_public_path, pic.public_path), - EXISTS( - SELECT 1 FROM follows vf - WHERE vf.follower_id = ? AND vf.followed_id = u.id - ) - FROM follows f - JOIN users u ON u.id = f.followed_id - LEFT JOIN media pic ON pic.id = u.profile_picture_media_id - WHERE f.follower_id = ? AND u.is_deleted = 0 - ORDER BY lower(u.username) - "#, - )?; - let rows = stmt - .query_map(params![viewer_id, account_id], |row| { - Ok(AccountView { - id: row.get(0)?, - username: row.get(1)?, - display_name: row.get(2)?, - bio: row.get(3)?, - profile_picture_path: row.get(4)?, - viewer_following: row.get::<_, i64>(5)? != 0, - }) - })? - .collect::, _>>()?; - Ok(rows) - }) - .await + after: Option, +) -> anyhow::Result<(Vec, bool)> { + follow_account_list(pool, account_id, viewer_id, FollowEdge::Following, after).await } pub async fn block(pool: &SqlitePool, blocker_id: i64, blocked_id: i64) -> anyhow::Result<()> { @@ -1014,6 +1106,7 @@ pub async fn block(pool: &SqlitePool, blocker_id: i64, blocked_id: i64) -> anyho } pool.call(move |conn| { let tx = conn.transaction()?; + ensure_account_actionable_tx(&tx, blocked_id)?; tx.execute( "INSERT OR IGNORE INTO blocks (blocker_id, blocked_id) VALUES (?, ?)", params![blocker_id, blocked_id], @@ -1068,15 +1161,39 @@ pub async fn mute(pool: &SqlitePool, muter_id: i64, muted_id: i64) -> anyhow::Re anyhow::bail!("cannot mute yourself"); } pool.call(move |conn| { - conn.execute( + let tx = conn.transaction()?; + ensure_account_actionable_tx(&tx, muted_id)?; + tx.execute( "INSERT OR IGNORE INTO mutes (muter_id, muted_id) VALUES (?, ?)", params![muter_id, muted_id], )?; + tx.commit()?; Ok(()) }) .await } +/// Rejects block/mute targets that do not exist or were deleted, mirroring the +/// follow target check so deleted accounts cannot accumulate invisible rows. +fn ensure_account_actionable_tx( + tx: &rusqlite::Transaction<'_>, + account_id: i64, +) -> anyhow::Result<()> { + let available = tx + .query_row( + "SELECT is_deleted = 0 FROM users WHERE id = ?", + [account_id], + |row| row.get::<_, i64>(0), + ) + .optional()? + .unwrap_or(0) + != 0; + if !available { + anyhow::bail!("account not found"); + } + Ok(()) +} + pub async fn unmute(pool: &SqlitePool, muter_id: i64, muted_id: i64) -> anyhow::Result<()> { pool.call(move |conn| { conn.execute( @@ -1114,7 +1231,7 @@ pub async fn muted_users( pub async fn add_muted_word(pool: &SqlitePool, user_id: i64, term: &str) -> anyhow::Result<()> { let term = clean_muted_word(term)?; - let normalized = term.to_ascii_lowercase(); + let normalized = term.to_lowercase(); pool.call(move |conn| { conn.execute( "INSERT OR IGNORE INTO muted_words (user_id, term, normalized_term) VALUES (?, ?, ?)", @@ -1364,14 +1481,16 @@ pub async fn delete_post( anyhow::bail!("cannot delete this post"); } pool.call(move |conn| { - conn.execute( + let tx = conn.transaction()?; + tx.execute( "UPDATE posts SET is_deleted = 1, deleted_at = CURRENT_TIMESTAMP WHERE id = ?", [post_id], )?; - conn.execute( + tx.execute( "UPDATE users SET pinned_post_id = NULL, updated_at = CURRENT_TIMESTAMP WHERE pinned_post_id = ?", [post_id], )?; + tx.commit()?; Ok(()) }) .await @@ -1469,14 +1588,16 @@ pub async fn search( let rows = if let Some(fts_query) = fts_query_from_user_input(query) { let mut post_sql = base_post_query(); post_sql.push_str(" AND p.id IN (SELECT rowid FROM posts_fts WHERE posts_fts MATCH ?)"); - append_viewer_filters(&mut post_sql, "p.user_id", viewer_id); + append_viewer_filters(&mut post_sql, "p.user_id", "p.text", viewer_id); post_sql.push_str(" LIMIT 40"); pool.call(move |conn| { if let Some(viewer_id) = viewer_id { query_post_rows( conn, &post_sql, - params![fts_query, viewer_id, viewer_id, viewer_id, viewer_id], + params![ + fts_query, viewer_id, viewer_id, viewer_id, viewer_id, viewer_id + ], ) } else { query_post_rows(conn, &post_sql, params![fts_query]) @@ -1620,12 +1741,37 @@ fn fts_query_from_user_input(query: &str) -> Option { } } +/// SQL fragment that hides notifications whose actor is blocked in either +/// direction, muted, or whose post text matches one of the recipient's muted +/// words. Binds `VIEWER_FILTER_BINDINGS` copies of the recipient's user id. +const NOTIFICATION_VISIBLE_SQL: &str = r#" + AND ( + n.actor_user_id IS NULL + OR ( + n.actor_user_id NOT IN (SELECT blocked_id FROM blocks WHERE blocker_id = ?) + AND NOT EXISTS (SELECT 1 FROM blocks WHERE blocker_id = n.actor_user_id AND blocked_id = ?) + AND n.actor_user_id NOT IN (SELECT muted_id FROM mutes WHERE muter_id = ?) + ) + ) + AND ( + p.user_id = ? + OR NOT EXISTS ( + SELECT 1 FROM muted_words mw + WHERE mw.user_id = ? AND rustpost_muted_word_match(COALESCE(p.text, ''), mw.normalized_term) + ) + ) +"#; + +fn push_notification_filter_bindings(bindings: &mut Vec, user_id: i64) { + bindings.extend(std::iter::repeat_n(user_id, VIEWER_FILTER_BINDINGS)); +} + pub async fn notifications( pool: &SqlitePool, user_id: i64, ) -> anyhow::Result> { pool.call(move |conn| { - let mut stmt = conn.prepare( + let sql = format!( r#" SELECT n.id, n.kind, n.actor_user_id, u.username, u.display_name, n.post_id, p.text, p.is_deleted, p.parent_post_id, p.quote_post_id, @@ -1639,12 +1785,16 @@ pub async fn notifications( OR n.actor_user_id IS NULL OR COALESCE(u.liked_posts_public, 0) != 0 ) + {NOTIFICATION_VISIBLE_SQL} ORDER BY n.id DESC LIMIT 80 "#, - )?; + ); + let mut stmt = conn.prepare(&sql)?; + let mut bindings = vec![user_id]; + push_notification_filter_bindings(&mut bindings, user_id); let rows = stmt - .query_map([user_id], |row| { + .query_map(params_from_iter(bindings), |row| { let kind = row.get::<_, String>(1)?; let post_id = row.get::<_, Option>(5)?; let post_is_deleted = row @@ -1771,21 +1921,24 @@ fn push_notification_group_item( pub async fn unread_notification_count(pool: &SqlitePool, user_id: i64) -> anyhow::Result { pool.call(move |conn| { - Ok(conn.query_row( + let sql = format!( r#" SELECT COUNT(*) FROM notifications n LEFT JOIN users u ON u.id = n.actor_user_id AND u.is_deleted = 0 + LEFT JOIN posts p ON p.id = n.post_id WHERE n.user_id = ? AND n.read_at IS NULL AND ( n.kind != 'like' OR n.actor_user_id IS NULL OR COALESCE(u.liked_posts_public, 0) != 0 ) + {NOTIFICATION_VISIBLE_SQL} "#, - [user_id], - |row| row.get(0), - )?) + ); + let mut bindings = vec![user_id]; + push_notification_filter_bindings(&mut bindings, user_id); + Ok(conn.query_row(&sql, params_from_iter(bindings), |row| row.get(0))?) }) .await } @@ -1850,15 +2003,21 @@ fn notification_group_counts_tx( target_post_id: Option, ) -> anyhow::Result<(i64, i64)> { if kind == "follow" { - return Ok(conn.query_row( + let sql = format!( r#" - SELECT COUNT(*), COALESCE(SUM(CASE WHEN read_at IS NULL THEN 1 ELSE 0 END), 0) - FROM notifications - WHERE user_id = ? AND kind = 'follow' - "#, - [user_id], - |row| Ok((row.get(0)?, row.get(1)?)), - )?); + SELECT COUNT(*), COALESCE(SUM(CASE WHEN n.read_at IS NULL THEN 1 ELSE 0 END), 0) + FROM notifications n + LEFT JOIN users actor ON actor.id = n.actor_user_id AND actor.is_deleted = 0 + LEFT JOIN posts p ON p.id = n.post_id + WHERE n.user_id = ? AND n.kind = 'follow' + {NOTIFICATION_VISIBLE_SQL} + "# + ); + let mut bindings = vec![user_id]; + push_notification_filter_bindings(&mut bindings, user_id); + return Ok(conn.query_row(&sql, params_from_iter(bindings), |row| { + Ok((row.get(0)?, row.get(1)?)) + })?); } let Some(target_expr) = notification_group_target_expr(kind) else { return Ok((0, 0)); @@ -1878,13 +2037,21 @@ fn notification_group_counts_tx( OR n.actor_user_id IS NULL OR COALESCE(actor.liked_posts_public, 0) != 0 ) + {NOTIFICATION_VISIBLE_SQL} "# ); - Ok( - conn.query_row(&sql, params![user_id, kind, target_post_id], |row| { - Ok((row.get(0)?, row.get(1)?)) - })?, - ) + let mut bindings = vec![ + rusqlite::types::Value::Integer(user_id), + rusqlite::types::Value::Text(kind.to_owned()), + rusqlite::types::Value::Integer(target_post_id), + ]; + bindings.extend(std::iter::repeat_n( + rusqlite::types::Value::Integer(user_id), + VIEWER_FILTER_BINDINGS, + )); + Ok(conn.query_row(&sql, params_from_iter(bindings), |row| { + Ok((row.get(0)?, row.get(1)?)) + })?) } fn notification_group_target_expr(kind: &str) -> Option<&'static str> { @@ -2011,7 +2178,7 @@ async fn post_events( if matches!(mode, "home" | "bookmarks") { sql.push_str(" AND p.parent_post_id IS NULL"); } - append_viewer_filters(&mut sql, "p.user_id", viewer_id); + append_viewer_filters(&mut sql, "p.user_id", "p.text", viewer_id); if let Some(cursor) = cursor { sql.push_str(" AND p.id < "); sql.push_str(&cursor.to_string()); @@ -2039,7 +2206,7 @@ async fn post_events_for_user( ) -> anyhow::Result> { let mut sql = base_post_query(); sql.push_str(" AND p.user_id = ? AND p.parent_post_id IS NULL"); - append_viewer_filters(&mut sql, "p.user_id", viewer_id); + append_viewer_filters(&mut sql, "p.user_id", "p.text", viewer_id); sql.push_str(" ORDER BY p.id DESC LIMIT 40"); let mut bindings = vec![user_id]; push_viewer_filter_bindings(&mut bindings, viewer_id); @@ -2056,7 +2223,7 @@ async fn reply_events_for_user( ) -> anyhow::Result> { let mut sql = base_post_query(); sql.push_str(" AND p.user_id = ? AND p.parent_post_id IS NOT NULL"); - append_viewer_filters(&mut sql, "p.user_id", viewer_id); + append_viewer_filters(&mut sql, "p.user_id", "p.text", viewer_id); sql.push_str(" ORDER BY p.id DESC LIMIT 40"); let mut bindings = vec![user_id]; push_viewer_filter_bindings(&mut bindings, viewer_id); @@ -2074,7 +2241,7 @@ async fn media_events_for_user( let mut sql = base_post_query(); sql.push_str(" AND p.user_id = ? AND "); sql.push_str(&media_surface_condition("p.id", "p.text")); - append_viewer_filters(&mut sql, "p.user_id", viewer_id); + append_viewer_filters(&mut sql, "p.user_id", "p.text", viewer_id); sql.push_str(" ORDER BY p.id DESC LIMIT 40"); let mut bindings = vec![user_id]; push_viewer_filter_bindings(&mut bindings, viewer_id); @@ -2091,7 +2258,7 @@ async fn liked_events_for_user( ) -> anyhow::Result> { let mut sql = base_post_query(); sql.push_str(" AND p.id IN (SELECT post_id FROM likes WHERE user_id = ?)"); - append_viewer_filters(&mut sql, "p.user_id", viewer_id); + append_viewer_filters(&mut sql, "p.user_id", "p.text", viewer_id); sql.push_str( " ORDER BY (SELECT created_at FROM likes WHERE user_id = ? AND post_id = p.id) DESC, p.id DESC LIMIT 40", ); @@ -2125,7 +2292,7 @@ async fn repost_events( if viewer_id.is_some() { sql.push_str(" AND r.user_id NOT IN (SELECT blocked_id FROM blocks WHERE blocker_id = ?)"); sql.push_str(" AND r.user_id NOT IN (SELECT muted_id FROM mutes WHERE muter_id = ?)"); - append_viewer_filters(&mut sql, "p.user_id", viewer_id); + append_viewer_filters(&mut sql, "p.user_id", "p.text", viewer_id); } sql.push_str(" ORDER BY r.id DESC LIMIT 40"); let mut bindings = Vec::new(); @@ -2153,7 +2320,19 @@ fn media_surface_condition(post_id_column: &str, text_column: &str) -> String { ) } -fn append_viewer_filters(sql: &mut String, user_column: &str, viewer_id: Option) { +/// Number of bound parameters added by [`append_viewer_filters`]. +const VIEWER_FILTER_BINDINGS: usize = 5; + +/// Appends the standard viewer visibility predicates: accounts the viewer +/// blocked, accounts that blocked the viewer, accounts the viewer muted, and +/// the viewer's muted words. The viewer's own posts are never hidden by their +/// own muted words. +fn append_viewer_filters( + sql: &mut String, + user_column: &str, + text_column: &str, + viewer_id: Option, +) { if viewer_id.is_some() { sql.push_str(&format!( " AND ({user_column} IS NULL OR {user_column} NOT IN (SELECT blocked_id FROM blocks WHERE blocker_id = ?))" @@ -2164,18 +2343,15 @@ fn append_viewer_filters(sql: &mut String, user_column: &str, viewer_id: Option< sql.push_str(&format!( " AND ({user_column} IS NULL OR NOT EXISTS (SELECT 1 FROM blocks WHERE blocker_id = {user_column} AND blocked_id = ?))" )); - sql.push_str( - " AND NOT EXISTS (SELECT 1 FROM muted_words mw WHERE mw.user_id = ? AND instr(lower(p.text), mw.normalized_term) > 0)", - ); + sql.push_str(&format!( + " AND ({user_column} IS NULL OR {user_column} = ? OR NOT EXISTS (SELECT 1 FROM muted_words mw WHERE mw.user_id = ? AND rustpost_muted_word_match({text_column}, mw.normalized_term)))" + )); } } fn push_viewer_filter_bindings(bindings: &mut Vec, viewer_id: Option) { if let Some(id) = viewer_id { - bindings.push(id); - bindings.push(id); - bindings.push(id); - bindings.push(id); + bindings.extend(std::iter::repeat_n(id, VIEWER_FILTER_BINDINGS)); } } @@ -2217,48 +2393,59 @@ fn map_post_row(row: &Row<'_>) -> rusqlite::Result { }) } +/// Enriches pre-fetched post rows with media, embeds, viewer state, counts, +/// and quote previews using a fixed number of batched queries. +/// +/// This replaced a per-row enrichment loop that issued up to seven queries per +/// post, which meant hundreds of round trips for a single 40-post timeline. async fn rows_to_posts( pool: &SqlitePool, rows: Vec, viewer_id: Option, ) -> anyhow::Result> { + if rows.is_empty() { + return Ok(Vec::new()); + } + pool.call(move |conn| enrich_posts(conn, rows, viewer_id)) + .await +} + +fn enrich_posts( + conn: &Connection, + rows: Vec, + viewer_id: Option, +) -> anyhow::Result> { + let visible_ids = rows + .iter() + .filter(|row| !row.original_unavailable) + .map(|row| row.id) + .collect::>(); + let media = media_for_posts(conn, &visible_ids)?; + let embeds = youtube_embeds_for_posts(conn, &visible_ids)?; + let viewer_likes = viewer_relation_ids(conn, viewer_id, ViewerRelation::Likes, &visible_ids)?; + let viewer_bookmarks = + viewer_relation_ids(conn, viewer_id, ViewerRelation::Bookmarks, &visible_ids)?; + let viewer_reposts = + viewer_relation_ids(conn, viewer_id, ViewerRelation::Reposts, &visible_ids)?; + let like_counts = visible_like_counts(conn, viewer_id, &visible_ids)?; + let quote_ids = rows + .iter() + .filter_map(|row| row.quote_post_id) + .collect::>() + .into_iter() + .collect::>(); + let quotes = quote_previews(conn, viewer_id, "e_ids)?; + let mut posts = Vec::with_capacity(rows.len()); for row in rows { + let unavailable = row.original_unavailable; let id = row.id; - let original_unavailable = row.original_unavailable; - let media = if original_unavailable { - Vec::new() - } else { - media_for_post(pool, id).await? - }; - let youtube_embeds = if original_unavailable { - Vec::new() - } else { - youtube_embeds_for_post(pool, id).await? - }; - let viewer_liked = if let Some(user_id) = viewer_id { - !original_unavailable && relation_exists(pool, "likes", user_id, id).await? - } else { - false - }; - let viewer_bookmarked = if let Some(user_id) = viewer_id { - !original_unavailable && relation_exists(pool, "bookmarks", user_id, id).await? - } else { - false - }; - let viewer_reposted = if let Some(user_id) = viewer_id { - !original_unavailable && relation_exists(pool, "reposts", user_id, id).await? - } else { - false - }; - let like_count = if original_unavailable { - 0 - } else { - visible_like_count(pool, viewer_id, id).await? - }; - let viewer_can_repost = - viewer_id.is_some_and(|user_id| !original_unavailable && row.user_id != Some(user_id)); - let quote = quote_preview_for_post(pool, row.quote_post_id, viewer_id).await?; + let quote = row.quote_post_id.map(|quote_id| { + quotes + .get("e_id) + .cloned() + .unwrap_or_else(|| unavailable_quote_preview(quote_id)) + }); posts.push(PostView { event_id: row.event_id, event_kind: if row.event_kind == "repost" { @@ -2277,101 +2464,219 @@ async fn rows_to_posts( created_at: row.created_at, edited_at: row.edited_at, event_created_at: row.event_created_at, - like_count, + like_count: if unavailable { + 0 + } else { + like_counts.get(&id).copied().unwrap_or(0) + }, repost_count: row.repost_count, reply_count: row.reply_count, - viewer_liked, - viewer_bookmarked, - viewer_reposted, - viewer_can_repost, + viewer_liked: viewer_likes.contains(&id), + viewer_bookmarked: viewer_bookmarks.contains(&id), + viewer_reposted: viewer_reposts.contains(&id), + viewer_can_repost: viewer_id + .is_some_and(|user_id| !unavailable && row.user_id != Some(user_id)), pinned_by_author: row.pinned_by_author, - original_unavailable, + original_unavailable: unavailable, reposted_by_user_id: row.repost_user_id, reposted_by_username: row.repost_username, reposted_by_display_name: row.repost_display_name, reposted_at: row.repost_created_at, quote, - media, - youtube_embeds, + media: media.get(&id).cloned().unwrap_or_default(), + youtube_embeds: embeds.get(&id).cloned().unwrap_or_default(), }); } Ok(posts) } -async fn visible_like_count( - pool: &SqlitePool, - viewer_id: Option, - post_id: i64, -) -> anyhow::Result { - let viewer_id = viewer_id.unwrap_or(-1); - pool.call(move |conn| { - Ok(conn.query_row( - r#" - SELECT COUNT(*) - FROM likes l - JOIN users u ON u.id = l.user_id AND u.is_deleted = 0 - WHERE l.post_id = ? AND (u.liked_posts_public != 0 OR l.user_id = ?) - "#, - params![post_id, viewer_id], - |row| row.get(0), - )?) - }) - .await +fn unavailable_quote_preview(quote_post_id: i64) -> QuotePreview { + QuotePreview { + id: quote_post_id, + username: None, + display_name: None, + anonymous_label: None, + text: String::new(), + created_at: String::new(), + unavailable: true, + } } -async fn quote_preview_for_post( - pool: &SqlitePool, - quote_post_id: Option, +fn sql_placeholders(count: usize) -> String { + std::iter::repeat_n("?", count) + .collect::>() + .join(",") +} + +fn media_for_posts( + conn: &Connection, + post_ids: &[i64], +) -> anyhow::Result>> { + if post_ids.is_empty() { + return Ok(HashMap::new()); + } + let sql = format!( + r#" + SELECT pm.post_id, m.public_path, m.mime_type, m.media_kind, m.alt_text, m.is_nsfw + FROM post_media pm + JOIN media m ON m.id = pm.media_id + WHERE pm.post_id IN ({}) + ORDER BY pm.post_id ASC, pm.position ASC + "#, + sql_placeholders(post_ids.len()) + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(params_from_iter(post_ids.iter()), |row| { + Ok(( + row.get::<_, i64>(0)?, + MediaView { + public_path: row.get(1)?, + mime_type: row.get(2)?, + media_kind: row.get(3)?, + alt_text: row.get(4)?, + is_nsfw: row.get::<_, i64>(5)? != 0, + }, + )) + })?; + let mut media: HashMap> = HashMap::new(); + for row in rows { + let (post_id, view) = row?; + media.entry(post_id).or_default().push(view); + } + Ok(media) +} + +fn youtube_embeds_for_posts( + conn: &Connection, + post_ids: &[i64], +) -> anyhow::Result>> { + if post_ids.is_empty() { + return Ok(HashMap::new()); + } + let sql = format!( + r#" + SELECT post_id, video_id, title + FROM post_embeds + WHERE provider = 'youtube' AND post_id IN ({}) + ORDER BY post_id ASC, position ASC, id ASC + "#, + sql_placeholders(post_ids.len()) + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(params_from_iter(post_ids.iter()), |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + )) + })?; + let mut embeds: HashMap> = HashMap::new(); + for row in rows { + let (post_id, video_id, title) = row?; + if let Some(embed) = crate::youtube::embed_from_stored(&video_id, title) { + embeds.entry(post_id).or_default().push(embed); + } + } + Ok(embeds) +} + +#[derive(Debug, Clone, Copy)] +enum ViewerRelation { + Likes, + Bookmarks, + Reposts, +} + +impl ViewerRelation { + const fn table(self) -> &'static str { + match self { + Self::Likes => "likes", + Self::Bookmarks => "bookmarks", + Self::Reposts => "reposts", + } + } +} + +fn viewer_relation_ids( + conn: &Connection, viewer_id: Option, -) -> anyhow::Result> { - let Some(quote_post_id) = quote_post_id else { - return Ok(None); + relation: ViewerRelation, + post_ids: &[i64], +) -> anyhow::Result> { + let Some(viewer_id) = viewer_id else { + return Ok(BTreeSet::new()); }; - pool.call(move |conn| { - let mut sql = r#" - SELECT q.id, q.user_id, u.username, u.display_name, q.anonymous_label, q.text, q.created_at - FROM posts q - LEFT JOIN users u ON u.id = q.user_id - WHERE q.id = ? AND q.is_deleted = 0 - AND (q.user_id IS NULL OR (u.is_deleted = 0 AND u.is_suspended = 0)) - "# - .to_owned(); - if viewer_id.is_some() { - sql.push_str( - " AND (q.user_id IS NULL OR q.user_id NOT IN (SELECT blocked_id FROM blocks WHERE blocker_id = ?))", - ); - sql.push_str( - " AND (q.user_id IS NULL OR q.user_id NOT IN (SELECT muted_id FROM mutes WHERE muter_id = ?))", - ); - sql.push_str( - " AND (q.user_id IS NULL OR NOT EXISTS (SELECT 1 FROM blocks WHERE blocker_id = q.user_id AND blocked_id = ?))", - ); - sql.push_str( - " AND NOT EXISTS (SELECT 1 FROM muted_words mw WHERE mw.user_id = ? AND instr(lower(q.text), mw.normalized_term) > 0)", - ); - } - let preview = if let Some(viewer_id) = viewer_id { - conn.query_row( - &sql, - params![quote_post_id, viewer_id, viewer_id, viewer_id, viewer_id], - map_quote_preview_row, - ) - .optional()? - } else { - conn.query_row(&sql, params![quote_post_id], map_quote_preview_row) - .optional()? - }; - Ok(Some(preview.unwrap_or_else(|| QuotePreview { - id: quote_post_id, - username: None, - display_name: None, - anonymous_label: None, - text: String::new(), - created_at: String::new(), - unavailable: true, - }))) - }) - .await + if post_ids.is_empty() { + return Ok(BTreeSet::new()); + } + let sql = format!( + "SELECT post_id FROM {} WHERE user_id = ? AND post_id IN ({})", + relation.table(), + sql_placeholders(post_ids.len()) + ); + let mut bindings = Vec::with_capacity(post_ids.len() + 1); + bindings.push(viewer_id); + bindings.extend_from_slice(post_ids); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(params_from_iter(bindings), |row| row.get::<_, i64>(0))?; + Ok(rows.collect::, _>>()?) +} + +fn visible_like_counts( + conn: &Connection, + viewer_id: Option, + post_ids: &[i64], +) -> anyhow::Result> { + if post_ids.is_empty() { + return Ok(HashMap::new()); + } + let sql = format!( + r#" + SELECT l.post_id, COUNT(*) + FROM likes l + JOIN users u ON u.id = l.user_id AND u.is_deleted = 0 + WHERE l.post_id IN ({}) AND (u.liked_posts_public != 0 OR l.user_id = ?) + GROUP BY l.post_id + "#, + sql_placeholders(post_ids.len()) + ); + let mut bindings = Vec::with_capacity(post_ids.len() + 1); + bindings.extend_from_slice(post_ids); + bindings.push(viewer_id.unwrap_or(-1)); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(params_from_iter(bindings), |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)) + })?; + Ok(rows.collect::, _>>()?) +} + +fn quote_previews( + conn: &Connection, + viewer_id: Option, + quote_ids: &[i64], +) -> anyhow::Result> { + if quote_ids.is_empty() { + return Ok(HashMap::new()); + } + let mut sql = format!( + r#" + SELECT q.id, q.user_id, u.username, u.display_name, q.anonymous_label, q.text, q.created_at + FROM posts q + LEFT JOIN users u ON u.id = q.user_id + WHERE q.id IN ({}) AND q.is_deleted = 0 + AND (q.user_id IS NULL OR (u.is_deleted = 0 AND u.is_suspended = 0)) + "#, + sql_placeholders(quote_ids.len()) + ); + append_viewer_filters(&mut sql, "q.user_id", "q.text", viewer_id); + let mut bindings = quote_ids.to_vec(); + push_viewer_filter_bindings(&mut bindings, viewer_id); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(params_from_iter(bindings), |row| { + let preview = map_quote_preview_row(row)?; + Ok((preview.id, preview)) + })?; + Ok(rows.collect::, _>>()?) } fn map_quote_preview_row(row: &Row<'_>) -> rusqlite::Result { @@ -2437,55 +2742,6 @@ fn user_relation_exists_tx( .is_some()) } -async fn relation_exists( - pool: &SqlitePool, - table: &str, - user_id: i64, - post_id: i64, -) -> anyhow::Result { - let sql = format!("SELECT 1 FROM {table} WHERE user_id = ? AND post_id = ?"); - pool.call(move |conn| { - Ok(conn - .query_row(&sql, params![user_id, post_id], |_| Ok(())) - .optional()? - .is_some()) - }) - .await -} - -async fn media_for_post(pool: &SqlitePool, post_id: i64) -> anyhow::Result> { - pool.call(move |conn| { - let mut stmt = conn.prepare( - r#" - SELECT m.public_path, m.mime_type, m.media_kind, m.alt_text, m.is_nsfw - FROM post_media pm JOIN media m ON m.id = pm.media_id - WHERE pm.post_id = ? ORDER BY pm.position ASC - "#, - )?; - let rows = stmt - .query_map([post_id], |row| { - Ok(MediaView { - public_path: row.get(0)?, - mime_type: row.get(1)?, - media_kind: row.get(2)?, - alt_text: row.get(3)?, - is_nsfw: row.get::<_, i64>(4)? != 0, - }) - })? - .collect::, _>>()?; - Ok(rows) - }) - .await -} - -async fn youtube_embeds_for_post( - pool: &SqlitePool, - post_id: i64, -) -> anyhow::Result> { - pool.call(move |conn| youtube_embeds_for_post_conn(conn, post_id).map_err(Into::into)) - .await -} - fn youtube_embeds_for_post_conn( conn: &Connection, post_id: i64, @@ -3650,7 +3906,9 @@ mod tests { let (pool, _settings, alice, bob) = fixture().await; follow(&pool, bob, alice).await.expect("follow"); - let accounts = following_accounts(&pool, bob).await.expect("accounts"); + let (accounts, _) = following_accounts(&pool, bob, None) + .await + .expect("accounts"); assert_eq!(accounts.len(), 1); assert!(accounts[0].profile_picture_path.is_none()); @@ -4617,7 +4875,7 @@ mod tests { .await .expect("carol follows alice"); - let accounts = following_accounts(&pool, alice) + let (accounts, _) = following_accounts(&pool, alice, None) .await .expect("following accounts"); assert_eq!(accounts.len(), 1); @@ -4625,4 +4883,281 @@ mod tests { assert_eq!(accounts[0].username, "bob"); assert!(accounts[0].viewer_following); } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_posts_all_commit_with_unique_ids() { + let (pool, settings, alice, bob) = fixture().await; + let mut handles = Vec::new(); + for index in 0..24 { + let pool = pool.clone(); + let settings = settings.clone(); + let author = if index % 2 == 0 { alice } else { bob }; + handles.push(tokio::spawn(async move { + create_post( + &pool, + &settings, + Some(author), + &format!("concurrent post {index}"), + None, + &[], + ) + .await + })); + } + let mut ids = BTreeSet::new(); + for handle in handles { + let id = handle.await.expect("task").expect("concurrent post"); + ids.insert(id); + } + assert_eq!(ids.len(), 24); + let count: i64 = pool + .call(|conn| { + Ok(conn.query_row( + "SELECT COUNT(*) FROM posts WHERE is_deleted = 0", + [], + |row| row.get(0), + )?) + }) + .await + .expect("post count"); + assert_eq!(count, 24); + + // All 24 posts share one-second timestamps; the merged timeline must + // still be ordered by numeric id, not by the "p:" event string. + let timeline = timeline(&pool, None, "local", None) + .await + .expect("timeline"); + let ids = timeline.iter().map(|post| post.id).collect::>(); + let mut newest_first = ids.clone(); + newest_first.sort_unstable_by(|left, right| right.cmp(left)); + assert_eq!(ids, newest_first); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_rate_limit_checks_record_at_most_the_limit() { + let (pool, _settings, alice, _bob) = fixture().await; + let actor = format!("user:{alice}"); + let mut handles = Vec::new(); + for _ in 0..16 { + let pool = pool.clone(); + let actor = actor.clone(); + handles.push(tokio::spawn(async move { + crate::rate_limit::check_and_record( + &pool, + crate::rate_limit::Scope::Post, + &actor, + 3, + 60, + ) + .await + })); + } + let mut accepted = 0; + for handle in handles { + if handle.await.expect("task").is_ok() { + accepted += 1; + } + } + assert_eq!(accepted, 3); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_follows_create_one_relationship() { + let (pool, _settings, alice, bob) = fixture().await; + let mut handles = Vec::new(); + for _ in 0..12 { + let pool = pool.clone(); + handles.push(tokio::spawn(async move { follow(&pool, alice, bob).await })); + } + for handle in handles { + handle.await.expect("task").expect("follow"); + } + let rows: i64 = pool + .call(move |conn| { + Ok(conn.query_row( + "SELECT COUNT(*) FROM follows WHERE follower_id = ? AND followed_id = ?", + params![alice, bob], + |row| row.get(0), + )?) + }) + .await + .expect("follow count"); + assert_eq!(rows, 1); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_block_and_unblock_leave_consistent_state() { + let (pool, _settings, alice, bob) = fixture().await; + follow(&pool, alice, bob).await.expect("follow"); + let mut handles = Vec::new(); + for index in 0..20 { + let pool = pool.clone(); + handles.push(tokio::spawn(async move { + if index % 2 == 0 { + block(&pool, alice, bob).await + } else { + unblock(&pool, alice, bob).await + } + })); + } + for handle in handles { + handle.await.expect("task").expect("block toggle"); + } + let (blocks, follows): (i64, i64) = pool + .call(move |conn| { + Ok(( + conn.query_row( + "SELECT COUNT(*) FROM blocks WHERE blocker_id = ? AND blocked_id = ?", + params![alice, bob], + |row| row.get(0), + )?, + conn.query_row( + "SELECT COUNT(*) FROM follows WHERE (follower_id = ? AND followed_id = ?) OR (follower_id = ? AND followed_id = ?)", + params![alice, bob, bob, alice], + |row| row.get(0), + )?, + )) + }) + .await + .expect("state"); + // A block must never coexist with a follow relationship. + assert!( + blocks == 0 || follows == 0, + "blocks={blocks} follows={follows}" + ); + } + + #[tokio::test] + async fn muted_words_are_case_insensitive_unicode_and_exempt_own_posts() { + let (pool, settings, alice, bob) = fixture().await; + add_muted_word(&pool, alice, "CAFÉ").await.expect("mute"); + add_muted_word(&pool, alice, "breaking news") + .await + .expect("mute phrase"); + add_muted_word(&pool, alice, "cat").await.expect("mute"); + // Duplicates (case-insensitive) collapse to one row. + add_muted_word(&pool, alice, "café") + .await + .expect("dup mute"); + let stored = muted_words(&pool, alice).await.expect("list"); + assert_eq!(stored.len(), 3); + for invalid in ["", " ", "\n"] { + assert!(add_muted_word(&pool, alice, invalid).await.is_err()); + } + assert!( + add_muted_word(&pool, alice, &"x".repeat(101)) + .await + .is_err() + ); + + let hidden = [ + "CAFÉ is open", + "a café visit", + "Breaking NEWS today", + "concatenate the strings", + "the cat sat down", + ]; + for text in hidden { + create_post(&pool, &settings, Some(bob), text, None, &[]) + .await + .expect("hidden post"); + } + create_post(&pool, &settings, Some(bob), "a dog appears", None, &[]) + .await + .expect("visible post"); + create_post(&pool, &settings, Some(alice), "my own CAFÉ cat", None, &[]) + .await + .expect("own post"); + + let timeline = timeline(&pool, Some(alice), "local", None) + .await + .expect("timeline"); + let texts = timeline + .iter() + .map(|post| post.text.as_str()) + .collect::>(); + assert!(texts.contains(&"a dog appears")); + assert!(texts.contains(&"my own CAFÉ cat")); + assert_eq!(timeline.len(), 2, "unexpected timeline: {texts:?}"); + + let (_, search_posts) = search(&pool, Some(alice), "concatenate") + .await + .expect("search"); + assert!(search_posts.is_empty()); + + remove_muted_word(&pool, alice, stored[0].id) + .await + .expect("remove"); + assert_eq!(muted_words(&pool, alice).await.expect("list").len(), 2); + } + + #[tokio::test] + async fn notifications_hide_blocked_muted_and_muted_word_content() { + let (pool, settings, alice, bob) = fixture().await; + create_post( + &pool, + &settings, + Some(bob), + "spoilers ahead @alice", + None, + &[], + ) + .await + .expect("mention post"); + assert_eq!( + notifications(&pool, alice) + .await + .expect("notifications") + .len(), + 1 + ); + + add_muted_word(&pool, alice, "spoilers") + .await + .expect("mute word"); + assert!( + notifications(&pool, alice) + .await + .expect("notifications") + .is_empty() + ); + assert_eq!( + unread_notification_count(&pool, alice) + .await + .expect("unread"), + 0 + ); + + remove_muted_word( + &pool, + alice, + muted_words(&pool, alice).await.expect("list")[0].id, + ) + .await + .expect("unmute word"); + follow(&pool, bob, alice).await.expect("follow"); + assert_eq!( + notifications(&pool, alice) + .await + .expect("notifications") + .len(), + 2 + ); + + block(&pool, alice, bob).await.expect("block"); + assert!( + notifications(&pool, alice) + .await + .expect("notifications") + .is_empty() + ); + unblock(&pool, alice, bob).await.expect("unblock"); + assert_eq!( + notifications(&pool, alice) + .await + .expect("notifications") + .len(), + 2 + ); + } } From c27ef084aac11ef545b121ef7e7df1d972930428 Mon Sep 17 00:00:00 2001 From: csd113 Date: Tue, 29 Sep 2026 07:53:25 -0700 Subject: [PATCH 17/21] Prepare RustPost 1.0.0 first release --- CHANGELOG.md | 22 +- Cargo.lock | 230 +- README.md | 33 +- docs/account-and-instance-features.md | 288 ++ src/account.rs | 1720 ++++++- src/auth.rs | 451 +- src/backup.rs | 370 +- src/config.rs | 104 + src/db.rs | 1480 +++++- src/demo_seed.rs | 4 +- src/errors.rs | 3 + src/identity.rs | 1012 ++++ src/instance.rs | 515 +++ src/lib.rs | 3 + src/media.rs | 6 +- src/portability.rs | 4378 ++++++++++++++++++ src/render.rs | 374 +- src/runtime.rs | 246 +- src/server.rs | 6112 +++++++++++++++++++------ src/social.rs | 2499 ++++++++-- 20 files changed, 17915 insertions(+), 1935 deletions(-) create mode 100644 docs/account-and-instance-features.md create mode 100644 src/identity.rs create mode 100644 src/instance.rs create mode 100644 src/portability.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e0500a..931ff50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ ## v1.0.0 - First Release +### Instance and social controls +- Protected accounts: users can require approval before someone follows them. Requests are pending until approved, never count as followers, can be rejected or cancelled, and are cleared by blocks. +- Instance announcements: administrators can publish, disable, or clear an announcement rendered next to the site name in the top bar. +- Maintenance mode: administrators can disable registration and post creation (including replies, edits, quotes, reposts, and account imports) while the site stays readable; administrators keep posting access to verify the instance, and the block policy is a single table-driven rule set. + +### Account lifecycle +- Account export/import: versioned `.tar.gz` archives with posts, profile data, media bytes, media list, outgoing follows, and settings; imports are validated, staged, remapped, and protected by repeat-import tracking. +- Archive hardening: compressed upload/export and decompressed-content limits are independent and configurable, structural limits are enforced incrementally while streaming, media bytes must match their declared size/hash/type, and failed imports leave no partial state. +- Admin forced password reset: a flagged account is restricted to the password-change and logout flows until the password changes, including on existing sessions. +- Admin forced logout: every active session for one account can be revoked without affecting other accounts. +- Account deletion grace period: deletion requests store a deadline (default 30 days, configurable), can be cancelled, block publishing while pending, and are finalized idempotently from persisted state with a durable media-cleanup journal. +- Username changes and history: handles are validated and changed atomically, previous handles stay reserved, same-owner reclamation is allowed, and old profile URLs render a history or released-handle page instead of silently resolving to a different person. + +### Database +- Schema version `4` adds account lifecycle columns, `follow_requests`, `username_history`, `instance_settings`, and `account_imports` with a forward migration from versions `1`–`3` and alpha adoption. + ### Runtime and Dependency Baseline - Raised the minimum supported Rust version to `1.91` and updated local, CI, and release-build documentation accordingly. - Upgraded the embedded Arti and Tor crate family to `0.45.0`, including the onion-stream API migration required by that release. @@ -29,13 +45,13 @@ - Audited the release build, clean temporary-runtime first-run flow, restart persistence, backup/restore behavior, runtime permissions, and operator documentation before tagging. ### Database Lineage -- Squashed the pre-release internal migration chain into a clean first-release SQLite schema baseline at database schema version `2`. +- Squashed the pre-release internal migration chain into a clean first-release SQLite schema baseline at database schema version `4`. - Fresh databases are now initialized directly from the baseline instead of replaying alpha development migrations. -- Existing current alpha databases that structurally match the baseline are marked as baseline version `2` without destructive changes or data loss. +- Existing compatible alpha and release-candidate databases are migrated to schema version `4` without destructive changes or data loss. - Incomplete, unknown, or structurally unsafe pre-release databases now fail closed with administrator guidance to back up/export/recreate/restore instead of attempting blind migration. - Added stricter schema diagnostics for required tables, columns, indexes, and triggers so startup, backups, restores, and admin health can report incompatible or corrupt database structure clearly. - `check` now reports database schema status without creating or migrating the database during diagnostics. -- Future post-release database changes should use normal forward migrations after baseline version `2`; released migration history must not be squashed or rewritten after the first public release. +- Future post-release database changes should use normal forward migrations after baseline version `4`; released migration history must not be squashed or rewritten after the first public release. ## v0.1.6 - Pinned Profiles diff --git a/Cargo.lock b/Cargo.lock index f45b193..2175921 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -178,11 +178,11 @@ dependencies = [ "libc", "once_cell", "postage", - "rand 0.10.2", + "rand 0.10.3", "safelog", "serde", "tempfile", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", "tor-async-utils", "tor-basic-utils", @@ -230,7 +230,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -561,9 +561,9 @@ checksum = "23a6f83e57018d15e7615a6f8e5e2c6b6b0226e24a1c562cd705f855a2bc751a" [[package]] name = "cc" -version = "1.4.7" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" dependencies = [ "find-msvc-tools", "shlex", @@ -675,9 +675,9 @@ checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" [[package]] name = "coarsetime" -version = "0.1.37" +version = "0.1.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e58eb270476aa4fc7843849f8a35063e8743b4dbcdf6dd0f8ea0886980c204c2" +checksum = "eae63702c5627c75addbfb1ea9d1b3842205a6ffd43e4cce884a2a9eb1828fd7" dependencies = [ "libc", "wasix", @@ -1006,7 +1006,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -1281,13 +1281,12 @@ dependencies = [ [[package]] name = "encoding_rs" -version = "0.8.41" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" dependencies = [ "cfg-if", "core_detect", - "multiversion", "multiversion_no_op", "rustversion", "scopeguard", @@ -1445,9 +1444,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.13" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "flate2" @@ -1499,7 +1498,7 @@ dependencies = [ "libc", "pwd-grp", "serde", - "thiserror 2.0.20", + "thiserror 2.0.21", "void", "walkdir", ] @@ -1511,7 +1510,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d10ce5036d49f8add4a11d08bc1b1afb0e3cefdd88778ab87fb4202d47b765e1" dependencies = [ "libc", - "thiserror 2.0.20", + "thiserror 2.0.21", "windows-sys 0.61.2", ] @@ -1883,9 +1882,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ "bytes", "http", @@ -2224,9 +2223,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.105" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", @@ -2305,9 +2304,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.24" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6480ccc157a1389bb2e4891b24751b0f798ba640d22386f23143fbcc89da195a" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" dependencies = [ "libc", ] @@ -2475,27 +2474,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "multiversion" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c" -dependencies = [ - "multiversion-macros", -] - -[[package]] -name = "multiversion-macros" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83" -dependencies = [ - "proc-macro2", - "quote", - "rustversion", - "syn 3.0.6", -] - [[package]] name = "multiversion_no_op" version = "1.0.0" @@ -3018,7 +2996,7 @@ dependencies = [ "derive-deftly", "libc", "paste", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -3070,9 +3048,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", "getrandom 0.4.3", @@ -3176,7 +3154,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" dependencies = [ "libredox", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -3408,7 +3386,7 @@ dependencies = [ "sha2 0.11.0", "tar", "tempfile", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "toml 1.1.6+spec-1.1.0", "tor-cell", @@ -3445,7 +3423,7 @@ dependencies = [ "educe", "either", "fluid-let", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -3638,9 +3616,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.23.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" +checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f" dependencies = [ "base64 0.23.1", "bs58", @@ -3659,9 +3637,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.23.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" +checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49" dependencies = [ "darling 0.24.1", "proc-macro2", @@ -3783,9 +3761,9 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "siphasher" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" [[package]] name = "slab" @@ -3812,15 +3790,15 @@ dependencies = [ "paste", "serde", "slotmap", - "thiserror 2.0.20", + "thiserror 2.0.21", "void", ] [[package]] name = "smallvec" -version = "1.16.1" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" [[package]] name = "socket2" @@ -4063,11 +4041,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl 2.0.20", + "thiserror-impl 2.0.21", ] [[package]] @@ -4083,9 +4061,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", @@ -4296,7 +4274,7 @@ dependencies = [ "pin-project", "postage", "sync_wrapper", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-util", "tor-basic-utils", @@ -4321,12 +4299,12 @@ dependencies = [ "itertools", "libc", "paste", - "rand 0.10.2", + "rand 0.10.3", "rand_chacha 0.10.0", "serde", "slab", "smallvec", - "thiserror 2.0.20", + "thiserror 2.0.21", "tracing", "weak-table", "web-time-compat", @@ -4344,7 +4322,7 @@ dependencies = [ "educe", "getrandom 0.4.3", "safelog", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-error", "tor-llcrypto", "zeroize", @@ -4365,9 +4343,9 @@ dependencies = [ "educe", "itertools", "paste", - "rand 0.10.2", + "rand 0.10.3", "smallvec", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-basic-utils", "tor-bytes", "tor-cert", @@ -4392,7 +4370,7 @@ dependencies = [ "derive_more", "digest 0.10.7", "saturating-time", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-bytes", "tor-checkable", "tor-error", @@ -4418,11 +4396,11 @@ dependencies = [ "oneshot-fused-workaround", "percent-encoding", "postage", - "rand 0.10.2", + "rand 0.10.3", "safelog", "serde", "serde_with", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-async-utils", "tor-basic-utils", "tor-cell", @@ -4452,7 +4430,7 @@ dependencies = [ "humantime", "itertools", "signature", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-llcrypto", "web-time-compat", ] @@ -4478,11 +4456,11 @@ dependencies = [ "once_cell", "oneshot-fused-workaround", "pin-project", - "rand 0.10.2", + "rand 0.10.3", "retry-error", "safelog", "serde", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-async-utils", "tor-basic-utils", "tor-cell", @@ -4533,7 +4511,7 @@ dependencies = [ "serde-value", "serde_ignored", "strum", - "thiserror 2.0.20", + "thiserror 2.0.21", "toml 1.1.6+spec-1.1.0", "tor-basic-utils", "tor-error", @@ -4551,7 +4529,7 @@ dependencies = [ "directories", "serde", "shellexpand", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-error", "tor-general-addr", ] @@ -4567,7 +4545,7 @@ dependencies = [ "hex", "imara-diff", "static_assertions", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-error", "tor-llcrypto", "tor-netdoc", @@ -4589,7 +4567,7 @@ dependencies = [ "httpdate", "itertools", "memchr", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-circmgr", "tor-error", "tor-hscrypto", @@ -4647,7 +4625,7 @@ dependencies = [ "oneshot-fused-workaround", "paste", "postage", - "rand 0.10.2", + "rand 0.10.3", "rusqlite", "safelog", "scopeguard", @@ -4656,7 +4634,7 @@ dependencies = [ "signature", "static_assertions", "strum", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", "tor-async-utils", "tor-basic-utils", @@ -4692,7 +4670,7 @@ dependencies = [ "retry-error", "static_assertions", "strum", - "thiserror 2.0.20", + "thiserror 2.0.21", "tracing", "void", "web-time-compat", @@ -4705,7 +4683,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b55314b3d95d4145c2d88cee46d4c623b209eceb980ac151c35ab97b2cba9de" dependencies = [ "derive_more", - "thiserror 2.0.20", + "thiserror 2.0.21", "void", ] @@ -4730,11 +4708,11 @@ dependencies = [ "oneshot-fused-workaround", "pin-project", "postage", - "rand 0.10.2", + "rand 0.10.3", "safelog", "serde", "strum", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-async-utils", "tor-basic-utils", "tor-config", @@ -4769,12 +4747,12 @@ dependencies = [ "itertools", "oneshot-fused-workaround", "postage", - "rand 0.10.2", + "rand 0.10.3", "retry-error", "safelog", "slotmap-careful", "strum", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-async-utils", "tor-basic-utils", "tor-bytes", @@ -4814,12 +4792,12 @@ dependencies = [ "humantime", "itertools", "paste", - "rand 0.10.2", + "rand 0.10.3", "safelog", "serde", "signature", "subtle", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-basic-utils", "tor-bytes", "tor-error", @@ -4857,14 +4835,14 @@ dependencies = [ "once_cell", "oneshot-fused-workaround", "postage", - "rand 0.10.2", + "rand 0.10.3", "rand_core 0.10.1", "retry-error", "safelog", "serde", "serde_with", "strum", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-async-utils", "tor-basic-utils", "tor-bytes", @@ -4901,11 +4879,11 @@ dependencies = [ "derive_more", "downcast-rs", "paste", - "rand 0.10.2", + "rand 0.10.3", "rsa", "signature", "ssh-key-fork-arti", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-bytes", "tor-cert", "tor-checkable", @@ -4932,12 +4910,12 @@ dependencies = [ "humantime", "inventory", "itertools", - "rand 0.10.2", + "rand 0.10.3", "safelog", "serde", "signature", "ssh-key-fork-arti", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-basic-utils", "tor-bytes", "tor-config", @@ -4972,7 +4950,7 @@ dependencies = [ "serde", "serde_with", "strum", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-basic-utils", "tor-bytes", "tor-config", @@ -5001,7 +4979,7 @@ dependencies = [ "getrandom 0.3.4", "getrandom 0.4.3", "hex", - "rand 0.10.2", + "rand 0.10.3", "rand_chacha 0.10.0", "rand_core 0.10.1", "rand_core 0.6.4", @@ -5016,7 +4994,7 @@ dependencies = [ "sha3 0.10.9", "signature", "subtle", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-error", "tor-memquota-cost", "visibility", @@ -5032,7 +5010,7 @@ checksum = "382fbfbe7b7a6e88165bc473362616b2360c5d07d2c66c9362c79b0f6da3d39c" dependencies = [ "futures", "humantime", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-error", "tor-rtcompat", "tracing", @@ -5060,7 +5038,7 @@ dependencies = [ "slotmap-careful", "static_assertions", "sysinfo", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-async-utils", "tor-basic-utils", "tor-config", @@ -5099,10 +5077,10 @@ dependencies = [ "humantime", "itertools", "num_enum", - "rand 0.10.2", + "rand 0.10.3", "serde", "strum", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", "tor-basic-utils", "tor-error", @@ -5142,7 +5120,7 @@ dependencies = [ "memchr", "paste", "phf", - "rand 0.10.2", + "rand 0.10.3", "rangemap", "saturating-time", "serde", @@ -5151,7 +5129,7 @@ dependencies = [ "smallvec", "strum", "subtle", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", "tinystr", "tor-basic-utils", @@ -5190,7 +5168,7 @@ dependencies = [ "sanitize-filename", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", "tor-async-utils", "tor-basic-utils", @@ -5230,7 +5208,7 @@ dependencies = [ "oneshot-fused-workaround", "pin-project", "postage", - "rand 0.10.2", + "rand 0.10.3", "rand_core 0.10.1", "safelog", "slotmap-careful", @@ -5238,7 +5216,7 @@ dependencies = [ "static_assertions", "strum", "subtle", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-util", "tor-async-utils", @@ -5276,7 +5254,7 @@ dependencies = [ "derive-deftly", "paste", "serde_with", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-basic-utils", "tor-bytes", ] @@ -5287,7 +5265,7 @@ version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "465cc59cd3713b1b1dcb3daafd21153509b49a91dfd19711df3143154806bc97" dependencies = [ - "rand 0.10.2", + "rand 0.10.3", "serde", "tor-basic-utils", "tor-linkspec", @@ -5321,7 +5299,7 @@ dependencies = [ "rustls-pki-types", "rustls-webpki", "socket2", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-util", "tor-error", @@ -5352,7 +5330,7 @@ dependencies = [ "priority-queue", "slotmap-careful", "strum", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-error", "tor-general-addr", "tor-rtcompat", @@ -5374,7 +5352,7 @@ dependencies = [ "educe", "safelog", "subtle", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-bytes", "tor-error", ] @@ -5388,7 +5366,7 @@ dependencies = [ "derive-deftly", "derive_more", "serde", - "thiserror 2.0.20", + "thiserror 2.0.21", "tor-memquota", ] @@ -5731,9 +5709,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -5744,9 +5722,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5754,9 +5732,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", @@ -5767,9 +5745,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] @@ -6177,9 +6155,9 @@ dependencies = [ [[package]] name = "xxhash-rust" -version = "0.8.18" +version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" +checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7" [[package]] name = "yoke" @@ -6206,18 +6184,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.57" +version = "0.8.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.57" +version = "0.8.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" dependencies = [ "proc-macro2", "quote", diff --git a/README.md b/README.md index 3ad741e..228acfd 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ [**Getting Started**](#-getting-started) · [**Configuration**](#-configuration) · [**CLI Reference**](#-cli-reference) · [**Security**](#-security-model) · [**Tor / Arti**](#-tor--arti) +**First stable release:** [v1.0.0](https://github.com/csd113/RustPost/releases/tag/v1.0.0) · [Changelog](CHANGELOG.md) + --- @@ -44,6 +46,10 @@ This repository is intended for operators who want a small, understandable self- | Sessions | Server-side, HttpOnly, SameSite=Lax cookies | | Profile customization | Bio, avatar, and banner with WebP conversion | | Admin CLI | Create admins and reset passwords from the command line | +| Username changes | Users can change their @handle; previous handles stay reserved to prevent impersonation, with history shown on profiles | +| Account deletion | Deletion starts a configurable grace period with a visible deadline, cancellation, and restart-safe finalization | +| Account export/import | Versioned `.tar.gz` archives with posts, profile, media bytes, media list, outgoing follows, and settings | +| Admin account controls | Forced password reset on next login and forced logout of every session | ### Social Features | Feature | Details | @@ -52,6 +58,7 @@ This repository is intended for operators who want a small, understandable self- | Replies & Threads | Threaded conversations with full timeline rendering | | Reposts | First-class timeline events; deleted originals render gracefully | | Follows, Blocks & Mutes | Standard social graph primitives | +| Protected accounts | Optional approval for new followers: pending requests never count as followers until approved | | Likes & Bookmarks | Likes are public; bookmarks are private | | Notifications | In-app notification feed | | Muted words | Per-user phrase list applied server-side | @@ -82,11 +89,13 @@ Block and mute rows are deleted when either account is deleted, and deleting a p ### Operations | Feature | Details | |---|---| -| Database | SQLite with WAL, foreign keys, a release schema baseline, FTS5, and timeline indexes | +| Database | SQLite with WAL, foreign keys, schema version 4, FTS5, and timeline indexes | | Rate limiting | SQLite-backed per-user and per-IP limits for all write operations | | Backups | Deterministic tar archive with manifest, hashes, DB snapshot, settings, media, assets, and optional Tor keys | | Restore | Staged manifest/hash/SQLite/settings validation before runtime file swaps | | Admin dashboard | Site health, users, media jobs, conversion state, and backup management | +| Announcements | Admin-controlled banner rendered next to the site name in the top bar | +| Maintenance mode | Admin toggle that disables posting and registration while the site stays readable | | 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 | @@ -133,7 +142,7 @@ This produces the release binary in `target/release/`: ### Install a release archive -Download the archive and matching `.sha256` file for your platform from the GitHub release, verify the checksum, then extract it. Unix archives contain `rustpost/rustpost-cli`; the Windows archive contains `rustpost/rustpost-cli.exe`. +Download the archive and matching `.sha256` file for your platform from the [v1.0.0 GitHub release](https://github.com/csd113/RustPost/releases/tag/v1.0.0), verify the checksum, then extract it. Unix archives contain `rustpost/rustpost-cli`; the Windows archive contains `rustpost/rustpost-cli.exe`. Example Unix user-local install: @@ -258,6 +267,22 @@ registration_captcha_enabled = false `registration_captcha_enabled` adds a single-use CAPTCHA challenge to registration only. Login is unchanged. +### Account lifecycle + +```toml +[accounts] +deletion_grace_period_days = 30 +max_archive_upload_bytes = 314572800 +max_archive_expanded_bytes = 1073741824 +max_archive_entries = 10000 +``` + +Deleting an account stores a deletion deadline instead of removing data immediately; the owner can cancel until the deadline, and finalization runs from the maintenance scheduler (including after a restart). The default is 30 days; `0` deletes immediately after password confirmation. + +The three archive ceilings are independent and documented in [docs/account-and-instance-features.md](docs/account-and-instance-features.md): the compressed upload/export size, the total decompressed media size accepted during import, and the entry count. The `/settings/import` body limit is derived from the compressed ceiling plus a small multipart allowance, so archive imports never depend on the global media body limit, and an oversized upload is rejected with a clear `413` page before any account state changes. + +Announcements and maintenance mode are managed from the admin dashboard and stored in the database, so they survive restarts and are included in backups. See [docs/account-and-instance-features.md](docs/account-and-instance-features.md) for the full behavior and policy details. + ### Post editing ```toml @@ -521,7 +546,7 @@ CI runs: format check → Clippy → tests → release build. A separate strict ### Release Artifacts -For this release, tag `v1.0.0`. Tagged releases matching `v*` produce: +The `v1.0.0` tag triggers release builds for: ``` rustpost-linux-x86_64.tar.gz @@ -560,7 +585,7 @@ Each archive contains `rustpost-cli` (`rustpost-cli.exe` on Windows), `README.md ## ✅ Release Verification -*Last sweep: **August 15, 2026*** +*Live-flow sweep: **August 15, 2026**. Rust release gates and advisory review: **September 29, 2026**.* Release validation distinguishes the required Rust gates from the dependency-advisory review: diff --git a/docs/account-and-instance-features.md b/docs/account-and-instance-features.md new file mode 100644 index 0000000..6335a24 --- /dev/null +++ b/docs/account-and-instance-features.md @@ -0,0 +1,288 @@ +# Account lifecycle, social approval, and instance controls + +This document describes the behavior added for: + +- [#26 Protected accounts](https://github.com/csd113/RustPost/issues/26) +- [#27 Instance-wide announcements](https://github.com/csd113/RustPost/issues/27) +- [#28 Maintenance mode](https://github.com/csd113/RustPost/issues/28) +- [#29 Account export and import](https://github.com/csd113/RustPost/issues/29) +- [#31 Admin forced password reset](https://github.com/csd113/RustPost/issues/31) +- [#32 Admin forced logout](https://github.com/csd113/RustPost/issues/32) +- [#33 Account deletion grace period](https://github.com/csd113/RustPost/issues/33) +- [#34 Username change and username history](https://github.com/csd113/RustPost/issues/34) + +All state is stored in the SQLite database (schema version 4) so it survives +restarts and is covered by instance backups. New routes use the existing +server-rendered forms, CSRF tokens, and no-JavaScript fallbacks. + +## Protected accounts (#26) + +- Each account has a persistent `Require approval for new followers` + preference on the settings page (`users.follow_approval_required`). +- Following a protected account creates a row in `follow_requests` and sends + the owner a `follow_request` notification. A pending request is **not** a + follow: it does not appear in follower counts or lists, does not grant feed + privileges, and does not create a `follows` row. +- The owner sees incoming requests at `/follow-requests` and can **Approve** + or **Reject** them. Approving creates the follow and notifies the requester + (`follow_request_approved`). Rejecting removes the request without a + notification. Requesters can cancel their own pending request from the same + page, from the profile button ("Requested"), or by unfollowing. +- Enforcement: self-follows are rejected; suspended or deleted targets cannot + be followed; a block in either direction removes pending requests in both + directions and prevents new ones; duplicate and concurrent requests are + idempotent through composite primary keys. +- Enabling protection **preserves existing followers**. Disabling protection + leaves already-pending requests pending — the owner can still approve or + reject them, and new follows are accepted immediately. Nothing is silently + auto-approved. +- Pending requests are cleaned up when the requester or target account is + deleted. + +## Instance announcements (#27) + +- Administrators manage the announcement on the admin dashboard + (`/admin`, "Announcement" panel). +- An enabled, non-empty announcement renders in the top bar next to the + RustPost/site name. On narrow screens it wraps onto its own line under the + brand row. +- Announcement text is limited to 280 characters, escapes control characters + (newlines and tabs are allowed), and is HTML-escaped when rendered. +- Stored in the `instance_settings` table, so it survives restarts, backup, + and restore. An empty or disabled announcement renders nothing. +- The "Clear announcement" button disables and empties the announcement in one + step. + +## Maintenance mode (#28) + +- Toggled by administrators on the admin dashboard (`/admin`, "Maintenance + mode" panel) with an optional message (280 characters, HTML-escaped). +- While enabled, these state-changing routes return `503` with the maintenance + message: + - `POST /register` (registration) — for everyone. + - `POST /posts` (new posts **and** replies) — except administrators. + - `POST /posts/{id}/edit` — except administrators. Editing is blocked so a + draft written before maintenance cannot be published by rewriting it. + - `POST /posts/{id}/quote` — except administrators. + - `POST /posts/{id}/repost` — except administrators. + - `POST /settings/import` (importing an archive publishes posts) — for + everyone except administrators. +- The policy lives in one table (`maintenance_policy` in `src/server.rs`) with + a table-driven test covering every publish route, method, and role, so new + routes cannot silently bypass maintenance checks. +- Administrator exemption: administrators can still post, reply, quote, + repost, edit, and import so they can verify the instance. They also keep full + access to the admin UI to disable maintenance mode. Registration is not + exempt because new accounts are not part of instance verification. +- The site stays readable: all `GET` pages, logins, sessions, likes, + bookmarks, follows, blocks, mutes, account settings, password changes, + exports, and account deletion flows keep working. Maintenance mode does not + silently disable security or account operations. +- The composer/reply form is not rendered for visitors who cannot post, and + every page shows a maintenance banner with the configured message. + +## Account export and import (#29) + +RustPost account archives are separate from full-instance backups. Export is +owner-only (`GET /settings/export`); import writes into the authenticated +destination account (`GET`/`POST /settings/import`) and never creates accounts. + +### Archive format (version 1) + +A gzip-compressed tar archive (`.tar.gz`) containing: + +| Entry | Contents | +| --- | --- | +| `manifest.json` | format version, app name, creation timestamp, random archive id, archive handle | +| `profile.json` | display name, bio, location, website, theme, NSFW blur, liked-posts visibility, follow-approval preference | +| `posts.json` | the account's posts, replies, and quotes with archive-local ids and remapped parent/root/quote references | +| `media.json` | media metadata (kind, MIME type, filename, byte length, alt text, NSFW flag, SHA-256) | +| `media/...` | media bytes for every entry in `media.json` (yes, bytes are included) | +| `follows.json` | outgoing follows as portable handle references with timestamps | +| `settings.json` | muted words | + +Limits: 300 MiB compressed per archive (upload and export output), 1 GiB of +total decompressed media, 10,000 entries, 5,000 posts, 1,000 media, 5,000 +follows, 500 muted words. The compressed and expanded ceilings are independent: +raising the upload limit never raises the decompressed limit, and extraction +enforces the expanded limit incrementally. Operators can tune all three archive +ceilings in `settings.toml` under `[accounts]`: + +```toml +[accounts] +max_archive_upload_bytes = 314572800 # compressed, also exported size +max_archive_expanded_bytes = 1073741824 # total decompressed media bytes +max_archive_entries = 10000 +``` + +Media bytes are hashed and verified during import, and declared media metadata +(byte length, SHA-256, MIME type/kind) must agree with the actual extracted +bytes; unrecognized or mismatched media content is rejected. + +### Never included + +Password hashes, sessions, CSRF tokens, delete-intent tokens, other secrets, +administrator flags, suspension/deletion state, forced password-reset state, +instance configuration, other accounts' posts/media, notifications, likes, +bookmarks, reposts, and account-import records for the exported account. + +Import summaries report what was skipped: follow targets that are missing, +deleted, suspended, self, or blocked, duplicate/pending follow states, and +reply/quote references that could not be remapped. A post whose media list +references media that is not part of the archive is rejected during validation +instead of silently importing an incomplete post. + +### Import rules + +- The destination account is always the authenticated account that performs + the import. Archive contents can never claim another account, change the + destination username, grant administrator status, resurrect sessions, or + clear forced-reset/deletion restrictions. +- The complete archive is validated before any database or filesystem + mutation: format version, JSON schema, per-record limits, path safety + (no absolute paths, `..`, backslashes, encoded traversal, non-ASCII or + case-colliding entry names, links, device files, or duplicates), media + hashes/lengths/content type, and both archive size ceilings. +- Structural limits are enforced incrementally while the gzip/tar stream is + read: entry count, per-entry declared size, total decompressed media bytes, + and cumulative JSON document bytes. A tiny compressed archive can never + expand into unbounded memory or disk. +- The upload is streamed to a staging file with the compressed limit applied + for every chunk; an oversized archive gets a `413` page naming the configured + limit instead of a generic transport failure. +- Media files are staged and installed before the database transaction + commits; failures remove copied files, and the whole import is recorded in + `account_imports` so re-importing the same archive into the same account is + rejected instead of duplicating content. +- Profile fields (display name, bio, location, website) fill only empty + destination fields; non-empty destination values win and are counted as + skipped. Theme, NSFW blur, liked-posts visibility, and follow-approval + preference are applied from the archive. Usernames are reported but never + changed. +- Posts are inserted with remapped ids. Reply/quote relationships to posts + that are not part of the archive are dropped. Reposts and likes/bookmarks of + other accounts' posts are not portable and are not imported. +- Follows resolve by canonical handle on the destination instance: + - existing, active accounts become follows; + - protected accounts become **pending follow requests** — import never + bypasses approval; + - missing, deleted, suspended, self, or blocked accounts are skipped and + counted in the import report. +- JSON fields such as `is_admin`, `is_suspended`, + `must_change_password`, `deletion_scheduled_at`, or `password_hash` are + ignored; they can never synthesize privileged or restricted destination + state. +- Media bytes are copied into the destination uploads directories with new + unique names. If an identical canonical media file already exists on the + destination, the import records a duplicate row pointing at the existing + file instead of copying bytes again. +- Imports are blocked for non-administrators while maintenance mode is active + and share the posting rate-limit budget. Accounts that are restricted to a + forced password change or pending deletion cannot start an import at all, so + an archive can never lift those restrictions. + +### Nonportable data + +Reposts, likes, bookmarks, notifications, sessions, passwords, administrator +privileges, private/deleted account state, other accounts' content, instance +configuration, and Tor keys are not ported by account archives. Follows are +resolved against accounts that already exist on the destination instance; +RustPost is non-federated and does not invent remote accounts. + +## Admin forced password reset (#31) + +- `POST /admin/users/{id}/require-password-reset` sets + `users.must_change_password` and writes an audit row. +- After that, every authenticated request (including existing sessions) is + redirected to `/settings/password?required=1` until the password changes. + The only reachable flows are the password change, logout, account deletion, + and account export, plus static assets. If the account state cannot be read, + the request fails closed instead of continuing. +- The password page renders only the password form and a logout button while + the restriction is active. +- Passwords are validated with the existing policy and hashed with the + existing Argon2id mechanism. The flag is cleared in the same database + transaction that stores the new hash, and all other sessions are revoked + exactly as in a normal password change. +- Repeating the admin action is harmless; a forced logout (`#32`) does not + clear the requirement. + +## Admin forced logout (#32) + +- `POST /admin/users/{id}/revoke-sessions` marks every active session of the + selected account revoked and writes an audit row. The action reports how many + sessions were revoked. +- Revoked cookies stop authenticating on the next request across all protected + routes; other accounts' sessions are unaffected, and the account can log in + again unless another restriction (suspension, pending deletion) applies. +- Repeated revocation is safe: it simply reports zero remaining sessions. + +## Account deletion grace period (#33) + +- Configuration: `[accounts] deletion_grace_period_days` in `settings.toml`, + default **30 days**, maximum 3650, `0` means immediate deletion after + password confirmation. Documented in the generated `settings.toml`. +- The delete flow (`/settings/delete` → `/settings/delete/confirm`) verifies + the password and stores `deletion_requested_at` and `deletion_scheduled_at` + instead of deleting immediately. The user sees the deadline and a cancel + button on the settings page, plus a site-wide banner while the deletion is + pending. +- Access policy while a deletion is pending: the account can still log in, + read, export its archive, change its password, log out, and cancel the + deletion. Publishing (posts, replies, quotes, reposts), imports, profile + edits, and social actions are refused with a clear message until the + deletion is cancelled. +- Permanent removal uses persisted timestamps and runs from the periodic + maintenance task (also on startup), so it survives restarts and does not + depend on an in-memory timer. Finalization is idempotent and re-checks the + deadline and cancellation inside one transaction, so concurrent cancellation + wins safely. +- Finalization runs the same scrubbing logic as password-confirmed deletion: + posts, reposts, likes, bookmarks, follows, blocks, mutes, follow requests, + notifications, reports, sessions, rate-limit rows, uploaded media owned by + the account, and shared media references are handled exactly as before. + Media still referenced by other accounts is preserved. +- Username reservations are released on permanent deletion: `username_history` + rows for the account are removed so the handles become available again. This + is a deliberate policy choice for a small, self-hosted instance; historical + handles of active accounts stay reserved. +- Deletion records anonymous release tombstones (`released_username:` + rows in `instance_settings`) for the handles it frees. A handle that no + account owns renders a tombstone page instead of a bare 404, and if someone + registers a released handle, the new profile is marked as a different + account. This keeps the username-release policy while ensuring an old + profile URL can never silently masquerade as the deleted account. +- Finalization writes a durable pending-media-deletion journal before the + database transaction commits and removes files afterwards, deleting the + journal only when every file is gone. A crash between commit and file + removal is recovered from the journal at startup, so the process is + restart-safe and idempotent without depending on in-memory state. +- Database/filesystem failures during finalization are logged, leave the + account intact for retry, and never leave a partially deleted account. + +## Username changes and history (#34) + +- `POST /settings/username` (settings page "Username" panel) requires the + current password. New handles are validated with the shared username rules + (allowed characters, length, reserved names) and canonicalized + case-insensitively. +- Uniqueness is enforced atomically in one transaction against both the + current `users.normalized_username` values and `username_history`, so + concurrent changes and registrations cannot claim the same handle. +- The previous handle is recorded in `username_history` with a timestamp. + Internal identity (user id), posts, follows, permissions, sessions, blocked + lists, exports, and deletion state are all keyed by user id and are + unaffected. +- Another account can never claim a handle that appears in history. The same + account may switch back to a handle it previously used; the stale history + row for that handle is replaced by the newer previous name. +- Profile pages show a "Previously known as @…" note for accounts with + history. Requesting a handle that no account currently owns but that has + history renders a "no account uses this handle" page listing the previous + holders with links — old profile links and `@mentions` never silently + resolve to a different person or redirect to a new owner. +- A handle released by permanent deletion becomes claimable again, but the + deletion records an anonymous release tombstone. Old URLs render a + "released handle" page while the handle is unclaimed, and a profile that + claims a released handle is explicitly marked as a different account, so an + old link can never appear to be the deleted account. diff --git a/src/account.rs b/src/account.rs index 5b45085..78f9c7c 100644 --- a/src/account.rs +++ b/src/account.rs @@ -1,13 +1,27 @@ use std::collections::BTreeSet; use std::fs; +use std::io::Write as _; use std::path::{Component, Path, PathBuf}; +use anyhow::Context as _; use rusqlite::{OptionalExtension as _, params}; +use uuid::Uuid; use crate::auth; +use crate::config::Settings; use crate::db::SqlitePool; use crate::runtime::RuntimePaths; +/// File-name prefix for durable pending-media-deletion journals. +/// +/// The runtime temp cleanup never matches this prefix, so a pending journal is +/// never removed behind the account deletion path's back. +const PENDING_MEDIA_DELETION_PREFIX: &str = "pending-media-deletion-"; +/// File-name suffix for durable pending-media-deletion journals. +const PENDING_MEDIA_DELETION_SUFFIX: &str = ".journal"; +/// Maximum pending-media-deletion journals recovered in one pass. +const MAX_PENDING_MEDIA_DELETION_JOURNALS_PER_PASS: usize = 1000; + #[derive(Debug, Clone, PartialEq, Eq)] pub enum DeleteAccountError { WrongPassword, @@ -59,14 +73,200 @@ pub async fn delete_account( return Err(DeleteAccountError::WrongPassword); } - let media_files = scrub_account_rows(pool, user_id, paths).await?; + let outcome = scrub_account_rows(pool, user_id, paths).await?; - let deleted_media_files = remove_media_files(&media_files, user_id); + let deleted_media_files = finish_media_cleanup(&outcome, user_id); Ok(AccountDeletionSummary { deleted_media_files, }) } +/// A pending account deletion. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeletionRequest { + /// When the deletion was requested. + pub requested_at: String, + /// When the account becomes eligible for permanent removal. + pub scheduled_at: String, +} + +/// Records a deletion request and the final deadline. +/// +/// The account keeps authenticating while the deadline is in the future and +/// [`cancel_deletion`] clears the request. A configured grace period of zero +/// schedules the deadline immediately. +pub async fn request_deletion( + pool: &SqlitePool, + settings: &Settings, + user_id: i64, +) -> anyhow::Result { + let grace_days = settings.accounts.deletion_grace_period_days; + let modifier = format!("+{grace_days} days"); + pool.call(move |conn| { + let tx = conn.transaction()?; + let existing = tx + .query_row( + "SELECT deletion_requested_at, deletion_scheduled_at FROM users WHERE id = ? AND is_deleted = 0", + [user_id], + |row| { + Ok(( + row.get::<_, Option>(0)?, + row.get::<_, Option>(1)?, + )) + }, + ) + .optional()?; + let Some((requested_at, scheduled_at)) = existing else { + anyhow::bail!("account not found"); + }; + if let (Some(requested_at), Some(scheduled_at)) = (requested_at, scheduled_at) { + tx.commit()?; + return Ok(DeletionRequest { + requested_at, + scheduled_at, + }); + } + tx.execute( + "UPDATE users SET deletion_requested_at = datetime('now'), deletion_scheduled_at = datetime('now', ?1), updated_at = CURRENT_TIMESTAMP WHERE id = ?", + params![modifier, user_id], + )?; + let (requested_at, scheduled_at) = tx.query_row( + "SELECT deletion_requested_at, deletion_scheduled_at FROM users WHERE id = ?", + [user_id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + )?; + tx.commit()?; + Ok(DeletionRequest { + requested_at, + scheduled_at, + }) + }) + .await +} + +/// Clears a pending deletion request. Returns `true` when a request existed. +/// +/// Cancellation is a no-op for accounts that are already permanently removed or +/// marked deleted, so it can never resurrect a deleted account. +pub async fn cancel_deletion(pool: &SqlitePool, user_id: i64) -> anyhow::Result { + pool.call(move |conn| { + let changed = conn.execute( + "UPDATE users SET deletion_requested_at = NULL, deletion_scheduled_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND is_deleted = 0 AND deletion_requested_at IS NOT NULL", + [user_id], + )?; + Ok(changed > 0) + }) + .await +} + +/// Reads the pending deletion window for an account, if any. +pub async fn deletion_status( + pool: &SqlitePool, + user_id: i64, +) -> anyhow::Result> { + pool.call(move |conn| { + conn.query_row( + "SELECT deletion_requested_at, deletion_scheduled_at FROM users WHERE id = ? AND is_deleted = 0 AND deletion_requested_at IS NOT NULL AND deletion_scheduled_at IS NOT NULL", + [user_id], + |row| { + Ok(DeletionRequest { + requested_at: row.get(0)?, + scheduled_at: row.get(1)?, + }) + }, + ) + .optional() + .map_err(Into::into) + }) + .await +} + +/// Permanently removes every account whose deletion deadline has passed. +/// +/// Runs the same scrubbing logic as [`delete_account`] without a password +/// check, is safe to call repeatedly, and is driven by persisted timestamps so +/// it works after restarts. Failures for one account are logged and retried on +/// the next call; the returned count is the number of accounts fully removed. +pub async fn finalize_due_deletions( + pool: &SqlitePool, + paths: &RuntimePaths, +) -> anyhow::Result { + let due_ids = pool + .call(|conn| { + let mut stmt = conn.prepare( + "SELECT id FROM users WHERE is_deleted = 0 AND deletion_scheduled_at IS NOT NULL AND datetime(deletion_scheduled_at) <= CURRENT_TIMESTAMP ORDER BY id", + )?; + let ids = stmt + .query_map([], |row| row.get::<_, i64>(0))? + .collect::, _>>()?; + Ok(ids) + }) + .await?; + let mut removed = 0; + for user_id in due_ids { + match finalize_due_deletion(pool, paths, user_id).await { + Ok(true) => removed += 1, + Ok(false) => {} + Err(error) => { + tracing::warn!( + user_id, + error = %error, + "failed to finalize due account deletion; will retry on the next pass" + ); + } + } + } + Ok(removed) +} + +/// Finalizes one due deletion, re-checking the persisted deadline and request +/// marker inside the scrubbing transaction so a concurrent cancellation wins. +/// +/// Returns `true` when the account was removed in this call. Two concurrent +/// callers can never both claim the same account: the claim and the row +/// deletion share one transaction, and the loser observes no due row. +async fn finalize_due_deletion( + pool: &SqlitePool, + paths: &RuntimePaths, + user_id: i64, +) -> anyhow::Result { + let paths = paths.clone(); + let result = pool + .call(move |conn| { + let result: Result, DeleteAccountError> = (|| { + let tx = conn.transaction()?; + let due = tx + .query_row( + "SELECT 1 FROM users WHERE id = ? AND is_deleted = 0 AND deletion_requested_at IS NOT NULL AND deletion_scheduled_at IS NOT NULL AND datetime(deletion_scheduled_at) <= CURRENT_TIMESTAMP", + [user_id], + |_| Ok(()), + ) + .optional()? + .is_some(); + if !due { + tx.commit()?; + return Ok(None); + } + let outcome = scrub_account_rows_in_tx(&tx, user_id, &paths)?; + commit_scrub_transaction(tx, &outcome, user_id)?; + Ok(Some(outcome)) + })(); + Ok(result) + }) + .await?; + let result = result?; + let Some(outcome) = result else { + return Ok(false); + }; + let deleted_media_files = finish_media_cleanup(&outcome, user_id); + tracing::info!( + user_id, + deleted_media_files, + "finalized scheduled account deletion" + ); + Ok(true) +} + #[derive(Debug, Clone)] struct AccountMedia { id: i64, @@ -75,6 +275,33 @@ struct AccountMedia { thumbnail_path: Option, } +/// Collects every normalized handle the account ever held so release +/// tombstones can be recorded in the deletion transaction. +/// +/// The current handle and the full username history are both included; the +/// caller records them before deleting the rows that store them. +fn account_normalized_usernames_in_tx( + tx: &rusqlite::Transaction<'_>, + user_id: i64, +) -> anyhow::Result> { + let mut usernames = BTreeSet::new(); + let current: Option = tx + .query_row( + "SELECT normalized_username FROM users WHERE id = ?", + [user_id], + |row| row.get(0), + ) + .optional()?; + usernames.extend(current); + let mut stmt = + tx.prepare("SELECT normalized_username FROM username_history WHERE user_id = ?")?; + let history = stmt + .query_map([user_id], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + usernames.extend(history); + Ok(usernames.into_iter().collect()) +} + fn account_media_in_tx( tx: &rusqlite::Transaction<'_>, user_id: i64, @@ -109,96 +336,154 @@ async fn scrub_account_rows( pool: &SqlitePool, user_id: i64, paths: &RuntimePaths, -) -> Result, DeleteAccountError> { +) -> Result { let paths = paths.clone(); pool.call(move |conn| { - let result: Result, DeleteAccountError> = (|| { + let result: Result = (|| { let tx = conn.transaction()?; - tx.execute( + let outcome = scrub_account_rows_in_tx(&tx, user_id, &paths)?; + commit_scrub_transaction(tx, &outcome, user_id)?; + Ok(outcome) + })(); + Ok(result) + }) + .await + .map_err(|err| DeleteAccountError::Database(err.to_string()))? +} + +/// Files whose database rows were removed, plus the durable journal that +/// records them until every file is gone. +struct ScrubOutcome { + media_files: BTreeSet, + journal_path: PathBuf, +} + +/// Commits a scrub transaction. +/// +/// When the commit fails the database rolls back and the media rows still +/// exist, so the just-written pending-deletion journal is discarded: recovery +/// must never delete files that survived a rollback. +fn commit_scrub_transaction( + tx: rusqlite::Transaction<'_>, + outcome: &ScrubOutcome, + user_id: i64, +) -> Result<(), DeleteAccountError> { + if let Err(error) = tx.commit() { + remove_journal(&outcome.journal_path, user_id); + return Err(DeleteAccountError::Database(error.to_string())); + } + Ok(()) +} + +/// Scrubs every row owned by one account inside an existing transaction. +/// +/// Shared by the password-confirmed immediate deletion path and the scheduled +/// finalization path. The caller commits; any error rolls the transaction back. +/// Before returning, a pending-media-deletion journal is written and fsynced so +/// a crash between the commit and the file removal leaves a durable record. +/// A journal write failure aborts the scrub and rolls the transaction back. +/// +/// Documented policy: permanent deletion releases the account's username +/// reservations. Release tombstones are recorded in `instance_settings` before +/// the `username_history` rows are deleted in this transaction, so old profile +/// URLs keep explaining that the handle belonged to a deleted account while +/// every handle becomes available again. +fn scrub_account_rows_in_tx( + tx: &rusqlite::Transaction<'_>, + user_id: i64, + paths: &RuntimePaths, +) -> Result { + tx.execute( "UPDATE users SET is_deleted = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND is_deleted = 0", [user_id], )?; - let media = account_media_in_tx(&tx, user_id)?; - let mut media_paths = Vec::new(); - for item in &media { - 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_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::>(); - tx.execute("DELETE FROM sessions WHERE user_id = ?", [user_id])?; - tx.execute("DELETE FROM muted_words WHERE user_id = ?", [user_id])?; - tx.execute( - "DELETE FROM rate_limit_events WHERE actor = ?", - [format!("user:{user_id}")], - )?; - tx.execute( - "DELETE FROM blocks WHERE blocker_id = ? OR blocked_id = ?", - params![user_id, user_id], - )?; - tx.execute( - "DELETE FROM mutes WHERE muter_id = ? OR muted_id = ?", - params![user_id, user_id], - )?; - tx.execute( - "DELETE FROM follows WHERE follower_id = ? OR followed_id = ?", - params![user_id, user_id], - )?; - tx.execute( + let media = account_media_in_tx(tx, user_id)?; + let mut media_paths = Vec::new(); + for item in &media { + 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_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::>(); + tx.execute("DELETE FROM sessions WHERE user_id = ?", [user_id])?; + tx.execute("DELETE FROM muted_words WHERE user_id = ?", [user_id])?; + tx.execute( + "DELETE FROM rate_limit_events WHERE actor = ?", + [format!("user:{user_id}")], + )?; + tx.execute( + "DELETE FROM blocks WHERE blocker_id = ? OR blocked_id = ?", + params![user_id, user_id], + )?; + tx.execute( + "DELETE FROM mutes WHERE muter_id = ? OR muted_id = ?", + params![user_id, user_id], + )?; + tx.execute( + "DELETE FROM follows WHERE follower_id = ? OR followed_id = ?", + params![user_id, user_id], + )?; + tx.execute( + "DELETE FROM follow_requests WHERE requester_id = ? OR target_id = ?", + params![user_id, user_id], + )?; + let released_usernames = account_normalized_usernames_in_tx(tx, user_id)?; + tx.execute("DELETE FROM username_history WHERE user_id = ?", [user_id])?; + crate::instance::record_released_usernames_tx(tx, &released_usernames)?; + tx.execute("DELETE FROM account_imports WHERE user_id = ?", [user_id])?; + tx.execute( "DELETE FROM notifications WHERE user_id = ? OR actor_user_id = ? OR post_id IN (SELECT id FROM posts WHERE user_id = ?)", params![user_id, user_id, user_id], )?; - tx.execute( + tx.execute( "DELETE FROM reports WHERE reporter_user_id = ? OR post_id IN (SELECT id FROM posts WHERE user_id = ?)", params![user_id, user_id], )?; - tx.execute( + tx.execute( "DELETE FROM likes WHERE user_id = ? OR post_id IN (SELECT id FROM posts WHERE user_id = ?)", params![user_id, user_id], )?; - tx.execute( + tx.execute( "DELETE FROM bookmarks WHERE user_id = ? OR post_id IN (SELECT id FROM posts WHERE user_id = ?)", params![user_id, user_id], )?; - tx.execute( + tx.execute( "DELETE FROM reposts WHERE user_id = ? OR post_id IN (SELECT id FROM posts WHERE user_id = ?)", params![user_id, user_id], )?; - 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( + 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) - })(); - Ok(result) + if remaining == 0 { + media_files.insert(safe_path); + } + } + tx.execute("DELETE FROM users WHERE id = ?", [user_id])?; + let journal_path = write_pending_media_deletion_journal(paths, user_id, &media_files)?; + Ok(ScrubOutcome { + media_files, + journal_path, }) - .await - .map_err(|err| DeleteAccountError::Database(err.to_string()))? } fn safe_media_path( @@ -212,17 +497,13 @@ fn safe_media_path( if !path.is_absolute() || has_parent_component(&path) { return Err(DeleteAccountError::UnsafeMediaPath(raw_path.to_owned())); } - if !allowed_media_roots(paths) - .iter() - .any(|root| path.starts_with(root)) - { - return Err(DeleteAccountError::UnsafeMediaPath(raw_path.to_owned())); - } + let roots = allowed_media_roots(paths); + let raw_under_roots = roots.iter().any(|root| path.starts_with(root)); if path.exists() { let canonical_path = path .canonicalize() .map_err(|err| DeleteAccountError::Database(err.to_string()))?; - let canonical_roots = allowed_media_roots(paths) + let canonical_roots = roots .iter() .map(|root| root.canonicalize()) .collect::, _>>() @@ -233,8 +514,21 @@ fn safe_media_path( { return Err(DeleteAccountError::UnsafeMediaPath(raw_path.to_owned())); } + // A canonical path can legitimately live outside the configured roots + // when a root's ancestor is a symlink (for example macOS `/var` versus + // `/private/var`). Accept it only when the raw path is under a + // configured root or already expressed in canonical root form, so a + // path that merely resolves into the uploads from elsewhere is still + // rejected. + let raw_under_canonical_roots = canonical_roots.iter().any(|root| path.starts_with(root)); + if !raw_under_roots && !raw_under_canonical_roots { + return Err(DeleteAccountError::UnsafeMediaPath(raw_path.to_owned())); + } return Ok(Some(canonical_path)); } + if !raw_under_roots { + return Err(DeleteAccountError::UnsafeMediaPath(raw_path.to_owned())); + } Ok(Some(path)) } @@ -281,13 +575,41 @@ fn promote_canonical_references( Ok(()) } -fn remove_media_files(paths: &BTreeSet, user_id: i64) -> usize { - let mut deleted = 0; +/// Removes the files recorded by one scrub and disposes of its journal. +/// +/// The journal is removed only when every file was removed or was already +/// missing. Any failure keeps the journal so +/// [`recover_pending_media_deletions`] can retry the cleanup after a restart. +fn finish_media_cleanup(outcome: &ScrubOutcome, user_id: i64) -> usize { + let removal = remove_media_files(&outcome.media_files, user_id); + if removal.complete { + remove_journal(&outcome.journal_path, user_id); + } else { + tracing::warn!( + user_id, + journal = %outcome.journal_path.display(), + "media file removal incomplete; keeping pending media deletion journal for retry" + ); + } + removal.deleted +} + +struct MediaFileRemoval { + deleted: usize, + complete: bool, +} + +fn remove_media_files(paths: &BTreeSet, user_id: i64) -> MediaFileRemoval { + let mut deletion = MediaFileRemoval { + deleted: 0, + complete: true, + }; for path in paths { match fs::remove_file(path) { - Ok(()) => deleted += 1, + Ok(()) => deletion.deleted += 1, Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => { + deletion.complete = false; tracing::warn!( user_id, path = %path.display(), @@ -297,7 +619,262 @@ fn remove_media_files(paths: &BTreeSet, user_id: i64) -> usize { } } } - deleted + deletion +} + +fn remove_journal(journal_path: &Path, user_id: i64) { + match fs::remove_file(journal_path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + tracing::warn!( + user_id, + journal = %journal_path.display(), + error = %error, + "failed to remove pending media deletion journal" + ); + } + } +} + +/// Writes and fsyncs a pending-media-deletion journal for one scrub. +/// +/// The journal lives under [`RuntimePaths::tmp_dir`] and records the numeric +/// owner id on the first line followed by one absolute media path per line. +/// Only paths that already passed [`safe_media_path`] are written, so recovery +/// can trust the validation and re-check it. Callers must roll the database +/// transaction back when this fails. +fn write_pending_media_deletion_journal( + paths: &RuntimePaths, + user_id: i64, + media_files: &BTreeSet, +) -> Result { + let journal_path = paths.tmp_dir.join(format!( + "{PENDING_MEDIA_DELETION_PREFIX}{user_id}-{}{PENDING_MEDIA_DELETION_SUFFIX}", + Uuid::new_v4().simple() + )); + if let Err(error) = write_journal_contents(&journal_path, user_id, media_files) { + let _ = fs::remove_file(&journal_path); + return Err(DeleteAccountError::Database(format!( + "failed to write pending media deletion journal {}: {error}", + journal_path.display() + ))); + } + if let Err(error) = sync_directory(&paths.tmp_dir) { + tracing::debug!( + dir = %paths.tmp_dir.display(), + error = %error, + "failed to fsync directory after writing pending media deletion journal" + ); + } + Ok(journal_path) +} + +fn write_journal_contents( + journal_path: &Path, + user_id: i64, + media_files: &BTreeSet, +) -> anyhow::Result<()> { + if let Some(parent) = journal_path.parent() { + fs::create_dir_all(parent)?; + } + let mut contents = String::new(); + contents.push_str(&format!("{user_id}\n")); + for path in media_files { + let Some(raw) = path.to_str() else { + anyhow::bail!("media path is not valid UTF-8"); + }; + contents.push_str(raw); + contents.push('\n'); + } + let mut file = fs::File::create(journal_path)?; + file.write_all(contents.as_bytes())?; + file.sync_all()?; + Ok(()) +} + +/// Fsyncs a directory so a newly created or removed journal survives a crash. +fn sync_directory(path: &Path) -> std::io::Result<()> { + fs::File::open(path)?.sync_all() +} + +/// Removes media files left behind by interrupted account deletions. +/// +/// Scans [`RuntimePaths::tmp_dir`] for pending-media-deletion journals, at most +/// [`MAX_PENDING_MEDIA_DELETION_JOURNALS_PER_PASS`] per call. Each recorded +/// path is re-validated with [`safe_media_path`]; unsafe entries are skipped +/// and logged, and malformed journals are retained for operator review. A +/// journal is removed only when every recorded file was removed or was already +/// missing. Returns the number of journals fully recovered. +/// +/// Safe to call repeatedly, both at startup and from a periodic scheduler; a +/// pass that loses a race with an in-flight finalization still converges on the +/// same end state because removals are idempotent. +pub async fn recover_pending_media_deletions(paths: &RuntimePaths) -> anyhow::Result { + let paths = paths.clone(); + tokio::task::spawn_blocking(move || recover_pending_media_deletions_blocking(&paths)) + .await + .map_err(|error| anyhow::anyhow!("pending media deletion recovery task failed: {error}"))? +} + +fn recover_pending_media_deletions_blocking(paths: &RuntimePaths) -> anyhow::Result { + let entries = match fs::read_dir(&paths.tmp_dir) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(error) => { + return Err(error).context(format!( + "failed to read temp directory {}", + paths.tmp_dir.display() + )); + } + }; + let mut attempted = 0usize; + let mut recovered = 0usize; + for entry in entries { + if attempted >= MAX_PENDING_MEDIA_DELETION_JOURNALS_PER_PASS { + tracing::warn!( + limit = MAX_PENDING_MEDIA_DELETION_JOURNALS_PER_PASS, + "pending media deletion recovery stopped early; too many journals to process" + ); + break; + } + let entry = match entry { + Ok(entry) => entry, + Err(error) => { + tracing::warn!(error = %error, "failed to read temp directory entry"); + continue; + } + }; + let journal_path = entry.path(); + if !is_pending_media_deletion_journal(&journal_path) { + continue; + } + attempted += 1; + match recover_pending_media_deletion_journal(paths, &journal_path) { + Ok(true) => recovered += 1, + Ok(false) => {} + Err(error) => { + tracing::warn!( + journal = %journal_path.display(), + error = %error, + "failed to recover pending media deletion journal; keeping it for review" + ); + } + } + } + Ok(recovered) +} + +fn is_pending_media_deletion_journal(path: &Path) -> bool { + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + return false; + }; + name.starts_with(PENDING_MEDIA_DELETION_PREFIX) && name.ends_with(PENDING_MEDIA_DELETION_SUFFIX) +} + +/// Removes one journal's files and then the journal itself. +/// +/// Returns `Ok(true)` when the journal was fully processed and removed, and +/// `Ok(false)` when it was skipped or must be retained. Symlinked journal files +/// are never read, and every recorded path is re-validated before removal. +fn recover_pending_media_deletion_journal( + paths: &RuntimePaths, + journal_path: &Path, +) -> anyhow::Result { + let metadata = fs::symlink_metadata(journal_path)?; + if !metadata.file_type().is_file() { + tracing::warn!( + journal = %journal_path.display(), + "ignoring pending media deletion journal that is not a regular file" + ); + return Ok(false); + } + let contents = fs::read_to_string(journal_path)?; + let (user_id, media_files) = parse_pending_media_deletion_journal(paths, &contents) + .with_context(|| { + format!( + "malformed pending media deletion journal {}", + journal_path.display() + ) + })?; + let mut deleted = 0usize; + let mut complete = true; + for path in &media_files { + match fs::remove_file(path) { + Ok(()) => deleted += 1, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + complete = false; + tracing::warn!( + user_id, + path = %path.display(), + error = %error, + "failed to remove media file from pending deletion journal" + ); + } + } + } + if !complete { + return Ok(false); + } + match fs::remove_file(journal_path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| { + format!( + "failed to remove pending media deletion journal {}", + journal_path.display() + ) + }); + } + } + tracing::info!( + user_id, + deleted, + journal = %journal_path.display(), + "recovered pending media deletion journal" + ); + Ok(true) +} + +/// Parses a journal into its owner id and already-validated absolute paths. +/// +/// The first line must be the numeric owner id; structurally malformed +/// journals produce an error so the caller retains them. Individual unsafe +/// path lines are skipped with a warning instead, and a path that cannot be +/// validated because of a transient filesystem error also retains the journal. +fn parse_pending_media_deletion_journal( + paths: &RuntimePaths, + contents: &str, +) -> anyhow::Result<(i64, Vec)> { + let mut lines = contents.lines(); + let Some(user_id_line) = lines.next() else { + anyhow::bail!("journal is empty"); + }; + let user_id = user_id_line + .trim() + .parse::() + .with_context(|| format!("journal user id line is not an integer: {user_id_line:?}"))?; + let mut media_files = Vec::new(); + for line in lines { + let raw = line.trim(); + if raw.is_empty() { + continue; + } + match safe_media_path(paths, raw) { + Ok(Some(path)) => media_files.push(path), + Ok(None) => {} + Err(DeleteAccountError::UnsafeMediaPath(path)) => { + tracing::warn!( + path = %path, + "skipping unsafe media path in pending deletion journal" + ); + } + Err(error) => return Err(error.into()), + } + } + Ok((user_id, media_files)) } #[cfg(test)] @@ -608,4 +1185,993 @@ mod tests { .expect("user exists"); assert!(exists); } + + async fn user_exists(pool: &SqlitePool, user_id: i64) -> bool { + pool.call(move |conn| { + Ok(conn + .query_row("SELECT 1 FROM users WHERE id = ?", [user_id], |_| Ok(())) + .optional()? + .is_some()) + }) + .await + .expect("user lookup") + } + + async fn expire_deletion_deadline(pool: &SqlitePool, user_id: i64) { + pool.call(move |conn| { + conn.execute( + "UPDATE users SET deletion_scheduled_at = datetime('now', '-1 second') WHERE id = ?", + [user_id], + )?; + Ok(()) + }) + .await + .expect("expire deadline"); + } + + fn pending_media_journals(paths: &RuntimePaths) -> Vec { + let Ok(entries) = fs::read_dir(&paths.tmp_dir) else { + return Vec::new(); + }; + let mut journals = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if is_pending_media_deletion_journal(&path) { + journals.push(path); + } + } + journals.sort(); + journals + } + + async fn insert_media_row(pool: &SqlitePool, user_id: i64, stored_path: &str) -> i64 { + let stored_path = stored_path.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) VALUES (?, 'media.webp', ?, '/uploads/images/media.webp', 'image/webp', 'image', 5)", + params![user_id, stored_path], + )?; + Ok(conn.last_insert_rowid()) + }) + .await + .expect("media row") + } + + #[tokio::test] + async fn request_deletion_sets_window_and_is_idempotent() { + let (_temp, _paths, pool, settings, alice, bob) = fixture().await; + + assert!( + deletion_status(&pool, alice) + .await + .expect("status") + .is_none() + ); + let first = request_deletion(&pool, &settings, alice) + .await + .expect("request"); + assert!(!first.requested_at.is_empty()); + assert!(first.scheduled_at > first.requested_at); + let second = request_deletion(&pool, &settings, alice) + .await + .expect("repeat request"); + assert_eq!(first, second); + assert_eq!( + deletion_status(&pool, alice).await.expect("status"), + Some(first) + ); + assert!( + deletion_status(&pool, bob) + .await + .expect("bob status") + .is_none() + ); + } + + #[tokio::test] + async fn request_deletion_with_zero_grace_schedules_immediately() { + let (_temp, _paths, pool, mut settings, alice, _bob) = fixture().await; + settings.accounts.deletion_grace_period_days = 0; + + let request = request_deletion(&pool, &settings, alice) + .await + .expect("request"); + + assert_eq!(request.requested_at, request.scheduled_at); + } + + #[tokio::test] + async fn request_deletion_rejects_missing_and_deleted_accounts() { + let (_temp, _paths, pool, settings, alice, _bob) = fixture().await; + + let missing = request_deletion(&pool, &settings, 9999).await; + assert_eq!( + missing.expect_err("missing account").to_string(), + "account not found" + ); + + pool.call(move |conn| { + conn.execute("UPDATE users SET is_deleted = 1 WHERE id = ?", [alice])?; + Ok(()) + }) + .await + .expect("mark deleted"); + let deleted = request_deletion(&pool, &settings, alice).await; + assert_eq!( + deleted.expect_err("deleted account").to_string(), + "account not found" + ); + } + + #[tokio::test] + async fn cancel_deletion_clears_and_is_idempotent() { + let (_temp, _paths, pool, settings, alice, _bob) = fixture().await; + request_deletion(&pool, &settings, alice) + .await + .expect("request"); + + assert!(cancel_deletion(&pool, alice).await.expect("cancel")); + assert!(!cancel_deletion(&pool, alice).await.expect("cancel again")); + assert!( + deletion_status(&pool, alice) + .await + .expect("status") + .is_none() + ); + + let re_requested = request_deletion(&pool, &settings, alice) + .await + .expect("re-request"); + assert!(!re_requested.requested_at.is_empty()); + } + + #[tokio::test] + async fn finalize_due_deletions_ignores_future_and_null_deadlines() { + let (_temp, paths, pool, settings, alice, bob) = fixture().await; + request_deletion(&pool, &settings, alice) + .await + .expect("request"); + + assert_eq!( + finalize_due_deletions(&pool, &paths) + .await + .expect("finalize before deadline"), + 0 + ); + assert!(user_exists(&pool, alice).await); + + pool.call(move |conn| { + conn.execute( + "UPDATE users SET deletion_scheduled_at = datetime('now', '+1 day') WHERE id = ?", + [alice], + )?; + Ok(()) + }) + .await + .expect("push deadline"); + assert_eq!( + finalize_due_deletions(&pool, &paths) + .await + .expect("finalize with future deadline"), + 0 + ); + assert!(user_exists(&pool, alice).await); + assert!(user_exists(&pool, bob).await); + } + + #[tokio::test] + async fn finalize_due_deletions_includes_the_exact_deadline() { + let (_temp, paths, pool, settings, alice, _bob) = fixture().await; + request_deletion(&pool, &settings, alice) + .await + .expect("request"); + pool.call(move |conn| { + conn.execute( + "UPDATE users SET deletion_scheduled_at = CURRENT_TIMESTAMP WHERE id = ?", + [alice], + )?; + Ok(()) + }) + .await + .expect("set exact deadline"); + + assert_eq!( + finalize_due_deletions(&pool, &paths) + .await + .expect("finalize at the deadline"), + 1 + ); + } + + #[tokio::test] + async fn finalize_due_deletions_removes_due_accounts_and_releases_handle() { + let (_temp, paths, pool, settings, alice, bob) = fixture().await; + social::create_post(&pool, &settings, Some(alice), "alice post", None, &[]) + .await + .expect("post"); + social::follow(&pool, bob, alice).await.expect("follow"); + let media_path = paths.uploads_images.join("alice-due.webp"); + fs::write(&media_path, b"image").expect("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-due.webp', ?, '/uploads/images/alice-due.webp', 'image/webp', 'image', 5)", + params![alice, media_path_string], + )?; + conn.execute( + "INSERT INTO follow_requests (requester_id, target_id) VALUES (?, ?)", + params![bob, alice], + )?; + conn.execute( + "INSERT INTO follow_requests (requester_id, target_id) VALUES (?, ?)", + params![alice, bob], + )?; + conn.execute( + "INSERT INTO account_imports (user_id, archive_id, format_version) VALUES (?, 'archive-1', 1)", + [alice], + )?; + Ok(()) + }) + .await + .expect("account rows"); + crate::identity::change_username(&pool, &settings, alice, "alice_scheduled") + .await + .expect("rename alice"); + request_deletion(&pool, &settings, alice) + .await + .expect("request deletion"); + expire_deletion_deadline(&pool, alice).await; + + let removed = finalize_due_deletions(&pool, &paths) + .await + .expect("finalize"); + + assert_eq!(removed, 1); + assert!(!media_path.exists()); + let counts: (i64, i64, i64, i64, i64, i64) = pool + .call(move |conn| { + Ok(( + conn.query_row("SELECT COUNT(*) FROM users WHERE id = ?", [alice], |row| { + row.get(0) + })?, + conn.query_row( + "SELECT COUNT(*) FROM posts WHERE user_id = ?", + [alice], + |row| row.get(0), + )?, + conn.query_row( + "SELECT COUNT(*) FROM media WHERE owner_user_id = ?", + [alice], + |row| row.get(0), + )?, + conn.query_row( + "SELECT COUNT(*) FROM follow_requests WHERE requester_id = ? OR target_id = ?", + params![alice, alice], + |row| row.get(0), + )?, + conn.query_row( + "SELECT COUNT(*) FROM username_history WHERE user_id = ?", + [alice], + |row| row.get(0), + )?, + conn.query_row( + "SELECT COUNT(*) FROM account_imports WHERE user_id = ?", + [alice], + |row| row.get(0), + )?, + )) + }) + .await + .expect("counts"); + assert_eq!(counts, (0, 0, 0, 0, 0, 0)); + assert!(user_exists(&pool, bob).await); + crate::auth::register_user(&pool, &settings, "alice", "very secure password", false) + .await + .expect("historical handle released"); + crate::auth::register_user( + &pool, + &settings, + "alice_scheduled", + "very secure password", + false, + ) + .await + .expect("current handle released"); + assert_eq!( + finalize_due_deletions(&pool, &paths) + .await + .expect("finalize again"), + 0 + ); + } + + #[tokio::test] + async fn finalize_due_deletions_yields_to_cancellation() { + let (_temp, paths, pool, settings, alice, _bob) = fixture().await; + request_deletion(&pool, &settings, alice) + .await + .expect("request"); + expire_deletion_deadline(&pool, alice).await; + + assert!(cancel_deletion(&pool, alice).await.expect("cancel first")); + assert_eq!( + finalize_due_deletions(&pool, &paths) + .await + .expect("finalize"), + 0 + ); + assert!(user_exists(&pool, alice).await); + assert!(!cancel_deletion(&pool, alice).await.expect("cancel again")); + } + + #[tokio::test] + async fn delete_account_scrubs_follow_requests_username_history_and_imports() { + let (_temp, paths, pool, settings, alice, bob) = fixture().await; + pool.call(move |conn| { + conn.execute( + "INSERT INTO follow_requests (requester_id, target_id) VALUES (?, ?)", + params![bob, alice], + )?; + conn.execute( + "INSERT INTO follow_requests (requester_id, target_id) VALUES (?, ?)", + params![alice, bob], + )?; + conn.execute( + "INSERT INTO account_imports (user_id, archive_id, format_version) VALUES (?, 'archive-1', 1)", + [alice], + )?; + Ok(()) + }) + .await + .expect("account rows"); + crate::identity::change_username(&pool, &settings, alice, "alice_immediate") + .await + .expect("rename alice"); + + delete_account(&pool, &paths, alice, "very secure password") + .await + .expect("delete account"); + + let counts: (i64, i64, i64) = pool + .call(move |conn| { + Ok(( + conn.query_row( + "SELECT COUNT(*) FROM follow_requests WHERE requester_id = ? OR target_id = ?", + params![alice, alice], + |row| row.get(0), + )?, + conn.query_row( + "SELECT COUNT(*) FROM username_history WHERE user_id = ?", + [alice], + |row| row.get(0), + )?, + conn.query_row( + "SELECT COUNT(*) FROM account_imports WHERE user_id = ?", + [alice], + |row| row.get(0), + )?, + )) + }) + .await + .expect("counts"); + assert_eq!(counts, (0, 0, 0)); + } + + #[tokio::test] + async fn concurrent_finalizations_delete_exactly_once() { + let (_temp, paths, pool, settings, alice, _bob) = fixture().await; + social::create_post(&pool, &settings, Some(alice), "alice post", None, &[]) + .await + .expect("post"); + let media_path = paths.uploads_images.join("concurrent.webp"); + fs::write(&media_path, b"image").expect("media file"); + insert_media_row(&pool, alice, &media_path.to_string_lossy()).await; + request_deletion(&pool, &settings, alice) + .await + .expect("request"); + expire_deletion_deadline(&pool, alice).await; + + let (first, second) = tokio::join!( + finalize_due_deletions(&pool, &paths), + finalize_due_deletions(&pool, &paths) + ); + let first = first.expect("first finalize"); + let second = second.expect("second finalize"); + + assert_eq!( + first + second, + 1, + "exactly one finalizer must claim the account" + ); + assert!(!user_exists(&pool, alice).await); + assert!(!media_path.exists()); + assert!(pending_media_journals(&paths).is_empty()); + let counts: (i64, i64) = pool + .call(move |conn| { + Ok(( + conn.query_row( + "SELECT COUNT(*) FROM posts WHERE user_id = ?", + [alice], + |row| row.get(0), + )?, + conn.query_row( + "SELECT COUNT(*) FROM media WHERE owner_user_id = ?", + [alice], + |row| row.get(0), + )?, + )) + }) + .await + .expect("counts"); + assert_eq!(counts, (0, 0)); + assert_eq!( + finalize_due_deletions(&pool, &paths) + .await + .expect("third finalize"), + 0 + ); + } + + #[tokio::test] + async fn cancellation_before_claim_keeps_account_intact() { + let (_temp, paths, pool, settings, alice, _bob) = fixture().await; + social::create_post(&pool, &settings, Some(alice), "alice post", None, &[]) + .await + .expect("post"); + let media_path = paths.uploads_images.join("cancelled.webp"); + fs::write(&media_path, b"image").expect("media file"); + insert_media_row(&pool, alice, &media_path.to_string_lossy()).await; + request_deletion(&pool, &settings, alice) + .await + .expect("request"); + expire_deletion_deadline(&pool, alice).await; + + assert!(cancel_deletion(&pool, alice).await.expect("cancel")); + assert!( + deletion_status(&pool, alice) + .await + .expect("status") + .is_none() + ); + assert_eq!( + finalize_due_deletions(&pool, &paths) + .await + .expect("finalize after cancellation"), + 0 + ); + assert!(user_exists(&pool, alice).await); + assert!(media_path.exists()); + let posts: i64 = pool + .call(move |conn| { + Ok(conn.query_row( + "SELECT COUNT(*) FROM posts WHERE user_id = ?", + [alice], + |row| row.get(0), + )?) + }) + .await + .expect("posts"); + assert_eq!(posts, 1); + assert_eq!(pending_media_journals(&paths).len(), 0); + } + + #[tokio::test] + async fn cancellation_after_finalization_cannot_resurrect_account() { + let (_temp, paths, pool, settings, alice, _bob) = fixture().await; + request_deletion(&pool, &settings, alice) + .await + .expect("request"); + expire_deletion_deadline(&pool, alice).await; + assert_eq!( + finalize_due_deletions(&pool, &paths) + .await + .expect("finalize"), + 1 + ); + + assert!(!cancel_deletion(&pool, alice).await.expect("cancel")); + assert!(!user_exists(&pool, alice).await); + assert!( + deletion_status(&pool, alice) + .await + .expect("status") + .is_none() + ); + } + + #[tokio::test] + async fn concurrent_cancel_and_finalize_never_half_scrub() { + let (_temp, paths, pool, settings, alice, _bob) = fixture().await; + social::create_post(&pool, &settings, Some(alice), "alice post", None, &[]) + .await + .expect("post"); + let media_path = paths.uploads_images.join("racing.webp"); + fs::write(&media_path, b"image").expect("media file"); + insert_media_row(&pool, alice, &media_path.to_string_lossy()).await; + let _session = auth::create_session(&pool, alice).await.expect("session"); + request_deletion(&pool, &settings, alice) + .await + .expect("request"); + expire_deletion_deadline(&pool, alice).await; + + let (cancelled, finalized) = tokio::join!( + cancel_deletion(&pool, alice), + finalize_due_deletions(&pool, &paths) + ); + let cancelled = cancelled.expect("cancel"); + let finalized = finalized.expect("finalize"); + + let present = user_exists(&pool, alice).await; + let counts: (i64, i64, i64) = pool + .call(move |conn| { + Ok(( + conn.query_row( + "SELECT COUNT(*) FROM posts WHERE user_id = ?", + [alice], + |row| row.get(0), + )?, + conn.query_row( + "SELECT COUNT(*) FROM sessions WHERE user_id = ?", + [alice], + |row| row.get(0), + )?, + conn.query_row( + "SELECT COUNT(*) FROM media WHERE owner_user_id = ?", + [alice], + |row| row.get(0), + )?, + )) + }) + .await + .expect("counts"); + if present { + assert!(cancelled, "a surviving account must have been cancelled"); + assert_eq!(finalized, 0); + assert_eq!( + counts, + (1, 1, 1), + "cancellation must leave every row intact" + ); + assert!(media_path.exists()); + assert!( + deletion_status(&pool, alice) + .await + .expect("status") + .is_none() + ); + } else { + assert!(!cancelled, "a removed account must reject cancellation"); + assert_eq!(finalized, 1); + assert_eq!(counts, (0, 0, 0), "deletion must remove every owned row"); + assert!(!media_path.exists()); + } + assert!(pending_media_journals(&paths).is_empty()); + assert_eq!( + finalize_due_deletions(&pool, &paths) + .await + .expect("sweep after the race"), + 0 + ); + assert_eq!(user_exists(&pool, alice).await, present); + } + + #[tokio::test] + async fn unsafe_media_path_rolls_finalization_back_until_fixed() { + let (_temp, paths, pool, settings, alice, _bob) = fixture().await; + social::create_post(&pool, &settings, Some(alice), "alice post", None, &[]) + .await + .expect("post"); + insert_media_row(&pool, alice, "../escape.webp").await; + request_deletion(&pool, &settings, alice) + .await + .expect("request"); + expire_deletion_deadline(&pool, alice).await; + let deadline = deletion_status(&pool, alice) + .await + .expect("status") + .expect("pending") + .scheduled_at; + + assert_eq!( + finalize_due_deletions(&pool, &paths) + .await + .expect("finalize with unsafe path"), + 0 + ); + assert!(user_exists(&pool, alice).await); + assert_eq!( + deletion_status(&pool, alice) + .await + .expect("status") + .expect("still pending") + .scheduled_at, + deadline, + "a rolled-back finalization must keep the original deadline" + ); + let counts: (i64, i64) = pool + .call(move |conn| { + Ok(( + conn.query_row( + "SELECT COUNT(*) FROM posts WHERE user_id = ?", + [alice], + |row| row.get(0), + )?, + conn.query_row( + "SELECT COUNT(*) FROM media WHERE owner_user_id = ?", + [alice], + |row| row.get(0), + )?, + )) + }) + .await + .expect("counts"); + assert_eq!(counts, (1, 1)); + assert!(pending_media_journals(&paths).is_empty()); + + let fixed_path = paths.uploads_images.join("fixed.webp"); + fs::write(&fixed_path, b"image").expect("fixed media file"); + let fixed = fixed_path.to_string_lossy().to_string(); + pool.call(move |conn| { + conn.execute( + "UPDATE media SET stored_path = ? WHERE owner_user_id = ?", + params![fixed, alice], + )?; + Ok(()) + }) + .await + .expect("fix stored path"); + + assert_eq!( + finalize_due_deletions(&pool, &paths) + .await + .expect("finalize after fix"), + 1 + ); + assert!(!user_exists(&pool, alice).await); + assert!(!fixed_path.exists()); + assert!(pending_media_journals(&paths).is_empty()); + } + + #[tokio::test] + async fn failed_media_removal_retains_journal_until_recovery() { + let (_temp, paths, pool, settings, alice, _bob) = fixture().await; + let blocked_path = paths.uploads_images.join("blocked-media"); + fs::create_dir(&blocked_path).expect("media directory"); + fs::write(blocked_path.join("inner.webp"), b"inner").expect("inner file"); + insert_media_row(&pool, alice, &blocked_path.to_string_lossy()).await; + request_deletion(&pool, &settings, alice) + .await + .expect("request"); + expire_deletion_deadline(&pool, alice).await; + + assert_eq!( + finalize_due_deletions(&pool, &paths) + .await + .expect("finalize"), + 1 + ); + assert!(!user_exists(&pool, alice).await); + assert_eq!( + pending_media_journals(&paths).len(), + 1, + "a failed removal must keep its journal" + ); + assert!(blocked_path.exists()); + + fs::remove_dir_all(&blocked_path).expect("clear blocked directory"); + fs::write(&blocked_path, b"image").expect("regular file"); + assert_eq!( + recover_pending_media_deletions(&paths) + .await + .expect("recover"), + 1 + ); + assert!(!blocked_path.exists()); + assert!(pending_media_journals(&paths).is_empty()); + assert_eq!( + recover_pending_media_deletions(&paths) + .await + .expect("recover again"), + 0 + ); + } + + #[tokio::test] + async fn recovery_processes_crash_journal_and_skips_unsafe_lines() { + let (temp, paths, _pool, _settings, alice, _bob) = fixture().await; + let real_path = paths.uploads_videos.join("crashed.mp4"); + fs::write(&real_path, b"video").expect("real media file"); + let missing_path = paths.uploads_thumbs.join("missing.webp"); + let outside_path = temp.path().join("outside.webp"); + fs::write(&outside_path, b"outside").expect("outside file"); + let journal_path = paths.tmp_dir.join(format!( + "{PENDING_MEDIA_DELETION_PREFIX}{alice}-crash{PENDING_MEDIA_DELETION_SUFFIX}" + )); + fs::write( + &journal_path, + format!( + "{alice}\n{}\n{}\n{}\n../relative.webp\n", + real_path.display(), + missing_path.display(), + outside_path.display() + ), + ) + .expect("crash journal"); + + let cleanup_removed = paths + .cleanup_stale_temp_files(std::time::Duration::ZERO) + .expect("temp cleanup"); + assert_eq!(cleanup_removed, 0); + assert!( + journal_path.exists(), + "runtime temp cleanup must not remove deletion journals" + ); + + assert_eq!( + recover_pending_media_deletions(&paths) + .await + .expect("recover"), + 1 + ); + assert!(!real_path.exists()); + assert!(!missing_path.exists()); + assert!( + outside_path.exists(), + "paths outside the upload roots must never be removed" + ); + assert!(!journal_path.exists()); + assert_eq!( + recover_pending_media_deletions(&paths) + .await + .expect("recover again"), + 0 + ); + } + + #[tokio::test] + async fn recovery_retains_malformed_journals() { + let (_temp, paths, _pool, _settings, alice, _bob) = fixture().await; + let real_path = paths.uploads_images.join("review-target.webp"); + fs::write(&real_path, b"image").expect("media file"); + let journal_path = paths.tmp_dir.join(format!( + "{PENDING_MEDIA_DELETION_PREFIX}malformed{PENDING_MEDIA_DELETION_SUFFIX}" + )); + + fs::write(&journal_path, "").expect("empty journal"); + assert_eq!( + recover_pending_media_deletions(&paths) + .await + .expect("recover empty"), + 0 + ); + assert!(journal_path.exists()); + + fs::write( + &journal_path, + format!("not-a-user-id\n{}\n", real_path.display()), + ) + .expect("garbage journal"); + assert_eq!( + recover_pending_media_deletions(&paths) + .await + .expect("recover garbage"), + 0 + ); + assert!(journal_path.exists()); + assert!(real_path.exists()); + + fs::write(&journal_path, format!("{alice}\n{}\n", real_path.display())) + .expect("fixed journal"); + assert_eq!( + recover_pending_media_deletions(&paths) + .await + .expect("recover fixed"), + 1 + ); + assert!(!real_path.exists()); + assert!(!journal_path.exists()); + } + + #[tokio::test] + async fn startup_and_periodic_sweeps_are_idempotent() { + let (_temp, paths, pool, settings, alice, bob) = fixture().await; + let alice_media = paths.uploads_images.join("restart-sweep.webp"); + fs::write(&alice_media, b"image").expect("alice media file"); + insert_media_row(&pool, alice, &alice_media.to_string_lossy()).await; + request_deletion(&pool, &settings, alice) + .await + .expect("request alice"); + expire_deletion_deadline(&pool, alice).await; + + assert_eq!( + finalize_due_deletions(&pool, &paths) + .await + .expect("startup finalize"), + 1 + ); + assert_eq!( + recover_pending_media_deletions(&paths) + .await + .expect("startup recovery"), + 0 + ); + assert_eq!( + finalize_due_deletions(&pool, &paths) + .await + .expect("periodic finalize"), + 0 + ); + assert_eq!( + recover_pending_media_deletions(&paths) + .await + .expect("periodic recovery"), + 0 + ); + assert!(!user_exists(&pool, alice).await); + assert!(!alice_media.exists()); + + let bob_media = paths.uploads_images.join("concurrent-sweep.webp"); + fs::write(&bob_media, b"image").expect("bob media file"); + insert_media_row(&pool, bob, &bob_media.to_string_lossy()).await; + request_deletion(&pool, &settings, bob) + .await + .expect("request bob"); + expire_deletion_deadline(&pool, bob).await; + + let (finalized, recovered) = tokio::join!( + finalize_due_deletions(&pool, &paths), + recover_pending_media_deletions(&paths) + ); + assert_eq!(finalized.expect("concurrent finalize"), 1); + recovered.expect("concurrent recovery"); + assert!(!user_exists(&pool, bob).await); + assert!(!bob_media.exists()); + assert!(pending_media_journals(&paths).is_empty()); + assert_eq!( + recover_pending_media_deletions(&paths) + .await + .expect("recover after sweep"), + 0 + ); + } + + #[tokio::test] + async fn zero_grace_deletion_removes_account_and_media_immediately() { + let (_temp, paths, pool, mut settings, alice, _bob) = fixture().await; + settings.accounts.deletion_grace_period_days = 0; + let media_path = paths.uploads_images.join("zero-grace.webp"); + fs::write(&media_path, b"image").expect("media file"); + insert_media_row(&pool, alice, &media_path.to_string_lossy()).await; + + let request = request_deletion(&pool, &settings, alice) + .await + .expect("request"); + assert_eq!(request.requested_at, request.scheduled_at); + assert_eq!( + finalize_due_deletions(&pool, &paths) + .await + .expect("finalize immediately"), + 1 + ); + assert!(!user_exists(&pool, alice).await); + assert!(!media_path.exists()); + assert!(pending_media_journals(&paths).is_empty()); + } + + #[tokio::test] + async fn zero_grace_cancellation_before_finalization_wins() { + let (_temp, paths, pool, mut settings, alice, _bob) = fixture().await; + settings.accounts.deletion_grace_period_days = 0; + let media_path = paths.uploads_images.join("zero-grace-cancel.webp"); + fs::write(&media_path, b"image").expect("media file"); + insert_media_row(&pool, alice, &media_path.to_string_lossy()).await; + request_deletion(&pool, &settings, alice) + .await + .expect("request"); + + assert!(cancel_deletion(&pool, alice).await.expect("cancel")); + assert_eq!( + finalize_due_deletions(&pool, &paths) + .await + .expect("finalize after cancellation"), + 0 + ); + assert!(user_exists(&pool, alice).await); + assert!(media_path.exists()); + assert!( + deletion_status(&pool, alice) + .await + .expect("status") + .is_none() + ); + } + + #[tokio::test] + async fn finalization_removes_every_session_for_the_account() { + let (_temp, paths, pool, settings, alice, _bob) = fixture().await; + let first = auth::create_session(&pool, alice) + .await + .expect("first session"); + let second = auth::create_session(&pool, alice) + .await + .expect("second session"); + request_deletion(&pool, &settings, alice) + .await + .expect("request"); + expire_deletion_deadline(&pool, alice).await; + + assert_eq!( + finalize_due_deletions(&pool, &paths) + .await + .expect("finalize"), + 1 + ); + + let sessions: i64 = pool + .call(move |conn| { + Ok(conn.query_row( + "SELECT COUNT(*) FROM sessions WHERE user_id = ?", + [alice], + |row| row.get(0), + )?) + }) + .await + .expect("sessions"); + assert_eq!(sessions, 0); + let token_hashes = vec![ + auth::hash_token(&first.token), + auth::hash_token(&second.token), + ]; + let resolved: i64 = pool + .call(move |conn| { + let mut resolved = 0; + for token_hash in &token_hashes { + resolved += conn.query_row( + "SELECT COUNT(*) FROM sessions WHERE token_hash = ?", + [token_hash.as_str()], + |row| row.get::<_, i64>(0), + )?; + } + Ok(resolved) + }) + .await + .expect("token lookups"); + assert_eq!( + resolved, 0, + "session tokens must not resolve after deletion" + ); + } + + #[tokio::test] + async fn concurrent_request_deletion_is_idempotent() { + let (_temp, _paths, pool, settings, alice, _bob) = fixture().await; + let (first, second) = tokio::join!( + request_deletion(&pool, &settings, alice), + request_deletion(&pool, &settings, alice) + ); + let first = first.expect("first request"); + let second = second.expect("second request"); + assert_eq!(first, second); + + let stored = deletion_status(&pool, alice) + .await + .expect("status") + .expect("pending"); + assert_eq!( + stored, first, + "concurrent requests must not move the deadline" + ); + + let repeated = request_deletion(&pool, &settings, alice) + .await + .expect("repeated request"); + assert_eq!(repeated, first); + assert_eq!( + deletion_status(&pool, alice) + .await + .expect("status") + .expect("still pending"), + first + ); + } } diff --git a/src/auth.rs b/src/auth.rs index 499bc2e..1fabffc 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -45,6 +45,11 @@ pub struct CurrentUser { pub is_suspended: bool, pub theme: Theme, pub nsfw_blur_enabled: bool, + /// Set by an administrator; the account is limited to the password-change + /// and logout flows until the password is changed. + pub must_change_password: bool, + /// Deadline for permanent account deletion, when a deletion is pending. + pub deletion_scheduled_at: Option, } #[derive(Debug, Clone)] @@ -62,6 +67,16 @@ pub enum LoginFailure { pub const USERNAME_TAKEN_MESSAGE: &str = "username is already taken"; +/// SQLite extended result code for a UNIQUE constraint failure. +const SQLITE_CONSTRAINT_UNIQUE: i32 = 2067; + +fn is_unique_violation(error: &rusqlite::Error) -> bool { + matches!( + error, + rusqlite::Error::SqliteFailure(code, _) if code.extended_code == SQLITE_CONSTRAINT_UNIQUE + ) +} + pub async fn register_user( pool: &SqlitePool, settings: &Settings, @@ -74,7 +89,8 @@ pub async fn register_user( let hash = hash_password_async(password.to_owned()).await?; let username = username.trim().to_owned(); pool.call(move |conn| { - let existing = conn + let tx = conn.transaction()?; + let current_taken = tx .query_row( "SELECT 1 FROM users WHERE normalized_username = ?", [&normalized], @@ -82,14 +98,30 @@ pub async fn register_user( ) .optional()? .is_some(); - if existing { + let historical_taken = tx + .query_row( + "SELECT 1 FROM username_history WHERE normalized_username = ?", + [&normalized], + |_| Ok(()), + ) + .optional()? + .is_some(); + if current_taken || historical_taken { anyhow::bail!(USERNAME_TAKEN_MESSAGE); } - conn.execute( + let inserted = tx.execute( "INSERT INTO users (username, normalized_username, password_hash, display_name, is_admin) VALUES (?, ?, ?, ?, ?)", params![username, normalized, hash, username, i64::from(is_admin)], - )?; - Ok(conn.last_insert_rowid()) + ); + let user_id = match inserted { + Ok(_) => tx.last_insert_rowid(), + Err(error) if is_unique_violation(&error) => { + anyhow::bail!(USERNAME_TAKEN_MESSAGE); + } + Err(error) => return Err(error.into()), + }; + tx.commit()?; + Ok(user_id) }) .await } @@ -233,10 +265,16 @@ pub async fn change_password( let current_session_hash = current_session_token.map(hash_token); pool.call(move |conn| { let tx = conn.transaction()?; - tx.execute( - "UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND is_deleted = 0", + let updated = tx.execute( + "UPDATE users SET password_hash = ?, must_change_password = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND is_deleted = 0", params![hash, user_id], )?; + if updated == 0 { + // The account was deleted after the password was verified. Rolling + // back keeps the flag and every session untouched instead of + // reporting a successful change that never happened. + anyhow::bail!("account not found"); + } if let Some(current_session_hash) = current_session_hash { tx.execute( "UPDATE sessions SET revoked_at = CURRENT_TIMESTAMP WHERE user_id = ? AND token_hash != ? AND revoked_at IS NULL", @@ -265,7 +303,8 @@ 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, u.nsfw_blur_enabled + SELECT u.id, u.username, u.display_name, u.is_admin, u.is_suspended, u.theme, + u.nsfw_blur_enabled, u.must_change_password, u.deletion_scheduled_at FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ? AND s.revoked_at IS NULL @@ -282,6 +321,8 @@ pub async fn current_user( is_suspended: row.get::<_, i64>(4)? != 0, theme: Theme::from(row.get::<_, String>(5)?.as_str()), nsfw_blur_enabled: row.get::<_, i64>(6)? != 0, + must_change_password: row.get::<_, i64>(7)? != 0, + deletion_scheduled_at: row.get(8)?, }) }, ) @@ -391,6 +432,78 @@ pub fn secure_token() -> String { mod tests { use super::*; + async fn fixture() -> (tempfile::TempDir, SqlitePool, Settings, i64) { + let temp = tempfile::tempdir().expect("temp dir"); + let pool = crate::db::connect(&temp.path().join("test.sqlite3")) + .await + .expect("connect"); + crate::db::migrate(&pool).await.expect("migrate"); + let settings = Settings::default(); + let user_id = register_user(&pool, &settings, "Alice", "very secure password", false) + .await + .expect("user"); + (temp, pool, settings, user_id) + } + + fn session_headers(token: &str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::header::COOKIE, + format!("rustpost_session={token}") + .parse() + .expect("session cookie"), + ); + headers + } + + async fn set_must_change_password(pool: &SqlitePool, user_id: i64) { + pool.call(move |conn| { + conn.execute( + "UPDATE users SET must_change_password = 1 WHERE id = ?", + [user_id], + )?; + Ok(()) + }) + .await + .expect("set must_change_password"); + } + + async fn must_change_password(pool: &SqlitePool, user_id: i64) -> i64 { + pool.call(move |conn| { + Ok(conn.query_row( + "SELECT must_change_password FROM users WHERE id = ?", + [user_id], + |row| row.get(0), + )?) + }) + .await + .expect("must_change_password") + } + + async fn active_session_count(pool: &SqlitePool, user_id: i64) -> i64 { + pool.call(move |conn| { + Ok(conn.query_row( + "SELECT COUNT(*) FROM sessions WHERE user_id = ? AND revoked_at IS NULL", + [user_id], + |row| row.get(0), + )?) + }) + .await + .expect("active sessions") + } + + async fn password_hash(pool: &SqlitePool, user_id: i64) -> String { + pool.call(move |conn| { + Ok(conn.query_row( + "SELECT password_hash FROM users WHERE id = ?", + [user_id], + |row| row.get(0), + )?) + }) + .await + .expect("password hash") + } + #[test] fn password_hash_verifies() { let hash = hash_password("correct horse battery").expect("hash"); @@ -411,11 +524,92 @@ mod tests { register_user(&pool, &settings, "Alice", "very secure password", false) .await .expect("first user"); - assert!( - register_user(&pool, &settings, "alice", "very secure password", false) + for candidate in ["alice", "ALICE", "aLiCe"] { + let error = register_user(&pool, &settings, candidate, "very secure password", false) .await - .is_err() - ); + .expect_err("case variant"); + assert_eq!(error.to_string(), USERNAME_TAKEN_MESSAGE); + } + let stored: String = pool + .call(|conn| { + Ok(conn.query_row( + "SELECT username FROM users WHERE normalized_username = 'alice'", + (), + |row| row.get(0), + )?) + }) + .await + .expect("stored username"); + assert_eq!(stored, "Alice"); + } + + #[tokio::test] + async fn registration_rejects_historical_handle() { + let temp = tempfile::tempdir().expect("temp dir"); + let pool = crate::db::connect(&temp.path().join("test.sqlite3")) + .await + .expect("connect"); + crate::db::migrate(&pool).await.expect("migrate"); + let settings = Settings::default(); + let alice = register_user(&pool, &settings, "alice", "very secure password", false) + .await + .expect("alice"); + crate::identity::change_username(&pool, &settings, alice, "alice_new") + .await + .expect("rename alice"); + + for candidate in ["alice", "ALICE"] { + let error = register_user(&pool, &settings, candidate, "very secure password", false) + .await + .expect_err("historical handle"); + assert_eq!(error.to_string(), USERNAME_TAKEN_MESSAGE); + } + } + + #[tokio::test] + async fn password_change_clears_must_change_password() { + let temp = tempfile::tempdir().expect("temp dir"); + let pool = crate::db::connect(&temp.path().join("test.sqlite3")) + .await + .expect("connect"); + crate::db::migrate(&pool).await.expect("migrate"); + let settings = Settings::default(); + let user_id = register_user(&pool, &settings, "Alice", "very secure password", false) + .await + .expect("user"); + pool.call(move |conn| { + conn.execute( + "UPDATE users SET must_change_password = 1 WHERE id = ?", + [user_id], + )?; + Ok(()) + }) + .await + .expect("set flag"); + + change_password( + &pool, + &settings, + user_id, + "very secure password", + "much better password", + "much better password", + None, + ) + .await + .expect("password change"); + + let flag: i64 = pool + .call(move |conn| { + Ok(conn.query_row( + "SELECT must_change_password FROM users WHERE id = ?", + [user_id], + |row| row.get(0), + )?) + }) + .await + .expect("flag"); + assert_eq!(flag, 0); } #[tokio::test] @@ -514,15 +708,10 @@ mod tests { #[tokio::test] async fn password_change_rejects_wrong_current_password() { - let temp = tempfile::tempdir().expect("temp dir"); - let pool = crate::db::connect(&temp.path().join("test.sqlite3")) - .await - .expect("connect"); - crate::db::migrate(&pool).await.expect("migrate"); - let settings = Settings::default(); - let user_id = register_user(&pool, &settings, "Alice", "very secure password", false) - .await - .expect("user"); + let (_temp, pool, settings, user_id) = fixture().await; + set_must_change_password(&pool, user_id).await; + let current = create_session(&pool, user_id).await.expect("current"); + let other = create_session(&pool, user_id).await.expect("other"); let result = change_password( &pool, @@ -531,7 +720,7 @@ mod tests { "wrong password", "much better password", "much better password", - None, + Some(¤t.token), ) .await; @@ -539,12 +728,31 @@ mod tests { result.expect_err("wrong current password").to_string(), "current password is incorrect" ); + assert_eq!(must_change_password(&pool, user_id).await, 1); + assert_eq!(active_session_count(&pool, user_id).await, 2); + assert!( + current_user(&pool, &session_headers(¤t.token)) + .await + .expect("current user") + .is_some() + ); + assert!( + current_user(&pool, &session_headers(&other.token)) + .await + .expect("other user") + .is_some() + ); assert!( login(&pool, "alice", "very secure password") .await .expect("old login") .is_ok() ); + assert_eq!( + active_session_count(&pool, user_id).await, + 3, + "login adds a session" + ); } #[tokio::test] @@ -619,4 +827,203 @@ mod tests { .is_none() ); } + + #[tokio::test] + async fn password_change_keeps_current_session_revokes_others_and_clears_flag() { + let (_temp, pool, settings, user_id) = fixture().await; + set_must_change_password(&pool, user_id).await; + let first = create_session(&pool, user_id).await.expect("first"); + let second = create_session(&pool, user_id).await.expect("second"); + let third = create_session(&pool, user_id).await.expect("third"); + + change_password( + &pool, + &settings, + user_id, + "very secure password", + "much better password", + "much better password", + Some(&first.token), + ) + .await + .expect("password change"); + + assert_eq!(must_change_password(&pool, user_id).await, 0); + let current = current_user(&pool, &session_headers(&first.token)) + .await + .expect("current user") + .expect("current session stays valid"); + assert!(!current.must_change_password); + for token in [&second.token, &third.token] { + assert!( + current_user(&pool, &session_headers(token)) + .await + .expect("revoked user") + .is_none() + ); + assert!( + csrf_hashes_for_cookie(&pool, token) + .await + .expect("csrf lookup") + .is_none() + ); + } + assert_eq!(active_session_count(&pool, user_id).await, 1); + assert!( + login(&pool, "alice", "much better password") + .await + .expect("new login") + .is_ok() + ); + assert!( + login(&pool, "alice", "very secure password") + .await + .expect("old login") + .is_err() + ); + } + + #[tokio::test] + async fn concurrent_password_change_and_read_leaves_no_stale_session() { + let (_temp, pool, settings, user_id) = fixture().await; + set_must_change_password(&pool, user_id).await; + let current = create_session(&pool, user_id).await.expect("current"); + let other = create_session(&pool, user_id).await.expect("other"); + + let change = change_password( + &pool, + &settings, + user_id, + "very secure password", + "much better password", + "much better password", + Some(¤t.token), + ); + let other_headers = session_headers(&other.token); + let read = current_user(&pool, &other_headers); + let (change, read) = tokio::join!(change, read); + + change.expect("password change"); + if let Some(user) = read.expect("read") { + assert!( + user.must_change_password, + "a read that saw the other session must predate the password change" + ); + } + assert!( + current_user(&pool, &session_headers(&other.token)) + .await + .expect("final read") + .is_none(), + "no session may remain valid after the change" + ); + assert_eq!(active_session_count(&pool, user_id).await, 1); + } + + #[tokio::test] + async fn password_change_for_deleted_account_is_not_reported_as_success() { + use futures_util::FutureExt as _; + + let (_temp, pool, settings, user_id) = fixture().await; + set_must_change_password(&pool, user_id).await; + let current = create_session(&pool, user_id).await.expect("current"); + let _other = create_session(&pool, user_id).await.expect("other"); + let hash_before = password_hash(&pool, user_id).await; + + let change = change_password( + &pool, + &settings, + user_id, + "very secure password", + "much better password", + "much better password", + Some(¤t.token), + ); + tokio::pin!(change); + assert!( + change.as_mut().now_or_never().is_none(), + "first poll only queues password verification" + ); + // The account is deleted after the password was verified but before the + // UPDATE runs. + pool.call(move |conn| { + conn.execute( + "UPDATE users SET is_deleted = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + [user_id], + )?; + Ok(()) + }) + .await + .expect("delete mid-flight"); + + let error = change + .await + .expect_err("a deleted account must not report a successful change"); + assert_eq!(error.to_string(), "account not found"); + assert_eq!(password_hash(&pool, user_id).await, hash_before); + assert_eq!(must_change_password(&pool, user_id).await, 1); + assert_eq!( + active_session_count(&pool, user_id).await, + 2, + "a failed change must not revoke sessions" + ); + } + + #[tokio::test] + async fn revoked_session_cookie_is_rejected() { + let (_temp, pool, _settings, user_id) = fixture().await; + let revoked = create_session(&pool, user_id).await.expect("revoked"); + let active = create_session(&pool, user_id).await.expect("active"); + + revoke_session(&pool, &revoked.token).await.expect("revoke"); + + assert!( + current_user(&pool, &session_headers(&revoked.token)) + .await + .expect("current user") + .is_none() + ); + assert!( + csrf_hashes_for_cookie(&pool, &revoked.token) + .await + .expect("csrf lookup") + .is_none() + ); + assert!( + current_user(&pool, &session_headers(&active.token)) + .await + .expect("current user") + .is_some() + ); + } + + #[tokio::test] + async fn expired_inserted_session_cookie_is_rejected() { + let (_temp, pool, _settings, user_id) = fixture().await; + let token = secure_token(); + let token_hash = hash_token(&token); + let csrf_hash = hash_token(&secure_token()); + pool.call(move |conn| { + conn.execute( + "INSERT INTO sessions (user_id, token_hash, csrf_token_hash, expires_at) VALUES (?, ?, ?, datetime('now', '-1 hour'))", + params![user_id, token_hash, csrf_hash], + )?; + Ok(()) + }) + .await + .expect("insert expired session"); + + assert!( + current_user(&pool, &session_headers(&token)) + .await + .expect("current user") + .is_none() + ); + assert!( + csrf_hashes_for_cookie(&pool, &token) + .await + .expect("csrf lookup") + .is_none() + ); + } } diff --git a/src/backup.rs b/src/backup.rs index 22be297..712857b 100644 --- a/src/backup.rs +++ b/src/backup.rs @@ -1550,15 +1550,158 @@ mod tests { test_db(&target.database_path, CURRENT_SCHEMA_VERSION); test_settings(&target.settings_path); fs::write(target.uploads_images.join("old.webp"), b"old").expect("old"); + let live_db = fs::read(&target.database_path).expect("live database"); + let live_settings = fs::read_to_string(&target.settings_path).expect("live settings"); let corrupted = corrupt_first_file_hash(&archive, target_temp.path()); let error = restore_backup(&target, &corrupted, false).expect_err("restore must fail"); - assert!(error.to_string().contains("validation")); + assert!(format!("{error:#}").contains("validation")); + assert_eq!( + fs::read(&target.database_path).expect("live database"), + live_db + ); + assert_eq!( + fs::read_to_string(&target.settings_path).expect("live settings"), + live_settings + ); assert!(target.uploads_images.join("old.webp").is_file()); assert!(!target.uploads_images.join("restored.webp").exists()); } + #[test] + fn backup_restore_round_trip_preserves_v4_account_lifecycle_state() { + let source_temp = tempfile::tempdir().expect("source"); + let source = RuntimePaths::from_data_dir(source_temp.path().join("source")); + source.ensure().expect("source ensure"); + test_db(&source.database_path, CURRENT_SCHEMA_VERSION); + test_settings(&source.settings_path); + { + let conn = Connection::open(&source.database_path).expect("source database"); + conn.execute_batch( + r#" + INSERT INTO users (id, username, normalized_username, password_hash, display_name, follow_approval_required, must_change_password, deletion_requested_at, deletion_scheduled_at) + VALUES (1, 'Alice', 'alice', 'hash', 'Alice', 1, 1, '2026-01-01T00:00:00Z', '2026-02-01T00:00:00Z'); + INSERT INTO users (id, username, normalized_username, password_hash, display_name) + VALUES (2, 'Bob', 'bob', 'hash', 'Bob'); + INSERT INTO follow_requests (requester_id, target_id, created_at) + VALUES (2, 1, '2026-01-03T00:00:00Z'); + INSERT INTO username_history (user_id, username, normalized_username, changed_at) + VALUES (1, 'OldAlice', 'oldalice', '2026-01-04T00:00:00Z'); + INSERT INTO username_history (user_id, username, normalized_username, changed_at) + VALUES (1, 'OlderAlice', 'olderalice', '2026-01-05T00:00:00Z'); + INSERT INTO instance_settings (key, value, updated_at) + VALUES ('announcement', 'Maintenance on Sunday', '2026-01-06T00:00:00Z'); + INSERT INTO instance_settings (key, value, updated_at) + VALUES ('announcement_enabled', '1', '2026-01-06T00:00:01Z'); + INSERT INTO instance_settings (key, value, updated_at) + VALUES ('maintenance_mode', '1', '2026-01-06T00:00:02Z'); + INSERT INTO instance_settings (key, value, updated_at) + VALUES ('maintenance_message', 'Back at 06:00 UTC', '2026-01-06T00:00:03Z'); + INSERT INTO account_imports ( + user_id, archive_id, format_version, imported_at, + posts_imported, media_imported, follows_imported, follows_pending, follows_skipped + ) + VALUES (1, 'archive-abc', 2, '2026-01-07T00:00:00Z', 11, 4, 7, 2, 1); + "#, + ) + .expect("seed v4 rows"); + } + let source_snapshot = account_lifecycle_snapshot(&source.database_path); + + let archive = create_backup(&source, false).expect("backup"); + let manifest = read_manifest_from_archive(&archive).expect("manifest"); + assert_eq!(manifest.db_schema_version, Some(CURRENT_SCHEMA_VERSION)); + + let target_temp = tempfile::tempdir().expect("target"); + let target = RuntimePaths::from_data_dir(target_temp.path().join("target")); + target.ensure().expect("target ensure"); + test_db(&target.database_path, CURRENT_SCHEMA_VERSION); + test_settings(&target.settings_path); + + let report = restore_backup(&target, &archive, false).expect("restore"); + + assert!(!report.tor_keys_restored); + assert_eq!( + account_lifecycle_snapshot(&target.database_path), + source_snapshot + ); + let conn = Connection::open(&target.database_path).expect("restored database"); + assert_eq!( + db::schema_version_from_connection(&conn).expect("schema version"), + CURRENT_SCHEMA_VERSION + ); + } + + #[test] + fn restore_rejects_newer_database_schema_version_without_touching_live_data() { + assert_crafted_backup_rejected( + |staging, staged| { + bump_staged_schema_version(staging, CURRENT_SCHEMA_VERSION + 1); + staged.manifest.db_schema_version = Some(CURRENT_SCHEMA_VERSION + 1); + }, + "migration metadata is not compatible", + ); + } + + #[test] + fn restore_rejects_manifest_schema_version_mismatch_without_touching_live_data() { + assert_crafted_backup_rejected( + |_staging, staged| { + staged.manifest.db_schema_version = Some(CURRENT_SCHEMA_VERSION - 1); + }, + "manifest schema version does not match restored database", + ); + } + + #[test] + fn restore_rejects_newer_manifest_format_without_touching_live_data() { + assert_crafted_backup_rejected( + |_staging, staged| { + staged.manifest.format_version = FORMAT_VERSION + 1; + }, + "unsupported backup manifest format version", + ); + } + + #[test] + fn restore_rejects_malformed_database_schema_without_touching_live_data() { + assert_crafted_backup_rejected( + |staging, _staged| { + let conn = + Connection::open(staging.join("db/rustpost.sqlite3")).expect("staged database"); + conn.execute("DROP INDEX idx_posts_created", []) + .expect("drop staged index"); + }, + "missing index idx_posts_created", + ); + } + + #[test] + fn restore_rejects_malformed_manifest_without_touching_live_data() { + let target_temp = tempfile::tempdir().expect("target"); + let (paths, db_bytes, settings) = live_target(target_temp.path(), "target"); + let staging = tempfile::tempdir().expect("staging"); + let archive = staging.path().join("malformed-manifest.tar"); + let file = File::create(&archive).expect("archive"); + let mut builder = Builder::new(file); + append_bytes( + &mut builder, + MANIFEST_PATH, + b"format_version = \"one\"", + 0o600, + EntryType::Regular, + ) + .expect("manifest"); + builder.finish().expect("finish"); + + let error = restore_backup(&paths, &archive, false).expect_err("malformed manifest"); + + let message = format!("{error:#}"); + assert!(message.contains("malformed"), "unexpected error: {message}"); + assert_live_target_untouched(&paths, &db_bytes, &settings); + } + #[test] fn restore_rejects_malicious_archive_entries() { let (_temp, paths) = test_paths(); @@ -1586,10 +1729,11 @@ mod tests { let archive = paths.tmp_dir.join("duplicate.tar"); let file = File::create(&archive).expect("archive"); let mut builder = Builder::new(file); + let manifest_toml = toml::to_string(&minimal_manifest()).expect("manifest toml"); append_bytes( &mut builder, MANIFEST_PATH, - b"not toml", + manifest_toml.as_bytes(), 0o600, EntryType::Regular, ) @@ -1614,7 +1758,10 @@ mod tests { let error = restore_backup(&paths, &archive, false).expect_err("duplicate"); - assert!(error.to_string().contains("validation")); + assert!( + format!("{error:#}").contains("duplicate entry settings.toml"), + "unexpected error: {error:#}" + ); } #[test] @@ -1733,6 +1880,223 @@ mod tests { ); } + type SnapshotUserRow = (i64, String, i64, i64, Option, Option); + type SnapshotAccountImportRow = (i64, String, i64, String, i64, i64, i64, i64, i64); + + #[derive(Debug, PartialEq, Eq)] + struct V4AccountLifecycleSnapshot { + users: Vec, + follow_requests: Vec<(i64, i64, String)>, + username_history: Vec<(i64, String, String, String)>, + instance_settings: Vec<(String, String, String)>, + account_imports: Vec, + } + + fn account_lifecycle_snapshot(path: &Path) -> V4AccountLifecycleSnapshot { + let conn = Connection::open(path).expect("snapshot database"); + let users = { + let mut stmt = conn + .prepare( + "SELECT id, username, follow_approval_required, must_change_password, deletion_requested_at, deletion_scheduled_at FROM users ORDER BY id", + ) + .expect("users statement"); + stmt.query_map([], |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + )) + }) + .expect("users query") + .collect::>>() + .expect("users rows") + }; + let follow_requests = { + let mut stmt = conn + .prepare( + "SELECT requester_id, target_id, created_at FROM follow_requests ORDER BY requester_id, target_id", + ) + .expect("follow requests statement"); + stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))) + .expect("follow requests query") + .collect::>>() + .expect("follow request rows") + }; + let username_history = { + let mut stmt = conn + .prepare( + "SELECT user_id, username, normalized_username, changed_at FROM username_history ORDER BY user_id, normalized_username", + ) + .expect("username history statement"); + stmt.query_map([], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) + }) + .expect("username history query") + .collect::>>() + .expect("username history rows") + }; + let instance_settings = { + let mut stmt = conn + .prepare("SELECT key, value, updated_at FROM instance_settings ORDER BY key") + .expect("instance settings statement"); + stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))) + .expect("instance settings query") + .collect::>>() + .expect("instance settings rows") + }; + let account_imports = { + let mut stmt = conn + .prepare( + "SELECT user_id, archive_id, format_version, imported_at, posts_imported, media_imported, follows_imported, follows_pending, follows_skipped FROM account_imports ORDER BY id", + ) + .expect("account imports statement"); + stmt.query_map([], |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + row.get(7)?, + row.get(8)?, + )) + }) + .expect("account imports query") + .collect::>>() + .expect("account import rows") + }; + V4AccountLifecycleSnapshot { + users, + follow_requests, + username_history, + instance_settings, + account_imports, + } + } + + fn minimal_manifest() -> BackupManifest { + BackupManifest { + format_version: FORMAT_VERSION, + rustpost_version: "test".to_owned(), + db_schema_version: None, + created_at: "2026-01-01T00:00:00Z".to_owned(), + tor_keys_included: false, + components: Vec::new(), + } + } + + fn live_target(root: &Path, tag: &str) -> (RuntimePaths, Vec, String) { + let paths = RuntimePaths::from_data_dir(root.join(tag)); + paths.ensure().expect("ensure target"); + test_db(&paths.database_path, CURRENT_SCHEMA_VERSION); + test_settings(&paths.settings_path); + let db_bytes = fs::read(&paths.database_path).expect("live database bytes"); + let settings = fs::read_to_string(&paths.settings_path).expect("live settings"); + (paths, db_bytes, settings) + } + + fn assert_live_target_untouched(paths: &RuntimePaths, db_bytes: &[u8], settings: &str) { + assert_eq!( + fs::read(&paths.database_path).expect("live database"), + db_bytes + ); + assert_eq!( + fs::read_to_string(&paths.settings_path).expect("live settings"), + settings + ); + assert!( + list_backups(paths).expect("backup list").is_empty(), + "a rejected restore must not create backups" + ); + } + + fn crafted_backup( + mutate: impl FnOnce(&Path, &mut StagedBackup), + ) -> (tempfile::TempDir, PathBuf) { + let source_temp = tempfile::tempdir().expect("source"); + let source = RuntimePaths::from_data_dir(source_temp.path().join("source")); + source.ensure().expect("source ensure"); + test_db(&source.database_path, CURRENT_SCHEMA_VERSION); + test_settings(&source.settings_path); + let archive = create_backup(&source, false).expect("backup"); + + let staging = tempfile::tempdir().expect("staging"); + let mut staged = + extract_archive_to_stage(&archive, staging.path(), false).expect("extract archive"); + mutate(staging.path(), &mut staged); + let crafted = staging.path().join("crafted.tar"); + repack_archive(staging.path(), &mut staged, &crafted).expect("repack archive"); + (staging, crafted) + } + + fn repack_archive( + staging_dir: &Path, + staged: &mut StagedBackup, + destination: &Path, + ) -> anyhow::Result<()> { + for component in &mut staged.manifest.components { + if component.kind == ManifestEntryKind::File { + let (size, hash) = hash_file(&staging_dir.join(&component.archive_path))?; + component.size = size; + component.sha256 = Some(hash); + } + } + let manifest_toml = toml::to_string(&staged.manifest)?; + let file = File::create(destination)?; + let mut builder = Builder::new(file); + append_bytes( + &mut builder, + MANIFEST_PATH, + manifest_toml.as_bytes(), + 0o600, + EntryType::Regular, + )?; + for (path, entry) in &staged.entries { + match entry.kind { + ManifestEntryKind::Directory => { + append_bytes(&mut builder, path, &[], 0o700, EntryType::Directory)?; + } + ManifestEntryKind::File => { + let bytes = fs::read(staging_dir.join(path))?; + append_bytes(&mut builder, path, &bytes, 0o600, EntryType::Regular)?; + } + } + } + builder.finish()?; + Ok(()) + } + + fn bump_staged_schema_version(staging_dir: &Path, version: i64) { + let conn = Connection::open(staging_dir.join("db/rustpost.sqlite3")).expect("staged db"); + conn.execute("DELETE FROM schema_migrations", []) + .expect("clear staged version"); + conn.execute( + "INSERT INTO schema_migrations (version) VALUES (?)", + [version], + ) + .expect("staged version"); + } + + fn assert_crafted_backup_rejected( + mutate: impl FnOnce(&Path, &mut StagedBackup), + expected: &str, + ) { + let (_staging, archive) = crafted_backup(mutate); + let target_temp = tempfile::tempdir().expect("target"); + let (paths, db_bytes, settings) = live_target(target_temp.path(), "target"); + + let error = restore_backup(&paths, &archive, false).expect_err("crafted backup"); + + let message = format!("{error:#}"); + assert!(message.contains(expected), "unexpected error: {message}"); + assert_live_target_untouched(&paths, &db_bytes, &settings); + } + fn archive_names(path: &Path) -> Vec { let file = File::open(path).expect("archive"); Archive::new(file) diff --git a/src/config.rs b/src/config.rs index 7f2591e..0823a68 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,6 +5,29 @@ use serde::{Deserialize, Serialize}; pub const DEFAULT_POST_EDIT_WINDOW_SECONDS: u64 = 15; pub const MAX_POST_EDIT_WINDOW_SECONDS: u64 = 300; +pub const DEFAULT_DELETION_GRACE_PERIOD_DAYS: u64 = 30; +pub const MAX_DELETION_GRACE_PERIOD_DAYS: u64 = 3_650; + +/// Default ceiling for the compressed size of one account archive. +/// +/// Applies both to upload (`/settings/import`) and to export output. This is +/// the *compressed* limit; see [`DEFAULT_MAX_ARCHIVE_EXPANDED_BYTES`] for the +/// independent decompressed-content ceiling. +pub const DEFAULT_MAX_ARCHIVE_UPLOAD_BYTES: u64 = 300 * 1024 * 1024; +/// Default ceiling for the total decompressed media bytes extracted from one +/// account archive. Independent of the compressed upload limit so a small +/// archive can never expand into unbounded disk use. +pub const DEFAULT_MAX_ARCHIVE_EXPANDED_BYTES: u64 = 1024 * 1024 * 1024; +/// Default ceiling for the number of tar entries in one account archive. +pub const DEFAULT_MAX_ARCHIVE_ENTRIES: usize = 10_000; +/// Upper bound accepted for `accounts.max_archive_upload_bytes`. +pub const MAX_ARCHIVE_UPLOAD_LIMIT_BYTES: u64 = 2 * 1024 * 1024 * 1024; +/// Upper bound accepted for `accounts.max_archive_expanded_bytes`. +pub const MAX_ARCHIVE_EXPANDED_LIMIT_BYTES: u64 = 16 * 1024 * 1024 * 1024; +/// Upper bound accepted for `accounts.max_archive_entries`. +pub const MAX_ARCHIVE_ENTRY_LIMIT: usize = 100_000; +/// Smallest limit operators may configure for either archive byte ceiling. +pub const MIN_ARCHIVE_LIMIT_BYTES: u64 = 64 * 1024; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Settings { @@ -55,6 +78,21 @@ pub struct AccountSettings { pub max_bio_len: usize, pub allow_profile_banners: bool, pub allow_profile_pictures: bool, + /// Days an account remains recoverable after requesting deletion. A value + /// of zero finalizes deletion immediately. + #[serde(default = "default_deletion_grace_period_days")] + pub deletion_grace_period_days: u64, + /// Compressed-size ceiling for account archives, in bytes. Applies to the + /// import upload and to export output. Independent of the expanded limit. + #[serde(default = "default_max_archive_upload_bytes")] + pub max_archive_upload_bytes: u64, + /// Maximum total decompressed media bytes accepted from one account + /// archive, in bytes. Checked incrementally while extracting. + #[serde(default = "default_max_archive_expanded_bytes")] + pub max_archive_expanded_bytes: u64, + /// Maximum number of tar entries accepted in one account archive. + #[serde(default = "default_max_archive_entries")] + pub max_archive_entries: usize, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -172,6 +210,10 @@ impl Default for Settings { max_bio_len: 240, allow_profile_banners: true, allow_profile_pictures: true, + deletion_grace_period_days: DEFAULT_DELETION_GRACE_PERIOD_DAYS, + max_archive_upload_bytes: DEFAULT_MAX_ARCHIVE_UPLOAD_BYTES, + max_archive_expanded_bytes: DEFAULT_MAX_ARCHIVE_EXPANDED_BYTES, + max_archive_entries: DEFAULT_MAX_ARCHIVE_ENTRIES, }, posts: PostSettings { max_text_chars: 280, @@ -275,6 +317,32 @@ impl Settings { validate_onion_service_name(&self.tor.onion_service_name)?; validate_display_onion_address(&self.tor.display_onion_address)?; validate_post_edit_window(self.posts.post_edit_window_seconds)?; + if self.accounts.deletion_grace_period_days > MAX_DELETION_GRACE_PERIOD_DAYS { + anyhow::bail!( + "accounts.deletion_grace_period_days must be {MAX_DELETION_GRACE_PERIOD_DAYS} days or less" + ); + } + if self.accounts.max_archive_upload_bytes < MIN_ARCHIVE_LIMIT_BYTES + || self.accounts.max_archive_upload_bytes > MAX_ARCHIVE_UPLOAD_LIMIT_BYTES + { + anyhow::bail!( + "accounts.max_archive_upload_bytes must be between {MIN_ARCHIVE_LIMIT_BYTES} and {MAX_ARCHIVE_UPLOAD_LIMIT_BYTES} bytes" + ); + } + if self.accounts.max_archive_expanded_bytes < MIN_ARCHIVE_LIMIT_BYTES + || self.accounts.max_archive_expanded_bytes > MAX_ARCHIVE_EXPANDED_LIMIT_BYTES + { + anyhow::bail!( + "accounts.max_archive_expanded_bytes must be between {MIN_ARCHIVE_LIMIT_BYTES} and {MAX_ARCHIVE_EXPANDED_LIMIT_BYTES} bytes" + ); + } + if self.accounts.max_archive_entries == 0 + || self.accounts.max_archive_entries > MAX_ARCHIVE_ENTRY_LIMIT + { + anyhow::bail!( + "accounts.max_archive_entries must be between 1 and {MAX_ARCHIVE_ENTRY_LIMIT}" + ); + } Ok(()) } } @@ -283,6 +351,22 @@ const fn default_post_edit_window_seconds() -> u64 { DEFAULT_POST_EDIT_WINDOW_SECONDS } +const fn default_deletion_grace_period_days() -> u64 { + DEFAULT_DELETION_GRACE_PERIOD_DAYS +} + +const fn default_max_archive_upload_bytes() -> u64 { + DEFAULT_MAX_ARCHIVE_UPLOAD_BYTES +} + +const fn default_max_archive_expanded_bytes() -> u64 { + DEFAULT_MAX_ARCHIVE_EXPANDED_BYTES +} + +const fn default_max_archive_entries() -> usize { + DEFAULT_MAX_ARCHIVE_ENTRIES +} + fn validate_post_edit_window(seconds: u64) -> anyhow::Result<()> { if seconds > MAX_POST_EDIT_WINDOW_SECONDS { anyhow::bail!( @@ -374,6 +458,22 @@ allow_profile_banners = {allow_profile_banners} # Allow users to upload profile picture images. allow_profile_pictures = {allow_profile_pictures} +# Days an account stays recoverable after deletion is requested. Set to 0 to +# delete immediately after password confirmation. +deletion_grace_period_days = {deletion_grace_period_days} + +# SECURITY: Maximum compressed size in bytes of one account archive. Applies to +# the /settings/import upload and to exported archives. Independent of the +# expanded limit below, so this value never controls decompressed content. +max_archive_upload_bytes = {max_archive_upload_bytes} + +# SECURITY: Maximum total decompressed media bytes accepted when importing one +# account archive. Enforced incrementally during extraction. +max_archive_expanded_bytes = {max_archive_expanded_bytes} + +# SECURITY: Maximum number of entries accepted in one account archive. +max_archive_entries = {max_archive_entries} + # Posts and interactions @@ -558,6 +658,10 @@ automatic_include_tor_keys = {backup_automatic_include_tor_keys} max_bio_len = settings.accounts.max_bio_len, allow_profile_banners = settings.accounts.allow_profile_banners, allow_profile_pictures = settings.accounts.allow_profile_pictures, + deletion_grace_period_days = settings.accounts.deletion_grace_period_days, + max_archive_upload_bytes = settings.accounts.max_archive_upload_bytes, + max_archive_expanded_bytes = settings.accounts.max_archive_expanded_bytes, + max_archive_entries = settings.accounts.max_archive_entries, max_text_chars = settings.posts.max_text_chars, post_edit_window_seconds = settings.posts.post_edit_window_seconds, max_images_per_post = settings.posts.max_images_per_post, diff --git a/src/db.rs b/src/db.rs index baf0cd4..ac7848f 100644 --- a/src/db.rs +++ b/src/db.rs @@ -6,12 +6,14 @@ use std::time::Duration; use anyhow::Context as _; use rusqlite::{Connection, OpenFlags, OptionalExtension as _}; -pub const CURRENT_SCHEMA_VERSION: i64 = 3; +pub const CURRENT_SCHEMA_VERSION: i64 = 4; -/// Pre-1.0 release schema version 1 (without the YouTube embed tables). +/// Pre-1.0 schema version 1 (without the `YouTube` embed tables). const RELEASE_V1_SCHEMA_VERSION: i64 = 1; -/// Release 1.0.0 shipped schema version 2 (the YouTube embed tables). +/// Pre-1.0 schema version 2 (with the `YouTube` embed tables). const RELEASE_V2_SCHEMA_VERSION: i64 = 2; +/// Pre-1.0 schema version 3 (with the release indexes). +const RELEASE_V3_SCHEMA_VERSION: i64 = 3; const OLD_ALPHA_SCHEMA_VERSION: i64 = 13; const INCOMPATIBLE_DATABASE_HINT: &str = "back up or export the instance, recreate a fresh RustPost data directory, and restore from a known-good backup instead of attempting a blind migration"; @@ -205,6 +207,7 @@ impl SchemaReport { enum MigrationState { Empty, Baseline, + ReleaseVersion3, ReleaseVersion2, ReleaseVersion1, AlphaLatest, @@ -247,6 +250,7 @@ fn ensure_release_baseline(conn: &Connection) -> anyhow::Result<()> { match migration_state(conn)? { MigrationState::Empty => initialize_release_baseline(conn)?, MigrationState::Baseline => {} + MigrationState::ReleaseVersion3 => migrate_release_v3_to_current(conn)?, MigrationState::ReleaseVersion2 => migrate_release_v2_to_current(conn)?, MigrationState::ReleaseVersion1 => migrate_release_v1_to_current(conn)?, MigrationState::AlphaLatest => adopt_latest_alpha_schema(conn)?, @@ -264,6 +268,15 @@ fn validate_or_normalize_restorable_schema( ) -> anyhow::Result<()> { match migration_state(conn)? { MigrationState::Baseline => validate_schema(conn), + MigrationState::ReleaseVersion3 => { + if !normalize { + anyhow::bail!( + "database schema version {RELEASE_V3_SCHEMA_VERSION} requires migration before use" + ); + } + migrate_release_v3_to_current(conn)?; + validate_schema(conn) + } MigrationState::ReleaseVersion2 => { if !normalize { anyhow::bail!( @@ -342,6 +355,9 @@ fn migration_state(conn: &Connection) -> anyhow::Result { if versions == [CURRENT_SCHEMA_VERSION] { return Ok(MigrationState::Baseline); } + if versions == [RELEASE_V3_SCHEMA_VERSION] { + return Ok(MigrationState::ReleaseVersion3); + } if versions == [RELEASE_V2_SCHEMA_VERSION] { return Ok(MigrationState::ReleaseVersion2); } @@ -416,9 +432,45 @@ fn migrate_release_v1_to_current(conn: &Connection) -> anyhow::Result<()> { /// session revocation, media reference checks, and rate-limit pruning. fn migrate_release_v2_to_current(conn: &Connection) -> anyhow::Result<()> { conn.execute_batch(RELEASE_V3_INDEXES)?; + reset_schema_migrations_to(conn, RELEASE_V3_SCHEMA_VERSION)?; + migrate_release_v3_to_current(conn) +} + +/// Adds the account lifecycle columns and tables used by protected accounts, +/// forced password resets, deletion grace periods, username history, instance +/// settings, and account imports. +fn migrate_release_v3_to_current(conn: &Connection) -> anyhow::Result<()> { + for (column, definition) in USER_COLUMNS_ADDED_IN_V4 { + add_column_if_missing(conn, "users", column, definition)?; + } + conn.execute_batch(RELEASE_V4_SCHEMA)?; reset_schema_migrations(conn) } +/// `ALTER TABLE ... ADD COLUMN` is not idempotent, so the migration checks the +/// current table shape first. `PRAGMA table_info` output is safe to inspect +/// because the table names here are compile-time constants. +fn add_column_if_missing( + conn: &Connection, + table: &str, + column: &str, + definition: &str, +) -> anyhow::Result<()> { + let exists = table_columns(conn, table)? + .iter() + .any(|existing| existing.name == column); + if exists { + return Ok(()); + } + conn.execute_batch(&format!( + "ALTER TABLE {} ADD COLUMN {} {};", + quoted_identifier(table), + quoted_identifier(column), + definition + ))?; + Ok(()) +} + fn inspect_schema(conn: &Connection) -> anyhow::Result { let mut issues = Vec::new(); let mut version = None; @@ -473,7 +525,9 @@ fn inspect_schema_objects(conn: &Connection) -> anyhow::Result> { fn inspect_alpha_schema_objects(conn: &Connection) -> anyhow::Result> { // Objects introduced after the alpha schema are reported as missing here - // but are created by the migration chain during adoption. + // but are created by the migration chain during adoption. The `users` + // column list may also differ because adoption appends the version 4 + // account lifecycle columns. Ok(inspect_schema_objects(conn)? .into_iter() .filter(|issue| { @@ -487,7 +541,17 @@ fn inspect_alpha_schema_objects(conn: &Connection) -> anyhow::Result | "missing index idx_sessions_user" | "missing index idx_post_media_media" | "missing index idx_rate_limit_created" - ) + | "missing table follow_requests" + | "missing index idx_follow_requests_target" + | "missing table username_history" + | "missing index idx_username_history_normalized" + | "missing table instance_settings" + | "missing table account_imports" + | "missing index idx_account_imports_user" + ) && !issue.starts_with("table users columns differ") + && !USER_COLUMNS_ADDED_IN_V4 + .iter() + .any(|(column, _definition)| issue == &format!("missing column users.{column}")) }) .collect()) } @@ -723,13 +787,68 @@ CREATE INDEX IF NOT EXISTS idx_post_embeds_post ON post_embeds(post_id, position CREATE INDEX IF NOT EXISTS idx_post_embeds_video ON post_embeds(provider, video_id); "#; +/// Columns appended to `users` by schema version 4. The order is part of the +/// schema contract: fresh baselines and migrated databases must match it. +const USER_COLUMNS_ADDED_IN_V4: &[(&str, &str)] = &[ + ("follow_approval_required", "INTEGER NOT NULL DEFAULT 0"), + ("must_change_password", "INTEGER NOT NULL DEFAULT 0"), + ("deletion_requested_at", "TEXT"), + ("deletion_scheduled_at", "TEXT"), +]; + +/// Tables and indexes introduced with schema version 4. Applied on top of a +/// version 3 database; every statement is idempotent. +const RELEASE_V4_SCHEMA: &str = r#" +CREATE TABLE IF NOT EXISTS follow_requests ( + requester_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + target_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (requester_id, target_id) +); + +CREATE INDEX IF NOT EXISTS idx_follow_requests_target ON follow_requests(target_id, requester_id); + +CREATE TABLE IF NOT EXISTS username_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + username TEXT NOT NULL, + normalized_username TEXT NOT NULL, + changed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (user_id, normalized_username) +); + +CREATE INDEX IF NOT EXISTS idx_username_history_normalized ON username_history(normalized_username); + +CREATE TABLE IF NOT EXISTS instance_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS account_imports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + archive_id TEXT NOT NULL, + format_version INTEGER NOT NULL, + imported_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + posts_imported INTEGER NOT NULL DEFAULT 0, + media_imported INTEGER NOT NULL DEFAULT 0, + follows_imported INTEGER NOT NULL DEFAULT 0, + follows_pending INTEGER NOT NULL DEFAULT 0, + follows_skipped INTEGER NOT NULL DEFAULT 0, + UNIQUE (user_id, archive_id) +); + +CREATE INDEX IF NOT EXISTS idx_account_imports_user ON account_imports(user_id, imported_at DESC); +"#; + const BASELINE_SCHEMA: &str = r#" CREATE TABLE schema_migrations ( version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); -INSERT INTO schema_migrations (version) VALUES (3); +INSERT INTO schema_migrations (version) VALUES (4); CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -751,7 +870,11 @@ CREATE TABLE users ( location TEXT NOT NULL DEFAULT '', nsfw_blur_enabled INTEGER NOT NULL DEFAULT 1, onboarding_completed_at TEXT, - liked_posts_public INTEGER NOT NULL DEFAULT 1 CHECK (liked_posts_public IN (0, 1)) + liked_posts_public INTEGER NOT NULL DEFAULT 1 CHECK (liked_posts_public IN (0, 1)), + follow_approval_required INTEGER NOT NULL DEFAULT 0, + must_change_password INTEGER NOT NULL DEFAULT 0, + deletion_requested_at TEXT, + deletion_scheduled_at TEXT ); CREATE TABLE sessions ( @@ -1021,6 +1144,48 @@ BEGIN THEN RAISE(ABORT, 'media canonical reference must point to same-kind canonical media') END; END; + +CREATE TABLE follow_requests ( + requester_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + target_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (requester_id, target_id) +); + +CREATE INDEX idx_follow_requests_target ON follow_requests(target_id, requester_id); + +CREATE TABLE username_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + username TEXT NOT NULL, + normalized_username TEXT NOT NULL, + changed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (user_id, normalized_username) +); + +CREATE INDEX idx_username_history_normalized ON username_history(normalized_username); + +CREATE TABLE instance_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE account_imports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + archive_id TEXT NOT NULL, + format_version INTEGER NOT NULL, + imported_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + posts_imported INTEGER NOT NULL DEFAULT 0, + media_imported INTEGER NOT NULL DEFAULT 0, + follows_imported INTEGER NOT NULL DEFAULT 0, + follows_pending INTEGER NOT NULL DEFAULT 0, + follows_skipped INTEGER NOT NULL DEFAULT 0, + UNIQUE (user_id, archive_id) +); + +CREATE INDEX idx_account_imports_user ON account_imports(user_id, imported_at DESC); "#; const REQUIRED_TABLES: &[&str] = &[ @@ -1044,6 +1209,10 @@ const REQUIRED_TABLES: &[&str] = &[ "reports", "posts_fts", "muted_words", + "follow_requests", + "username_history", + "instance_settings", + "account_imports", ]; const FTS_SHADOW_TABLES: &[&str] = &[ @@ -1200,6 +1369,21 @@ const REQUIRED_INDEXES: &[RequiredIndex] = &[ name: "idx_media_unique_canonical_normalized_sha256", unique: true, }, + RequiredIndex { + table: "follow_requests", + name: "idx_follow_requests_target", + unique: false, + }, + RequiredIndex { + table: "username_history", + name: "idx_username_history_normalized", + unique: false, + }, + RequiredIndex { + table: "account_imports", + name: "idx_account_imports_user", + unique: false, + }, ]; const REQUIRED_TRIGGERS: &[RequiredTrigger] = &[ @@ -1402,6 +1586,38 @@ const REQUIRED_COLUMNS: &[RequiredColumn] = &[ default_value: Some("1"), primary_key_position: 0, }, + RequiredColumn { + table: "users", + name: "follow_approval_required", + type_name: "INTEGER", + not_null: true, + default_value: Some("0"), + primary_key_position: 0, + }, + RequiredColumn { + table: "users", + name: "must_change_password", + type_name: "INTEGER", + not_null: true, + default_value: Some("0"), + primary_key_position: 0, + }, + RequiredColumn { + table: "users", + name: "deletion_requested_at", + type_name: "TEXT", + not_null: false, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "users", + name: "deletion_scheduled_at", + type_name: "TEXT", + not_null: false, + default_value: None, + primary_key_position: 0, + }, RequiredColumn { table: "sessions", name: "id", @@ -2282,6 +2498,174 @@ const REQUIRED_COLUMNS: &[RequiredColumn] = &[ default_value: Some("CURRENT_TIMESTAMP"), primary_key_position: 0, }, + RequiredColumn { + table: "follow_requests", + name: "requester_id", + type_name: "INTEGER", + not_null: true, + default_value: None, + primary_key_position: 1, + }, + RequiredColumn { + table: "follow_requests", + name: "target_id", + type_name: "INTEGER", + not_null: true, + default_value: None, + primary_key_position: 2, + }, + RequiredColumn { + table: "follow_requests", + name: "created_at", + type_name: "TEXT", + not_null: true, + default_value: Some("CURRENT_TIMESTAMP"), + primary_key_position: 0, + }, + RequiredColumn { + table: "username_history", + name: "id", + type_name: "INTEGER", + not_null: false, + default_value: None, + primary_key_position: 1, + }, + RequiredColumn { + table: "username_history", + name: "user_id", + type_name: "INTEGER", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "username_history", + name: "username", + type_name: "TEXT", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "username_history", + name: "normalized_username", + type_name: "TEXT", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "username_history", + name: "changed_at", + type_name: "TEXT", + not_null: true, + default_value: Some("CURRENT_TIMESTAMP"), + primary_key_position: 0, + }, + RequiredColumn { + table: "instance_settings", + name: "key", + type_name: "TEXT", + not_null: false, + default_value: None, + primary_key_position: 1, + }, + RequiredColumn { + table: "instance_settings", + name: "value", + type_name: "TEXT", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "instance_settings", + name: "updated_at", + type_name: "TEXT", + not_null: true, + default_value: Some("CURRENT_TIMESTAMP"), + primary_key_position: 0, + }, + RequiredColumn { + table: "account_imports", + name: "id", + type_name: "INTEGER", + not_null: false, + default_value: None, + primary_key_position: 1, + }, + RequiredColumn { + table: "account_imports", + name: "user_id", + type_name: "INTEGER", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "account_imports", + name: "archive_id", + type_name: "TEXT", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "account_imports", + name: "format_version", + type_name: "INTEGER", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "account_imports", + name: "imported_at", + type_name: "TEXT", + not_null: true, + default_value: Some("CURRENT_TIMESTAMP"), + primary_key_position: 0, + }, + RequiredColumn { + table: "account_imports", + name: "posts_imported", + type_name: "INTEGER", + not_null: true, + default_value: Some("0"), + primary_key_position: 0, + }, + RequiredColumn { + table: "account_imports", + name: "media_imported", + type_name: "INTEGER", + not_null: true, + default_value: Some("0"), + primary_key_position: 0, + }, + RequiredColumn { + table: "account_imports", + name: "follows_imported", + type_name: "INTEGER", + not_null: true, + default_value: Some("0"), + primary_key_position: 0, + }, + RequiredColumn { + table: "account_imports", + name: "follows_pending", + type_name: "INTEGER", + not_null: true, + default_value: Some("0"), + primary_key_position: 0, + }, + RequiredColumn { + table: "account_imports", + name: "follows_skipped", + type_name: "INTEGER", + not_null: true, + default_value: Some("0"), + primary_key_position: 0, + }, ]; #[cfg(test)] @@ -2339,35 +2723,418 @@ mod tests { assert!(busy_timeout >= 5_000); } - #[tokio::test] - async fn canonical_media_constraints_reject_unsafe_references() { + #[test] + fn fresh_database_has_v4_user_lifecycle_columns_with_defaults() { let temp = tempfile::tempdir().expect("temp dir"); - let pool = connect(&temp.path().join("test.sqlite3")) - .await - .expect("connect"); - migrate(&pool).await.expect("migrate"); + let conn = Connection::open(temp.path().join("fresh.sqlite3")).expect("open"); + conn.pragma_update(None, "foreign_keys", "ON") + .expect("foreign keys"); + initialize_release_baseline(&conn).expect("baseline"); - 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()); + assert_eq!( + schema_version_from_connection(&conn).expect("schema version"), + CURRENT_SCHEMA_VERSION + ); - 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)", - [], + for (column, type_name, not_null, default_value) in [ + ("follow_approval_required", "INTEGER", true, Some("0")), + ("must_change_password", "INTEGER", true, Some("0")), + ("deletion_requested_at", "TEXT", false, None), + ("deletion_scheduled_at", "TEXT", false, None), + ] { + let actual = users_column(&conn, column); + assert_eq!(actual.type_name, type_name, "users.{column} type"); + assert_eq!(actual.not_null, not_null, "users.{column} nullability"); + assert_eq!( + actual.default_value.as_deref(), + default_value, + "users.{column} default" ); - assert!(cross_kind.is_err()); + assert_eq!(actual.primary_key_position, 0, "users.{column} primary key"); + } - 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)", + conn.execute( + "INSERT INTO users (id, username, normalized_username, password_hash, display_name) VALUES (1, 'Alice', 'alice', 'hash', 'Alice')", + [], + ) + .expect("user"); + let defaults: (i64, i64, Option, Option) = conn + .query_row( + "SELECT follow_approval_required, must_change_password, deletion_requested_at, deletion_scheduled_at FROM users WHERE id = 1", [], - ); + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .expect("defaults"); + assert_eq!(defaults, (0, 0, None, None)); + assert!( + inspect_schema(&conn).expect("schema").is_compatible(), + "fresh baseline must satisfy its own schema requirements" + ); + } + + #[test] + fn fresh_database_has_v4_follow_tables_with_expected_columns() { + let temp = tempfile::tempdir().expect("temp dir"); + let conn = Connection::open(temp.path().join("fresh.sqlite3")).expect("open"); + initialize_release_baseline(&conn).expect("baseline"); + + for table in ["follow_requests", "username_history"] { + assert!( + table_exists(&conn, table).expect("table lookup"), + "missing table {table}" + ); + } + + assert_eq!( + column_shapes(&conn, "follow_requests"), + vec![ + ( + "requester_id".to_owned(), + "INTEGER".to_owned(), + true, + None, + 1 + ), + ("target_id".to_owned(), "INTEGER".to_owned(), true, None, 2), + ( + "created_at".to_owned(), + "TEXT".to_owned(), + true, + Some("CURRENT_TIMESTAMP".to_owned()), + 0 + ), + ] + ); + assert_eq!( + column_shapes(&conn, "username_history"), + vec![ + ("id".to_owned(), "INTEGER".to_owned(), false, None, 1), + ("user_id".to_owned(), "INTEGER".to_owned(), true, None, 0), + ("username".to_owned(), "TEXT".to_owned(), true, None, 0), + ( + "normalized_username".to_owned(), + "TEXT".to_owned(), + true, + None, + 0 + ), + ( + "changed_at".to_owned(), + "TEXT".to_owned(), + true, + Some("CURRENT_TIMESTAMP".to_owned()), + 0 + ), + ] + ); + } + + #[test] + fn fresh_database_has_v4_instance_tables_with_expected_columns() { + let temp = tempfile::tempdir().expect("temp dir"); + let conn = Connection::open(temp.path().join("fresh.sqlite3")).expect("open"); + initialize_release_baseline(&conn).expect("baseline"); + + for table in ["instance_settings", "account_imports"] { + assert!( + table_exists(&conn, table).expect("table lookup"), + "missing table {table}" + ); + } + + assert_eq!( + column_shapes(&conn, "instance_settings"), + vec![ + ("key".to_owned(), "TEXT".to_owned(), false, None, 1), + ("value".to_owned(), "TEXT".to_owned(), true, None, 0), + ( + "updated_at".to_owned(), + "TEXT".to_owned(), + true, + Some("CURRENT_TIMESTAMP".to_owned()), + 0 + ), + ] + ); + assert_eq!( + column_shapes(&conn, "account_imports"), + vec![ + ("id".to_owned(), "INTEGER".to_owned(), false, None, 1), + ("user_id".to_owned(), "INTEGER".to_owned(), true, None, 0), + ("archive_id".to_owned(), "TEXT".to_owned(), true, None, 0), + ( + "format_version".to_owned(), + "INTEGER".to_owned(), + true, + None, + 0 + ), + ( + "imported_at".to_owned(), + "TEXT".to_owned(), + true, + Some("CURRENT_TIMESTAMP".to_owned()), + 0 + ), + ( + "posts_imported".to_owned(), + "INTEGER".to_owned(), + true, + Some("0".to_owned()), + 0 + ), + ( + "media_imported".to_owned(), + "INTEGER".to_owned(), + true, + Some("0".to_owned()), + 0 + ), + ( + "follows_imported".to_owned(), + "INTEGER".to_owned(), + true, + Some("0".to_owned()), + 0 + ), + ( + "follows_pending".to_owned(), + "INTEGER".to_owned(), + true, + Some("0".to_owned()), + 0 + ), + ( + "follows_skipped".to_owned(), + "INTEGER".to_owned(), + true, + Some("0".to_owned()), + 0 + ), + ] + ); + } + + #[test] + fn fresh_database_enforces_v4_unique_constraints_indexes_and_foreign_keys() { + let temp = tempfile::tempdir().expect("temp dir"); + let conn = Connection::open(temp.path().join("fresh.sqlite3")).expect("open"); + initialize_release_baseline(&conn).expect("baseline"); + + assert_eq!( + unique_index_column_sets(&conn, "follow_requests"), + vec![vec!["requester_id".to_owned(), "target_id".to_owned()]] + ); + assert!( + unique_index_column_sets(&conn, "username_history").contains(&vec![ + "user_id".to_owned(), + "normalized_username".to_owned() + ]), + "username_history must keep UNIQUE(user_id, normalized_username)" + ); + assert!( + unique_index_column_sets(&conn, "account_imports") + .contains(&vec!["user_id".to_owned(), "archive_id".to_owned()]), + "account_imports must keep UNIQUE(user_id, archive_id)" + ); + + for (table, name) in [ + ("follow_requests", "idx_follow_requests_target"), + ("username_history", "idx_username_history_normalized"), + ("account_imports", "idx_account_imports_user"), + ] { + assert!( + table_indexes(&conn, table) + .expect("indexes") + .iter() + .any(|(index, unique)| index == name && !unique), + "missing index {name}" + ); + } + assert_eq!( + index_columns(&conn, "idx_follow_requests_target"), + vec!["target_id".to_owned(), "requester_id".to_owned()] + ); + assert_eq!( + index_columns(&conn, "idx_username_history_normalized"), + vec!["normalized_username".to_owned()] + ); + assert_eq!( + index_columns(&conn, "idx_account_imports_user"), + vec!["user_id".to_owned(), "imported_at".to_owned()] + ); + + assert_eq!( + foreign_key_actions(&conn, "follow_requests"), + vec![ + ( + "users".to_owned(), + "requester_id".to_owned(), + "CASCADE".to_owned() + ), + ( + "users".to_owned(), + "target_id".to_owned(), + "CASCADE".to_owned() + ), + ] + ); + assert_eq!( + foreign_key_actions(&conn, "username_history"), + vec![( + "users".to_owned(), + "user_id".to_owned(), + "CASCADE".to_owned() + )] + ); + assert_eq!( + foreign_key_actions(&conn, "account_imports"), + vec![( + "users".to_owned(), + "user_id".to_owned(), + "CASCADE".to_owned() + )] + ); + } + + #[test] + fn fresh_database_v4_rows_cascade_when_a_user_is_deleted() { + let temp = tempfile::tempdir().expect("temp dir"); + let conn = Connection::open(temp.path().join("fresh.sqlite3")).expect("open"); + conn.pragma_update(None, "foreign_keys", "ON") + .expect("foreign keys"); + initialize_release_baseline(&conn).expect("baseline"); + conn.execute_batch( + r#" + INSERT INTO users (id, username, normalized_username, password_hash, display_name) + VALUES (1, 'Alice', 'alice', 'hash', 'Alice'); + INSERT INTO users (id, username, normalized_username, password_hash, display_name) + VALUES (2, 'Bob', 'bob', 'hash', 'Bob'); + INSERT INTO follow_requests (requester_id, target_id) VALUES (1, 2); + INSERT INTO username_history (user_id, username, normalized_username) VALUES (2, 'Bobby', 'bobby'); + INSERT INTO account_imports (user_id, archive_id, format_version) VALUES (2, 'archive-1', 1); + "#, + ) + .expect("seed rows"); + + conn.execute("DELETE FROM users WHERE id = 2", []) + .expect("delete user"); + + assert_eq!( + table_count(&conn, "follow_requests").expect("follow requests"), + 0 + ); + assert_eq!( + table_count(&conn, "username_history").expect("username history"), + 0 + ); + assert_eq!( + table_count(&conn, "account_imports").expect("account imports"), + 0 + ); + } + + #[test] + fn fresh_database_rejects_duplicate_v4_rows() { + let temp = tempfile::tempdir().expect("temp dir"); + let conn = Connection::open(temp.path().join("fresh.sqlite3")).expect("open"); + initialize_release_baseline(&conn).expect("baseline"); + conn.execute_batch( + r#" + INSERT INTO users (id, username, normalized_username, password_hash, display_name) + VALUES (1, 'Alice', 'alice', 'hash', 'Alice'); + INSERT INTO users (id, username, normalized_username, password_hash, display_name) + VALUES (2, 'Bob', 'bob', 'hash', 'Bob'); + "#, + ) + .expect("users"); + + conn.execute( + "INSERT INTO follow_requests (requester_id, target_id) VALUES (1, 2)", + [], + ) + .expect("first follow request"); + assert!( + conn.execute( + "INSERT INTO follow_requests (requester_id, target_id) VALUES (1, 2)", + [], + ) + .is_err(), + "duplicate follow requests must be rejected" + ); + + conn.execute( + "INSERT INTO username_history (user_id, username, normalized_username) VALUES (1, 'OldAlice', 'oldalice')", + [], + ) + .expect("first history row"); + assert!( + conn.execute( + "INSERT INTO username_history (user_id, username, normalized_username) VALUES (1, 'OlderAlice', 'oldalice')", + [], + ) + .is_err(), + "duplicate username history rows for one account must be rejected" + ); + + conn.execute( + "INSERT INTO account_imports (user_id, archive_id, format_version) VALUES (1, 'archive-1', 1)", + [], + ) + .expect("first import"); + assert!( + conn.execute( + "INSERT INTO account_imports (user_id, archive_id, format_version) VALUES (1, 'archive-1', 2)", + [], + ) + .is_err(), + "duplicate account imports for one archive must be rejected" + ); + + conn.execute( + "INSERT INTO instance_settings (key, value) VALUES ('announcement', 'hi')", + [], + ) + .expect("first instance setting"); + assert!( + conn.execute( + "INSERT INTO instance_settings (key, value) VALUES ('announcement', 'again')", + [], + ) + .is_err(), + "duplicate instance setting keys must be rejected" + ); + } + + #[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(()) }) @@ -2422,30 +3189,30 @@ mod tests { .await .expect("connect"); pool.call(|conn| { - initialize_release_baseline(conn)?; - conn.execute("DROP TABLE post_embeds", [])?; - reset_schema_migrations_to(conn, RELEASE_V1_SCHEMA_VERSION)?; - Ok(()) + install_release_v1_fixture(conn)?; + insert_legacy_migration_fixture(conn) }) .await .expect("version 1 fixture"); + let legacy_before = pool + .call(|conn| legacy_rows(conn)) + .await + .expect("legacy rows"); - migrate(&pool).await.expect("migrate version 1"); + assert_release_upgrade_preserves_data(&pool, legacy_before).await; - let (embed_count, version): (i64, i64) = pool + let (embed_count, version) = pool .call(|conn| { Ok(( - conn.query_row("SELECT COUNT(*) FROM post_embeds", [], |row| row.get(0))?, + table_count(conn, "post_embeds")?, schema_version_from_connection(conn)?, )) }) .await .expect("post embeds"); - let report = schema_report(&pool).await.expect("schema report"); assert_eq!(embed_count, 0); assert_eq!(version, CURRENT_SCHEMA_VERSION); - assert!(report.is_compatible(), "{}", report.summary()); } #[tokio::test] @@ -2455,46 +3222,230 @@ mod tests { .await .expect("connect"); pool.call(|conn| { - initialize_release_baseline(conn)?; - conn.execute( - "INSERT INTO users (username, normalized_username, password_hash, display_name) VALUES ('Alice', 'alice', 'hash', 'Alice')", - [], + install_release_v2_fixture(conn)?; + insert_legacy_migration_fixture(conn) + }) + .await + .expect("version 2 fixture"); + let legacy_before = pool + .call(|conn| legacy_rows(conn)) + .await + .expect("legacy rows"); + + assert_release_upgrade_preserves_data(&pool, legacy_before).await; + + let root_index = pool + .call(|conn| { + Ok(table_indexes(conn, "posts")? + .iter() + .any(|(name, _unique)| name == "idx_posts_root")) + }) + .await + .expect("release index"); + + assert!(root_index, "version 3 migration must add idx_posts_root"); + } + + #[tokio::test] + async fn release_version_3_databases_gain_account_lifecycle_state_without_data_loss() { + let temp = tempfile::tempdir().expect("temp dir"); + let pool = connect(&temp.path().join("test.sqlite3")) + .await + .expect("connect"); + pool.call(|conn| { + install_release_v3_fixture(conn)?; + insert_legacy_migration_fixture(conn) + }) + .await + .expect("version 3 fixture"); + let legacy_before = pool + .call(|conn| legacy_rows(conn)) + .await + .expect("legacy rows"); + + assert_release_upgrade_preserves_data(&pool, legacy_before).await; + } + + #[tokio::test] + async fn release_version_3_wrong_type_v4_column_is_rejected_without_data_loss() { + let temp = tempfile::tempdir().expect("temp dir"); + let pool = connect(&temp.path().join("test.sqlite3")) + .await + .expect("connect"); + pool.call(|conn| { + install_release_v3_fixture(conn)?; + insert_legacy_migration_fixture(conn)?; + conn.execute_batch( + r#" + ALTER TABLE users ADD COLUMN follow_approval_required TEXT; + "#, )?; - for index in [ - "idx_posts_root", - "idx_media_owner", - "idx_sessions_user", - "idx_post_media_media", - "idx_rate_limit_created", - ] { - conn.execute(&format!("DROP INDEX {index}"), [])?; + Ok(()) + }) + .await + .expect("wrong-typed version 3 fixture"); + let legacy_before = pool + .call(|conn| legacy_rows(conn)) + .await + .expect("legacy rows"); + + let error = migrate(&pool) + .await + .expect_err("wrong column type must be rejected"); + let message = format!("{error:#}"); + let (version, legacy_after, v4_tables) = pool + .call(|conn| { + Ok(( + schema_version_from_connection(conn)?, + legacy_rows(conn)?, + existing_v4_tables(conn), + )) + }) + .await + .expect("state after rejected migration"); + + assert!( + message.contains("users.follow_approval_required"), + "unexpected error: {message}" + ); + assert!( + message.contains("expected INTEGER"), + "unexpected error: {message}" + ); + assert_eq!(version, RELEASE_V3_SCHEMA_VERSION); + assert_eq!(legacy_after, legacy_before); + assert!(v4_tables.is_empty(), "rolled back tables: {v4_tables:?}"); + } + + #[tokio::test] + async fn release_version_3_partial_v4_table_is_rejected_without_data_loss() { + let temp = tempfile::tempdir().expect("temp dir"); + let pool = connect(&temp.path().join("test.sqlite3")) + .await + .expect("connect"); + pool.call(|conn| { + install_release_v3_fixture(conn)?; + insert_legacy_migration_fixture(conn)?; + conn.execute_batch( + r#" + CREATE TABLE username_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + username TEXT NOT NULL, + normalized_username TEXT NOT NULL + ); + "#, + )?; + Ok(()) + }) + .await + .expect("partial version 3 fixture"); + let legacy_before = pool + .call(|conn| legacy_rows(conn)) + .await + .expect("legacy rows"); + + let error = migrate(&pool) + .await + .expect_err("partial v4 table must be rejected"); + let message = format!("{error:#}"); + let (version, legacy_after, user_columns, follow_requests, history_rows) = pool + .call(|conn| { + Ok(( + schema_version_from_connection(conn)?, + legacy_rows(conn)?, + table_columns(conn, "users")? + .into_iter() + .map(|column| column.name) + .collect::>(), + table_exists(conn, "follow_requests")?, + table_count(conn, "username_history")?, + )) + }) + .await + .expect("state after rejected migration"); + + assert!( + message.contains("table username_history columns differ"), + "unexpected error: {message}" + ); + assert!( + message.contains("changed_at"), + "unexpected error: {message}" + ); + assert_eq!(version, RELEASE_V3_SCHEMA_VERSION); + assert_eq!(legacy_after, legacy_before); + assert!( + !user_columns + .iter() + .any(|column| column == "follow_approval_required"), + "v4 user columns must be rolled back: {user_columns:?}" + ); + assert!( + !follow_requests, + "v4 tables created during the failed migration must be rolled back" + ); + assert_eq!(history_rows, 0); + } + + #[tokio::test] + async fn alpha_schema_missing_v4_account_lifecycle_state_is_adopted_without_data_loss() { + let temp = tempfile::tempdir().expect("temp dir"); + let pool = connect(&temp.path().join("test.sqlite3")) + .await + .expect("connect"); + pool.call(|conn| { + install_latest_alpha_fixture(conn)?; + insert_preservation_fixture(conn)?; + for (column, _definition) in USER_COLUMNS_ADDED_IN_V4 { + conn.execute_batch(&format!( + "ALTER TABLE users DROP COLUMN {};", + quoted_identifier(column) + ))?; } - reset_schema_migrations_to(conn, RELEASE_V2_SCHEMA_VERSION)?; + conn.execute_batch( + r#" + DROP TABLE follow_requests; + DROP TABLE username_history; + DROP TABLE instance_settings; + DROP TABLE account_imports; + "#, + )?; Ok(()) }) .await - .expect("version 2 fixture"); + .expect("alpha fixture without v4 account lifecycle state"); - migrate(&pool).await.expect("migrate version 2"); + migrate(&pool).await.expect("adopt alpha schema"); - let (version, users, root_index) = pool + let (version, presence, users, posts, media, defaults) = pool .call(|conn| { Ok(( schema_version_from_connection(conn)?, - conn.query_row("SELECT COUNT(*) FROM users", [], |row| row.get::<_, i64>(0))?, - table_indexes(conn, "posts")? - .iter() - .any(|(name, _unique)| name == "idx_posts_root"), + v4_presence(conn)?, + table_count(conn, "users")?, + table_count(conn, "posts")?, + table_count(conn, "media")?, + conn.query_row( + "SELECT follow_approval_required, must_change_password FROM users WHERE id = 1", + [], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)), + )?, )) }) .await - .expect("post-migration state"); - let report = schema_report(&pool).await.expect("schema report"); + .expect("adopted state"); assert_eq!(version, CURRENT_SCHEMA_VERSION); - assert_eq!(users, 1); - assert!(root_index); - assert!(report.is_compatible(), "{}", report.summary()); + assert_eq!(presence.tables.len(), 4); + assert!(presence.user_columns.ends_with(&[ + "follow_approval_required".to_owned(), + "must_change_password".to_owned(), + "deletion_requested_at".to_owned(), + "deletion_scheduled_at".to_owned(), + ])); + assert_eq!((users, posts, media), (2, 2, 1)); + assert_eq!(defaults, (0, 0)); } #[tokio::test] @@ -2907,6 +3858,407 @@ mod tests { assert_eq!(social_count, 5); } + fn users_column(conn: &Connection, name: &str) -> ColumnInfo { + table_columns(conn, "users") + .expect("users columns") + .into_iter() + .find(|column| column.name == name) + .unwrap_or_else(|| panic!("missing users.{name}")) + } + + fn column_shapes( + conn: &Connection, + table: &str, + ) -> Vec<(String, String, bool, Option, i64)> { + table_columns(conn, table) + .expect("table columns") + .into_iter() + .map(|column| { + ( + column.name, + column.type_name, + column.not_null, + column.default_value, + column.primary_key_position, + ) + }) + .collect() + } + + fn unique_index_column_sets(conn: &Connection, table: &str) -> Vec> { + let mut sets = Vec::new(); + for (name, unique) in table_indexes(conn, table).expect("indexes") { + if unique { + sets.push(index_columns(conn, &name)); + } + } + sets + } + + fn index_columns(conn: &Connection, name: &str) -> Vec { + let sql = format!("PRAGMA index_info({})", quoted_identifier(name)); + let mut stmt = conn.prepare(&sql).expect("index info"); + stmt.query_map([], |row| row.get::<_, String>(2)) + .expect("index columns") + .collect::>>() + .expect("index column rows") + } + + fn foreign_key_actions(conn: &Connection, table: &str) -> Vec<(String, String, String)> { + let sql = format!("PRAGMA foreign_key_list({})", quoted_identifier(table)); + let mut stmt = conn.prepare(&sql).expect("foreign key list"); + let mut actions = stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(6)?, + )) + }) + .expect("foreign keys") + .collect::>>() + .expect("foreign key rows"); + actions.sort(); + actions + } + + fn install_release_v3_fixture(conn: &Connection) -> anyhow::Result<()> { + initialize_release_baseline(conn)?; + for (column, _definition) in USER_COLUMNS_ADDED_IN_V4 { + conn.execute_batch(&format!( + "ALTER TABLE users DROP COLUMN {};", + quoted_identifier(column) + ))?; + } + conn.execute_batch( + r#" + DROP TABLE follow_requests; + DROP TABLE username_history; + DROP TABLE instance_settings; + DROP TABLE account_imports; + "#, + )?; + reset_schema_migrations_to(conn, RELEASE_V3_SCHEMA_VERSION) + } + + fn install_release_v2_fixture(conn: &Connection) -> anyhow::Result<()> { + install_release_v3_fixture(conn)?; + for index in [ + "idx_posts_root", + "idx_media_owner", + "idx_sessions_user", + "idx_post_media_media", + "idx_rate_limit_created", + ] { + conn.execute(&format!("DROP INDEX {index}"), [])?; + } + reset_schema_migrations_to(conn, RELEASE_V2_SCHEMA_VERSION) + } + + fn install_release_v1_fixture(conn: &Connection) -> anyhow::Result<()> { + install_release_v2_fixture(conn)?; + conn.execute_batch("DROP TABLE post_embeds;")?; + reset_schema_migrations_to(conn, RELEASE_V1_SCHEMA_VERSION) + } + + fn insert_legacy_migration_fixture(conn: &Connection) -> anyhow::Result<()> { + conn.execute_batch( + r#" + INSERT INTO users (id, username, normalized_username, password_hash, display_name, created_at, updated_at) + VALUES (1, 'Alice', 'alice', 'hash', 'Alice', '2024-01-01T00:00:00Z', '2024-01-01T00:00:00Z'); + INSERT INTO users (id, username, normalized_username, password_hash, display_name, created_at, updated_at) + VALUES (2, 'Bob', 'bob', 'hash', 'Bob', '2024-01-02T00:00:00Z', '2024-01-02T00:00:00Z'); + INSERT INTO posts (id, user_id, text, created_at) + VALUES (1, 1, 'alpha post', '2024-01-03T00:00:00Z'); + INSERT INTO posts (id, user_id, text, created_at) + VALUES (2, 2, 'beta post', '2024-01-04T00:00:00Z'); + INSERT INTO media (id, owner_user_id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len, original_sha256) + VALUES (1, 1, 'alpha.png', '/data/uploads/images/alpha.webp', '/uploads/images/alpha.webp', 'image/webp', 'image', 42, 'legacy-sha'); + "#, + )?; + Ok(()) + } + + fn insert_v4_account_lifecycle_fixture(conn: &Connection) -> anyhow::Result<()> { + conn.execute_batch( + r#" + UPDATE users SET + follow_approval_required = 1, + must_change_password = 1, + deletion_requested_at = '2026-01-01T00:00:00Z', + deletion_scheduled_at = '2026-02-01T00:00:00Z' + WHERE id = 1; + INSERT INTO follow_requests (requester_id, target_id, created_at) + VALUES (1, 2, '2026-01-02T00:00:00Z'); + INSERT INTO username_history (user_id, username, normalized_username, changed_at) + VALUES (1, 'OldAlice', 'oldalice', '2026-01-03T00:00:00Z'); + INSERT INTO username_history (user_id, username, normalized_username, changed_at) + VALUES (1, 'OlderAlice', 'olderalice', '2026-01-04T00:00:00Z'); + INSERT INTO instance_settings (key, value, updated_at) + VALUES ('announcement', 'hello', '2026-01-05T00:00:00Z'); + INSERT INTO instance_settings (key, value, updated_at) + VALUES ('maintenance_mode', '1', '2026-01-06T00:00:00Z'); + INSERT INTO account_imports ( + user_id, archive_id, format_version, imported_at, + posts_imported, media_imported, follows_imported, follows_pending, follows_skipped + ) + VALUES (1, 'archive-1', 2, '2026-01-07T00:00:00Z', 3, 4, 5, 6, 7); + "#, + )?; + Ok(()) + } + + #[derive(Debug, PartialEq, Eq)] + struct LegacyRows { + users: Vec<(i64, String, String, String)>, + posts: Vec<(i64, String, String)>, + media: Vec<(i64, String, String)>, + } + + fn legacy_rows(conn: &Connection) -> anyhow::Result { + let mut users_stmt = + conn.prepare("SELECT id, username, display_name, created_at FROM users ORDER BY id")?; + let users = users_stmt + .query_map([], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) + })? + .collect::>>()?; + let mut posts_stmt = conn.prepare("SELECT id, text, created_at FROM posts ORDER BY id")?; + let posts = posts_stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))? + .collect::>>()?; + let mut media_stmt = + conn.prepare("SELECT id, stored_path, original_sha256 FROM media ORDER BY id")?; + let media = media_stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))? + .collect::>>()?; + Ok(LegacyRows { + users, + posts, + media, + }) + } + + type UserLifecycleRow = (i64, i64, i64, Option, Option); + type AccountImportRow = (i64, String, i64, String, i64, i64, i64, i64, i64); + + #[derive(Debug, PartialEq, Eq)] + struct AccountLifecycleRows { + users: Vec, + follow_requests: Vec<(i64, i64, String)>, + username_history: Vec<(i64, String, String, String)>, + instance_settings: Vec<(String, String, String)>, + account_imports: Vec, + } + + fn account_lifecycle_rows(conn: &Connection) -> anyhow::Result { + let mut users_stmt = conn.prepare( + "SELECT id, follow_approval_required, must_change_password, deletion_requested_at, deletion_scheduled_at FROM users ORDER BY id", + )?; + let users = users_stmt + .query_map([], |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + )) + })? + .collect::>>()?; + let mut follow_stmt = conn.prepare( + "SELECT requester_id, target_id, created_at FROM follow_requests ORDER BY requester_id, target_id", + )?; + let follow_requests = follow_stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))? + .collect::>>()?; + let mut history_stmt = conn.prepare( + "SELECT user_id, username, normalized_username, changed_at FROM username_history ORDER BY user_id, normalized_username", + )?; + let username_history = history_stmt + .query_map([], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) + })? + .collect::>>()?; + let mut settings_stmt = + conn.prepare("SELECT key, value, updated_at FROM instance_settings ORDER BY key")?; + let instance_settings = settings_stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))? + .collect::>>()?; + let mut imports_stmt = conn.prepare( + "SELECT user_id, archive_id, format_version, imported_at, posts_imported, media_imported, follows_imported, follows_pending, follows_skipped FROM account_imports ORDER BY id", + )?; + let account_imports = imports_stmt + .query_map([], |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + row.get(7)?, + row.get(8)?, + )) + })? + .collect::>>()?; + Ok(AccountLifecycleRows { + users, + follow_requests, + username_history, + instance_settings, + account_imports, + }) + } + + #[derive(Debug, PartialEq, Eq)] + struct V4Presence { + tables: Vec<&'static str>, + user_columns: Vec, + indexes: Vec<&'static str>, + } + + fn v4_presence(conn: &Connection) -> anyhow::Result { + let mut tables = Vec::new(); + for table in [ + "follow_requests", + "username_history", + "instance_settings", + "account_imports", + ] { + if table_exists(conn, table)? { + tables.push(table); + } + } + let user_columns = table_columns(conn, "users")? + .into_iter() + .map(|column| column.name) + .collect(); + let mut indexes = Vec::new(); + for (table, name) in [ + ("follow_requests", "idx_follow_requests_target"), + ("username_history", "idx_username_history_normalized"), + ("account_imports", "idx_account_imports_user"), + ] { + if table_indexes(conn, table)? + .iter() + .any(|(index, unique)| index == name && !unique) + { + indexes.push(name); + } + } + Ok(V4Presence { + tables, + user_columns, + indexes, + }) + } + + fn existing_v4_tables(conn: &Connection) -> Vec<&'static str> { + let mut tables = Vec::new(); + for table in [ + "follow_requests", + "username_history", + "instance_settings", + "account_imports", + ] { + if table_exists(conn, table).expect("table lookup") { + tables.push(table); + } + } + tables + } + + async fn assert_release_upgrade_preserves_data(pool: &SqlitePool, legacy_before: LegacyRows) { + migrate(pool) + .await + .expect("migrate historical release schema"); + + let (issues, version, presence, defaults, legacy_after) = pool + .call(|conn| { + Ok(( + inspect_schema(conn)?.issues().to_vec(), + schema_version_from_connection(conn)?, + v4_presence(conn)?, + conn.query_row( + "SELECT follow_approval_required, must_change_password, deletion_requested_at, deletion_scheduled_at FROM users WHERE id = 1", + [], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + )) + }, + )?, + legacy_rows(conn)?, + )) + }) + .await + .expect("post-migration state"); + + assert!(issues.is_empty(), "{}", issues.join("; ")); + assert_eq!(version, CURRENT_SCHEMA_VERSION); + assert_eq!( + presence.tables, + vec![ + "follow_requests", + "username_history", + "instance_settings", + "account_imports" + ] + ); + assert!( + presence.user_columns.ends_with(&[ + "follow_approval_required".to_owned(), + "must_change_password".to_owned(), + "deletion_requested_at".to_owned(), + "deletion_scheduled_at".to_owned(), + ]), + "missing v4 users columns: {:?}", + presence.user_columns + ); + assert_eq!( + presence.indexes, + vec![ + "idx_follow_requests_target", + "idx_username_history_normalized", + "idx_account_imports_user" + ] + ); + assert_eq!(defaults, (0, 0, None, None)); + assert_eq!(legacy_after, legacy_before); + + pool.call(|conn| insert_v4_account_lifecycle_fixture(conn)) + .await + .expect("v4 rows"); + let lifecycle_before = pool + .call(|conn| account_lifecycle_rows(conn)) + .await + .expect("lifecycle rows"); + + migrate(pool) + .await + .expect("second migrate must be idempotent"); + + let (version_again, lifecycle_after, legacy_again) = pool + .call(|conn| { + Ok(( + schema_version_from_connection(conn)?, + account_lifecycle_rows(conn)?, + legacy_rows(conn)?, + )) + }) + .await + .expect("idempotent state"); + + assert_eq!(version_again, CURRENT_SCHEMA_VERSION); + assert_eq!(lifecycle_after, lifecycle_before); + assert_eq!(legacy_again, legacy_before); + } + fn install_latest_alpha_fixture(conn: &Connection) -> anyhow::Result<()> { conn.execute_batch(BASELINE_SCHEMA)?; conn.execute("DELETE FROM schema_migrations", [])?; diff --git a/src/demo_seed.rs b/src/demo_seed.rs index 005175a..35a82ea 100644 --- a/src/demo_seed.rs +++ b/src/demo_seed.rs @@ -253,7 +253,9 @@ async fn create_social_graph(pool: &SqlitePool, ids: &DemoIds) -> anyhow::Result (ids.omar, ids.ada), (ids.omar, ids.tess), ] { - social::follow(pool, follower, followed).await?; + // The seeding script only cares that the relationship is recorded, + // whether the follow is immediate or pending approval. + let _outcome = social::follow(pool, follower, followed).await?; } Ok(()) } diff --git a/src/errors.rs b/src/errors.rs index def9ce4..867af01 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -10,6 +10,8 @@ pub enum AppError { #[error("forbidden")] Forbidden, #[error("{0}")] + PayloadTooLarge(String), + #[error("{0}")] RateLimited(String), #[error("not found")] NotFound, @@ -32,6 +34,7 @@ impl IntoResponse for AppError { "authentication required".to_owned(), ), Self::Forbidden => (StatusCode::FORBIDDEN, "forbidden".to_owned()), + Self::PayloadTooLarge(message) => (StatusCode::PAYLOAD_TOO_LARGE, message), Self::RateLimited(message) => (StatusCode::TOO_MANY_REQUESTS, message), Self::NotFound => (StatusCode::NOT_FOUND, "not found".to_owned()), Self::Io(_) | Self::Sqlite(_) | Self::Anyhow(_) => { diff --git a/src/identity.rs b/src/identity.rs new file mode 100644 index 0000000..0a9a8e7 --- /dev/null +++ b/src/identity.rs @@ -0,0 +1,1012 @@ +//! Account lifecycle operations: administrator-forced password resets, forced +//! session revocation, and username changes with history. +//! +//! The functions here intentionally keep callers on the existing +//! authentication, auditing, and validation paths. They are used by HTTP +//! handlers in [`crate::server`] and by the account settings pages. + +use rusqlite::{OptionalExtension as _, params}; + +use crate::config::Settings; +use crate::db::SqlitePool; +use crate::validation; + +/// SQLite extended result code for a UNIQUE constraint failure. +const SQLITE_CONSTRAINT_UNIQUE: i32 = 2067; + +fn is_unique_violation(error: &rusqlite::Error) -> bool { + matches!( + error, + rusqlite::Error::SqliteFailure(code, _) if code.extended_code == SQLITE_CONSTRAINT_UNIQUE + ) +} + +/// Returned when a username change cannot be applied. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UsernameChangeError { + /// The handle is already used by another account, now or in the past. + Taken, + /// The handle fails the existing username rules. + Invalid, + /// The account does not exist or is marked deleted. + NotFound, + /// The requested handle is already the account's current handle. + Unchanged, +} + +impl std::fmt::Display for UsernameChangeError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Taken => formatter.write_str("username is already taken"), + Self::Invalid => formatter.write_str("username is invalid"), + Self::NotFound => formatter.write_str("account not found"), + Self::Unchanged => formatter.write_str("that is already your username"), + } + } +} + +impl std::error::Error for UsernameChangeError {} + +/// A completed username change. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UsernameChange { + pub previous_username: String, + pub username: String, +} + +/// One previously held handle for an account. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UsernameHistoryEntry { + pub username: String, + pub changed_at: String, +} + +/// One account that previously held a handle that no current account owns. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HistoricalUsernameHolder { + pub user_id: i64, + pub username: String, + pub display_name: String, + pub changed_at: String, +} + +/// Marks an account as requiring a password change before it can use anything +/// other than the password-change and logout flows, and writes an audit row. +pub async fn require_password_reset( + pool: &SqlitePool, + admin_user_id: i64, + user_id: i64, +) -> anyhow::Result<()> { + let updated = pool + .call(move |conn| { + Ok(conn.execute( + "UPDATE users SET must_change_password = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND is_deleted = 0", + [user_id], + )?) + }) + .await?; + if updated == 0 { + anyhow::bail!("account not found"); + } + crate::admin::audit( + pool, + admin_user_id, + "require_password_reset", + &format!("user:{user_id}"), + ) + .await +} + +/// Revokes every active session belonging to one account and writes an audit +/// row. Returns the number of sessions revoked. +pub async fn revoke_user_sessions( + pool: &SqlitePool, + admin_user_id: i64, + user_id: i64, +) -> anyhow::Result { + // The account check and the revocation share one transaction so a + // concurrent deletion cannot slip between them and turn the documented + // "account not found" rejection into a silent no-op success. + let revoked = pool + .call(move |conn| { + let tx = conn.transaction()?; + let exists = tx + .query_row( + "SELECT 1 FROM users WHERE id = ? AND is_deleted = 0", + [user_id], + |_| Ok(()), + ) + .optional()? + .is_some(); + if !exists { + return Ok(None); + } + let revoked = tx.execute( + "UPDATE sessions SET revoked_at = CURRENT_TIMESTAMP WHERE user_id = ? AND revoked_at IS NULL", + [user_id], + )?; + tx.commit()?; + Ok(Some(revoked)) + }) + .await?; + let Some(revoked) = revoked else { + anyhow::bail!("account not found"); + }; + crate::admin::audit( + pool, + admin_user_id, + "revoke_user_sessions", + &format!("user:{user_id}"), + ) + .await?; + Ok(revoked) +} + +/// Changes an account's handle atomically. +/// +/// The previous handle is recorded in `username_history` in the same +/// transaction. Reclaiming a handle the same account previously held is +/// allowed and removes the stale history row that matches the new handle. +pub async fn change_username( + pool: &SqlitePool, + settings: &Settings, + user_id: i64, + new_username: &str, +) -> Result { + let normalized = + validation::normalize_username(new_username, settings.accounts.max_username_len) + .map_err(|_invalid| UsernameChangeError::Invalid)?; + let display_username = new_username.trim().to_owned(); + let result = pool + .call(move |conn| { + let tx = conn.transaction()?; + let current = tx + .query_row( + "SELECT username, normalized_username, is_deleted FROM users WHERE id = ?", + [user_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + )) + }, + ) + .optional()?; + let Some((previous_username, current_normalized, is_deleted)) = current else { + return Ok(Err(UsernameChangeError::NotFound)); + }; + if is_deleted != 0 { + return Ok(Err(UsernameChangeError::NotFound)); + } + if current_normalized == normalized { + return Ok(Err(UsernameChangeError::Unchanged)); + } + let current_owner = tx + .query_row( + "SELECT 1 FROM users WHERE normalized_username = ? AND id != ?", + params![normalized, user_id], + |_| Ok(()), + ) + .optional()? + .is_some(); + if current_owner { + return Ok(Err(UsernameChangeError::Taken)); + } + let history_owner = tx + .query_row( + "SELECT user_id FROM username_history WHERE normalized_username = ? AND user_id != ? LIMIT 1", + params![normalized, user_id], + |row| row.get::<_, i64>(0), + ) + .optional()?; + if history_owner.is_some() { + return Ok(Err(UsernameChangeError::Taken)); + } + tx.execute( + "DELETE FROM username_history WHERE user_id = ? AND normalized_username = ?", + params![user_id, normalized], + )?; + tx.execute( + "INSERT INTO username_history (user_id, username, normalized_username, changed_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)", + params![user_id, previous_username, current_normalized], + )?; + match tx.execute( + "UPDATE users SET username = ?, normalized_username = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND is_deleted = 0", + params![display_username, normalized, user_id], + ) { + Ok(_) => {} + Err(error) if is_unique_violation(&error) => { + return Ok(Err(UsernameChangeError::Taken)); + } + Err(error) => return Err(error.into()), + } + match tx.commit() { + Ok(()) => {} + Err(error) if is_unique_violation(&error) => { + return Ok(Err(UsernameChangeError::Taken)); + } + Err(error) => return Err(error.into()), + } + Ok(Ok(UsernameChange { + previous_username, + username: display_username, + })) + }) + .await; + match result { + Ok(inner) => inner, + Err(error) => { + tracing::error!(user_id, error = %error, "username change failed"); + Err(UsernameChangeError::Invalid) + } + } +} + +/// Historically held handles for one account, newest first. +pub async fn username_history( + pool: &SqlitePool, + user_id: i64, +) -> anyhow::Result> { + pool.call(move |conn| { + let mut stmt = conn.prepare( + "SELECT username, changed_at FROM username_history WHERE user_id = ? ORDER BY changed_at DESC, id DESC", + )?; + let rows = stmt + .query_map([user_id], |row| { + Ok(UsernameHistoryEntry { + username: row.get(0)?, + changed_at: row.get(1)?, + }) + })? + .collect::, _>>()?; + Ok(rows) + }) + .await +} + +/// Resolves a normalized handle that no current account owns to the accounts +/// that previously held it, newest first. +pub async fn historical_username_holders( + pool: &SqlitePool, + normalized_username: &str, +) -> anyhow::Result> { + let normalized_username = normalized_username.to_owned(); + pool.call(move |conn| { + let mut stmt = conn.prepare( + r#" + SELECT u.id, u.username, u.display_name, h.changed_at + FROM username_history h + JOIN users u ON u.id = h.user_id + WHERE h.normalized_username = ? AND u.is_deleted = 0 + ORDER BY h.changed_at DESC + "#, + )?; + let rows = stmt + .query_map([normalized_username], |row| { + Ok(HistoricalUsernameHolder { + user_id: row.get(0)?, + username: row.get(1)?, + display_name: row.get(2)?, + changed_at: row.get(3)?, + }) + })? + .collect::, _>>()?; + Ok(rows) + }) + .await +} + +/// Normalizes a candidate handle with the shared username rules. +/// +/// Exposed so callers can validate without duplicating the rules. +pub fn normalize_candidate_username( + settings: &Settings, + candidate: &str, +) -> Result { + validation::normalize_username(candidate, settings.accounts.max_username_len) + .map_err(|_invalid| UsernameChangeError::Invalid) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth; + + async fn fixture() -> (tempfile::TempDir, SqlitePool, Settings, i64, i64) { + let temp = tempfile::tempdir().expect("temp dir"); + let pool = crate::db::connect(&temp.path().join("test.sqlite3")) + .await + .expect("connect"); + crate::db::migrate(&pool).await.expect("migrate"); + let settings = Settings::default(); + let admin = + auth::register_user(&pool, &settings, "admin_user", "very secure password", true) + .await + .expect("admin"); + let alice = auth::register_user(&pool, &settings, "alice", "very secure password", false) + .await + .expect("alice"); + (temp, pool, settings, admin, alice) + } + + async fn must_change_password(pool: &SqlitePool, user_id: i64) -> bool { + pool.call(move |conn| { + Ok(conn.query_row( + "SELECT must_change_password FROM users WHERE id = ?", + [user_id], + |row| row.get::<_, i64>(0), + )? != 0) + }) + .await + .expect("must_change_password") + } + + async fn audit_count(pool: &SqlitePool, action: &str, target: &str) -> i64 { + let action = action.to_owned(); + let target = target.to_owned(); + pool.call(move |conn| { + Ok(conn.query_row( + "SELECT COUNT(*) FROM admin_audit_log WHERE action = ? AND target = ?", + params![action, target], + |row| row.get(0), + )?) + }) + .await + .expect("audit count") + } + + async fn username(pool: &SqlitePool, user_id: i64) -> Option<(String, String)> { + pool.call(move |conn| { + Ok(conn + .query_row( + "SELECT username, normalized_username FROM users WHERE id = ?", + [user_id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .optional()?) + }) + .await + .expect("username lookup") + } + + fn session_headers(token: &str) -> axum::http::HeaderMap { + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + axum::http::header::COOKIE, + format!("rustpost_session={token}") + .parse() + .expect("session cookie"), + ); + headers + } + + /// Current plus historical rows for one canonical handle. + async fn handle_rows(pool: &SqlitePool, normalized: &str) -> i64 { + let normalized = normalized.to_owned(); + pool.call(move |conn| { + Ok(conn.query_row( + "SELECT (SELECT COUNT(*) FROM users WHERE normalized_username = ?1) + (SELECT COUNT(*) FROM username_history WHERE normalized_username = ?1)", + [&normalized], + |row| row.get(0), + )?) + }) + .await + .expect("handle rows") + } + + /// Any canonical handle owned by a current account and also reserved in history. + async fn cross_table_conflicts(pool: &SqlitePool) -> i64 { + pool.call(|conn| { + Ok(conn.query_row( + "SELECT COUNT(*) FROM users u WHERE EXISTS (SELECT 1 FROM username_history h WHERE h.normalized_username = u.normalized_username)", + (), + |row| row.get(0), + )?) + }) + .await + .expect("cross table conflicts") + } + + async fn duplicate_history_rows(pool: &SqlitePool) -> i64 { + pool.call(|conn| { + Ok(conn.query_row( + "SELECT COUNT(*) FROM (SELECT normalized_username FROM username_history GROUP BY normalized_username HAVING COUNT(*) > 1)", + (), + |row| row.get(0), + )?) + }) + .await + .expect("duplicate history rows") + } + + #[tokio::test] + async fn require_password_reset_sets_flag_and_audits() { + let (_temp, pool, _settings, admin, alice) = fixture().await; + + require_password_reset(&pool, admin, alice) + .await + .expect("require reset"); + + assert!(must_change_password(&pool, alice).await); + assert_eq!( + audit_count(&pool, "require_password_reset", &format!("user:{alice}")).await, + 1 + ); + assert_eq!( + audit_count(&pool, "require_password_reset", "user:9999").await, + 0 + ); + + // Repeating the admin action is idempotent for the flag but records + // one audit row per action. + require_password_reset(&pool, admin, alice) + .await + .expect("repeat reset"); + assert!(must_change_password(&pool, alice).await); + assert_eq!( + audit_count(&pool, "require_password_reset", &format!("user:{alice}")).await, + 2 + ); + } + + #[tokio::test] + async fn require_password_reset_restricts_existing_and_new_sessions() { + let (_temp, pool, settings, admin, alice) = fixture().await; + let before = auth::create_session(&pool, alice).await.expect("session"); + + require_password_reset(&pool, admin, alice) + .await + .expect("require reset"); + + let before_user = auth::current_user(&pool, &session_headers(&before.token)) + .await + .expect("current user") + .expect("session created before the flag stays valid"); + assert!(before_user.must_change_password); + + let after = auth::create_session(&pool, alice).await.expect("session"); + let after_user = auth::current_user(&pool, &session_headers(&after.token)) + .await + .expect("current user") + .expect("session created after the flag is restricted too"); + assert!(after_user.must_change_password); + + // The forced-reset flow clears the restriction and revokes the other + // session in one change. + auth::change_password( + &pool, + &settings, + alice, + "very secure password", + "much better password", + "much better password", + Some(&after.token), + ) + .await + .expect("change password"); + + let after_user = auth::current_user(&pool, &session_headers(&after.token)) + .await + .expect("current user") + .expect("session used for the change"); + assert!(!after_user.must_change_password); + assert!( + auth::current_user(&pool, &session_headers(&before.token)) + .await + .expect("current user") + .is_none(), + "the earlier session must be revoked by the forced reset" + ); + } + + #[tokio::test] + async fn require_password_reset_rejects_missing_and_deleted_accounts() { + let (_temp, pool, _settings, admin, alice) = fixture().await; + + let missing = require_password_reset(&pool, admin, 9999).await; + assert_eq!( + missing.expect_err("missing account").to_string(), + "account not found" + ); + + pool.call(move |conn| { + conn.execute("UPDATE users SET is_deleted = 1 WHERE id = ?", [alice])?; + Ok(()) + }) + .await + .expect("mark deleted"); + let deleted = require_password_reset(&pool, admin, alice).await; + assert_eq!( + deleted.expect_err("deleted account").to_string(), + "account not found" + ); + assert!(!must_change_password(&pool, alice).await); + assert_eq!( + audit_count(&pool, "require_password_reset", &format!("user:{alice}")).await, + 0 + ); + } + + #[tokio::test] + async fn revoke_user_sessions_revokes_and_is_idempotent() { + let (_temp, pool, settings, admin, alice) = fixture().await; + let bob = auth::register_user(&pool, &settings, "bob", "very secure password", false) + .await + .expect("bob"); + let _alice_one = auth::create_session(&pool, alice).await.expect("alice one"); + let _alice_two = auth::create_session(&pool, alice).await.expect("alice two"); + let _bob_one = auth::create_session(&pool, bob).await.expect("bob one"); + + assert_eq!( + revoke_user_sessions(&pool, admin, alice) + .await + .expect("revoke"), + 2 + ); + assert_eq!( + revoke_user_sessions(&pool, admin, alice) + .await + .expect("revoke again"), + 0 + ); + + let (alice_active, bob_active): (i64, i64) = pool + .call(move |conn| { + Ok(( + conn.query_row( + "SELECT COUNT(*) FROM sessions WHERE user_id = ? AND revoked_at IS NULL", + [alice], + |row| row.get(0), + )?, + conn.query_row( + "SELECT COUNT(*) FROM sessions WHERE user_id = ? AND revoked_at IS NULL", + [bob], + |row| row.get(0), + )?, + )) + }) + .await + .expect("active sessions"); + assert_eq!(alice_active, 0); + assert_eq!(bob_active, 1); + assert_eq!( + audit_count(&pool, "revoke_user_sessions", &format!("user:{alice}")).await, + 2 + ); + } + + #[tokio::test] + async fn revoke_user_sessions_rejects_missing_and_deleted_accounts() { + let (_temp, pool, _settings, admin, alice) = fixture().await; + + let error = revoke_user_sessions(&pool, admin, 9999) + .await + .expect_err("missing account"); + + assert_eq!(error.to_string(), "account not found"); + + pool.call(move |conn| { + conn.execute("UPDATE users SET is_deleted = 1 WHERE id = ?", [alice])?; + Ok(()) + }) + .await + .expect("mark deleted"); + let error = revoke_user_sessions(&pool, admin, alice) + .await + .expect_err("deleted account"); + + assert_eq!(error.to_string(), "account not found"); + assert_eq!( + audit_count(&pool, "revoke_user_sessions", &format!("user:{alice}")).await, + 0 + ); + } + + #[tokio::test] + async fn change_username_records_history_and_normalizes() { + let (_temp, pool, settings, _admin, alice) = fixture().await; + + let change = change_username(&pool, &settings, alice, " NewName ") + .await + .expect("change username"); + + assert_eq!( + change, + UsernameChange { + previous_username: "alice".to_owned(), + username: "NewName".to_owned(), + } + ); + let (display, normalized) = username(&pool, alice).await.expect("account"); + assert_eq!(display, "NewName"); + assert_eq!(normalized, "newname"); + let history = username_history(&pool, alice).await.expect("history"); + assert_eq!(history.len(), 1); + assert_eq!(history[0].username, "alice"); + assert!(!history[0].changed_at.is_empty()); + } + + #[tokio::test] + async fn change_username_rejects_currently_taken_handle() { + let (_temp, pool, settings, _admin, alice) = fixture().await; + let bob = auth::register_user(&pool, &settings, "bob", "very secure password", false) + .await + .expect("bob"); + + let error = change_username(&pool, &settings, alice, "Bob") + .await + .expect_err("taken"); + + assert_eq!(error, UsernameChangeError::Taken); + assert_eq!(username(&pool, alice).await.expect("alice").0, "alice"); + assert_eq!(username(&pool, bob).await.expect("bob").0, "bob"); + } + + #[tokio::test] + async fn change_username_rejects_another_accounts_history() { + let (_temp, pool, settings, _admin, alice) = fixture().await; + let bob = auth::register_user(&pool, &settings, "bob", "very secure password", false) + .await + .expect("bob"); + change_username(&pool, &settings, alice, "alice_new") + .await + .expect("rename alice"); + + let error = change_username(&pool, &settings, bob, "alice") + .await + .expect_err("historical handle"); + + assert_eq!(error, UsernameChangeError::Taken); + } + + #[tokio::test] + async fn change_username_allows_same_owner_reclamation() { + let (_temp, pool, settings, _admin, alice) = fixture().await; + let bob = auth::register_user(&pool, &settings, "bob", "very secure password", false) + .await + .expect("bob"); + change_username(&pool, &settings, alice, "alice_new") + .await + .expect("rename away"); + + let change = change_username(&pool, &settings, alice, "alice") + .await + .expect("reclaim"); + + assert_eq!(change.previous_username, "alice_new"); + assert_eq!(change.username, "alice"); + let history = username_history(&pool, alice).await.expect("history"); + assert_eq!(history.len(), 1); + assert_eq!(history[0].username, "alice_new"); + assert_eq!( + historical_username_holders(&pool, "alice") + .await + .expect("holders") + .len(), + 0 + ); + // Reclaiming replaces the stale row for the reclaimed handle, so it + // stays reserved for the owner but never also appears in history. + assert_eq!(handle_rows(&pool, "alice").await, 1); + assert_eq!(handle_rows(&pool, "alice_new").await, 1); + assert_eq!( + change_username(&pool, &settings, bob, "alIce") + .await + .expect_err("reserved"), + UsernameChangeError::Taken + ); + } + + #[tokio::test] + async fn change_username_rejects_invalid_handles() { + let (_temp, pool, settings, _admin, alice) = fixture().await; + let too_long = "a".repeat(settings.accounts.max_username_len + 1); + + for candidate in ["settings", "bad name", "bad!chars", "", " ", &too_long] { + let error = change_username(&pool, &settings, alice, candidate) + .await + .expect_err("invalid handle"); + assert_eq!( + error, + UsernameChangeError::Invalid, + "candidate {candidate:?} should be invalid" + ); + } + assert_eq!(username(&pool, alice).await.expect("account").0, "alice"); + } + + #[tokio::test] + async fn change_username_accumulates_history_newest_first() { + let (_temp, pool, settings, _admin, alice) = fixture().await; + change_username(&pool, &settings, alice, "handle_one") + .await + .expect("first change"); + change_username(&pool, &settings, alice, "handle_two") + .await + .expect("second change"); + change_username(&pool, &settings, alice, "handle_three") + .await + .expect("third change"); + + let history = username_history(&pool, alice).await.expect("history"); + let names = history + .iter() + .map(|entry| entry.username.as_str()) + .collect::>(); + assert_eq!(names, ["handle_two", "handle_one", "alice"]); + } + + #[tokio::test] + async fn change_username_unchanged_keeps_display_form() { + let (_temp, pool, settings, _admin, alice) = fixture().await; + + let error = change_username(&pool, &settings, alice, "ALICE") + .await + .expect_err("unchanged"); + + assert_eq!(error, UsernameChangeError::Unchanged); + assert_eq!(username(&pool, alice).await.expect("account").0, "alice"); + assert_eq!( + username_history(&pool, alice).await.expect("history").len(), + 0 + ); + } + + #[tokio::test] + async fn change_username_rejects_missing_and_deleted_accounts() { + let (_temp, pool, settings, _admin, alice) = fixture().await; + + let missing = change_username(&pool, &settings, 9999, "alice_new") + .await + .expect_err("missing"); + assert_eq!(missing, UsernameChangeError::NotFound); + + pool.call(move |conn| { + conn.execute("UPDATE users SET is_deleted = 1 WHERE id = ?", [alice])?; + Ok(()) + }) + .await + .expect("mark deleted"); + let deleted = change_username(&pool, &settings, alice, "alice_new") + .await + .expect_err("deleted"); + assert_eq!(deleted, UsernameChangeError::NotFound); + } + + #[tokio::test] + async fn change_username_concurrent_claims_leave_exactly_one_winner() { + let (_temp, pool, settings, _admin, alice) = fixture().await; + let bob = auth::register_user(&pool, &settings, "bob", "very secure password", false) + .await + .expect("bob"); + + let alice_result = change_username(&pool, &settings, alice, "shared_handle"); + let bob_result = change_username(&pool, &settings, bob, "shared_handle"); + let (alice_result, bob_result) = tokio::join!(alice_result, bob_result); + + let winners = usize::from(alice_result.is_ok()) + usize::from(bob_result.is_ok()); + assert_eq!(winners, 1, "exactly one concurrent claim should win"); + let loser = alice_result + .err() + .or_else(|| bob_result.err()) + .expect("one concurrent claim should fail"); + assert_eq!(loser, UsernameChangeError::Taken); + } + + #[tokio::test] + async fn historical_holders_exclude_deleted_accounts() { + let (_temp, pool, settings, _admin, alice) = fixture().await; + change_username(&pool, &settings, alice, "alice_new") + .await + .expect("rename"); + + let holders = historical_username_holders(&pool, "alice") + .await + .expect("holders"); + assert_eq!(holders.len(), 1); + assert_eq!(holders[0].user_id, alice); + assert_eq!(holders[0].username, "alice_new"); + assert_eq!(holders[0].display_name, "alice"); + assert!(!holders[0].changed_at.is_empty()); + assert!( + historical_username_holders(&pool, "never_used") + .await + .expect("empty") + .is_empty() + ); + + pool.call(move |conn| { + conn.execute("UPDATE users SET is_deleted = 1 WHERE id = ?", [alice])?; + Ok(()) + }) + .await + .expect("mark deleted"); + assert!( + historical_username_holders(&pool, "alice") + .await + .expect("deleted") + .is_empty() + ); + } + + #[tokio::test] + async fn username_history_reserves_case_variants_of_old_handle() { + let (_temp, pool, settings, _admin, alice) = fixture().await; + let bob = auth::register_user(&pool, &settings, "bob", "very secure password", false) + .await + .expect("bob"); + change_username(&pool, &settings, alice, "MixedCase") + .await + .expect("rename alice"); + + assert_eq!(username(&pool, alice).await.expect("alice").0, "MixedCase"); + for variant in [ + "mixedcase", + "MIXEDCASE", + "MixedCase", + "MiXeDcAsE", + "alice", + "ALICE", + "aLiCe", + ] { + assert_eq!( + change_username(&pool, &settings, bob, variant) + .await + .expect_err("historical case variant"), + UsernameChangeError::Taken, + "variant {variant:?} must stay reserved" + ); + let registration = + auth::register_user(&pool, &settings, variant, "very secure password", false) + .await + .expect_err("historical case variant registration"); + assert_eq!(registration.to_string(), auth::USERNAME_TAKEN_MESSAGE); + } // Same owner and same canonical handle: Unchanged, display case kept, + // and no extra history row appears. + assert_eq!( + change_username(&pool, &settings, alice, "MIXEDCASE") + .await + .expect_err("unchanged"), + UsernameChangeError::Unchanged + ); + assert_eq!(username(&pool, alice).await.expect("alice").0, "MixedCase"); + let history = username_history(&pool, alice).await.expect("history"); + let names = history + .iter() + .map(|entry| entry.username.as_str()) + .collect::>(); + assert_eq!(names, ["alice"]); + } + + #[tokio::test] + async fn repeated_reclaims_keep_exact_history_and_reserve_old_handles() { + let (_temp, pool, settings, _admin, alice) = fixture().await; + let bob = auth::register_user(&pool, &settings, "bob", "very secure password", false) + .await + .expect("bob"); + + change_username(&pool, &settings, alice, "handle_b") + .await + .expect("first rename"); + assert_eq!(handle_rows(&pool, "alice").await, 1); + assert_eq!(handle_rows(&pool, "handle_b").await, 1); + + change_username(&pool, &settings, alice, "alice") + .await + .expect("reclaim alice"); + assert_eq!(handle_rows(&pool, "alice").await, 1); + assert_eq!(handle_rows(&pool, "handle_b").await, 1); + assert_eq!( + username_history(&pool, alice).await.expect("history").len(), + 1, + "reclaiming a handle must replace its stale history row" + ); + + change_username(&pool, &settings, alice, "handle_c") + .await + .expect("second rename"); + assert_eq!(handle_rows(&pool, "alice").await, 1); + assert_eq!(handle_rows(&pool, "handle_b").await, 1); + assert_eq!(handle_rows(&pool, "handle_c").await, 1); + + let history = username_history(&pool, alice).await.expect("history"); + let names = history + .iter() + .map(|entry| entry.username.as_str()) + .collect::>(); + assert_eq!( + names, + ["alice", "handle_b"], + "history must be newest-first with no stale rows" + ); + assert_eq!(duplicate_history_rows(&pool).await, 0); + assert_eq!(cross_table_conflicts(&pool).await, 0); + + for reserved in ["alice", "ALICE", "handle_b", "HANDLE_B"] { + assert_eq!( + change_username(&pool, &settings, bob, reserved) + .await + .expect_err("reserved handle"), + UsernameChangeError::Taken, + "{reserved} must stay reserved" + ); + } + // Both vacated handles still resolve to their former owner. + for former in ["alice", "handle_b"] { + let holders = historical_username_holders(&pool, former) + .await + .expect("holders"); + assert_eq!(holders.len(), 1); + assert_eq!(holders[0].user_id, alice); + assert_eq!(holders[0].username, "handle_c"); + } + } + + #[tokio::test] + async fn concurrent_case_variant_claims_leave_exactly_one_winner() { + let (_temp, pool, settings, _admin, alice) = fixture().await; + let bob = auth::register_user(&pool, &settings, "bob", "very secure password", false) + .await + .expect("bob"); + + let alice_result = change_username(&pool, &settings, alice, "Shared"); + let bob_result = change_username(&pool, &settings, bob, "shared"); + let (alice_result, bob_result) = tokio::join!(alice_result, bob_result); + + let winners = usize::from(alice_result.is_ok()) + usize::from(bob_result.is_ok()); + assert_eq!(winners, 1, "exactly one case variant may win"); + let loser = alice_result + .err() + .or_else(|| bob_result.err()) + .expect("one claim must fail"); + assert_eq!(loser, UsernameChangeError::Taken); + assert_eq!(handle_rows(&pool, "shared").await, 1); + assert_eq!(cross_table_conflicts(&pool).await, 0); + } + + #[tokio::test] + async fn registration_racing_rename_claims_handle_exactly_once() { + let (_temp, pool, settings, _admin, alice) = fixture().await; + + let registration = auth::register_user( + &pool, + &settings, + "FreshHandle", + "very secure password", + false, + ); + let rename = change_username(&pool, &settings, alice, "freshhandle"); + let (registration, rename) = tokio::join!(registration, rename); + + let registration_won = registration.is_ok(); + let rename_won = rename.is_ok(); + assert!( + registration_won ^ rename_won, + "exactly one racer must win: registration={registration:?} rename={rename:?}" + ); + if let Err(error) = ®istration { + assert_eq!(error.to_string(), auth::USERNAME_TAKEN_MESSAGE); + } + if let Err(error) = &rename { + assert_eq!(*error, UsernameChangeError::Taken); + } + assert_eq!(handle_rows(&pool, "freshhandle").await, 1); + assert_eq!(cross_table_conflicts(&pool).await, 0); + + if registration_won { + let winner = registration.expect("registration winner"); + let (display, normalized) = username(&pool, winner).await.expect("winner"); + assert_eq!(display, "FreshHandle"); + assert_eq!(normalized, "freshhandle"); + assert_eq!(username(&pool, alice).await.expect("alice").1, "alice"); + } else { + let (display, normalized) = username(&pool, alice).await.expect("alice"); + assert_eq!(display, "freshhandle"); + assert_eq!(normalized, "freshhandle"); + let history = username_history(&pool, alice).await.expect("history"); + assert_eq!(history.len(), 1); + assert_eq!(history[0].username, "alice"); + } + } +} diff --git a/src/instance.rs b/src/instance.rs new file mode 100644 index 0000000..1d8a600 --- /dev/null +++ b/src/instance.rs @@ -0,0 +1,515 @@ +//! Instance-wide settings that administrators can change while the server is +//! running: the top-bar announcement and maintenance mode. +//! +//! Values live in the `instance_settings` key/value table so they survive +//! restarts, are covered by database backups, and do not depend on rewriting +//! `settings.toml` from the web UI. + +use rusqlite::{Connection, OptionalExtension as _}; + +use crate::db::SqlitePool; + +/// Maximum announcement length in Unicode characters. +pub const ANNOUNCEMENT_MAX_CHARS: usize = 280; +/// Maximum maintenance message length in Unicode characters. +pub const MAINTENANCE_MESSAGE_MAX_CHARS: usize = 280; +/// Prefix for released-username tombstones in `instance_settings`. +/// +/// When an account is permanently deleted its handles become claimable again. +/// A tombstone records that the handle was previously held by an account that +/// no longer exists, so an old profile URL can never silently appear to +/// represent the deleted account when someone new registers the same name. +pub const RELEASED_USERNAME_KEY_PREFIX: &str = "released_username:"; +/// Maximum tombstones recorded for one account deletion, bounding storage. +pub const MAX_RELEASED_USERNAMES_PER_ACCOUNT: usize = 50; +/// Message shown while maintenance mode is enabled without a custom message. +pub const DEFAULT_MAINTENANCE_MESSAGE: &str = + "RustPost is in maintenance mode. Posting and registration are temporarily disabled."; + +/// Durable instance state rendered in every page layout and enforced by the +/// maintenance and announcement guards. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct InstanceSettings { + pub announcement: String, + pub announcement_enabled: bool, + pub maintenance_mode: bool, + pub maintenance_message: String, +} + +impl InstanceSettings { + /// Announcement text to render, or `None` when disabled or empty. + #[must_use] + pub fn announcement_text(&self) -> Option<&str> { + if !self.announcement_enabled { + return None; + } + let trimmed = self.announcement.trim(); + if trimmed.is_empty() { + return None; + } + Some(trimmed) + } + + /// Maintenance notice to render, or `None` when maintenance is off. + #[must_use] + pub fn maintenance_notice(&self) -> Option<&str> { + if !self.maintenance_mode { + return None; + } + let trimmed = self.maintenance_message.trim(); + if trimmed.is_empty() { + return Some(DEFAULT_MAINTENANCE_MESSAGE); + } + Some(trimmed) + } +} + +/// Reads the instance settings from an already-open connection. +/// +/// Missing keys fall back to the [`InstanceSettings::default`] values. Unknown +/// keys are ignored. Stored booleans are `"1"`/`"0"`, and any other value is +/// treated as `false`: a corrupt flag must never silently enable maintenance +/// mode or an announcement. +pub fn load_from_connection(conn: &Connection) -> anyhow::Result { + let mut settings = InstanceSettings::default(); + let mut stmt = conn.prepare("SELECT key, value FROM instance_settings")?; + let rows = stmt.query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + })?; + for row in rows { + let (key, value) = row?; + match key.as_str() { + "announcement" => settings.announcement = value, + "announcement_enabled" => settings.announcement_enabled = truthy(&value), + "maintenance_mode" => settings.maintenance_mode = truthy(&value), + "maintenance_message" => settings.maintenance_message = value, + _ => {} + } + } + Ok(settings) +} + +/// Interprets a stored boolean. Only the canonical `"1"` written by the save +/// helpers counts as `true`; anything else is `false`. +fn truthy(value: &str) -> bool { + value == "1" +} + +/// Reads the instance settings through the database worker. +pub async fn load(pool: &SqlitePool) -> anyhow::Result { + pool.call(|conn| load_from_connection(conn)).await +} + +/// Stores the announcement text and enabled flag. +/// +/// Rejects text longer than [`ANNOUNCEMENT_MAX_CHARS`] and control characters +/// other than newlines and tabs. An empty announcement is stored as disabled. +pub async fn save_announcement(pool: &SqlitePool, text: &str, enabled: bool) -> anyhow::Result<()> { + validate_setting_text( + text, + ANNOUNCEMENT_MAX_CHARS, + "announcement is too long", + "announcement contains unsupported control characters", + )?; + let text = text.to_owned(); + let enabled = enabled && !text.trim().is_empty(); + pool.call(move |conn| { + let tx = conn.transaction()?; + upsert_setting_tx(&tx, "announcement", &text)?; + upsert_setting_tx(&tx, "announcement_enabled", if enabled { "1" } else { "0" })?; + tx.commit()?; + Ok(()) + }) + .await +} + +/// Stores the maintenance flag and optional message. +/// +/// Rejects messages longer than [`MAINTENANCE_MESSAGE_MAX_CHARS`] and control +/// characters other than newlines and tabs. +pub async fn save_maintenance( + pool: &SqlitePool, + enabled: bool, + message: &str, +) -> anyhow::Result<()> { + validate_setting_text( + message, + MAINTENANCE_MESSAGE_MAX_CHARS, + "maintenance message is too long", + "maintenance message contains unsupported control characters", + )?; + let message = message.to_owned(); + pool.call(move |conn| { + let tx = conn.transaction()?; + upsert_setting_tx(&tx, "maintenance_mode", if enabled { "1" } else { "0" })?; + upsert_setting_tx(&tx, "maintenance_message", &message)?; + tx.commit()?; + Ok(()) + }) + .await +} + +fn validate_setting_text( + text: &str, + max_chars: usize, + too_long_error: &str, + control_chars_error: &str, +) -> anyhow::Result<()> { + if text.chars().count() > max_chars { + anyhow::bail!("{too_long_error}"); + } + if text + .chars() + .any(|ch| ch.is_control() && !matches!(ch, '\n' | '\r' | '\t')) + { + anyhow::bail!("{control_chars_error}"); + } + Ok(()) +} + +fn upsert_setting_tx(tx: &rusqlite::Transaction<'_>, key: &str, value: &str) -> anyhow::Result<()> { + tx.execute( + r#" + INSERT INTO instance_settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP + "#, + rusqlite::params![key, value], + )?; + Ok(()) +} + +/// Records release tombstones for handles freed by an account deletion. +/// +/// Must run inside the deletion transaction. Only normalized handles are +/// stored; no user identity is retained. Handles are ignored when empty, +/// control-containing, or longer than a username, and at most +/// [`MAX_RELEASED_USERNAMES_PER_ACCOUNT`] handles are recorded. +pub fn record_released_usernames_tx( + tx: &rusqlite::Transaction<'_>, + normalized_usernames: &[String], +) -> anyhow::Result<()> { + let count = normalized_usernames + .len() + .min(MAX_RELEASED_USERNAMES_PER_ACCOUNT); + for normalized in normalized_usernames.iter().take(count) { + if normalized.is_empty() + || normalized.chars().count() > 128 + || normalized.chars().any(char::is_control) + { + continue; + } + upsert_setting_tx( + tx, + &released_username_key(normalized), + RELEASED_USERNAME_TOMBSTONE_VALUE, + )?; + } + Ok(()) +} + +/// Records one release tombstone on its own. Used by tests and by callers +/// outside a deletion transaction; the deletion path uses +/// [`record_released_usernames_tx`] inside its transaction. +pub async fn record_released_username( + pool: &SqlitePool, + normalized_username: &str, +) -> anyhow::Result<()> { + let normalized = normalized_username.to_owned(); + pool.call(move |conn| { + let tx = conn.transaction()?; + record_released_usernames_tx(&tx, std::slice::from_ref(&normalized))?; + tx.commit()?; + Ok(()) + }) + .await +} + +/// Whether a handle was released by a permanently deleted account. +pub async fn username_was_released( + pool: &SqlitePool, + normalized_username: &str, +) -> anyhow::Result { + let key = released_username_key(normalized_username); + pool.call(move |conn| { + Ok(conn + .query_row( + "SELECT 1 FROM instance_settings WHERE key = ?", + [key], + |_| Ok(()), + ) + .optional()? + .is_some()) + }) + .await +} + +fn released_username_key(normalized_username: &str) -> String { + format!("{RELEASED_USERNAME_KEY_PREFIX}{normalized_username}") +} + +/// Stored value for a release tombstone. The timestamp lives in `updated_at`. +const RELEASED_USERNAME_TOMBSTONE_VALUE: &str = "1"; + +#[cfg(test)] +mod tests { + use super::*; + + async fn fixture() -> (tempfile::TempDir, SqlitePool) { + let temp = tempfile::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) + } + + #[tokio::test] + async fn empty_instance_settings_use_defaults() { + let (_temp, pool) = fixture().await; + + let settings = load(&pool).await.expect("load"); + + assert_eq!(settings, InstanceSettings::default()); + assert_eq!(settings.announcement_text(), None); + assert_eq!(settings.maintenance_notice(), None); + } + + #[tokio::test] + async fn saved_settings_survive_a_fresh_connection() { + let (temp, pool) = fixture().await; + save_announcement(&pool, " Hello\nworld ", true) + .await + .expect("announcement"); + save_maintenance(&pool, true, " back at 20:00 ") + .await + .expect("maintenance"); + + let reloaded = load(&pool).await.expect("reload"); + assert_eq!(reloaded.announcement, " Hello\nworld "); + assert!(reloaded.announcement_enabled); + assert_eq!(reloaded.announcement_text(), Some("Hello\nworld")); + assert!(reloaded.maintenance_mode); + assert_eq!(reloaded.maintenance_message, " back at 20:00 "); + assert_eq!(reloaded.maintenance_notice(), Some("back at 20:00")); + + let reopened = crate::db::connect(&temp.path().join("test.sqlite3")) + .await + .expect("reopen"); + let persisted = load(&reopened).await.expect("persisted"); + assert_eq!(persisted, reloaded); + } + + #[tokio::test] + async fn saves_refresh_updated_at() { + let (_temp, pool) = fixture().await; + save_announcement(&pool, "first", true) + .await + .expect("announcement"); + pool.call(|conn| { + conn.execute( + "UPDATE instance_settings SET updated_at = '2000-01-01 00:00:00'", + [], + )?; + Ok(()) + }) + .await + .expect("age rows"); + + save_announcement(&pool, "second", true) + .await + .expect("second announcement"); + + let updated_at: String = pool + .call(|conn| { + Ok(conn.query_row( + "SELECT updated_at FROM instance_settings WHERE key = 'announcement'", + [], + |row| row.get(0), + )?) + }) + .await + .expect("updated_at"); + assert_ne!(updated_at, "2000-01-01 00:00:00"); + } + + #[tokio::test] + async fn enabled_empty_announcement_renders_nothing() { + let (_temp, pool) = fixture().await; + + save_announcement(&pool, " ", true) + .await + .expect("empty announcement"); + + let settings = load(&pool).await.expect("load"); + assert!(!settings.announcement_enabled); + assert_eq!(settings.announcement_text(), None); + + save_announcement(&pool, "visible text", false) + .await + .expect("disabled announcement"); + let settings = load(&pool).await.expect("load"); + assert!(!settings.announcement_enabled); + assert_eq!(settings.announcement_text(), None); + } + + #[tokio::test] + async fn rejects_long_and_control_character_settings() { + let (_temp, pool) = fixture().await; + + let max_length = "é".repeat(ANNOUNCEMENT_MAX_CHARS); + save_announcement(&pool, &max_length, true) + .await + .expect("max-length announcement"); + let too_long = save_announcement(&pool, &"x".repeat(ANNOUNCEMENT_MAX_CHARS + 1), true) + .await + .expect_err("too-long announcement"); + assert_eq!(too_long.to_string(), "announcement is too long"); + let control = save_announcement(&pool, "bad\u{7}text", true) + .await + .expect_err("control characters"); + assert_eq!( + control.to_string(), + "announcement contains unsupported control characters" + ); + save_announcement(&pool, "line\nbreak\ttab", true) + .await + .expect("allowed whitespace"); + + let too_long = + save_maintenance(&pool, true, &"m".repeat(MAINTENANCE_MESSAGE_MAX_CHARS + 1)) + .await + .expect_err("too-long maintenance message"); + assert_eq!(too_long.to_string(), "maintenance message is too long"); + let control = save_maintenance(&pool, true, "bad\u{0}text") + .await + .expect_err("control characters"); + assert_eq!( + control.to_string(), + "maintenance message contains unsupported control characters" + ); + } + + #[tokio::test] + async fn empty_maintenance_message_uses_the_default_notice() { + let (_temp, pool) = fixture().await; + + save_maintenance(&pool, true, " ") + .await + .expect("maintenance on"); + let settings = load(&pool).await.expect("load"); + assert!(settings.maintenance_mode); + assert_eq!( + settings.maintenance_notice(), + Some(DEFAULT_MAINTENANCE_MESSAGE) + ); + + save_maintenance(&pool, false, "") + .await + .expect("maintenance off"); + let settings = load(&pool).await.expect("load"); + assert!(!settings.maintenance_mode); + assert_eq!(settings.maintenance_notice(), None); + } + + #[tokio::test] + async fn corrupt_booleans_and_unknown_keys_default_off() { + let (_temp, pool) = fixture().await; + pool.call(|conn| { + conn.execute( + "INSERT INTO instance_settings (key, value) VALUES ('maintenance_mode', 'maybe')", + [], + )?; + conn.execute( + "INSERT INTO instance_settings (key, value) VALUES ('announcement_enabled', 'yes')", + [], + )?; + conn.execute( + "INSERT INTO instance_settings (key, value) VALUES ('unknown_future_key', 'x')", + [], + )?; + Ok(()) + }) + .await + .expect("corrupt rows"); + + let settings = load(&pool).await.expect("load"); + + assert!(!settings.maintenance_mode); + assert!(!settings.announcement_enabled); + assert_eq!(settings, InstanceSettings::default()); + } + + #[tokio::test] + async fn released_usernames_are_recorded_and_queryable() { + let (_temp, pool) = fixture().await; + + assert!(!username_was_released(&pool, "alice").await.expect("query")); + record_released_username(&pool, "alice") + .await + .expect("record"); + assert!(username_was_released(&pool, "alice").await.expect("query")); + assert!(!username_was_released(&pool, "bob").await.expect("query")); + + // Recording again refreshes the timestamp without duplicating a key. + record_released_username(&pool, "alice") + .await + .expect("record"); + let rows: i64 = pool + .call(|conn| { + Ok(conn.query_row( + "SELECT COUNT(*) FROM instance_settings WHERE key = 'released_username:alice'", + [], + |row| row.get(0), + )?) + }) + .await + .expect("count"); + assert_eq!(rows, 1); + + // Tombstones must not leak into the announcement/maintenance view. + assert_eq!( + load(&pool).await.expect("load"), + InstanceSettings::default() + ); + } + + #[tokio::test] + async fn released_usernames_are_bounded_and_screened() { + let (_temp, pool) = fixture().await; + let mut handles = (0..MAX_RELEASED_USERNAMES_PER_ACCOUNT + 25) + .map(|index| format!("handle{index}")) + .collect::>(); + handles.push(String::new()); + handles.push("bad\u{7}name".to_owned()); + + pool.call(move |conn| { + let tx = conn.transaction()?; + record_released_usernames_tx(&tx, &handles)?; + tx.commit()?; + Ok(()) + }) + .await + .expect("record bounded"); + + let count: i64 = pool + .call(|conn| { + Ok(conn.query_row( + "SELECT COUNT(*) FROM instance_settings WHERE key LIKE 'released_username:%'", + [], + |row| row.get(0), + )?) + }) + .await + .expect("count"); + assert_eq!( + count, + i64::try_from(MAX_RELEASED_USERNAMES_PER_ACCOUNT).expect("limit fits") + ); + assert!(!username_was_released(&pool, "").await.expect("empty")); + assert!( + !username_was_released(&pool, "bad\u{7}name") + .await + .expect("control") + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 30c1567..cae6a45 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28,8 +28,11 @@ pub mod demo_seed; pub mod errors; pub mod favicon; pub mod ffmpeg; +pub mod identity; +pub mod instance; pub mod logging; pub mod media; +pub mod portability; pub mod rate_limit; pub mod registration_captcha; pub mod render; diff --git a/src/media.rs b/src/media.rs index e4b18a5..6396cef 100644 --- a/src/media.rs +++ b/src/media.rs @@ -271,7 +271,7 @@ async fn prepare_staged_upload( /// image while keeping a full RGBA decode under ~400 MB. const MAX_IMAGE_PIXELS: u64 = 100_000_000; -/// Reads width/height from the headers of the image formats RustPost accepts. +/// Reads width/height from the headers of the image formats `RustPost` accepts. /// Returns `None` for unknown or truncated headers. fn image_dimensions(data: &[u8]) -> Option<(u32, u32)> { const PNG_SIGNATURE: &[u8] = b"\x89PNG\r\n\x1a\n"; @@ -1096,7 +1096,7 @@ fn unique_media_path(dir: &Path, basename: &str, extension: &str) -> PathBuf { .open(&candidate) { Ok(_claim) => return candidate, - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} // The directory may be missing or read-only; return the intended // path so the caller surfaces the real I/O error. Err(_error) => return candidate, @@ -1690,7 +1690,7 @@ mod tests { let png = tiny_png_bytes(); assert_eq!(image_dimensions(&png), Some((1, 1))); - let mut oversized = png.clone(); + let mut oversized = png; oversized[16..20].copy_from_slice(&40_000_u32.to_be_bytes()); oversized[20..24].copy_from_slice(&40_000_u32.to_be_bytes()); let (width, height) = image_dimensions(&oversized).expect("png dimensions"); diff --git a/src/portability.rs b/src/portability.rs new file mode 100644 index 0000000..529c215 --- /dev/null +++ b/src/portability.rs @@ -0,0 +1,4378 @@ +//! Account export and import archives. +//! +//! `RustPost` user archives are distinct from full-instance backups: they carry +//! one account's posts, profile data, media, outgoing follows, muted words and +//! media bytes, and are imported into an authenticated destination account. +//! Password hashes, sessions, tokens, administrative flags, instance +//! configuration, notifications, interactions (likes/bookmarks/reposts), and +//! other accounts' data are never included. +//! +//! The on-disk format is a gzip-compressed tar archive: +//! +//! ```text +//! manifest.json { format_version, app, created_at, archive_id, username, entry_count } +//! profile.json { display_name, bio, location, website, theme, nsfw_blur_enabled, +//! liked_posts_public, follow_approval_required, created_at } +//! posts.json [ { id, text, created_at, edited_at?, parent_id?, root_id?, +//! quote_id?, media_ids, embeds } ] +//! media.json [ { id, original_filename, mime_type, media_kind, byte_len, +//! alt_text, is_nsfw, conversion_state, sha256, archive_path } ] +//! follows.json [ { username, created_at } ] +//! settings.json { muted_words: [ { term, created_at } ] } +//! media/. one entry per listed media document, bytes included +//! ``` +//! +//! Every entry is validated before any database or filesystem mutation. Media +//! bytes are streamed and hashed, never buffered whole; a bounded prefix is +//! sniffed with `infer` and must match the declared mime type. The compressed +//! size, entry count, decompressed media total, and cumulative document bytes +//! are all enforced incrementally while reading, so a compressed bomb cannot +//! consume unbounded disk or memory. Imports apply in one database transaction +//! plus a staged-file install: media files are copied to the destination +//! upload directories first, the transaction commits the database rows, and +//! copied files are removed again if anything fails. +//! +//! Behaviour decisions worth knowing: +//! +//! * Posts are exported only when authored by the account and not deleted. +//! Reply/root/quote references are kept only when the referenced post is +//! also part of the export, otherwise the reference is dropped (export) or +//! set to `NULL` (import). +//! * Media is exported for media rows owned by the account that are attached +//! to an exported post. `sha256` and `byte_len` are computed from the bytes +//! actually written to the archive, not from the source row. +//! * Import fills empty destination profile text fields (`display_name`, +//! `bio`, `location`, `website`) from the archive and always applies the +//! archive's preference fields (theme, NSFW blur, public likes, follow +//! approval). Conflicts are counted in [`ImportReport`]. +//! * Imported follows resolve against the destination instance by normalized +//! handle. Missing, deleted, suspended, self, and blocked (either direction) +//! targets are skipped; protected targets always produce a pending +//! `follow_requests` row and can never be followed directly. +//! * Imported media keeps the existing content-dedupe invariants: a matching +//! canonical row is reused (no second copy) via `canonical_media_id`. +//! * Usernames, password hashes, administrative flags, suspension, and +//! deletion state are never written from an archive. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read, Write}; +use std::path::{Component, Path, PathBuf}; + +use anyhow::Context as _; +use chrono::{SecondsFormat, Utc}; +use flate2::Compression; +use flate2::read::GzDecoder; +use flate2::write::GzEncoder; +use rusqlite::{OptionalExtension as _, params}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; +use tar::{Archive, Builder, EntryType, Header}; +use uuid::Uuid; + +use crate::config::Settings; +use crate::db::SqlitePool; +use crate::runtime::RuntimePaths; +use crate::validation::{clean_post_text, normalize_username, validate_profile_text}; + +/// Current archive format version. Importers reject other versions. +pub const FORMAT_VERSION: u32 = 1; +/// Manifest path inside the archive. +pub const MANIFEST_PATH: &str = "manifest.json"; +/// Hard ceiling for one archive, matching the instance upload body limit. +pub const MAX_ARCHIVE_BYTES: u64 = 300 * 1024 * 1024; +/// Hard ceiling for the number of archive entries. +pub const MAX_ENTRIES: usize = 10_000; +/// Prefix used for staged export files under `RuntimePaths::tmp_dir`. +pub const EXPORT_TMP_PREFIX: &str = "account-export-"; +/// Prefix used for staged import uploads under `RuntimePaths::tmp_dir`. +pub const IMPORT_TMP_PREFIX: &str = "account-import-"; + +const APP_NAME: &str = "rustpost"; +const MEDIA_PREFIX: &str = "media/"; +const MEDIA_DIR: &str = "media"; +const PROFILE_PATH: &str = "profile.json"; +const POSTS_PATH: &str = "posts.json"; +const MEDIA_PATH: &str = "media.json"; +const FOLLOWS_PATH: &str = "follows.json"; +const SETTINGS_PATH: &str = "settings.json"; +const DOCUMENT_PATHS: [&str; 6] = [ + MANIFEST_PATH, + PROFILE_PATH, + POSTS_PATH, + MEDIA_PATH, + FOLLOWS_PATH, + SETTINGS_PATH, +]; + +/// Independent limits applied to account archive export and import. +/// +/// `compressed_bytes` bounds the archive file itself (upload and export), +/// while `expanded_bytes` bounds the total decompressed media content. The two +/// limits are deliberately independent: raising the upload ceiling never +/// raises the decompressed ceiling, and a tiny compressed archive can never +/// expand past `expanded_bytes`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ArchiveLimits { + pub compressed_bytes: u64, + pub expanded_bytes: u64, + pub entries: usize, +} + +impl ArchiveLimits { + #[must_use] + pub fn from_settings(settings: &Settings) -> Self { + Self { + compressed_bytes: settings.accounts.max_archive_upload_bytes, + expanded_bytes: settings.accounts.max_archive_expanded_bytes, + entries: settings.accounts.max_archive_entries, + } + } +} + +/// Records that must be duplicated rather than silently truncated. +const MAX_POSTS: usize = 5_000; +const MAX_MEDIA: usize = 1_000; +const MAX_FOLLOWS: usize = 5_000; +const MAX_MUTED_WORDS: usize = 500; +const MAX_EMBEDS_PER_POST: usize = 32; +const MAX_ALT_TEXT_CHARS: usize = 4_096; +const MAX_ORIGINAL_FILENAME_CHARS: usize = 255; +const MAX_CONVERSION_STATE_CHARS: usize = 64; +const MAX_EMBED_TITLE_CHARS: usize = 512; +const MAX_EMBED_URL_CHARS: usize = 2_048; +const MAX_EMBED_VIDEO_ID_CHARS: usize = 128; +const MAX_LOCATION_CHARS: usize = 100; +const MAX_WEBSITE_CHARS: usize = 2_048; +const MAX_TIMESTAMP_CHARS: usize = 64; +const MANIFEST_MAX_BYTES: u64 = 256 * 1024; +const DOCUMENT_MAX_BYTES: u64 = 16 * 1024 * 1024; +/// Cumulative ceiling for every JSON document in one archive, in addition to +/// the per-document caps. Six maximum-size documents would otherwise hold up +/// to 96 MiB in memory at once. +const MAX_TOTAL_DOCUMENT_BYTES: u64 = 64 * 1024 * 1024; +/// Number of leading media bytes collected while streaming extraction so the +/// `infer` crate can check the content type without buffering whole entries. +const MEDIA_SNIFF_PREFIX_BYTES: usize = 512; +/// Longest accepted media entry file name, in bytes. Longer names are rejected +/// because they cannot be represented safely on common filesystems. +const MAX_MEDIA_FILE_NAME_BYTES: usize = 255; +const HASH_PREFIX_LEN: usize = 16; +const COPY_BUFFER_BYTES: usize = 64 * 1024; +const ALREADY_IMPORTED_MESSAGE: &str = "this archive has already been imported into this account"; + +/// SQLite extended result codes for the `UNIQUE(user_id, archive_id)` +/// constraint on `account_imports`. +const SQLITE_CONSTRAINT_UNIQUE: i32 = 2_067; +const SQLITE_CONSTRAINT_PRIMARYKEY: i32 = 1_555; + +const FOLLOW_NOTIFICATION_MESSAGE: &str = "followed you"; +const FOLLOW_REQUEST_NOTIFICATION_MESSAGE: &str = "requested to follow you"; + +/// Result of writing one account archive. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExportReport { + pub archive_path: PathBuf, + pub archive_id: String, + pub bytes: u64, + pub posts: usize, + pub media: usize, + pub follows: usize, + pub muted_words: usize, +} + +/// Result of importing one account archive into a destination account. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ImportReport { + pub archive_id: String, + pub archive_username: String, + pub posts_imported: usize, + pub media_imported: usize, + pub follows_imported: usize, + pub follows_pending: usize, + pub follows_skipped: usize, + /// Reply/root/quote references in the archive that pointed at posts which + /// are not part of the archive and were therefore dropped. + pub post_references_dropped: usize, + pub muted_words_imported: usize, + pub profile_fields_applied: usize, + pub profile_fields_skipped: usize, +} + +// --------------------------------------------------------------------------- +// Archive documents +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ManifestDocument { + format_version: u32, + app: String, + created_at: String, + archive_id: String, + username: String, + entry_count: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ProfileDocument { + display_name: String, + bio: String, + location: String, + website: String, + theme: String, + nsfw_blur_enabled: bool, + liked_posts_public: bool, + follow_approval_required: bool, + created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PostDocument { + id: i64, + text: String, + created_at: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + edited_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + parent_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + root_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + quote_id: Option, + #[serde(default)] + media_ids: Vec, + #[serde(default)] + embeds: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct EmbedDocument { + provider: String, + video_id: String, + original_url: String, + canonical_url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + title: Option, + thumbnail_url: String, + embed_url: String, + position: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct MediaDocument { + id: i64, + original_filename: String, + mime_type: String, + media_kind: String, + byte_len: u64, + alt_text: String, + is_nsfw: bool, + conversion_state: String, + sha256: String, + archive_path: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct FollowDocument { + username: String, + created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct SettingsDocument { + muted_words: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct MutedWordDocument { + term: String, + created_at: String, +} + +// --------------------------------------------------------------------------- +// Export +// --------------------------------------------------------------------------- + +/// Writes a complete archive for `user_id` into `destination`. +/// +/// `destination` must name a file inside an existing directory; the archive is +/// written to a temporary sibling and renamed into place, so a failed export +/// never leaves a partial file. A `..` component, a symlink, a non-file +/// destination, or a missing parent directory is rejected. The caller usually +/// stages under `RuntimePaths::tmp_dir` using [`EXPORT_TMP_PREFIX`]. +#[expect( + clippy::too_many_lines, + reason = "export builds the manifest, documents, and media entries in one ordered pass" +)] +pub async fn export_account( + pool: &SqlitePool, + paths: &RuntimePaths, + user_id: i64, + destination: &Path, + limits: ArchiveLimits, +) -> anyhow::Result { + let destination_dir = validate_export_destination(destination)?; + let snapshot = load_export_snapshot(pool, user_id).await?; + + let mut media_plans = Vec::with_capacity(snapshot.media.len()); + let mut media_archive_ids: HashMap = HashMap::with_capacity(snapshot.media.len()); + for (index, media) in snapshot.media.iter().enumerate() { + let archive_id = i64::try_from(index)?; + let source = validate_export_media_source( + paths, + &media.media_kind, + &media.mime_type, + &media.stored_path, + )?; + let (byte_len, sha256) = hash_file_streaming(&source)?; + let extension = export_media_extension(&media.mime_type, &media.media_kind); + media_archive_ids.insert(media.id, archive_id); + media_plans.push(ExportMediaPlan { + archive_id, + archive_path: format!("{MEDIA_PREFIX}{index}.{extension}"), + source, + byte_len, + sha256, + original_filename: media.original_filename.clone(), + mime_type: media.mime_type.clone(), + media_kind: media.media_kind.clone(), + alt_text: media.alt_text.clone(), + is_nsfw: media.is_nsfw, + conversion_state: media.conversion_state.clone(), + }); + } + + let post_ids = snapshot + .posts + .iter() + .map(|post| post.id) + .collect::>(); + let mut posts = Vec::with_capacity(snapshot.posts.len()); + let mut dropped_references = 0usize; + for post in &snapshot.posts { + let parent_id = keep_exported_reference(post.parent_post_id, &post_ids); + let root_id = keep_exported_reference(post.root_post_id, &post_ids); + let quote_id = keep_exported_reference(post.quote_post_id, &post_ids); + dropped_references += usize::from(post.parent_post_id.is_some() && parent_id.is_none()) + + usize::from(post.root_post_id.is_some() && root_id.is_none()) + + usize::from(post.quote_post_id.is_some() && quote_id.is_none()); + posts.push(PostDocument { + id: post.id, + text: post.text.clone(), + created_at: post.created_at.clone(), + edited_at: post.edited_at.clone(), + parent_id, + root_id, + quote_id, + media_ids: post + .media_ids + .iter() + .filter_map(|id| media_archive_ids.get(id).copied()) + .collect(), + embeds: post.embeds.clone(), + }); + } + if dropped_references > 0 { + // `ExportReport` has no counter for this, so record it in the log. + tracing::info!( + user_id, + dropped_references, + "account export dropped post references whose target is not part of the archive" + ); + } + + let entry_count = DOCUMENT_PATHS.len() + media_plans.len(); + if entry_count > limits.entries { + anyhow::bail!( + "account archive would contain {entry_count} entries; the limit is {}", + limits.entries + ); + } + let manifest = ManifestDocument { + format_version: FORMAT_VERSION, + app: APP_NAME.to_owned(), + created_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), + archive_id: Uuid::new_v4().to_string(), + username: snapshot.username.clone(), + entry_count, + }; + let settings_document = snapshot.muted_words_document(); + let documents = ExportDocuments { + manifest: &manifest, + profile: &snapshot.profile, + posts: &posts, + follows: &snapshot.follows, + settings: &settings_document, + media_plans: &media_plans, + }; + let bytes = write_export_archive(destination, &destination_dir, &documents, limits)?; + + Ok(ExportReport { + archive_path: destination.to_path_buf(), + archive_id: manifest.archive_id, + bytes, + posts: posts.len(), + media: media_plans.len(), + follows: snapshot.follows.len(), + muted_words: snapshot.muted_words.len(), + }) +} + +/// Rejects destinations that are not a plain file name inside an existing +/// directory, and returns that parent directory. +fn validate_export_destination(destination: &Path) -> anyhow::Result { + if destination + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + anyhow::bail!("export destination must not contain '..' components"); + } + let Some(file_name) = destination.file_name().and_then(|name| name.to_str()) else { + anyhow::bail!("export destination must be a plain file name"); + }; + if file_name.is_empty() || file_name == "." || file_name == ".." { + anyhow::bail!("export destination must be a plain file name"); + } + if file_name.contains(['/', '\\', ':']) || file_name.chars().any(char::is_control) { + anyhow::bail!("export destination file name contains unsupported characters"); + } + match fs::symlink_metadata(destination) { + Ok(metadata) => { + if metadata.file_type().is_symlink() { + anyhow::bail!("export destination must not be a symbolic link"); + } + if !metadata.is_file() { + anyhow::bail!("export destination already exists and is not a regular file"); + } + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| { + format!( + "failed to inspect export destination {}", + destination.display() + ) + }); + } + } + let parent = destination + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .map_or_else(|| PathBuf::from("."), Path::to_path_buf); + let metadata = fs::metadata(&parent).with_context(|| { + format!( + "export destination directory {} does not exist", + parent.display() + ) + })?; + if !metadata.is_dir() { + anyhow::bail!("export destination parent is not a directory"); + } + Ok(parent) +} + +/// Keeps a post reference only when the referenced post is also exported. +fn keep_exported_reference(reference: Option, exported: &BTreeSet) -> Option { + reference.filter(|id| exported.contains(id)) +} + +struct ExportSnapshot { + username: String, + profile: ProfileDocument, + posts: Vec, + media: Vec, + follows: Vec, + muted_words: Vec, +} + +impl ExportSnapshot { + fn muted_words_document(&self) -> SettingsDocument { + SettingsDocument { + muted_words: self.muted_words.clone(), + } + } +} + +struct ExportPostRow { + id: i64, + text: String, + created_at: String, + edited_at: Option, + parent_post_id: Option, + root_post_id: Option, + quote_post_id: Option, + media_ids: Vec, + embeds: Vec, +} + +struct ExportMediaRow { + id: i64, + original_filename: String, + mime_type: String, + media_kind: String, + alt_text: String, + is_nsfw: bool, + conversion_state: String, + stored_path: String, +} + +/// Reads every exportable record in one consistent snapshot. Only non-deleted +/// posts authored by the account are considered, and media is limited to rows +/// owned by the account that are attached to one of those posts. +#[expect( + clippy::too_many_lines, + reason = "one consistent read of every exportable record family keeps the snapshot coherent" +)] +async fn load_export_snapshot(pool: &SqlitePool, user_id: i64) -> anyhow::Result { + pool.call(move |conn| { + let tx = conn.transaction()?; + let account = tx + .query_row( + r#" + SELECT username, display_name, bio, location, website, theme, + nsfw_blur_enabled, liked_posts_public, follow_approval_required, created_at + FROM users WHERE id = ? AND is_deleted = 0 + "#, + [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::<_, String>(5)?, + row.get::<_, i64>(6)? != 0, + row.get::<_, i64>(7)? != 0, + row.get::<_, i64>(8)? != 0, + row.get::<_, String>(9)?, + )) + }, + ) + .optional()?; + let Some(( + username, + display_name, + bio, + location, + website, + theme, + nsfw_blur_enabled, + liked_posts_public, + follow_approval_required, + created_at, + )) = account + else { + anyhow::bail!("account does not exist or has been deleted"); + }; + let profile = ProfileDocument { + display_name, + bio, + location, + website, + theme, + nsfw_blur_enabled, + liked_posts_public, + follow_approval_required, + created_at, + }; + + let mut posts = { + let mut statement = tx.prepare( + r#" + SELECT id, text, created_at, edited_at, parent_post_id, root_post_id, quote_post_id + FROM posts WHERE user_id = ? AND is_deleted = 0 ORDER BY id ASC + "#, + )?; + statement + .query_map([user_id], |row| { + Ok(ExportPostRow { + id: row.get(0)?, + text: row.get(1)?, + created_at: row.get(2)?, + edited_at: row.get(3)?, + parent_post_id: row.get(4)?, + root_post_id: row.get(5)?, + quote_post_id: row.get(6)?, + media_ids: Vec::new(), + embeds: Vec::new(), + }) + })? + .collect::, _>>()? + }; + let post_index: HashMap = posts + .iter() + .enumerate() + .map(|(index, post)| (post.id, index)) + .collect(); + { + let mut statement = tx.prepare( + r#" + SELECT pm.post_id, pm.media_id + FROM post_media pm + JOIN posts p ON p.id = pm.post_id + WHERE p.user_id = ? AND p.is_deleted = 0 + ORDER BY pm.post_id ASC, pm.position ASC, pm.media_id ASC + "#, + )?; + for row in statement.query_map([user_id], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)) + })? { + let (post_id, media_id) = row?; + if let Some(index) = post_index.get(&post_id) { + posts[*index].media_ids.push(media_id); + } + } + } + { + let mut statement = tx.prepare( + r#" + SELECT post_id, provider, video_id, original_url, canonical_url, title, + thumbnail_url, embed_url, position + FROM post_embeds + WHERE post_id IN (SELECT id FROM posts WHERE user_id = ? AND is_deleted = 0) + ORDER BY post_id ASC, position ASC + "#, + )?; + for row in statement.query_map([user_id], |row| { + Ok(( + row.get::<_, i64>(0)?, + EmbedDocument { + provider: row.get(1)?, + video_id: row.get(2)?, + original_url: row.get(3)?, + canonical_url: row.get(4)?, + title: row.get(5)?, + thumbnail_url: row.get(6)?, + embed_url: row.get(7)?, + position: row.get(8)?, + }, + )) + })? { + let (post_id, embed) = row?; + if let Some(index) = post_index.get(&post_id) { + posts[*index].embeds.push(embed); + } + } + } + + let media = { + let mut statement = tx.prepare( + r#" + SELECT DISTINCT m.id, m.original_filename, m.mime_type, m.media_kind, + m.alt_text, m.is_nsfw, m.conversion_state, m.stored_path + FROM media m + JOIN post_media pm ON pm.media_id = m.id + JOIN posts p ON p.id = pm.post_id + WHERE p.user_id = ? AND p.is_deleted = 0 AND m.owner_user_id = ? + ORDER BY m.id ASC + "#, + )?; + statement + .query_map(params![user_id, user_id], |row| { + Ok(ExportMediaRow { + id: row.get(0)?, + original_filename: row.get(1)?, + mime_type: row.get(2)?, + media_kind: row.get(3)?, + alt_text: row.get(4)?, + is_nsfw: row.get::<_, i64>(5)? != 0, + conversion_state: row.get(6)?, + stored_path: row.get(7)?, + }) + })? + .collect::, _>>()? + }; + + let follows = { + let mut statement = tx.prepare( + r#" + SELECT u.username, f.created_at + FROM follows f JOIN users u ON u.id = f.followed_id + WHERE f.follower_id = ? + ORDER BY u.normalized_username ASC, u.id ASC + "#, + )?; + statement + .query_map([user_id], |row| { + Ok(FollowDocument { + username: row.get(0)?, + created_at: row.get(1)?, + }) + })? + .collect::, _>>()? + }; + + let muted_words = { + let mut statement = tx.prepare( + "SELECT term, created_at FROM muted_words WHERE user_id = ? ORDER BY normalized_term ASC, id ASC", + )?; + statement + .query_map([user_id], |row| { + Ok(MutedWordDocument { + term: row.get(0)?, + created_at: row.get(1)?, + }) + })? + .collect::, _>>()? + }; + + tx.commit()?; + Ok(ExportSnapshot { + username, + profile, + posts, + media, + follows, + muted_words, + }) + }) + .await +} + +/// Confirms a stored media path is a regular file inside the upload roots the +/// media kind may live in. Stored paths come from the database, which must be +/// treated as untrusted after a restore. +fn validate_export_media_source( + paths: &RuntimePaths, + media_kind: &str, + mime_type: &str, + stored_path: &str, +) -> anyhow::Result { + let allowed_roots: [&Path; 2] = match media_kind { + "image" => [&paths.uploads_images, &paths.uploads_originals], + "video" => [&paths.uploads_videos, &paths.uploads_originals], + _ => anyhow::bail!("media row has an unsupported media kind"), + }; + if mime_type.is_empty() || !mime_type.starts_with(&format!("{media_kind}/")) { + anyhow::bail!("media row mime type does not match its media kind"); + } + let path = PathBuf::from(stored_path); + if !path.is_absolute() + || path + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + anyhow::bail!("stored media path is not a safe absolute path"); + } + if !allowed_roots.iter().any(|root| path.starts_with(root)) { + anyhow::bail!("stored media path is outside the upload directories"); + } + let metadata = fs::symlink_metadata(&path) + .with_context(|| format!("media file {} is missing", path.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + anyhow::bail!("stored media path is not a regular file"); + } + Ok(path) +} + +struct ExportMediaPlan { + archive_id: i64, + archive_path: String, + source: PathBuf, + byte_len: u64, + sha256: String, + original_filename: String, + mime_type: String, + media_kind: String, + alt_text: String, + is_nsfw: bool, + conversion_state: String, +} + +impl ExportMediaPlan { + fn document(&self) -> MediaDocument { + MediaDocument { + id: self.archive_id, + original_filename: self.original_filename.clone(), + mime_type: self.mime_type.clone(), + media_kind: self.media_kind.clone(), + byte_len: self.byte_len, + alt_text: self.alt_text.clone(), + is_nsfw: self.is_nsfw, + conversion_state: self.conversion_state.clone(), + sha256: self.sha256.clone(), + archive_path: self.archive_path.clone(), + } + } +} + +struct ExportDocuments<'a> { + manifest: &'a ManifestDocument, + profile: &'a ProfileDocument, + posts: &'a [PostDocument], + follows: &'a [FollowDocument], + settings: &'a SettingsDocument, + media_plans: &'a [ExportMediaPlan], +} + +type ExportBuilder = Builder>>; + +/// Writes the gzip tar archive to a temporary sibling and renames it into +/// place. The temporary file is removed on every failure path. +fn write_export_archive( + destination: &Path, + destination_dir: &Path, + documents: &ExportDocuments<'_>, + limits: ArchiveLimits, +) -> anyhow::Result { + let Some(file_name) = destination.file_name().and_then(|name| name.to_str()) else { + anyhow::bail!("export destination must be a plain file name"); + }; + let temp_path = destination_dir.join(format!(".{file_name}.{}.tmp", Uuid::new_v4().simple())); + let mut guard = TempOutputGuard::new(temp_path.clone()); + let file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp_path) + .with_context(|| format!("failed to create account archive {}", temp_path.display()))?; + let writer = CappedWriter::new(file, limits.compressed_bytes); + let encoder = GzEncoder::new(writer, Compression::default()); + let mut builder = Builder::new(encoder); + + append_json_entry(&mut builder, MANIFEST_PATH, documents.manifest)?; + append_json_entry(&mut builder, PROFILE_PATH, documents.profile)?; + append_json_entry(&mut builder, POSTS_PATH, documents.posts)?; + let media_documents = documents + .media_plans + .iter() + .map(ExportMediaPlan::document) + .collect::>(); + append_json_entry(&mut builder, MEDIA_PATH, &media_documents)?; + append_json_entry(&mut builder, FOLLOWS_PATH, documents.follows)?; + append_json_entry(&mut builder, SETTINGS_PATH, documents.settings)?; + for plan in documents.media_plans { + append_media_entry(&mut builder, plan)?; + } + + builder + .finish() + .with_context(|| "failed to finish account archive")?; + let encoder = builder + .into_inner() + .context("failed to finish account archive")?; + let writer = encoder + .finish() + .context("failed to finish account archive")?; + let bytes = writer.written; + let file = writer.into_inner(); + file.sync_all() + .with_context(|| "failed to flush account archive")?; + drop(file); + fs::rename(&temp_path, destination).with_context(|| { + format!( + "failed to install account archive {}", + destination.display() + ) + })?; + guard.disarm(); + Ok(bytes) +} + +fn append_json_entry( + builder: &mut ExportBuilder, + archive_path: &str, + value: &T, +) -> anyhow::Result<()> { + let bytes = serde_json::to_vec(value)?; + let size = u64::try_from(bytes.len())?; + let header = archive_entry_header(archive_path, size)?; + builder + .append(&header, bytes.as_slice()) + .with_context(|| format!("failed to write archive entry {archive_path}"))?; + Ok(()) +} + +fn append_media_entry(builder: &mut ExportBuilder, plan: &ExportMediaPlan) -> anyhow::Result<()> { + let mut source = File::open(&plan.source) + .with_context(|| format!("failed to open media file {}", plan.source.display()))?; + let header = archive_entry_header(&plan.archive_path, plan.byte_len)?; + builder + .append(&header, &mut source) + .with_context(|| format!("failed to write archive entry {}", plan.archive_path))?; + Ok(()) +} + +fn archive_entry_header(archive_path: &str, size: u64) -> anyhow::Result
{ + let mut header = Header::new_ustar(); + header.set_path(archive_path)?; + header.set_entry_type(EntryType::Regular); + header.set_size(size); + header.set_mode(0o600); + header.set_uid(0); + header.set_gid(0); + header.set_mtime(0); + header.set_cksum(); + Ok(header) +} + +/// Removes the staged archive if it was not renamed into place. +struct TempOutputGuard { + path: PathBuf, + armed: bool, +} + +impl TempOutputGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempOutputGuard { + fn drop(&mut self) { + if !self.armed { + return; + } + if let Err(error) = fs::remove_file(&self.path) + && error.kind() != io::ErrorKind::NotFound + { + tracing::debug!( + path = %self.path.display(), + error = %error, + "failed to remove staged account archive" + ); + } + } +} + +/// Counts written bytes and fails once the archive would exceed `limit`. +/// Wrapping the gzip writer means the cap applies to the compressed archive +/// size, which is what the upload limit constrains. +struct CappedWriter { + inner: W, + written: u64, + limit: u64, +} + +impl CappedWriter { + fn new(inner: W, limit: u64) -> Self { + Self { + inner, + written: 0, + limit, + } + } + + fn into_inner(self) -> W { + self.inner + } +} + +impl Write for CappedWriter { + fn write(&mut self, buffer: &[u8]) -> io::Result { + let next = self + .written + .saturating_add(u64::try_from(buffer.len()).unwrap_or(u64::MAX)); + if next > self.limit { + return Err(io::Error::other(format!( + "account archive exceeds the {}-byte limit", + self.limit + ))); + } + let written = self.inner.write(buffer)?; + self.written = self + .written + .saturating_add(u64::try_from(written).unwrap_or(u64::MAX)); + Ok(written) + } + + fn flush(&mut self) -> io::Result<()> { + self.inner.flush() + } +} + +fn hash_file_streaming(path: &Path) -> anyhow::Result<(u64, String)> { + let mut file = File::open(path) + .with_context(|| format!("failed to open media file {}", path.display()))?; + let mut hasher = Sha256::new(); + let mut buffer = vec![0_u8; COPY_BUFFER_BYTES]; + let mut size = 0_u64; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + size = size.saturating_add(u64::try_from(read)?); + hasher.update(&buffer[..read]); + } + Ok((size, hex_lower(hasher.finalize().as_ref()))) +} + +/// Maps an archive mime type to a sanitized extension. The stored host path +/// and original file name are never used for archive entry names. +fn export_media_extension(mime_type: &str, media_kind: &str) -> &'static str { + match (media_kind, mime_type) { + ("image", "image/jpeg") => "jpg", + ("image", "image/png") => "png", + ("image", "image/gif") => "gif", + ("image", "image/webp") => "webp", + ("image", _) => "img", + ("video", "video/webm") => "webm", + ("video", "video/quicktime") => "mov", + ("video", _) => "mp4", + _ => "bin", + } +} + +// --------------------------------------------------------------------------- +// Import: archive validation and extraction +// --------------------------------------------------------------------------- + +/// Validates and imports `archive_path` into the authenticated destination +/// account `user_id`. +/// +/// Nothing is written until the whole archive validates. Media files are +/// copied from the staging directory into the destination upload directories +/// first, then one database transaction inserts every row; if the transaction +/// fails the copied files are removed. Imported follows respect protected +/// accounts by creating pending requests, and archives cannot change the +/// destination username, password, or administrator status. +pub async fn import_account( + pool: &SqlitePool, + paths: &RuntimePaths, + settings: &Settings, + user_id: i64, + archive_path: &Path, +) -> anyhow::Result { + // 1. Extract and validate the entire archive before any mutation. + fs::create_dir_all(&paths.tmp_dir).with_context(|| { + format!( + "failed to create account import staging directory {}", + paths.tmp_dir.display() + ) + })?; + let staging = tempfile::Builder::new() + .prefix(IMPORT_TMP_PREFIX) + .tempdir_in(&paths.tmp_dir) + .with_context(|| "failed to create account import staging directory")?; + let limits = ArchiveLimits::from_settings(settings); + let extracted = extract_archive(archive_path, staging.path(), limits)?; + let staged = parse_archive(settings, &extracted, limits)?; + drop(extracted); + + // 2. Destination and repeat-import checks, still before any mutation. + ensure_import_destination(pool, user_id).await?; + if archive_already_imported(pool, user_id, &staged.manifest.archive_id).await? { + anyhow::bail!(ALREADY_IMPORTED_MESSAGE); + } + + // 3. Copy media bytes into the upload directories, claiming unique names. + let installed = install_media_files(pool, paths, settings, &staged.media).await?; + + // 4. Apply every database row in one transaction. The transaction reads + // the installed file paths, so the files must exist before it starts. + let plan = ImportPlan { + archive_id: staged.manifest.archive_id, + archive_username: staged.manifest.username, + profile: staged.profile, + posts: staged.posts, + media: staged.media, + media_install: installed.decisions, + follows: staged.follows, + muted_words: staged.muted_words, + }; + let max_username_len = settings.accounts.max_username_len; + let result = apply_import(pool, user_id, max_username_len, plan).await; + match result { + Ok((report, unused_files)) => { + // Cleanup order: files that turned out to duplicate an existing + // canonical row are only removed after the transaction commits. + remove_files_best_effort(&unused_files); + Ok(report) + } + Err(error) => { + // Cleanup order: the transaction rolled back, so remove every file + // copied into the upload directories. The staging directory removes + // itself when `staging` drops at the end of this scope. + remove_files_best_effort(&installed.installed_paths); + Err(error) + } + } +} + +/// Everything read from a validated archive, plus the staging location of the +/// media bytes. Documents are small JSON buffers; media stays on disk. +struct ExtractedArchive { + documents: BTreeMap>, + media_files: BTreeMap, + entry_count: usize, +} + +struct ExtractedMediaFile { + staged_path: PathBuf, + size: u64, + sha256: String, + /// Bounded copy of the entry's first bytes, used for content sniffing. + prefix: Vec, +} + +/// Extracts the archive into `staging`, enforcing structural limits. Every +/// path, entry type, duplicate name, and size is checked before a single byte +/// is written outside the staging directory, and byte limits are enforced +/// incrementally while entries stream so a compressed bomb never expands past +/// `limits.expanded_bytes` on disk or in memory. +#[expect( + clippy::too_many_lines, + reason = "one ordered pass validates paths, documents, and streamed media together" +)] +fn extract_archive( + archive_path: &Path, + staging: &Path, + limits: ArchiveLimits, +) -> anyhow::Result { + let metadata = fs::symlink_metadata(archive_path) + .with_context(|| format!("account archive {} does not exist", archive_path.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + anyhow::bail!("account archive is not a regular file"); + } + if metadata.len() > limits.compressed_bytes { + anyhow::bail!( + "account archive is too large ({} bytes); the limit is {} bytes", + metadata.len(), + limits.compressed_bytes + ); + } + + let file = File::open(archive_path) + .with_context(|| format!("failed to open account archive {}", archive_path.display()))?; + let decoder = GzDecoder::new(file); + let mut archive = Archive::new(decoder); + let entries = archive + .entries() + .with_context(|| "account archive is not a valid gzip tar archive")?; + + let mut documents = BTreeMap::new(); + let mut media_files = BTreeMap::new(); + let mut seen = BTreeSet::new(); + let mut entry_count = 0usize; + let mut total_document_bytes = 0u64; + let mut extracted_media_bytes = 0u64; + + for entry in entries { + let mut entry = entry.with_context(|| "account archive is not a valid gzip tar archive")?; + entry_count += 1; + if entry_count > limits.entries { + anyhow::bail!( + "account archive contains more than {} entries", + limits.entries + ); + } + let (name, entry_type) = validate_entry_path(&entry)?; + // Extraction targets can live on case-insensitive filesystems, so + // `media/0.png` and `media/0.PNG` must collide before either is written. + if !seen.insert(name.to_ascii_lowercase()) { + anyhow::bail!("account archive contains duplicate entry {name}"); + } + if entry_type.is_dir() { + if name != MEDIA_DIR { + anyhow::bail!("account archive contains unexpected directory {name}"); + } + continue; + } + if !entry_type.is_file() { + anyhow::bail!("account archive contains an unsupported entry type for {name}"); + } + if DOCUMENT_PATHS.contains(&name.as_str()) { + let limit = document_limit(&name); + let bytes = read_limited_entry(&mut entry, limit) + .with_context(|| format!("{name} is too large or unreadable"))?; + total_document_bytes = total_document_bytes + .checked_add(u64::try_from(bytes.len())?) + .ok_or_else(|| anyhow::anyhow!("account archive document size overflow"))?; + if total_document_bytes > MAX_TOTAL_DOCUMENT_BYTES { + anyhow::bail!( + "account archive JSON documents exceed the {MAX_TOTAL_DOCUMENT_BYTES}-byte total limit" + ); + } + documents.insert(name, bytes); + } else if let Some(relative) = name.strip_prefix(MEDIA_PREFIX) { + let relative_path = Path::new(relative); + let valid_relative = !relative.is_empty() + && relative_path + .components() + .all(|component| matches!(component, Component::Normal(_))); + if !valid_relative || relative_path.components().count() != 1 { + anyhow::bail!("account archive contains an invalid media path {name}"); + } + // Reject an oversized entry from its header before reading any of + // its bytes so a declared giant size cannot consume disk. + let declared_size = entry + .header() + .size() + .with_context(|| format!("account archive entry {name} has an invalid size"))?; + if declared_size > limits.expanded_bytes { + anyhow::bail!( + "account archive media entry {name} declares {declared_size} bytes; the limit is {} bytes", + limits.expanded_bytes + ); + } + // A valid archive can never list more media documents than + // `media.json` accepts, so stop extracting sooner than the general + // entry cap allows. + if media_files.len() >= MAX_MEDIA { + anyhow::bail!("account archive contains more than {MAX_MEDIA} media files"); + } + let media_dir = staging.join(MEDIA_DIR); + fs::create_dir_all(&media_dir)?; + let target = media_dir.join(relative); + let (size, sha256, prefix, total) = extract_media_entry( + &mut entry, + &target, + extracted_media_bytes, + limits.expanded_bytes, + )?; + extracted_media_bytes = total; + media_files.insert( + name, + ExtractedMediaFile { + staged_path: target, + size, + sha256, + prefix, + }, + ); + } else { + anyhow::bail!("account archive contains unexpected entry {name}"); + } + } + + for document in DOCUMENT_PATHS { + if !documents.contains_key(document) { + anyhow::bail!("account archive is missing required document {document}"); + } + } + Ok(ExtractedArchive { + documents, + media_files, + entry_count, + }) +} + +/// Validates one tar path and returns its normalized name and type. +fn validate_entry_path(entry: &tar::Entry<'_, impl Read>) -> anyhow::Result<(String, EntryType)> { + let entry_type = entry.header().entry_type(); + if !entry_type.is_file() && !entry_type.is_dir() { + anyhow::bail!("account archive contains an unsupported entry type"); + } + let raw = entry.path_bytes(); + let raw = std::str::from_utf8(raw.as_ref()) + .map_err(|_utf8| anyhow::anyhow!("account archive contains a non-UTF-8 path"))?; + if raw.is_empty() || raw.starts_with('/') { + anyhow::bail!("account archive contains an absolute or empty path"); + } + // Archives written by RustPost only use ASCII names; anything else is + // almost certainly crafted to confuse normalization or filesystems. + if !raw.is_ascii() { + anyhow::bail!("account archive contains a non-ASCII path"); + } + if raw.contains('\\') + || raw.contains(':') + || raw.contains("//") + || raw.contains('\u{2215}') + || raw.contains('\u{2044}') + || raw.contains('\u{29f8}') + || raw.contains('\u{ff0f}') + { + anyhow::bail!("account archive path contains unsafe characters"); + } + let lower = raw.to_ascii_lowercase(); + if lower.contains("%2e") || lower.contains("%2f") || lower.contains("%5c") { + anyhow::bail!("account archive path contains unsafe characters"); + } + let normalized = if entry_type.is_dir() { + raw.strip_suffix('/').unwrap_or(raw) + } else { + raw + }; + if normalized.is_empty() || normalized.contains("//") || normalized.ends_with('/') { + anyhow::bail!("account archive path contains unsafe separators"); + } + let path = Path::new(normalized); + if path.is_absolute() + || path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + anyhow::bail!("account archive entry path contains traversal"); + } + if let Some(relative) = normalized.strip_prefix(MEDIA_PREFIX) + && !relative.is_empty() + { + let file_name = Path::new(relative) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(relative); + if file_name.len() > MAX_MEDIA_FILE_NAME_BYTES { + anyhow::bail!("account archive media entry name is too long"); + } + if file_name.ends_with('.') || file_name.ends_with(' ') { + anyhow::bail!("account archive media entry name ends with '.' or a space"); + } + } + Ok((normalized.to_owned(), entry_type)) +} + +fn document_limit(name: &str) -> u64 { + if name == MANIFEST_PATH { + MANIFEST_MAX_BYTES + } else { + DOCUMENT_MAX_BYTES + } +} + +fn read_limited_entry( + entry: &mut tar::Entry<'_, impl Read>, + limit: u64, +) -> anyhow::Result> { + let mut buffer = Vec::new(); + let mut limited = entry.take(limit.saturating_add(1)); + limited.read_to_end(&mut buffer)?; + if u64::try_from(buffer.len()).unwrap_or(u64::MAX) > limit { + anyhow::bail!("document exceeds the {limit}-byte limit"); + } + Ok(buffer) +} + +/// Streams one media entry into staging while hashing it, collecting a small +/// content-sniffing prefix, and enforcing the per-entry and running-total byte +/// caps. Memory use is one fixed-size buffer plus the bounded prefix. +fn extract_media_entry( + entry: &mut tar::Entry<'_, impl Read>, + target: &Path, + already_extracted: u64, + expanded_limit: u64, +) -> anyhow::Result<(u64, String, Vec, u64)> { + let mut output = OpenOptions::new() + .write(true) + .create_new(true) + .open(target) + .with_context(|| format!("failed to extract media file {}", target.display()))?; + let mut hasher = Sha256::new(); + let mut buffer = vec![0_u8; COPY_BUFFER_BYTES]; + let mut prefix = Vec::with_capacity(MEDIA_SNIFF_PREFIX_BYTES); + let mut size = 0_u64; + let mut total = already_extracted; + loop { + let read = entry.read(&mut buffer).with_context(|| { + format!( + "failed to read account archive media entry for {}", + target.display() + ) + })?; + if read == 0 { + break; + } + let read = u64::try_from(read)?; + size = size + .checked_add(read) + .ok_or_else(|| anyhow::anyhow!("account archive media size overflow"))?; + total = total + .checked_add(read) + .ok_or_else(|| anyhow::anyhow!("account archive media size overflow"))?; + if size > expanded_limit || total > expanded_limit { + anyhow::bail!("account archive media exceeds the {expanded_limit}-byte limit"); + } + let chunk = &buffer[..usize::try_from(read)?]; + if prefix.len() < MEDIA_SNIFF_PREFIX_BYTES { + let take = (MEDIA_SNIFF_PREFIX_BYTES - prefix.len()).min(chunk.len()); + prefix.extend_from_slice(&chunk[..take]); + } + hasher.update(chunk); + output.write_all(chunk)?; + } + output + .sync_all() + .with_context(|| format!("failed to flush extracted media file {}", target.display()))?; + Ok((size, hex_lower(hasher.finalize().as_ref()), prefix, total)) +} + +// --------------------------------------------------------------------------- +// Import: document validation +// --------------------------------------------------------------------------- + +struct StagedArchive { + manifest: ManifestDocument, + profile: ProfileDocument, + posts: Vec, + media: Vec, + follows: Vec, + muted_words: Vec, +} + +struct ValidatedPost { + archive_id: i64, + text: String, + created_at: String, + edited_at: Option, + parent_id: Option, + root_id: Option, + quote_id: Option, + media_ids: Vec, + embeds: Vec, +} + +struct ValidatedMedia { + id: i64, + original_filename: String, + mime_type: String, + media_kind: String, + byte_len: u64, + alt_text: String, + is_nsfw: bool, + sha256: String, + archive_path: String, + staged_path: PathBuf, +} + +/// Parses and validates every document, applying destination instance limits. +fn parse_archive( + settings: &Settings, + extracted: &ExtractedArchive, + limits: ArchiveLimits, +) -> anyhow::Result { + let document = |name: &str| -> anyhow::Result<&[u8]> { + extracted + .documents + .get(name) + .map(Vec::as_slice) + .ok_or_else(|| anyhow::anyhow!("account archive is missing required document {name}")) + }; + + let manifest: ManifestDocument = parse_document(MANIFEST_PATH, document(MANIFEST_PATH)?)?; + if manifest.format_version != FORMAT_VERSION { + anyhow::bail!( + "unsupported account archive format version {} (this instance supports {FORMAT_VERSION})", + manifest.format_version + ); + } + if manifest.app != APP_NAME { + anyhow::bail!("account archive is not a RustPost account archive"); + } + validate_timestamp_text(&manifest.created_at, "manifest created_at")?; + if manifest.archive_id.is_empty() + || manifest.archive_id.chars().count() > 128 + || manifest.archive_id.chars().any(char::is_control) + { + anyhow::bail!("manifest archive_id is invalid"); + } + if manifest.username.is_empty() + || manifest.username.chars().count() > 128 + || manifest.username.chars().any(char::is_control) + { + anyhow::bail!("manifest username is invalid"); + } + if manifest.entry_count != extracted.entry_count { + anyhow::bail!("manifest entry_count does not match the number of archive entries"); + } + + let mut profile: ProfileDocument = parse_document(PROFILE_PATH, document(PROFILE_PATH)?)?; + if profile.theme != "light" && profile.theme != "dark" { + anyhow::bail!("profile theme must be light or dark"); + } + validate_profile_text(&profile.display_name, &profile.bio, settings) + .with_context(|| "profile contains invalid text")?; + if profile.location.chars().count() > MAX_LOCATION_CHARS + || profile.location.chars().any(char::is_control) + { + anyhow::bail!("profile location is invalid"); + } + let website = profile.website.trim().to_owned(); + if website.chars().count() > MAX_WEBSITE_CHARS || website.chars().any(char::is_control) { + anyhow::bail!("profile website is invalid"); + } + if !(website.is_empty() || website.starts_with("http://") || website.starts_with("https://")) { + anyhow::bail!("profile website must start with http:// or https://"); + } + profile.website = website; + validate_timestamp_text(&profile.created_at, "profile created_at")?; + + let media_documents: Vec = parse_document(MEDIA_PATH, document(MEDIA_PATH)?)?; + let media = validate_media_documents(&media_documents, extracted, limits)?; + let media_ids = media.iter().map(|entry| entry.id).collect::>(); + + let post_documents: Vec = parse_document(POSTS_PATH, document(POSTS_PATH)?)?; + let posts = validate_post_documents(&post_documents, settings, &media_ids)?; + + let follows: Vec = parse_document(FOLLOWS_PATH, document(FOLLOWS_PATH)?)?; + if follows.len() > MAX_FOLLOWS { + anyhow::bail!("follows.json contains more than {MAX_FOLLOWS} entries"); + } + for follow in &follows { + if follow.username.is_empty() + || follow.username.chars().count() > 128 + || follow.username.chars().any(char::is_control) + { + anyhow::bail!("follows.json contains an invalid username"); + } + validate_timestamp_text(&follow.created_at, "follows.json created_at")?; + } + + let settings_document: SettingsDocument = + parse_document(SETTINGS_PATH, document(SETTINGS_PATH)?)?; + if settings_document.muted_words.len() > MAX_MUTED_WORDS { + anyhow::bail!("settings.json contains more than {MAX_MUTED_WORDS} muted words"); + } + let mut muted_words = Vec::with_capacity(settings_document.muted_words.len()); + for word in settings_document.muted_words { + let term = clean_muted_word(&word.term)?; + validate_timestamp_text(&word.created_at, "settings.json muted word created_at")?; + muted_words.push(MutedWordDocument { + term, + created_at: word.created_at, + }); + } + + Ok(StagedArchive { + manifest, + profile, + posts, + media, + follows, + muted_words, + }) +} + +/// `serde_json` applies its default recursion limit (128), so deeply nested +/// documents are rejected instead of risking stack exhaustion. +fn parse_document(name: &str, bytes: &[u8]) -> anyhow::Result { + serde_json::from_slice(bytes).with_context(|| format!("{name} is not a valid JSON document")) +} + +#[expect( + clippy::too_many_lines, + reason = "media documents are validated in two passes over ids, paths, bytes, and sniffed content" +)] +fn validate_media_documents( + documents: &[MediaDocument], + extracted: &ExtractedArchive, + limits: ArchiveLimits, +) -> anyhow::Result> { + if documents.len() > MAX_MEDIA { + anyhow::bail!("media.json contains more than {MAX_MEDIA} media entries"); + } + + let mut ids = BTreeSet::new(); + let mut paths = BTreeSet::new(); + let mut declared_total = 0_u64; + for document in documents { + if document.id < 0 { + anyhow::bail!("media.json contains a negative media id"); + } + if !ids.insert(document.id) { + anyhow::bail!("media.json contains duplicate media id {}", document.id); + } + if document.media_kind != "image" && document.media_kind != "video" { + anyhow::bail!("media {} has an unsupported media kind", document.id); + } + if document.mime_type.is_empty() + || document.mime_type.chars().count() > 255 + || document.mime_type.chars().any(char::is_control) + || !document + .mime_type + .starts_with(&format!("{}/", document.media_kind)) + { + anyhow::bail!( + "media {} mime type does not match its media kind", + document.id + ); + } + if document.sha256.len() != 64 + || !document.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + anyhow::bail!("media {} sha256 is not 64 hex characters", document.id); + } + if document.original_filename.chars().count() > MAX_ORIGINAL_FILENAME_CHARS { + anyhow::bail!("media {} original filename is too long", document.id); + } + if document.alt_text.chars().count() > MAX_ALT_TEXT_CHARS { + anyhow::bail!("media {} alt text is too long", document.id); + } + if document.conversion_state.chars().count() > MAX_CONVERSION_STATE_CHARS { + anyhow::bail!("media {} conversion state is too long", document.id); + } + if !paths.insert(document.archive_path.clone()) { + anyhow::bail!( + "media.json lists archive path {} more than once", + document.archive_path + ); + } + declared_total = declared_total + .checked_add(document.byte_len) + .ok_or_else(|| anyhow::anyhow!("media.json byte length total overflow"))?; + if declared_total > limits.expanded_bytes { + anyhow::bail!( + "media.json declares more than the {}-byte account archive media limit", + limits.expanded_bytes + ); + } + } + + let mut validated = Vec::with_capacity(documents.len()); + for document in documents { + let Some(extracted_file) = extracted.media_files.get(&document.archive_path) else { + anyhow::bail!( + "media {} archive path {} is missing from the archive", + document.id, + document.archive_path + ); + }; + if extracted_file.size != document.byte_len { + anyhow::bail!( + "media {} byte length does not match the extracted file", + document.id + ); + } + if !extracted_file.sha256.eq_ignore_ascii_case(&document.sha256) { + anyhow::bail!( + "media {} sha256 does not match the extracted file", + document.id + ); + } + // Interactive uploads derive the stored mime type from `infer`; an + // imported file must agree with its declared type so a mislabeled + // payload is never installed as media. + let Some(sniffed) = infer::get(&extracted_file.prefix) else { + anyhow::bail!( + "media {} content is not a recognized media type", + document.id + ); + }; + if !sniffed + .mime_type() + .eq_ignore_ascii_case(&document.mime_type) + { + anyhow::bail!( + "media {} content type {} does not match the declared {}", + document.id, + sniffed.mime_type(), + document.mime_type + ); + } + validated.push(ValidatedMedia { + id: document.id, + original_filename: document.original_filename.clone(), + mime_type: document.mime_type.clone(), + media_kind: document.media_kind.clone(), + byte_len: document.byte_len, + alt_text: document.alt_text.clone(), + is_nsfw: document.is_nsfw, + sha256: document.sha256.to_ascii_lowercase(), + archive_path: document.archive_path.clone(), + staged_path: extracted_file.staged_path.clone(), + }); + } + + if extracted.media_files.len() != documents.len() { + for name in extracted.media_files.keys() { + if !paths.contains(name) { + anyhow::bail!("account archive contains unlisted media file {name}"); + } + } + anyhow::bail!("media.json does not list every media file in the archive"); + } + Ok(validated) +} + +fn validate_post_documents( + documents: &[PostDocument], + settings: &Settings, + media_ids: &BTreeSet, +) -> anyhow::Result> { + if documents.len() > MAX_POSTS { + anyhow::bail!("posts.json contains more than {MAX_POSTS} posts"); + } + let mut ids = BTreeSet::new(); + for document in documents { + if document.id < 0 { + anyhow::bail!("posts.json contains a negative post id"); + } + if !ids.insert(document.id) { + anyhow::bail!("posts.json contains duplicate post id {}", document.id); + } + } + + let mut validated = Vec::with_capacity(documents.len()); + for document in documents { + let text = clean_post_text( + &document.text, + settings.posts.max_text_chars, + document.media_ids.len(), + ) + .with_context(|| format!("post {} text is invalid", document.id))?; + validate_timestamp_text(&document.created_at, "post created_at")?; + if let Some(edited_at) = &document.edited_at { + validate_timestamp_text(edited_at, "post edited_at")?; + } + for media_id in &document.media_ids { + if !media_ids.contains(media_id) { + anyhow::bail!( + "post {} references unknown media id {media_id}", + document.id + ); + } + } + if document.embeds.len() > MAX_EMBEDS_PER_POST { + anyhow::bail!( + "post {} contains more than {MAX_EMBEDS_PER_POST} embeds", + document.id + ); + } + let mut embed_positions = BTreeSet::new(); + let mut embed_videos = BTreeSet::new(); + for embed in &document.embeds { + if embed.provider != "youtube" { + anyhow::bail!( + "post {} contains an unsupported embed provider", + document.id + ); + } + if embed.video_id.is_empty() + || embed.video_id.chars().count() > MAX_EMBED_VIDEO_ID_CHARS + || embed.video_id.chars().any(char::is_control) + { + anyhow::bail!("post {} contains an invalid embed video id", document.id); + } + if embed.position < 0 || !embed_positions.insert(embed.position) { + anyhow::bail!("post {} contains duplicate embed positions", document.id); + } + if !embed_videos.insert(embed.video_id.clone()) { + anyhow::bail!("post {} lists the same embed video twice", document.id); + } + for url in [ + &embed.original_url, + &embed.canonical_url, + &embed.thumbnail_url, + &embed.embed_url, + ] { + if url.is_empty() + || url.chars().count() > MAX_EMBED_URL_CHARS + || url.chars().any(char::is_control) + { + anyhow::bail!("post {} contains an invalid embed URL", document.id); + } + } + if let Some(title) = &embed.title + && (title.chars().count() > MAX_EMBED_TITLE_CHARS + || title.chars().any(char::is_control)) + { + anyhow::bail!("post {} contains an invalid embed title", document.id); + } + } + validated.push(ValidatedPost { + archive_id: document.id, + text, + created_at: document.created_at.clone(), + edited_at: document.edited_at.clone(), + parent_id: document.parent_id, + root_id: document.root_id, + quote_id: document.quote_id, + media_ids: document.media_ids.clone(), + embeds: document.embeds.clone(), + }); + } + Ok(validated) +} + +fn validate_timestamp_text(value: &str, field: &str) -> anyhow::Result<()> { + if value.is_empty() + || value.chars().count() > MAX_TIMESTAMP_CHARS + || value.chars().any(char::is_control) + { + anyhow::bail!("{field} is not a valid timestamp"); + } + Ok(()) +} + +/// Mirrors `social::clean_muted_word` (private) so imports normalize muted +/// words exactly like the interactive path. +fn clean_muted_word(term: &str) -> anyhow::Result { + let trimmed = term.trim(); + if trimmed.is_empty() { + anyhow::bail!("muted word cannot be empty"); + } + if trimmed.chars().count() > 100 { + anyhow::bail!("muted word is too long"); + } + if trimmed + .chars() + .any(|ch| ch.is_control() && !matches!(ch, '\n' | '\r' | '\t')) + { + anyhow::bail!("muted word contains unsupported control characters"); + } + Ok(trimmed.to_owned()) +} + +// --------------------------------------------------------------------------- +// Import: destination checks and media installation +// --------------------------------------------------------------------------- + +/// Rejects deleted and deletion-pending destination accounts. +async fn ensure_import_destination(pool: &SqlitePool, user_id: i64) -> anyhow::Result<()> { + let state = pool + .call(move |conn| { + conn.query_row( + "SELECT is_deleted, deletion_requested_at, deletion_scheduled_at FROM users WHERE id = ?", + [user_id], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, Option>(2)?, + )) + }, + ) + .optional() + .map_err(Into::into) + }) + .await?; + match state { + None => anyhow::bail!("import destination account does not exist"), + Some((is_deleted, _, _)) if is_deleted != 0 => { + anyhow::bail!("import destination account is deleted") + } + Some((_, requested_at, scheduled_at)) + if requested_at.is_some() || scheduled_at.is_some() => + { + anyhow::bail!("import destination account is pending deletion") + } + Some(_) => Ok(()), + } +} + +async fn archive_already_imported( + pool: &SqlitePool, + user_id: i64, + archive_id: &str, +) -> anyhow::Result { + let archive_id = archive_id.to_owned(); + let existing = pool + .call(move |conn| { + Ok(conn + .query_row( + "SELECT 1 FROM account_imports WHERE user_id = ? AND archive_id = ?", + params![user_id, archive_id], + |_| Ok(()), + ) + .optional()? + .is_some()) + }) + .await?; + Ok(existing) +} + +/// One archived media entry's installation decision. +enum MediaInstallDecision { + /// The bytes were copied by us to these destination paths. + Copied { + stored_path: PathBuf, + public_path: String, + }, + /// An existing canonical row already stores these bytes. + Canonical { + canonical_id: i64, + stored_path: String, + public_path: String, + }, +} + +struct InstalledMedia { + decisions: Vec, + installed_paths: Vec, +} + +struct CanonicalMedia { + id: i64, + stored_path: String, + public_path: String, +} + +/// Copies every non-duplicate media entry into the destination upload +/// directories before the database transaction runs. On failure all files +/// copied so far are removed. +async fn install_media_files( + pool: &SqlitePool, + paths: &RuntimePaths, + settings: &Settings, + media: &[ValidatedMedia], +) -> anyhow::Result { + let mut decisions = Vec::with_capacity(media.len()); + let mut installed_paths = Vec::new(); + for entry in media { + match install_one_media(pool, paths, settings, entry).await { + Ok((decision, copied_path)) => { + if let Some(copied_path) = copied_path { + installed_paths.push(copied_path); + } + decisions.push(decision); + } + Err(error) => { + remove_files_best_effort(&installed_paths); + return Err(error); + } + } + } + Ok(InstalledMedia { + decisions, + installed_paths, + }) +} + +async fn install_one_media( + pool: &SqlitePool, + paths: &RuntimePaths, + settings: &Settings, + entry: &ValidatedMedia, +) -> anyhow::Result<(MediaInstallDecision, Option)> { + if let Some(canonical) = find_canonical_media(pool, &entry.media_kind, &entry.sha256).await? { + return Ok(( + MediaInstallDecision::Canonical { + canonical_id: canonical.id, + stored_path: canonical.stored_path, + public_path: canonical.public_path, + }, + None, + )); + } + let directory = media_install_dir(paths, settings, &entry.media_kind, &entry.mime_type)?; + fs::create_dir_all(directory).with_context(|| { + format!( + "failed to create media upload directory {}", + directory.display() + ) + })?; + let extension = import_media_extension(&entry.mime_type, &entry.media_kind)?; + let basename = import_media_basename(&entry.original_filename, &entry.sha256); + let target = claim_media_path(directory, &basename, extension)?; + if let Err(error) = copy_media_bytes(&entry.staged_path, &target, entry.byte_len) { + let _ = fs::remove_file(&target); + return Err(error) + .with_context(|| format!("failed to install imported media {}", entry.archive_path)); + } + let public_path = public_media_path(paths, &target)?; + Ok(( + MediaInstallDecision::Copied { + stored_path: target.clone(), + public_path, + }, + Some(target), + )) +} + +async fn find_canonical_media( + pool: &SqlitePool, + media_kind: &str, + sha256: &str, +) -> anyhow::Result> { + let media_kind = media_kind.to_owned(); + let sha256 = sha256.to_owned(); + pool.call(move |conn| { + conn.query_row( + r#" + SELECT id, stored_path, public_path FROM media + WHERE canonical_media_id IS NULL AND media_kind = ? AND original_sha256 = ? + ORDER BY id ASC LIMIT 1 + "#, + params![media_kind, sha256], + |row| { + Ok(CanonicalMedia { + id: row.get(0)?, + stored_path: row.get(1)?, + public_path: row.get(2)?, + }) + }, + ) + .optional() + .map_err(Into::into) + }) + .await +} + +/// Chooses the upload directory for imported bytes. Types outside the +/// instance's convertible allow-list are stored in `originals` because the +/// server layer would never convert or serve them from images/videos. +fn media_install_dir<'a>( + paths: &'a RuntimePaths, + settings: &Settings, + media_kind: &str, + mime_type: &str, +) -> anyhow::Result<&'a Path> { + match media_kind { + "image" => { + if settings + .media + .allowed_image_mime_types + .iter() + .any(|allowed| allowed == mime_type) + { + Ok(&paths.uploads_images) + } else { + Ok(&paths.uploads_originals) + } + } + "video" => { + if settings + .media + .allowed_video_mime_types + .iter() + .any(|allowed| allowed == mime_type) + { + Ok(&paths.uploads_videos) + } else { + Ok(&paths.uploads_originals) + } + } + _ => anyhow::bail!("media entry has an unsupported media kind"), + } +} + +fn public_media_path(paths: &RuntimePaths, path: &Path) -> anyhow::Result { + let filename = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| anyhow::anyhow!("installed media path has no file name"))?; + 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_originals) { + return Ok(format!("/uploads/originals/{filename}")); + } + anyhow::bail!("installed media path is outside the upload directories") +} + +/// Claims an unused destination file name with `create_new` so concurrent +/// imports can never overwrite each other's files. +fn claim_media_path(directory: &Path, basename: &str, extension: &str) -> anyhow::Result { + let fallback = directory.join(format!( + "{basename}-{}.{extension}", + Uuid::new_v4().simple() + )); + let candidates = [ + directory.join(format!("{basename}.{extension}")), + fallback, + directory.join(format!( + "{basename}-{}.{extension}", + Uuid::new_v4().simple() + )), + ]; + for candidate in candidates { + match OpenOptions::new() + .write(true) + .create_new(true) + .open(&candidate) + { + Ok(_claimed) => return Ok(candidate), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} + Err(error) => { + return Err(error).with_context(|| { + format!("failed to create media file {}", candidate.display()) + }); + } + } + } + anyhow::bail!("failed to claim a unique file name for imported media") +} + +fn copy_media_bytes(source: &Path, target: &Path, expected: u64) -> anyhow::Result<()> { + let mut source_file = File::open(source) + .with_context(|| format!("failed to open staged media file {}", source.display()))?; + let mut target_file = OpenOptions::new() + .write(true) + .truncate(true) + .open(target) + .with_context(|| format!("failed to open media destination {}", target.display()))?; + let copied = io::copy(&mut source_file, &mut target_file)?; + target_file + .sync_all() + .with_context(|| format!("failed to flush media destination {}", target.display()))?; + if copied != expected { + anyhow::bail!("copied media size does not match the archive entry"); + } + Ok(()) +} + +/// Mirrors `media::stable_media_basename` (private): sanitized original stem +/// plus a short content-hash prefix. +fn import_media_basename(original_filename: &str, 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 = sha256.len().min(HASH_PREFIX_LEN); + format!("{}-{}", sanitized, &sha256[..prefix_len]) +} + +/// Mirrors `media::safe_extension` (private). +fn import_media_extension(mime_type: &str, media_kind: &str) -> anyhow::Result<&'static str> { + match (media_kind, mime_type) { + ("image", "image/jpeg") => Ok("jpg"), + ("image", "image/png") => Ok("png"), + ("image", "image/gif") => Ok("gif"), + ("image", "image/webp") => Ok("webp"), + ("image", _) => Ok("img"), + ("video", "video/webm") => Ok("webm"), + ("video", "video/quicktime") => Ok("mov"), + ("video", _) => Ok("mp4"), + _ => anyhow::bail!("media entry has an unsupported media kind"), + } +} + +fn remove_files_best_effort(paths: &[PathBuf]) { + for path in paths { + if let Err(error) = fs::remove_file(path) + && error.kind() != io::ErrorKind::NotFound + { + tracing::warn!( + path = %path.display(), + error = %error, + "failed to remove imported media file during cleanup" + ); + } + } +} + +// --------------------------------------------------------------------------- +// Import: database transaction +// --------------------------------------------------------------------------- + +struct ImportPlan { + archive_id: String, + archive_username: String, + profile: ProfileDocument, + posts: Vec, + media: Vec, + media_install: Vec, + follows: Vec, + muted_words: Vec, +} + +struct DestinationRow { + display_name: String, + bio: String, + location: String, + website: String, + theme: String, + nsfw_blur_enabled: bool, + liked_posts_public: bool, + follow_approval_required: bool, +} + +/// Applies the import in one transaction. Returns the report plus files that +/// were copied but turned out to be unnecessary duplicates; the caller removes +/// those only after the commit succeeds. +#[expect( + clippy::too_many_lines, + reason = "the import transaction inserts every record family in one auditable block" +)] +async fn apply_import( + pool: &SqlitePool, + user_id: i64, + max_username_len: usize, + plan: ImportPlan, +) -> anyhow::Result<(ImportReport, Vec)> { + pool.call(move |conn| { + let tx = conn.transaction()?; + + // Re-check the destination inside the transaction; concurrent account + // deletion must not race with the import. + let destination = tx + .query_row( + r#" + SELECT is_deleted, deletion_requested_at, deletion_scheduled_at, + display_name, bio, location, website, theme, nsfw_blur_enabled, + liked_posts_public, follow_approval_required + FROM users WHERE id = ? + "#, + [user_id], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, Option>(2)?, + DestinationRow { + display_name: row.get(3)?, + bio: row.get(4)?, + location: row.get(5)?, + website: row.get(6)?, + theme: row.get(7)?, + nsfw_blur_enabled: row.get::<_, i64>(8)? != 0, + liked_posts_public: row.get::<_, i64>(9)? != 0, + follow_approval_required: row.get::<_, i64>(10)? != 0, + }, + )) + }, + ) + .optional()? + .ok_or_else(|| anyhow::anyhow!("import destination account does not exist"))?; + if destination.0 != 0 { + anyhow::bail!("import destination account is deleted"); + } + if destination.1.is_some() || destination.2.is_some() { + anyhow::bail!("import destination account is pending deletion"); + } + let current = destination.3; + + // Profile: fill only empty text fields, apply preference fields as-is. + let mut profile_fields_applied = 0usize; + let mut profile_fields_skipped = 0usize; + let display_name = fill_empty_field( + ¤t.display_name, + &plan.profile.display_name, + &mut profile_fields_applied, + &mut profile_fields_skipped, + ); + let bio = fill_empty_field( + ¤t.bio, + &plan.profile.bio, + &mut profile_fields_applied, + &mut profile_fields_skipped, + ); + let location = fill_empty_field( + ¤t.location, + &plan.profile.location, + &mut profile_fields_applied, + &mut profile_fields_skipped, + ); + let website = fill_empty_field( + ¤t.website, + &plan.profile.website, + &mut profile_fields_applied, + &mut profile_fields_skipped, + ); + let theme = replace_preference( + ¤t.theme, + &plan.profile.theme, + &mut profile_fields_applied, + &mut profile_fields_skipped, + ); + let nsfw_blur_enabled = replace_preference( + current.nsfw_blur_enabled, + plan.profile.nsfw_blur_enabled, + &mut profile_fields_applied, + &mut profile_fields_skipped, + ); + let liked_posts_public = replace_preference( + current.liked_posts_public, + plan.profile.liked_posts_public, + &mut profile_fields_applied, + &mut profile_fields_skipped, + ); + let follow_approval_required = replace_preference( + current.follow_approval_required, + plan.profile.follow_approval_required, + &mut profile_fields_applied, + &mut profile_fields_skipped, + ); + tx.execute( + r#" + UPDATE users SET display_name = ?, bio = ?, location = ?, website = ?, + theme = ?, nsfw_blur_enabled = ?, liked_posts_public = ?, + follow_approval_required = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + "#, + params![ + display_name, + bio, + location, + website, + theme, + i64::from(nsfw_blur_enabled), + i64::from(liked_posts_public), + i64::from(follow_approval_required), + user_id, + ], + )?; + + // Media rows first so posts can attach remapped media ids. + if plan.media.len() != plan.media_install.len() { + anyhow::bail!("internal import error: media install plan is incomplete"); + } + let mut media_id_map: HashMap = HashMap::with_capacity(plan.media.len()); + let mut unused_copies: Vec = Vec::new(); + for (media, decision) in plan.media.iter().zip(plan.media_install.iter()) { + let (stored_path, public_path, canonical_id) = match decision { + MediaInstallDecision::Copied { + stored_path, + public_path, + } => { + // A canonical row may have appeared since planning. Prefer + // it and leave our copy for post-commit cleanup. + match find_canonical_media_tx(&tx, &media.media_kind, &media.sha256)? { + Some((canonical_id, canonical_stored, canonical_public)) => { + unused_copies.push(stored_path.clone()); + (canonical_stored, canonical_public, Some(canonical_id)) + } + None => ( + stored_path.to_string_lossy().to_string(), + public_path.clone(), + None, + ), + } + } + MediaInstallDecision::Canonical { + canonical_id, + stored_path, + public_path, + } => { + let still_valid = tx + .query_row( + r#" + SELECT 1 FROM media + WHERE id = ? AND canonical_media_id IS NULL + AND media_kind = ? AND original_sha256 = ? + "#, + params![canonical_id, media.media_kind, media.sha256], + |_| Ok(()), + ) + .optional()? + .is_some(); + if !still_valid { + anyhow::bail!( + "media deduplication target disappeared during import" + ); + } + (stored_path.clone(), public_path.clone(), Some(*canonical_id)) + } + }; + tx.execute( + r#" + INSERT INTO media ( + owner_user_id, original_filename, stored_path, public_path, + mime_type, media_kind, byte_len, alt_text, conversion_state, + original_sha256, canonical_media_id, is_nsfw + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'imported', ?, ?, ?) + "#, + params![ + user_id, + media.original_filename, + stored_path, + public_path, + media.mime_type, + media.media_kind, + i64::try_from(media.byte_len)?, + media.alt_text, + media.sha256, + canonical_id, + i64::from(media.is_nsfw), + ], + )?; + media_id_map.insert(media.id, tx.last_insert_rowid()); + } + + // Posts are inserted in two phases: first without references, then an + // UPDATE per row. This avoids ordering problems when a quote points at + // a post with a higher row id while keeping foreign keys valid. + let mut post_id_map: HashMap = HashMap::with_capacity(plan.posts.len()); + for post in &plan.posts { + tx.execute( + "INSERT INTO posts (user_id, text, created_at, edited_at) VALUES (?, ?, ?, ?)", + params![user_id, post.text, post.created_at, post.edited_at], + )?; + post_id_map.insert(post.archive_id, tx.last_insert_rowid()); + } + let mut post_references_dropped = 0usize; + for post in &plan.posts { + let new_id = *post_id_map + .get(&post.archive_id) + .ok_or_else(|| anyhow::anyhow!("internal import error: post id missing"))?; + let parent_id = post.parent_id.and_then(|id| post_id_map.get(&id).copied()); + let root_id = post.root_id.and_then(|id| post_id_map.get(&id).copied()); + let quote_id = post.quote_id.and_then(|id| post_id_map.get(&id).copied()); + post_references_dropped += usize::from(post.parent_id.is_some() && parent_id.is_none()) + + usize::from(post.root_id.is_some() && root_id.is_none()) + + usize::from(post.quote_id.is_some() && quote_id.is_none()); + if parent_id.is_some() || root_id.is_some() || quote_id.is_some() { + tx.execute( + "UPDATE posts SET parent_post_id = ?, root_post_id = ?, quote_post_id = ? WHERE id = ?", + params![parent_id, root_id, quote_id, new_id], + )?; + } + for (position, media_id) in post.media_ids.iter().enumerate() { + let new_media_id = media_id_map.get(media_id).copied().ok_or_else(|| { + anyhow::anyhow!("internal import error: media id missing") + })?; + tx.execute( + "INSERT INTO post_media (post_id, media_id, position) VALUES (?, ?, ?)", + params![new_id, new_media_id, i64::try_from(position)?], + )?; + } + for embed in &post.embeds { + tx.execute( + r#" + INSERT INTO post_embeds ( + post_id, provider, video_id, original_url, canonical_url, title, + thumbnail_url, embed_url, position, fetched_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CASE WHEN ? IS NULL THEN NULL ELSE CURRENT_TIMESTAMP END) + "#, + params![ + new_id, + embed.provider, + embed.video_id, + embed.original_url, + embed.canonical_url, + embed.title, + embed.thumbnail_url, + embed.embed_url, + embed.position, + embed.title, + ], + )?; + } + } + + // Follows resolve against the destination instance; protected targets + // always use follow_requests and never bypass approval. + let mut follows_imported = 0usize; + let mut follows_pending = 0usize; + let mut follows_skipped = 0usize; + for follow in &plan.follows { + let Ok(normalized) = normalize_username(&follow.username, max_username_len) else { + follows_skipped += 1; + continue; + }; + let target = tx + .query_row( + "SELECT id, is_deleted, is_suspended, follow_approval_required FROM users WHERE normalized_username = ?", + [normalized], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, i64>(3)?, + )) + }, + ) + .optional()?; + let Some((target_id, is_deleted, is_suspended, approval_required)) = target else { + follows_skipped += 1; + continue; + }; + if target_id == user_id || is_deleted != 0 || is_suspended != 0 { + follows_skipped += 1; + continue; + } + if is_blocked_tx(&tx, user_id, target_id)? { + follows_skipped += 1; + continue; + } + if approval_required != 0 { + let already_following = tx + .query_row( + "SELECT 1 FROM follows WHERE follower_id = ? AND followed_id = ?", + params![user_id, target_id], + |_| Ok(()), + ) + .optional()? + .is_some(); + if already_following { + follows_skipped += 1; + continue; + } + let inserted = tx.execute( + "INSERT OR IGNORE INTO follow_requests (requester_id, target_id, created_at) VALUES (?, ?, ?)", + params![user_id, target_id, follow.created_at], + )?; + if inserted > 0 { + create_notification_tx( + &tx, + target_id, + user_id, + "follow_request", + FOLLOW_REQUEST_NOTIFICATION_MESSAGE, + )?; + follows_pending += 1; + } else { + follows_skipped += 1; + } + } else { + let inserted = tx.execute( + "INSERT OR IGNORE INTO follows (follower_id, followed_id, created_at) VALUES (?, ?, ?)", + params![user_id, target_id, follow.created_at], + )?; + if inserted > 0 { + create_notification_tx( + &tx, + target_id, + user_id, + "follow", + FOLLOW_NOTIFICATION_MESSAGE, + )?; + follows_imported += 1; + } else { + follows_skipped += 1; + } + } + } + + // Muted words: duplicates are silently skipped by the unique index. + let mut muted_words_imported = 0usize; + for word in &plan.muted_words { + let normalized = word.term.to_lowercase(); + let inserted = tx.execute( + "INSERT OR IGNORE INTO muted_words (user_id, term, normalized_term, created_at) VALUES (?, ?, ?, ?)", + params![user_id, word.term, normalized, word.created_at], + )?; + if inserted > 0 { + muted_words_imported += 1; + } + } + + let report = ImportReport { + archive_id: plan.archive_id.clone(), + archive_username: plan.archive_username.clone(), + posts_imported: plan.posts.len(), + media_imported: plan.media.len(), + follows_imported, + follows_pending, + follows_skipped, + post_references_dropped, + muted_words_imported, + profile_fields_applied, + profile_fields_skipped, + }; + // The UNIQUE(user_id, archive_id) constraint makes concurrent double + // imports fail here, inside the transaction, and roll everything back. + let insert_result = tx.execute( + r#" + INSERT INTO account_imports ( + user_id, archive_id, format_version, posts_imported, media_imported, + follows_imported, follows_pending, follows_skipped + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + "#, + params![ + user_id, + plan.archive_id, + i64::from(FORMAT_VERSION), + i64::try_from(report.posts_imported)?, + i64::try_from(report.media_imported)?, + i64::try_from(report.follows_imported)?, + i64::try_from(report.follows_pending)?, + i64::try_from(report.follows_skipped)?, + ], + ); + match insert_result { + Ok(_) => {} + Err(rusqlite::Error::SqliteFailure(error, _message)) + if error.code == rusqlite::ErrorCode::ConstraintViolation + && matches!(error.extended_code, SQLITE_CONSTRAINT_UNIQUE | SQLITE_CONSTRAINT_PRIMARYKEY) => + { + anyhow::bail!(ALREADY_IMPORTED_MESSAGE); + } + Err(error) => return Err(error.into()), + } + + tx.commit()?; + Ok((report, unused_copies)) + }) + .await +} + +fn fill_empty_field( + current: &str, + archived: &str, + applied: &mut usize, + skipped: &mut usize, +) -> String { + if current.is_empty() && !archived.is_empty() { + *applied += 1; + archived.to_owned() + } else { + *skipped += 1; + current.to_owned() + } +} + +fn replace_preference( + current: T, + archived: T, + applied: &mut usize, + skipped: &mut usize, +) -> T { + if current == archived { + *skipped += 1; + current + } else { + *applied += 1; + archived + } +} + +fn find_canonical_media_tx( + tx: &rusqlite::Transaction<'_>, + media_kind: &str, + sha256: &str, +) -> anyhow::Result> { + let canonical = tx + .query_row( + r#" + SELECT id, stored_path, public_path FROM media + WHERE canonical_media_id IS NULL AND media_kind = ? AND original_sha256 = ? + ORDER BY id ASC LIMIT 1 + "#, + params![media_kind, sha256], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }, + ) + .optional()?; + Ok(canonical) +} + +fn is_blocked_tx(tx: &rusqlite::Transaction<'_>, left: i64, right: i64) -> anyhow::Result { + let blocked = tx + .query_row( + r#" + SELECT 1 FROM blocks + WHERE (blocker_id = ? AND blocked_id = ?) OR (blocker_id = ? AND blocked_id = ?) + LIMIT 1 + "#, + params![left, right, right, left], + |_| Ok(()), + ) + .optional()? + .is_some(); + Ok(blocked) +} + +/// Mirrors `social::create_notification_tx` (private): one notification per +/// (actor, kind) pair, and never a self-notification. +fn create_notification_tx( + tx: &rusqlite::Transaction<'_>, + user_id: i64, + actor_id: i64, + kind: &str, + message: &str, +) -> anyhow::Result<()> { + if user_id == actor_id { + return Ok(()); + } + let exists = tx + .query_row( + r#" + SELECT 1 FROM notifications + WHERE user_id = ? AND kind = ? AND actor_user_id = ? AND post_id IS NULL + LIMIT 1 + "#, + params![user_id, kind, actor_id], + |_| Ok(()), + ) + .optional()? + .is_some(); + if exists { + return Ok(()); + } + tx.execute( + "INSERT INTO notifications (user_id, actor_user_id, post_id, kind, message) VALUES (?, ?, NULL, ?, ?)", + params![user_id, actor_id, kind, message], + )?; + Ok(()) +} + +fn hex_lower(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut encoded = String::with_capacity(bytes.len() * 2); + for &byte in bytes { + encoded.push(char::from(HEX[usize::from(byte >> 4)])); + encoded.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + encoded +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{auth, config::Settings, db}; + + struct TestInstance { + temp: tempfile::TempDir, + paths: RuntimePaths, + pool: SqlitePool, + settings: Settings, + } + + async fn test_instance() -> TestInstance { + let temp = tempfile::tempdir().expect("temp dir"); + let paths = RuntimePaths::from_data_dir(temp.path().join("data")); + paths.ensure().expect("runtime paths"); + let pool = db::connect(&paths.database_path).await.expect("connect"); + db::migrate(&pool).await.expect("migrate"); + TestInstance { + temp, + paths, + pool, + settings: Settings::default(), + } + } + + fn sha256_hex(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + let digest = hasher.finalize(); + let mut encoded = String::with_capacity(digest.len() * 2); + for byte in digest { + encoded.push_str(&format!("{byte:02x}")); + } + encoded + } + + fn document_bytes(value: &T) -> Vec { + serde_json::to_vec(value).expect("document json") + } + + fn build_archive(path: &Path, entries: &[(String, Vec)]) { + let typed = entries + .iter() + .map(|(name, bytes)| (name.as_str(), EntryType::Regular, bytes.as_slice())) + .collect::>(); + write_archive_entries(path, &typed); + } + + /// Builds a gzip tar archive from explicit entry types. Names up to 100 + /// bytes are written verbatim so adversarial names survive unmodified; + /// longer names go through tar-rs's GNU long-name extension. + fn build_typed_archive(path: &Path, entries: &[(String, EntryType, Vec)]) { + let typed = entries + .iter() + .map(|(name, entry_type, bytes)| (name.as_str(), *entry_type, bytes.as_slice())) + .collect::>(); + write_archive_entries(path, &typed); + } + + fn write_archive_entries(path: &Path, entries: &[(&str, EntryType, &[u8])]) { + let file = File::create(path).expect("archive file"); + let encoder = GzEncoder::new(file, Compression::default()); + let mut builder = Builder::new(encoder); + for (name, entry_type, bytes) in entries { + let mut header = Header::new_gnu(); + header.set_entry_type(*entry_type); + header.set_size(u64::try_from(bytes.len()).expect("entry size")); + header.set_mode(0o600); + if name.len() <= 100 { + let name_bytes = name.as_bytes(); + header.as_gnu_mut().expect("gnu header").name[..name_bytes.len()] + .copy_from_slice(name_bytes); + header.set_cksum(); + builder.append(&header, *bytes).expect("append entry"); + } else { + builder + .append_data(&mut header, name, *bytes) + .expect("append long entry"); + } + } + let encoder = builder.into_inner().expect("tar finish"); + encoder.finish().expect("gzip finish"); + } + + /// Minimal PNG-magic bytes; enough for `infer` to accept the content. + fn png_bytes(payload: &[u8]) -> Vec { + let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec(); + bytes.extend_from_slice(payload); + bytes + } + + /// Minimal JPEG-magic bytes; enough for `infer` to accept the content. + fn jpeg_bytes(payload: &[u8]) -> Vec { + let mut bytes = b"\xff\xd8\xff\xe0".to_vec(); + bytes.extend_from_slice(payload); + bytes + } + + /// A raw tar header with the name written verbatim into the GNU name + /// field, bypassing tar-rs path normalization that would strip `.` + /// components or reject unsafe names. + fn raw_header(name: &str, entry_type: EntryType, size: u64) -> Header { + let mut header = Header::new_gnu(); + let name_bytes = name.as_bytes(); + assert!(name_bytes.len() <= 100, "raw tar names must fit one field"); + header.as_gnu_mut().expect("gnu header").name[..name_bytes.len()] + .copy_from_slice(name_bytes); + header.set_entry_type(entry_type); + header.set_size(size); + header.set_mode(0o600); + header.set_cksum(); + header + } + + fn push_raw_entry(tar_bytes: &mut Vec, name: &str, entry_type: EntryType, data: &[u8]) { + push_raw_header( + tar_bytes, + name, + entry_type, + u64::try_from(data.len()).expect("entry size"), + ); + tar_bytes.extend_from_slice(data); + let padding = (512 - data.len() % 512) % 512; + tar_bytes.extend(std::iter::repeat_n(0_u8, padding)); + } + + fn push_raw_header(tar_bytes: &mut Vec, name: &str, entry_type: EntryType, size: u64) { + tar_bytes.extend_from_slice(raw_header(name, entry_type, size).as_bytes()); + } + + fn write_gzip(path: &Path, bytes: &[u8]) { + let file = File::create(path).expect("archive file"); + let mut encoder = GzEncoder::new(file, Compression::default()); + encoder.write_all(bytes).expect("gzip write"); + encoder.finish().expect("gzip finish"); + } + + fn manifest_document(entry_count: usize) -> ManifestDocument { + ManifestDocument { + format_version: FORMAT_VERSION, + app: APP_NAME.to_owned(), + created_at: "2026-01-01T00:00:00Z".to_owned(), + archive_id: Uuid::new_v4().to_string(), + username: "alice".to_owned(), + entry_count, + } + } + + fn valid_profile_document() -> ProfileDocument { + ProfileDocument { + display_name: "Archived".to_owned(), + bio: "archived bio".to_owned(), + location: "Somewhere".to_owned(), + website: "https://example.com".to_owned(), + theme: "dark".to_owned(), + nsfw_blur_enabled: false, + liked_posts_public: false, + follow_approval_required: false, + created_at: "2026-01-01 00:00:00".to_owned(), + } + } + + fn standard_documents() -> Vec<(String, Vec)> { + vec![ + ( + PROFILE_PATH.to_owned(), + document_bytes(&valid_profile_document()), + ), + ( + POSTS_PATH.to_owned(), + document_bytes(&Vec::::new()), + ), + ( + MEDIA_PATH.to_owned(), + document_bytes(&Vec::::new()), + ), + ( + FOLLOWS_PATH.to_owned(), + document_bytes(&Vec::::new()), + ), + ( + SETTINGS_PATH.to_owned(), + document_bytes(&SettingsDocument { + muted_words: Vec::new(), + }), + ), + ] + } + + fn replace_document(documents: &mut [(String, Vec)], name: &str, bytes: Vec) { + let entry = documents + .iter_mut() + .find(|(entry_name, _)| entry_name == name) + .expect("document exists"); + entry.1 = bytes; + } + + fn write_test_archive(path: &Path, documents: Vec<(String, Vec)>, format_version: u32) { + let mut manifest = manifest_document(documents.len() + 1); + manifest.format_version = format_version; + let mut entries = vec![(MANIFEST_PATH.to_owned(), document_bytes(&manifest))]; + entries.extend(documents); + build_archive(path, &entries); + } + + /// Writes an archive whose documents may use non-regular entry types. The + /// manifest's `entry_count` always covers every entry written. + fn write_typed_test_archive( + path: &Path, + entries: Vec<(String, EntryType, Vec)>, + format_version: u32, + ) { + let mut manifest = manifest_document(entries.len() + 1); + manifest.format_version = format_version; + let mut all = vec![( + MANIFEST_PATH.to_owned(), + EntryType::Regular, + document_bytes(&manifest), + )]; + all.extend(entries); + build_typed_archive(path, &all); + } + + fn test_media_document(byte_len: u64, sha256: String) -> MediaDocument { + media_document(0, "media/0.png", byte_len, sha256) + } + + fn media_document(id: i64, archive_path: &str, byte_len: u64, sha256: String) -> MediaDocument { + MediaDocument { + id, + original_filename: "photo.png".to_owned(), + mime_type: "image/png".to_owned(), + media_kind: "image".to_owned(), + byte_len, + alt_text: String::new(), + is_nsfw: false, + conversion_state: "original".to_owned(), + sha256, + archive_path: archive_path.to_owned(), + } + } + + async fn register_user(instance: &TestInstance, username: &str) -> i64 { + auth::register_user( + &instance.pool, + &instance.settings, + username, + "very secure password", + false, + ) + .await + .expect("registered user") + } + + /// Limits an operator could not configure through validation, used to make + /// overflow and boundary cases reachable without huge fixtures. + fn adversarial_settings(expanded_bytes: u64, entries: usize) -> Settings { + let mut settings = Settings::default(); + settings.accounts.max_archive_expanded_bytes = expanded_bytes; + settings.accounts.max_archive_entries = entries; + settings + } + + #[derive(Debug, PartialEq, Eq)] + struct DestinationSnapshot { + posts: i64, + media: i64, + post_media: i64, + follows: i64, + follow_requests: i64, + muted_words: i64, + notifications: i64, + account_imports: i64, + upload_files: Vec, + tmp_entries: Vec, + } + + async fn snapshot_instance(instance: &TestInstance, user_id: i64) -> DestinationSnapshot { + let counts = instance + .pool + .call(move |conn| { + let count = |sql: &str| -> rusqlite::Result { + conn.query_row(sql, [user_id], |row| row.get(0)) + }; + Ok(( + count("SELECT COUNT(*) FROM posts WHERE user_id = ?")?, + count("SELECT COUNT(*) FROM media WHERE owner_user_id = ?")?, + count( + "SELECT COUNT(*) FROM post_media pm JOIN posts p ON p.id = pm.post_id WHERE p.user_id = ?", + )?, + count("SELECT COUNT(*) FROM follows WHERE follower_id = ?")?, + count("SELECT COUNT(*) FROM follow_requests WHERE requester_id = ?")?, + count("SELECT COUNT(*) FROM muted_words WHERE user_id = ?")?, + count("SELECT COUNT(*) FROM notifications WHERE user_id = ?")?, + count("SELECT COUNT(*) FROM account_imports WHERE user_id = ?")?, + )) + }) + .await + .expect("snapshot counts"); + DestinationSnapshot { + posts: counts.0, + media: counts.1, + post_media: counts.2, + follows: counts.3, + follow_requests: counts.4, + muted_words: counts.5, + notifications: counts.6, + account_imports: counts.7, + upload_files: upload_listing(&instance.paths), + tmp_entries: directory_listing(&instance.paths.tmp_dir), + } + } + + fn upload_listing(paths: &RuntimePaths) -> Vec { + let mut files = Vec::new(); + for (label, root) in [ + ("originals", &paths.uploads_originals), + ("images", &paths.uploads_images), + ("videos", &paths.uploads_videos), + ("thumbs", &paths.uploads_thumbs), + ] { + collect_files(root, label, &mut files); + } + files.sort(); + files + } + + fn collect_files(root: &Path, prefix: &str, out: &mut Vec) { + let Ok(entries) = fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + let path = entry.path(); + if path.is_dir() { + collect_files(&path, &format!("{prefix}/{name}"), out); + } else { + let size = fs::symlink_metadata(&path).map_or(0, |metadata| metadata.len()); + out.push(format!("{prefix}/{name}:{size}")); + } + } + } + + fn directory_listing(dir: &Path) -> Vec { + let mut names = fs::read_dir(dir) + .map(|entries| { + entries + .flatten() + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .collect::>() + }) + .unwrap_or_default(); + names.sort(); + names + } + + /// Runs one import that must fail, asserting the user-facing message and + /// that the destination database, upload directories, and staging + /// directory are byte-for-byte unchanged. + async fn import_rejected( + instance: &TestInstance, + settings: &Settings, + user_id: i64, + archive_path: &Path, + expected: &str, + ) -> String { + let before = snapshot_instance(instance, user_id).await; + let error = import_account( + &instance.pool, + &instance.paths, + settings, + user_id, + archive_path, + ) + .await + .expect_err("archive must be rejected"); + let message = format!("{error:#}"); + assert!( + message.contains(expected), + "expected {expected:?} in rejection message {message:?}" + ); + let after = snapshot_instance(instance, user_id).await; + assert_eq!( + before, after, + "a rejected import must not mutate the destination" + ); + assert!( + after + .tmp_entries + .iter() + .all(|name| !name.starts_with(IMPORT_TMP_PREFIX)), + "import staging entries left behind: {:?}", + after.tmp_entries + ); + message + } + + #[tokio::test] + #[expect( + clippy::too_many_lines, + reason = "one end-to-end scenario covering export, import, conflicts, and repeat import" + )] + async fn export_import_roundtrip_between_instances() { + let source = test_instance().await; + let destination = test_instance().await; + let source_settings = source.settings.clone(); + let destination_settings = destination.settings.clone(); + + let alice = auth::register_user( + &source.pool, + &source_settings, + "alice", + "very secure password", + false, + ) + .await + .expect("alice"); + let carol = auth::register_user( + &source.pool, + &source_settings, + "carol", + "very secure password", + false, + ) + .await + .expect("carol"); + let dave = auth::register_user( + &source.pool, + &source_settings, + "dave", + "very secure password", + false, + ) + .await + .expect("dave"); + let ghost = auth::register_user( + &source.pool, + &source_settings, + "ghost", + "very secure password", + false, + ) + .await + .expect("ghost"); + let blocked_user = auth::register_user( + &source.pool, + &source_settings, + "blocked_user", + "very secure password", + false, + ) + .await + .expect("blocked_user"); + let suspended_user = auth::register_user( + &source.pool, + &source_settings, + "suspended_user", + "very secure password", + false, + ) + .await + .expect("suspended_user"); + let source_bob = auth::register_user( + &source.pool, + &source_settings, + "bob", + "very secure password", + false, + ) + .await + .expect("bob"); + + source + .pool + .call(move |conn| { + conn.execute( + r#" + UPDATE users SET display_name = 'Alice', bio = 'alice bio', + location = 'Berlin', website = 'https://example.com', + theme = 'dark', nsfw_blur_enabled = 0, + liked_posts_public = 0, follow_approval_required = 1 + WHERE id = ? + "#, + [alice], + )?; + Ok(()) + }) + .await + .expect("profile"); + source + .pool + .call(move |conn| { + for target in [ + carol, + dave, + ghost, + blocked_user, + suspended_user, + source_bob, + ] { + conn.execute( + "INSERT INTO follows (follower_id, followed_id, created_at) VALUES (?, ?, '2026-01-01 00:00:00')", + params![alice, target], + )?; + } + Ok(()) + }) + .await + .expect("follows"); + source + .pool + .call(move |conn| { + conn.execute( + "INSERT INTO muted_words (user_id, term, normalized_term, created_at) VALUES (?, 'Spoiler', 'spoiler', '2026-01-01 00:00:00')", + [alice], + )?; + conn.execute( + "INSERT INTO muted_words (user_id, term, normalized_term, created_at) VALUES (?, 'politics', 'politics', '2026-01-02 00:00:00')", + [alice], + )?; + Ok(()) + }) + .await + .expect("muted words"); + + let media_bytes = png_bytes(b"alice media bytes"); + let media_path = source.paths.uploads_images.join("alice-photo.png"); + fs::write(&media_path, &media_bytes).expect("media file"); + let media_path_string = media_path.to_string_lossy().to_string(); + let media_sha = sha256_hex(&media_bytes); + let media_len = i64::try_from(media_bytes.len()).expect("media len"); + let (_source_post1, _source_post2) = source + .pool + .call(move |conn| { + conn.execute( + r#" + INSERT INTO media ( + owner_user_id, original_filename, stored_path, public_path, + mime_type, media_kind, byte_len, alt_text, conversion_state, + original_sha256, is_nsfw + ) + VALUES (?, 'alice-photo.png', ?, '/uploads/images/alice-photo.png', 'image/png', 'image', ?, 'alt text', 'original', ?, 1) + "#, + params![alice, media_path_string, media_len, media_sha], + )?; + let media_id = conn.last_insert_rowid(); + conn.execute( + "INSERT INTO posts (user_id, text, created_at) VALUES (?, 'alice first post', '2026-01-02 03:04:05')", + [alice], + )?; + let post1 = conn.last_insert_rowid(); + conn.execute( + "INSERT INTO post_media (post_id, media_id, position) VALUES (?, ?, 0)", + params![post1, media_id], + )?; + conn.execute( + r#" + INSERT INTO post_embeds ( + post_id, provider, video_id, original_url, canonical_url, title, + thumbnail_url, embed_url, position, fetched_at + ) + VALUES (?, 'youtube', 'dQw4w9WgXcQ', 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', 'A video', 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg', 'https://www.youtube.com/embed/dQw4w9WgXcQ', 0, CURRENT_TIMESTAMP) + "#, + [post1], + )?; + conn.execute( + "INSERT INTO posts (user_id, text, parent_post_id, root_post_id, created_at) VALUES (?, 'alice reply', ?, ?, '2026-01-02 03:05:06')", + params![alice, post1, post1], + )?; + let post2 = conn.last_insert_rowid(); + Ok((post1, post2)) + }) + .await + .expect("posts"); + + let export_dir = source.temp.path().join("exports"); + fs::create_dir_all(&export_dir).expect("export dir"); + let archive_path = export_dir.join("alice-account.tar.gz"); + let export = export_account( + &source.pool, + &source.paths, + alice, + &archive_path, + ArchiveLimits::from_settings(&source_settings), + ) + .await + .expect("export"); + assert_eq!(export.posts, 2); + assert_eq!(export.media, 1); + assert_eq!(export.follows, 6); + assert_eq!(export.muted_words, 2); + assert!(export.bytes > 0); + assert!(archive_path.is_file()); + + let traversal_destination = export_dir.join("..").join("escape.tar.gz"); + let error = export_account( + &source.pool, + &source.paths, + alice, + &traversal_destination, + ArchiveLimits::from_settings(&source_settings), + ) + .await + .expect_err("traversal destination"); + assert!(error.to_string().contains(".."), "{error}"); + let missing_parent = export_dir.join("missing").join("out.tar.gz"); + let error = export_account( + &source.pool, + &source.paths, + alice, + &missing_parent, + ArchiveLimits::from_settings(&source_settings), + ) + .await + .expect_err("missing parent"); + assert!(error.to_string().contains("does not exist"), "{error}"); + + let bob = auth::register_user( + &destination.pool, + &destination_settings, + "bob", + "very secure password", + false, + ) + .await + .expect("bob"); + let carol_dest = auth::register_user( + &destination.pool, + &destination_settings, + "carol", + "very secure password", + false, + ) + .await + .expect("carol destination"); + let dave_dest = auth::register_user( + &destination.pool, + &destination_settings, + "dave", + "very secure password", + false, + ) + .await + .expect("dave destination"); + let blocked_dest = auth::register_user( + &destination.pool, + &destination_settings, + "blocked_user", + "very secure password", + false, + ) + .await + .expect("blocked destination"); + let suspended_dest = auth::register_user( + &destination.pool, + &destination_settings, + "suspended_user", + "very secure password", + false, + ) + .await + .expect("suspended destination"); + destination + .pool + .call(move |conn| { + conn.execute( + "UPDATE users SET follow_approval_required = 1 WHERE id = ?", + [dave_dest], + )?; + conn.execute( + "UPDATE users SET is_suspended = 1 WHERE id = ?", + [suspended_dest], + )?; + conn.execute( + "INSERT INTO blocks (blocker_id, blocked_id) VALUES (?, ?)", + params![blocked_dest, bob], + )?; + conn.execute( + "INSERT INTO muted_words (user_id, term, normalized_term) VALUES (?, 'Spoiler', 'spoiler')", + [bob], + )?; + Ok(()) + }) + .await + .expect("destination setup"); + + let report = import_account( + &destination.pool, + &destination.paths, + &destination_settings, + bob, + &archive_path, + ) + .await + .expect("import"); + assert_eq!(report.archive_username, "alice"); + assert_eq!(report.posts_imported, 2); + assert_eq!(report.media_imported, 1); + assert_eq!(report.follows_imported, 1); + assert_eq!(report.follows_pending, 1); + assert_eq!(report.follows_skipped, 4); + assert_eq!(report.muted_words_imported, 1); + assert_eq!(report.profile_fields_applied, 7); + assert_eq!(report.profile_fields_skipped, 1); + + let account = destination + .pool + .call(move |conn| { + Ok(conn.query_row( + r#" + SELECT username, is_admin, theme, nsfw_blur_enabled, liked_posts_public, + follow_approval_required, display_name, bio, location, website + FROM users WHERE id = ? + "#, + [bob], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, String>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, i64>(4)?, + row.get::<_, i64>(5)?, + row.get::<_, String>(6)?, + row.get::<_, String>(7)?, + row.get::<_, String>(8)?, + row.get::<_, String>(9)?, + )) + }, + )?) + }) + .await + .expect("account"); + assert_eq!(account.0, "bob"); + assert_eq!(account.1, 0); + assert_eq!(account.2, "dark"); + assert_eq!(account.3, 0); + assert_eq!(account.4, 0); + assert_eq!(account.5, 1); + assert_eq!(account.6, "bob"); + assert_eq!(account.7, "alice bio"); + assert_eq!(account.8, "Berlin"); + assert_eq!(account.9, "https://example.com"); + assert!( + auth::verify_user_password(&destination.pool, bob, "very secure password") + .await + .expect("password check") + ); + let sessions: i64 = destination + .pool + .call(|conn| Ok(conn.query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))?)) + .await + .expect("sessions"); + assert_eq!(sessions, 0); + + let posts = destination + .pool + .call(move |conn| { + let mut statement = conn.prepare( + "SELECT id, text, parent_post_id, root_post_id, created_at FROM posts WHERE user_id = ? ORDER BY id ASC", + )?; + let rows = statement + .query_map([bob], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, String>(4)?, + )) + })? + .collect::, _>>()?; + Ok(rows) + }) + .await + .expect("posts"); + assert_eq!(posts.len(), 2); + assert_eq!(posts[0].1, "alice first post"); + assert_eq!(posts[1].1, "alice reply"); + assert_eq!(posts[0].4, "2026-01-02 03:04:05"); + assert_eq!(posts[1].2, Some(posts[0].0)); + assert_eq!(posts[1].3, Some(posts[0].0)); + + let media = destination + .pool + .call(move |conn| { + Ok(conn.query_row( + r#" + SELECT owner_user_id, stored_path, public_path, byte_len, conversion_state, + original_sha256, mime_type, media_kind, alt_text, is_nsfw, original_filename + FROM media WHERE owner_user_id = ? + "#, + [bob], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, String>(6)?, + row.get::<_, String>(7)?, + row.get::<_, String>(8)?, + row.get::<_, i64>(9)?, + row.get::<_, String>(10)?, + )) + }, + )?) + }) + .await + .expect("media"); + assert_eq!(media.0, bob); + assert_eq!(media.3, media_len); + assert_eq!(media.4, "imported"); + assert_eq!(media.5, sha256_hex(&media_bytes)); + assert_eq!(media.6, "image/png"); + assert_eq!(media.7, "image"); + assert_eq!(media.8, "alt text"); + assert_eq!(media.9, 1); + assert_eq!(media.10, "alice-photo.png"); + assert!(Path::new(&media.1).starts_with(&destination.paths.uploads_images)); + assert!(media.2.starts_with("/uploads/images/")); + assert_eq!( + fs::read(&media.1).expect("imported media bytes"), + media_bytes + ); + + let media_links: i64 = destination + .pool + .call(move |conn| { + Ok(conn.query_row( + "SELECT COUNT(*) FROM post_media pm JOIN posts p ON p.id = pm.post_id WHERE p.user_id = ?", + [bob], + |row| row.get(0), + )?) + }) + .await + .expect("post media"); + assert_eq!(media_links, 1); + let embed_video: String = destination + .pool + .call(move |conn| { + Ok(conn.query_row( + "SELECT pe.video_id FROM post_embeds pe JOIN posts p ON p.id = pe.post_id WHERE p.user_id = ?", + [bob], + |row| row.get(0), + )?) + }) + .await + .expect("embed"); + assert_eq!(embed_video, "dQw4w9WgXcQ"); + + let follows = destination + .pool + .call(move |conn| { + let mut statement = conn.prepare( + "SELECT followed_id FROM follows WHERE follower_id = ? ORDER BY followed_id", + )?; + let rows = statement + .query_map([bob], |row| row.get::<_, i64>(0))? + .collect::, _>>()?; + Ok(rows) + }) + .await + .expect("follows"); + assert_eq!(follows, vec![carol_dest]); + let pending = destination + .pool + .call(move |conn| { + let mut statement = conn.prepare( + "SELECT target_id FROM follow_requests WHERE requester_id = ? ORDER BY target_id", + )?; + let rows = statement + .query_map([bob], |row| row.get::<_, i64>(0))? + .collect::, _>>()?; + Ok(rows) + }) + .await + .expect("pending requests"); + assert_eq!(pending, vec![dave_dest]); + let notifications = destination + .pool + .call(move |conn| { + let mut statement = conn.prepare( + "SELECT user_id, kind FROM notifications WHERE actor_user_id = ? ORDER BY user_id", + )?; + let rows = statement + .query_map([bob], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) + })? + .collect::, _>>()?; + Ok(rows) + }) + .await + .expect("notifications"); + assert!(notifications.contains(&(carol_dest, "follow".to_owned()))); + assert!(notifications.contains(&(dave_dest, "follow_request".to_owned()))); + assert_eq!(notifications.len(), 2); + let muted_terms = destination + .pool + .call(move |conn| { + let mut statement = + conn.prepare("SELECT term FROM muted_words WHERE user_id = ? ORDER BY term")?; + let rows = statement + .query_map([bob], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + Ok(rows) + }) + .await + .expect("muted words"); + assert_eq!( + muted_terms, + vec!["Spoiler".to_owned(), "politics".to_owned()] + ); + + // A second export has a new archive id, so importing it into another + // destination account is allowed and must reuse the canonical media + // row instead of copying the bytes again. + let dedupe_archive = export_dir.join("alice-account-dedupe.tar.gz"); + export_account( + &source.pool, + &source.paths, + alice, + &dedupe_archive, + ArchiveLimits::from_settings(&source_settings), + ) + .await + .expect("dedupe export"); + let dedupe_report = import_account( + &destination.pool, + &destination.paths, + &destination_settings, + carol_dest, + &dedupe_archive, + ) + .await + .expect("dedupe import"); + assert_eq!(dedupe_report.media_imported, 1); + let duplicate_media = destination + .pool + .call(move |conn| { + Ok(conn + .query_row( + "SELECT canonical_media_id, stored_path FROM media WHERE owner_user_id = ?", + [carol_dest], + |row| Ok((row.get::<_, Option>(0)?, row.get::<_, String>(1)?)), + ) + .optional()?) + }) + .await + .expect("duplicate media"); + let (canonical_media_id, duplicate_stored_path) = + duplicate_media.expect("duplicate media row"); + assert!(canonical_media_id.is_some()); + assert_eq!(duplicate_stored_path, media.1); + let image_files = fs::read_dir(&destination.paths.uploads_images) + .expect("uploads dir") + .count(); + assert_eq!(image_files, 1); + + let error = import_account( + &destination.pool, + &destination.paths, + &destination_settings, + bob, + &archive_path, + ) + .await + .expect_err("double import"); + assert!( + error.to_string().contains("already been imported"), + "{error}" + ); + let posts_after: i64 = destination + .pool + .call(move |conn| { + Ok(conn.query_row( + "SELECT COUNT(*) FROM posts WHERE user_id = ?", + [bob], + |row| row.get(0), + )?) + }) + .await + .expect("posts after"); + assert_eq!(posts_after, 2); + } + + #[tokio::test] + async fn import_rejects_invalid_archives() { + let instance = test_instance().await; + let user = register_user(&instance, "importer").await; + let settings = instance.settings.clone(); + let archive_dir = instance.temp.path().join("archives"); + fs::create_dir_all(&archive_dir).expect("archive dir"); + + let path = archive_dir.join("wrong-version.tar.gz"); + write_test_archive(&path, standard_documents(), 2); + import_rejected(&instance, &settings, user, &path, "format version").await; + + let path = archive_dir.join("traversal.tar.gz"); + let mut documents = standard_documents(); + documents.push(("../evil".to_owned(), b"x".to_vec())); + write_test_archive(&path, documents, FORMAT_VERSION); + import_rejected(&instance, &settings, user, &path, "traversal").await; + + let path = archive_dir.join("absolute.tar.gz"); + let mut documents = standard_documents(); + documents.push(("/evil".to_owned(), b"x".to_vec())); + write_test_archive(&path, documents, FORMAT_VERSION); + import_rejected(&instance, &settings, user, &path, "absolute").await; + + let path = archive_dir.join("duplicate.tar.gz"); + let mut documents = standard_documents(); + documents.push(( + POSTS_PATH.to_owned(), + document_bytes(&Vec::::new()), + )); + write_test_archive(&path, documents, FORMAT_VERSION); + import_rejected(&instance, &settings, user, &path, "duplicate").await; + + let path = archive_dir.join("duplicate-manifest.tar.gz"); + let mut documents = standard_documents(); + documents.push(( + MANIFEST_PATH.to_owned(), + document_bytes(&manifest_document(7)), + )); + write_test_archive(&path, documents, FORMAT_VERSION); + import_rejected(&instance, &settings, user, &path, "duplicate").await; + + let path = archive_dir.join("malformed.tar.gz"); + let mut documents = standard_documents(); + replace_document(&mut documents, POSTS_PATH, b"{".to_vec()); + write_test_archive(&path, documents, FORMAT_VERSION); + import_rejected(&instance, &settings, user, &path, "JSON").await; + + let path = archive_dir.join("sha-mismatch.tar.gz"); + let media_bytes = b"tiny archive media".to_vec(); + let mut documents = standard_documents(); + replace_document( + &mut documents, + MEDIA_PATH, + document_bytes(&vec![test_media_document( + u64::try_from(media_bytes.len()).expect("media len"), + "0".repeat(64), + )]), + ); + documents.push(("media/0.png".to_owned(), media_bytes)); + write_test_archive(&path, documents, FORMAT_VERSION); + import_rejected(&instance, &settings, user, &path, "sha256").await; + + // The declared byte total must respect the settings-derived expanded + // limit, not the legacy compile-time constant. + let path = archive_dir.join("too-many-bytes.tar.gz"); + let media_bytes = b"tiny archive media".to_vec(); + let mut documents = standard_documents(); + replace_document( + &mut documents, + MEDIA_PATH, + document_bytes(&vec![test_media_document( + MAX_ARCHIVE_BYTES + 1, + sha256_hex(&media_bytes), + )]), + ); + documents.push(("media/0.png".to_owned(), media_bytes)); + write_test_archive(&path, documents, FORMAT_VERSION); + let bounded = adversarial_settings(64 * 1024, MAX_ENTRIES); + import_rejected(&instance, &bounded, user, &path, "declares more than").await; + + let path = archive_dir.join("not-gzip.tar.gz"); + fs::write(&path, b"not a gzip archive").expect("write"); + import_rejected(&instance, &settings, user, &path, "gzip tar").await; + + let path = archive_dir.join("corrupt-gzip-header.tar.gz"); + let mut bytes = fs::read(archive_dir.join("wrong-version.tar.gz")).expect("read archive"); + bytes[3] = 0xff; // invalid gzip compression method + fs::write(&path, bytes).expect("write"); + import_rejected(&instance, &settings, user, &path, "gzip tar").await; + } + + #[test] + fn capped_writer_rejects_output_past_the_limit() { + let mut writer = CappedWriter::new(Vec::new(), 4); + writer.write_all(b"abc").expect("within limit"); + let error = writer.write_all(b"de").expect_err("over limit"); + assert!(error.to_string().contains("limit"), "{error}"); + } + + #[tokio::test] + async fn export_allows_account_with_no_posts_or_media() { + let instance = test_instance().await; + let user = auth::register_user( + &instance.pool, + &instance.settings, + "empty_account", + "very secure password", + false, + ) + .await + .expect("user"); + let export_dir = instance.temp.path().join("exports"); + fs::create_dir_all(&export_dir).expect("export dir"); + let archive_path = export_dir.join("empty-account.tar.gz"); + + let report = export_account( + &instance.pool, + &instance.paths, + user, + &archive_path, + ArchiveLimits::from_settings(&instance.settings), + ) + .await + .expect("export"); + + assert_eq!(report.posts, 0); + assert_eq!(report.media, 0); + assert_eq!(report.follows, 0); + assert_eq!(report.muted_words, 0); + assert!(archive_path.is_file()); + } + + #[tokio::test] + async fn import_rejects_gzip_bomb_without_mutations() { + let instance = test_instance().await; + let user = register_user(&instance, "bomb_target").await; + let archive_dir = instance.temp.path().join("archives"); + fs::create_dir_all(&archive_dir).expect("archive dir"); + + // 4 MiB of zeros compresses to a few KiB, so only the decompressed + // limit can stop this archive. + let media_bytes = vec![0_u8; 4 * 1024 * 1024]; + let mut documents = standard_documents(); + replace_document( + &mut documents, + MEDIA_PATH, + document_bytes(&vec![test_media_document( + u64::try_from(media_bytes.len()).expect("media len"), + sha256_hex(&media_bytes), + )]), + ); + documents.push(("media/0.png".to_owned(), media_bytes)); + let path = archive_dir.join("bomb.tar.gz"); + write_test_archive(&path, documents, FORMAT_VERSION); + let compressed_size = fs::metadata(&path).expect("archive metadata").len(); + assert!( + compressed_size < 64 * 1024, + "fixture must remain a tiny compressed bomb, got {compressed_size} bytes" + ); + + let settings = adversarial_settings(64 * 1024, MAX_ENTRIES); + import_rejected(&instance, &settings, user, &path, "declares").await; + } + + #[tokio::test] + async fn import_rejects_entry_count_over_limit() { + let instance = test_instance().await; + let user = register_user(&instance, "entry_limit").await; + let archive_dir = instance.temp.path().join("archives"); + fs::create_dir_all(&archive_dir).expect("archive dir"); + + let path = archive_dir.join("many-entries.tar.gz"); + write_test_archive(&path, standard_documents(), FORMAT_VERSION); + + let mut settings = instance.settings.clone(); + settings.accounts.max_archive_entries = 3; + import_rejected(&instance, &settings, user, &path, "more than 3 entries").await; + } + + #[tokio::test] + async fn import_rejects_media_entry_count_over_limit() { + let instance = test_instance().await; + let user = register_user(&instance, "media_count").await; + let archive_dir = instance.temp.path().join("archives"); + fs::create_dir_all(&archive_dir).expect("archive dir"); + + // One more media entry than media.json would ever accept must be + // rejected while extracting, not after staging a thousand files. + let mut documents = standard_documents(); + for index in 0..=MAX_MEDIA { + documents.push(( + format!("{MEDIA_PREFIX}{index}.png"), + png_bytes(b"media count"), + )); + } + let path = archive_dir.join("too-many-media-files.tar.gz"); + write_test_archive(&path, documents, FORMAT_VERSION); + import_rejected( + &instance, + &instance.settings, + user, + &path, + "more than 1000 media files", + ) + .await; + } + + #[tokio::test] + async fn import_rejects_oversized_media_headers() { + let instance = test_instance().await; + let user = register_user(&instance, "oversized_header").await; + let archive_dir = instance.temp.path().join("archives"); + fs::create_dir_all(&archive_dir).expect("archive dir"); + + // The tar header itself declares more bytes than the expanded limit, + // so the entry must be rejected before any of it is written. + let media_bytes = vec![0x42_u8; 2 * 1024 * 1024]; + let mut documents = standard_documents(); + replace_document( + &mut documents, + MEDIA_PATH, + document_bytes(&vec![test_media_document( + u64::try_from(media_bytes.len()).expect("media len"), + sha256_hex(&media_bytes), + )]), + ); + documents.push(("media/0.png".to_owned(), media_bytes)); + let path = archive_dir.join("oversized-entry.tar.gz"); + write_test_archive(&path, documents, FORMAT_VERSION); + let settings = adversarial_settings(1024 * 1024, MAX_ENTRIES); + import_rejected(&instance, &settings, user, &path, "declares").await; + + // A hostile header may declare u64::MAX bytes with no data at all. + let mut entries = vec![( + MANIFEST_PATH.to_owned(), + EntryType::Regular, + document_bytes(&manifest_document(7)), + )]; + entries.extend( + standard_documents() + .into_iter() + .map(|(name, bytes)| (name, EntryType::Regular, bytes)), + ); + let mut tar_bytes = Vec::new(); + for (name, entry_type, bytes) in &entries { + push_raw_entry(&mut tar_bytes, name, *entry_type, bytes); + } + // tar-rs itself rejects sizes that overflow its position counter, so + // declare the largest size it parses and let the entry header check + // stop it before any bytes are written. + push_raw_header( + &mut tar_bytes, + "media/0.png", + EntryType::Regular, + u64::MAX / 2, + ); + let path = archive_dir.join("huge-declared-header.tar.gz"); + write_gzip(&path, &tar_bytes); + import_rejected(&instance, &instance.settings, user, &path, "declares").await; + } + + #[tokio::test] + async fn import_rejects_declared_byte_len_overflow() { + let instance = test_instance().await; + let user = register_user(&instance, "overflow_target").await; + let archive_dir = instance.temp.path().join("archives"); + fs::create_dir_all(&archive_dir).expect("archive dir"); + + // One document alone declares more than the expanded limit. + let media_bytes = png_bytes(b"overflow-check"); + let mut documents = standard_documents(); + replace_document( + &mut documents, + MEDIA_PATH, + document_bytes(&vec![test_media_document( + u64::MAX, + sha256_hex(&media_bytes), + )]), + ); + documents.push(("media/0.png".to_owned(), media_bytes.clone())); + let path = archive_dir.join("u64-max-declared.tar.gz"); + write_test_archive(&path, documents, FORMAT_VERSION); + import_rejected( + &instance, + &instance.settings, + user, + &path, + "declares more than", + ) + .await; + + // Two documents whose declared total overflows a u64 addition. + let half = u64::MAX / 2 + 1; + let mut documents = standard_documents(); + replace_document( + &mut documents, + MEDIA_PATH, + document_bytes(&vec![ + media_document(0, "media/0.png", half, sha256_hex(&media_bytes)), + media_document(1, "media/1.png", half, sha256_hex(&media_bytes)), + ]), + ); + documents.push(("media/0.png".to_owned(), media_bytes.clone())); + documents.push(("media/1.png".to_owned(), media_bytes)); + let path = archive_dir.join("declared-total-overflow.tar.gz"); + write_test_archive(&path, documents, FORMAT_VERSION); + // The largest representable limit still must not wrap on addition. + let settings = adversarial_settings(u64::MAX, MAX_ENTRIES); + import_rejected(&instance, &settings, user, &path, "overflow").await; + } + + #[tokio::test] + async fn import_rejects_total_document_bytes_over_limit() { + let instance = test_instance().await; + let user = register_user(&instance, "document_limit").await; + let archive_dir = instance.temp.path().join("archives"); + fs::create_dir_all(&archive_dir).expect("archive dir"); + + // Four maximum-size documents plus the manifest cross the cumulative + // 64 MiB document cap even though each one is individually allowed. + let big = vec![b'x'; usize::try_from(DOCUMENT_MAX_BYTES).expect("document size")]; + let mut documents = standard_documents(); + for name in [PROFILE_PATH, POSTS_PATH, MEDIA_PATH, FOLLOWS_PATH] { + replace_document(&mut documents, name, big.clone()); + } + let path = archive_dir.join("total-documents.tar.gz"); + write_test_archive(&path, documents, FORMAT_VERSION); + import_rejected(&instance, &instance.settings, user, &path, "total limit").await; + } + + #[tokio::test] + async fn import_rejects_media_metadata_disagreement() { + let instance = test_instance().await; + let user = register_user(&instance, "metadata_target").await; + let archive_dir = instance.temp.path().join("archives"); + fs::create_dir_all(&archive_dir).expect("archive dir"); + + // Declared PNG but the bytes are a JPEG. + let jpeg = jpeg_bytes(b"jpeg payload"); + let mut documents = standard_documents(); + replace_document( + &mut documents, + MEDIA_PATH, + document_bytes(&vec![test_media_document( + u64::try_from(jpeg.len()).expect("media len"), + sha256_hex(&jpeg), + )]), + ); + documents.push(("media/0.png".to_owned(), jpeg)); + let path = archive_dir.join("sniff-mismatch.tar.gz"); + write_test_archive(&path, documents, FORMAT_VERSION); + import_rejected( + &instance, + &instance.settings, + user, + &path, + "does not match the declared", + ) + .await; + + // Bytes `infer` cannot classify must not be installed as media. + let junk = b"not a media file".to_vec(); + let mut documents = standard_documents(); + replace_document( + &mut documents, + MEDIA_PATH, + document_bytes(&vec![test_media_document( + u64::try_from(junk.len()).expect("media len"), + sha256_hex(&junk), + )]), + ); + documents.push(("media/0.png".to_owned(), junk)); + let path = archive_dir.join("sniff-unknown.tar.gz"); + write_test_archive(&path, documents, FORMAT_VERSION); + import_rejected( + &instance, + &instance.settings, + user, + &path, + "recognized media type", + ) + .await; + + // Declared byte_len disagreeing with the extracted size. + let png = png_bytes(b"byte len mismatch"); + let mut documents = standard_documents(); + replace_document( + &mut documents, + MEDIA_PATH, + document_bytes(&vec![test_media_document( + u64::try_from(png.len()).expect("media len") + 1, + sha256_hex(&png), + )]), + ); + documents.push(("media/0.png".to_owned(), png)); + let path = archive_dir.join("byte-len-mismatch.tar.gz"); + write_test_archive(&path, documents, FORMAT_VERSION); + import_rejected(&instance, &instance.settings, user, &path, "does not match").await; + } + + #[tokio::test] + async fn import_rejects_media_document_mismatches() { + let instance = test_instance().await; + let user = register_user(&instance, "media_docs").await; + let archive_dir = instance.temp.path().join("archives"); + fs::create_dir_all(&archive_dir).expect("archive dir"); + + let media_bytes = png_bytes(b"media document"); + let media_len = u64::try_from(media_bytes.len()).expect("media len"); + let media_sha = sha256_hex(&media_bytes); + + // Duplicate media ids. + let mut documents = standard_documents(); + replace_document( + &mut documents, + MEDIA_PATH, + document_bytes(&vec![ + media_document(0, "media/0.png", media_len, media_sha.clone()), + media_document(0, "media/0.png", media_len, media_sha.clone()), + ]), + ); + documents.push(("media/0.png".to_owned(), media_bytes.clone())); + let path = archive_dir.join("duplicate-media-ids.tar.gz"); + write_test_archive(&path, documents, FORMAT_VERSION); + import_rejected( + &instance, + &instance.settings, + user, + &path, + "duplicate media id", + ) + .await; + + // The same archive path listed twice. + let mut documents = standard_documents(); + replace_document( + &mut documents, + MEDIA_PATH, + document_bytes(&vec![ + media_document(0, "media/0.png", media_len, media_sha.clone()), + media_document(1, "media/0.png", media_len, media_sha.clone()), + ]), + ); + documents.push(("media/0.png".to_owned(), media_bytes.clone())); + let path = archive_dir.join("duplicate-media-paths.tar.gz"); + write_test_archive(&path, documents, FORMAT_VERSION); + import_rejected(&instance, &instance.settings, user, &path, "more than once").await; + + // A listed file that is not in the archive. + let mut documents = standard_documents(); + replace_document( + &mut documents, + MEDIA_PATH, + document_bytes(&vec![media_document( + 0, + "media/0.png", + media_len, + media_sha.clone(), + )]), + ); + let path = archive_dir.join("listed-file-missing.tar.gz"); + write_test_archive(&path, documents, FORMAT_VERSION); + import_rejected( + &instance, + &instance.settings, + user, + &path, + "missing from the archive", + ) + .await; + + // An archive media file that media.json never lists. + let mut documents = standard_documents(); + documents.push(("media/0.png".to_owned(), media_bytes.clone())); + let path = archive_dir.join("unlisted-media-file.tar.gz"); + write_test_archive(&path, documents, FORMAT_VERSION); + import_rejected( + &instance, + &instance.settings, + user, + &path, + "unlisted media file", + ) + .await; + + // Case-variant media entry names collide on case-insensitive targets. + let mut documents = standard_documents(); + documents.push(("media/0.png".to_owned(), media_bytes.clone())); + documents.push(("media/0.PNG".to_owned(), media_bytes)); + let path = archive_dir.join("case-variant-media.tar.gz"); + write_test_archive(&path, documents, FORMAT_VERSION); + import_rejected( + &instance, + &instance.settings, + user, + &path, + "duplicate entry", + ) + .await; + } + + #[tokio::test] + async fn import_rejects_unsafe_entry_paths() { + let instance = test_instance().await; + let user = register_user(&instance, "path_target").await; + let archive_dir = instance.temp.path().join("archives"); + fs::create_dir_all(&archive_dir).expect("archive dir"); + + let cases = [ + ("média.png", "non-ASCII"), + ("media/../evil.png", "traversal"), + ("/evil.png", "absolute"), + ("media\\evil.png", "unsafe characters"), + ("C:evil.png", "unsafe characters"), + ("media/./evil.png", "invalid media path"), + ("media//evil.png", "unsafe characters"), + ("%2e%2e/evil.png", "unsafe characters"), + ("media/a/b.png", "invalid media path"), + ]; + for (index, (name, expected)) in cases.iter().enumerate() { + let mut documents = standard_documents(); + documents.push(((*name).to_owned(), png_bytes(b"path payload"))); + let path = archive_dir.join(format!("unsafe-{index}.tar.gz")); + write_test_archive(&path, documents, FORMAT_VERSION); + import_rejected(&instance, &instance.settings, user, &path, expected).await; + } + + // Media file names may not end in '.' or a space, and may not exceed + // the per-component length every common filesystem accepts. + let long_name = format!("{MEDIA_PREFIX}{}", "a".repeat(300)); + let tail_cases = [ + ("media/evil.png.", "ends with"), + ("media/evil.png ", "ends with"), + (long_name.as_str(), "too long"), + ]; + for (index, (name, expected)) in tail_cases.iter().enumerate() { + let mut documents = standard_documents(); + documents.push(((*name).to_owned(), png_bytes(b"path payload"))); + let path = archive_dir.join(format!("media-name-{index}.tar.gz")); + write_test_archive(&path, documents, FORMAT_VERSION); + import_rejected(&instance, &instance.settings, user, &path, expected).await; + } + } + + #[tokio::test] + async fn import_rejects_unsupported_entry_types() { + let instance = test_instance().await; + let user = register_user(&instance, "type_target").await; + let archive_dir = instance.temp.path().join("archives"); + fs::create_dir_all(&archive_dir).expect("archive dir"); + + let cases = [ + (EntryType::Symlink, "unsupported entry type"), + (EntryType::Link, "unsupported entry type"), + (EntryType::Char, "unsupported entry type"), + (EntryType::Fifo, "unsupported entry type"), + ]; + for (index, (entry_type, expected)) in cases.into_iter().enumerate() { + let mut entries = standard_documents() + .into_iter() + .map(|(name, bytes)| (name, EntryType::Regular, bytes)) + .collect::>(); + entries.push(("media/link".to_owned(), entry_type, Vec::new())); + let path = archive_dir.join(format!("entry-type-{index}.tar.gz")); + write_typed_test_archive(&path, entries, FORMAT_VERSION); + import_rejected(&instance, &instance.settings, user, &path, expected).await; + } + + // A directory entry may only be the flat `media/` directory itself. + let mut entries = standard_documents() + .into_iter() + .map(|(name, bytes)| (name, EntryType::Regular, bytes)) + .collect::>(); + entries.push(("media/foo".to_owned(), EntryType::Directory, Vec::new())); + let path = archive_dir.join("directory-media-foo.tar.gz"); + write_typed_test_archive(&path, entries, FORMAT_VERSION); + import_rejected( + &instance, + &instance.settings, + user, + &path, + "unexpected directory", + ) + .await; + } + + #[tokio::test] + async fn import_rejects_truncated_media_entry() { + let instance = test_instance().await; + let user = register_user(&instance, "truncated_target").await; + let archive_dir = instance.temp.path().join("archives"); + fs::create_dir_all(&archive_dir).expect("archive dir"); + + let media_bytes = png_bytes(&vec![0xAB_u8; 4096]); + let media_len = u64::try_from(media_bytes.len()).expect("media len"); + let mut documents = standard_documents(); + replace_document( + &mut documents, + MEDIA_PATH, + document_bytes(&vec![media_document( + 0, + "media/0.png", + media_len, + sha256_hex(&media_bytes), + )]), + ); + + let mut entries = vec![( + MANIFEST_PATH.to_owned(), + EntryType::Regular, + document_bytes(&manifest_document(documents.len() + 2)), + )]; + entries.extend( + documents + .into_iter() + .map(|(name, bytes)| (name, EntryType::Regular, bytes)), + ); + let mut tar_bytes = Vec::new(); + for (name, entry_type, bytes) in &entries { + push_raw_entry(&mut tar_bytes, name, *entry_type, bytes); + } + push_raw_header(&mut tar_bytes, "media/0.png", EntryType::Regular, media_len); + tar_bytes.extend_from_slice(&media_bytes[..media_bytes.len() / 2]); + let path = archive_dir.join("truncated-media.tar.gz"); + write_gzip(&path, &tar_bytes); + + // tar-rs notices the entry's remaining declared bytes are missing when + // it advances to the next header, and reports the truncated stream. + import_rejected(&instance, &instance.settings, user, &path, "unexpected EOF").await; + } + + #[tokio::test] + async fn import_enforces_compressed_size_boundary() { + let instance = test_instance().await; + let archive_dir = instance.temp.path().join("archives"); + fs::create_dir_all(&archive_dir).expect("archive dir"); + + let path = archive_dir.join("boundary.tar.gz"); + write_test_archive(&path, standard_documents(), FORMAT_VERSION); + let size = fs::metadata(&path).expect("archive metadata").len(); + assert!(size > 0); + + let mut at_limit = instance.settings.clone(); + at_limit.accounts.max_archive_upload_bytes = size; + let at_limit_user = register_user(&instance, "at_limit").await; + let report = import_account( + &instance.pool, + &instance.paths, + &at_limit, + at_limit_user, + &path, + ) + .await + .expect("an archive exactly at the compressed limit must import"); + assert_eq!(report.posts_imported, 0); + + let mut over_limit = instance.settings.clone(); + over_limit.accounts.max_archive_upload_bytes = size - 1; + let over_limit_user = register_user(&instance, "over_limit").await; + import_rejected(&instance, &over_limit, over_limit_user, &path, "too large").await; + } +} diff --git a/src/render.rs b/src/render.rs index 6fab22e..7f3851b 100644 --- a/src/render.rs +++ b/src/render.rs @@ -2,8 +2,8 @@ use std::fmt::Write as _; use crate::auth::{CurrentUser, Theme}; use crate::social::{ - AccountView, MediaView, NotificationGroupView, PostView, ProfileTimelineTab, QuotePreview, - TimelineEventKind, + AccountView, FollowRequestView, MediaView, NotificationGroupView, PostView, ProfileTimelineTab, + QuotePreview, TimelineEventKind, }; use crate::youtube::{self, YoutubeEmbed}; use axum::http::StatusCode; @@ -17,6 +17,14 @@ pub struct LayoutContext { pub following_count: Option, pub notification_unread_count: Option, pub favicon_content_type: &'static str, + /// Enabled instance announcement, rendered beside the site name. + pub announcement: Option, + /// Maintenance notice, rendered under the header while maintenance is on. + pub maintenance_notice: Option, + /// Notice for accounts with a pending deletion deadline. + pub account_notice: Option, + /// Pending incoming follow requests, shown as a nav badge. + pub pending_follow_requests: Option, } impl Default for LayoutContext { @@ -28,6 +36,10 @@ impl Default for LayoutContext { following_count: None, notification_unread_count: None, favicon_content_type: "image/x-icon", + announcement: None, + maintenance_notice: None, + account_notice: None, + pending_follow_requests: None, } } } @@ -147,6 +159,7 @@ pub fn layout_with_context( String::new() }; let notifications = notification_nav_link(context.notification_unread_count.unwrap_or(0)); + let requests = follow_requests_nav_link(context.pending_follow_requests.unwrap_or(0)); let logout = csrf.map_or_else(String::new, |token| { format!( r#"
"#, @@ -156,7 +169,7 @@ pub fn layout_with_context( }); let profile = nav_link(&format!("/users/{}", user.username), "Profile", "profile"); format!( - "{}{}{}{notifications}{}{}{admin}{logout}", + "{}{}{}{notifications}{requests}{}{}{admin}{logout}", nav_link("/home", "Home Feed", "home"), nav_link("/following", "Following", "users"), nav_link("/search", "Search", "search"), @@ -177,6 +190,9 @@ pub fn layout_with_context( let side_panel = dashboard_panel(user, context); let theme = user.map_or(Theme::Light, |user| user.theme).as_str(); let header_tor = tor_header_indicator(context.tor_onion_address.as_deref()); + let announcement = announcement_banner(context.announcement.as_deref()); + let maintenance = maintenance_banner(context.maintenance_notice.as_deref()); + let account_notice = account_notice_banner(context.account_notice.as_deref()); format!( r#" @@ -193,7 +209,8 @@ pub fn layout_with_context( - + +{maintenance}{account_notice}
{}
{}
{}
{}
@@ -206,6 +223,7 @@ pub fn layout_with_context( CSS, html_escape::encode_text(&brand_mark.to_string()), html_escape::encode_text(site_name), + announcement, header_tor, auth_nav, left_rail, @@ -215,6 +233,33 @@ pub fn layout_with_context( ) } +fn announcement_banner(announcement: Option<&str>) -> String { + announcement.map_or_else(String::new, |announcement| { + format!( + r#"{}"#, + html_escape::encode_text(announcement) + ) + }) +} + +fn maintenance_banner(notice: Option<&str>) -> String { + notice.map_or_else(String::new, |notice| { + format!( + r#"

{}

"#, + html_escape::encode_text(notice) + ) + }) +} + +fn account_notice_banner(notice: Option<&str>) -> String { + notice.map_or_else(String::new, |notice| { + format!( + r#""#, + html_escape::encode_text(notice) + ) + }) +} + 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); @@ -280,6 +325,17 @@ fn notification_nav_link(unread_count: i64) -> String { } } +fn follow_requests_nav_link(pending_count: i64) -> String { + if pending_count > 0 { + format!( + r#"{}Requests {pending_count}"#, + icon_svg("user-check") + ) + } else { + nav_link("/follow-requests", "Requests", "user-check") + } +} + /// Picks the singular or plural label for a count. fn plural<'a>(count: i64, singular: &'a str, plural: &'a str) -> &'a str { if count == 1 { singular } else { plural } @@ -1125,11 +1181,13 @@ document.addEventListener("submit", async (event) => { followForm.action = data.action; const button = followForm.querySelector("button"); if (button) { + const label = data.following ? "Following" : (data.requested ? "Requested" : "Follow"); + const aria = data.following ? "Unfollow this account" : (data.requested ? "Cancel follow request" : "Follow this account"); button.classList.toggle("active", data.following); - button.textContent = data.following ? "Following" : "Follow"; + button.textContent = label; button.setAttribute("aria-pressed", data.following ? "true" : "false"); - button.setAttribute("aria-label", data.following ? "Unfollow this account" : "Follow this account"); - button.setAttribute("title", data.following ? "Unfollow this account" : "Follow this account"); + button.setAttribute("aria-label", aria); + button.setAttribute("title", aria); } } document.querySelectorAll(`[data-profile-followers="${data.user_id}"]`).forEach((node) => { @@ -1381,11 +1439,15 @@ pub fn accounts(accounts: &[AccountView], csrf: &str) -> String { ) }, ); - let action = if account.viewer_following { - follow_form(account.id, csrf, true) - } else { - follow_form(account.id, csrf, false) - }; + let action = follow_form( + account.id, + csrf, + if account.viewer_following { + FollowButtonState::Following + } else { + FollowButtonState::Follow + }, + ); format!( r#""#, avatar, @@ -1647,27 +1709,58 @@ fn search_user_results(users: &[AccountView]) -> String { ) } -pub fn follow_form(user_id: i64, csrf: &str, following: bool) -> String { - let (action, label, aria_label) = if following { - ( +/// Follow button state on profiles, account lists, and search results. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FollowButtonState { + /// No relationship; submitting sends a follow or a follow request. + Follow, + /// An accepted follow exists; submitting unfollows. + Following, + /// A pending request exists; submitting cancels the request. + Requested, +} + +impl FollowButtonState { + #[must_use] + pub const fn from_flags(following: bool, requested: bool) -> Self { + if following { + Self::Following + } else if requested { + Self::Requested + } else { + Self::Follow + } + } +} + +pub fn follow_form(user_id: i64, csrf: &str, state: FollowButtonState) -> String { + let (action, label, aria_label, active) = match state { + FollowButtonState::Following => ( format!("/users/{user_id}/unfollow"), "Following", "Unfollow this account", - ) - } else { - ( + true, + ), + FollowButtonState::Requested => ( + format!("/users/{user_id}/follow/cancel"), + "Requested", + "Cancel follow request", + false, + ), + FollowButtonState::Follow => ( format!("/users/{user_id}/follow"), "Follow", "Follow this account", - ) + false, + ), }; format!( r#"
"#, html_escape::encode_double_quoted_attribute(&action), user_id, html_escape::encode_double_quoted_attribute(csrf), - if following { " active" } else { "" }, - if following { "true" } else { "false" }, + if active { " active" } else { "" }, + if active { "true" } else { "false" }, html_escape::encode_double_quoted_attribute(aria_label), html_escape::encode_double_quoted_attribute(aria_label), html_escape::encode_text(label) @@ -2752,18 +2845,28 @@ fn notification_open_control( .map_or_else(String::new, |id| { format!(r#""#) }); + // Group kinds without a post target (for example follow requests) are + // marked read through their explicit notification ids. + let group_kind = if notification.kind == "follow" || notification.group_target_post_id.is_some() + { + format!( + r#""#, + html_escape::encode_double_quoted_attribute(¬ification.kind) + ) + } else { + String::new() + }; format!( - r#"
{group_target}
"#, + r#"
{group_kind}{group_target}
"#, html_escape::encode_double_quoted_attribute(form_id), html_escape::encode_double_quoted_attribute(csrf), html_escape::encode_double_quoted_attribute(¬ification_ids), - html_escape::encode_double_quoted_attribute(¬ification.kind), html_escape::encode_double_quoted_attribute(target) ) } fn notification_preview(notification: &NotificationGroupView, target: Option<&str>) -> String { - if notification.kind == "follow" { + if is_account_notification(¬ification.kind) { return String::new(); } let text = if notification.post_available { @@ -2790,8 +2893,19 @@ fn notification_preview(notification: &NotificationGroupView, target: Option<&st } } +/// Notification kinds that point at an account rather than a post. +fn is_account_notification(kind: &str) -> bool { + matches!( + kind, + "follow" | "follow_request" | "follow_request_approved" + ) +} + fn notification_target(notification: &NotificationGroupView) -> Option { - if notification.kind == "follow" { + if notification.kind == "follow_request" { + return Some("/follow-requests".to_owned()); + } + if is_account_notification(¬ification.kind) { return notification .actors .first() @@ -2855,6 +2969,8 @@ fn notification_action_text(kind: &str) -> &'static str { "quote" => "quoted your post", "follow" => "followed you", "mention" => "mentioned you in a post", + "follow_request" => "requested to follow you", + "follow_request_approved" => "approved your follow request", _ => "sent you a notification", } } @@ -2867,6 +2983,8 @@ fn notification_group_action_text(kind: &str) -> &'static str { "quote" => "quoted your post", "follow" => "followed you", "mention" => "mentioned you in a post", + "follow_request" => "requested to follow you", + "follow_request_approved" => "approved your follow request", _ => "sent you notifications", } } @@ -2877,7 +2995,7 @@ fn notification_kind_label(kind: &str) -> &'static str { "like" => "L", "repost" => "Re", "quote" => "Q", - "follow" => "F", + "follow" | "follow_request" | "follow_request_approved" => "F", "mention" => "@", _ => "N", } @@ -2930,6 +3048,191 @@ pub fn thread_back_control() -> String { .to_owned() } +/// "Previously known as" note for profiles that changed handles. +pub fn username_history_note(history: &[crate::identity::UsernameHistoryEntry]) -> String { + if history.is_empty() { + return String::new(); + } + let handles = history + .iter() + .map(|entry| format!("@{}", entry.username)) + .collect::>() + .join(", "); + format!( + r#"

Previously known as {}

"#, + html_escape::encode_text(&handles) + ) +} + +/// Renders the page for a handle that no current account owns but that has +/// history on this instance, so old profile links cannot silently resolve to a +/// different person. +pub fn historical_username_page( + requested: &str, + holders: &[crate::identity::HistoricalUsernameHolder], +) -> String { + let rows = holders + .iter() + .map(|holder| { + format!( + r#"
  • {} @{} held until {}
  • "#, + html_escape::encode_double_quoted_attribute(&holder.username), + html_escape::encode_text(&holder.display_name), + html_escape::encode_text(&holder.username), + html_escape::encode_text(&holder.changed_at), + ) + }) + .collect::>() + .join(""); + format!( + r#"

    No account uses @{requested}

    This handle is not in use right now, but it was used before. RustPost does not redirect old profile links so they cannot silently point at a different person.

    Accounts that previously used this handle

      {rows}
    "#, + requested = html_escape::encode_text(requested), + ) +} + +/// Notice rendered on a profile whose handle was previously held by a +/// permanently deleted account, so an old link cannot silently appear to +/// represent the deleted account. +pub fn released_username_profile_note() -> String { + r#"

    This handle was previously used by an account that has since been permanently deleted. The current profile is a different account.

    "# + .to_owned() +} + +/// Page rendered for a handle released by a permanently deleted account. +/// +/// Used when no current account owns the handle. Old profile URLs remain +/// distinguishable from the deleted account without blocking the handle from +/// being claimed again. +pub fn released_username_page(requested: &str) -> String { + format!( + r#"

    No account uses @{requested}

    This handle was used by an account that has since been permanently deleted. The name is available again, and this page does not represent the deleted account.

    Register this handle

    "#, + requested = html_escape::encode_text(requested), + ) +} + +/// Renders the maintenance page shown for state-changing requests that are +/// disabled while maintenance mode is on. +pub fn maintenance_page(site_name: &str, notice: &str) -> String { + let body = format!( + r#"

    503 unavailable

    Maintenance in progress

    {}

    Back to Home Feed

    "#, + html_escape::encode_text(notice) + ); + layout(None, "Maintenance in progress", &body, site_name) +} + +/// Pending follow requests, split into incoming requests awaiting the viewer's +/// decision and outgoing requests the viewer can cancel. +pub fn follow_requests_page( + incoming: &[FollowRequestView], + outgoing: &[FollowRequestView], + csrf: &str, +) -> String { + format!( + r#"{}{}"#, + follow_request_section( + "Follow requests", + "Accounts that need your approval before they can follow you.", + incoming, + csrf, + FollowRequestDirection::Incoming, + ), + follow_request_section( + "Sent requests", + "Requests you sent to protected accounts. You can cancel them here.", + outgoing, + csrf, + FollowRequestDirection::Outgoing, + ), + ) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FollowRequestDirection { + Incoming, + Outgoing, +} + +fn follow_request_section( + title: &str, + help: &str, + requests: &[FollowRequestView], + csrf: &str, + direction: FollowRequestDirection, +) -> String { + let content = if requests.is_empty() { + match direction { + FollowRequestDirection::Incoming => compact_empty_state( + "No follow requests.", + "Requests appear here when someone asks to follow a protected account.", + ), + FollowRequestDirection::Outgoing => compact_empty_state( + "No sent requests.", + "Requests you send to protected accounts appear here.", + ), + } + } else { + let rows = requests + .iter() + .map(|request| follow_request_row(request, csrf, direction)) + .collect::>() + .join(""); + format!(r#""#) + }; + format!( + r#"

    {}

    {}

    {content}
    "#, + html_escape::encode_text(title), + html_escape::encode_text(help), + ) +} + +fn follow_request_row( + request: &FollowRequestView, + csrf: &str, + direction: FollowRequestDirection, +) -> String { + let csrf = html_escape::encode_double_quoted_attribute(csrf); + let username = html_escape::encode_double_quoted_attribute(&request.username); + let actions = match direction { + FollowRequestDirection::Incoming => { + let approve = format!( + r#"
    "#, + request.user_id + ); + let reject = format!( + r#"
    "#, + request.user_id + ); + format!("{approve}{reject}") + } + FollowRequestDirection::Outgoing => format!( + r#"
    "#, + request.user_id + ), + }; + let avatar = request.profile_picture_path.as_ref().map_or_else( + || { + let initial = request.display_name.chars().next().unwrap_or('R'); + format!( + r#""#, + html_escape::encode_text(&initial.to_string()) + ) + }, + |path| { + format!( + r#""#, + html_escape::encode_double_quoted_attribute(path) + ) + }, + ); + format!( + r#""#, + html_escape::encode_text(&request.display_name), + html_escape::encode_text(&request.username), + html_escape::encode_text(&request.bio), + html_escape::encode_text(&request.created_at), + ) +} + pub fn notice(kind: &str, message: &str) -> String { let role = if kind == "error" { "alert" } else { "status" }; format!( @@ -2996,9 +3299,16 @@ nav button{border-color:transparent;background:transparent;color:var(--link-stro .profile-title-row>div{min-width:0}.profile-title-row h1{margin:0;overflow-wrap:anywhere}.profile-bio{margin:.55rem 0 0;overflow-wrap:anywhere}.profile-state-note{color:var(--muted-strong);font-weight:700} .notice.info{border-color:var(--border-strong);background:var(--surface-subtle)} @media (prefers-reduced-motion:reduce){*,*::before,*::after{transition-duration:.01ms !important;animation-duration:.01ms !important}} +.announcement{min-width:0;max-width:min(34rem,100%);color:var(--muted-strong);font-size:.86rem;font-weight:600;line-height:1.25;overflow-wrap:anywhere;white-space:normal;flex:0 1 auto} +.maintenance-notice{margin:0 0 var(--section-gap);border-color:var(--border-strong);background:var(--surface-subtle)} +.account-notice{margin:0 0 var(--section-gap)} +.follow-request-row{display:grid;gap:.55rem;border:1px solid var(--border);border-radius:8px;padding:.75rem;background:var(--surface)} +.follow-request-row .actions{display:flex;gap:.45rem;flex-wrap:wrap;align-items:center} +.username-history{margin:.35rem 0 0;color:var(--muted);font-size:.9rem} +.username-history-list{list-style:none;margin:.35rem 0 0;padding:0;display:grid;gap:.2rem} @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}.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,.onboarding-media-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-switch-row{grid-template-columns:1fr;gap:.55rem}.settings-switch-toggle,.settings-switch-control{justify-self:start}.settings-form-actions{justify-content:stretch}.settings-form-actions button,.settings-danger-action .button-link{width:100%;justify-content:center}.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}} +@media (max-width:600px){main{padding:.75rem}.header-inner{align-items:flex-start;flex-direction:column}.header-brand-row{align-items:center;flex-wrap:wrap;width:100%;gap:.55rem}.announcement{flex-basis:100%;max-width:100%}.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,.onboarding-media-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-switch-row{grid-template-columns:1fr;gap:.55rem}.settings-switch-toggle,.settings-switch-control{justify-self:start}.settings-form-actions{justify-content:stretch}.settings-form-actions button,.settings-danger-action .button-link{width:100%;justify-content:center}.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)] @@ -3033,6 +3343,8 @@ mod tests { is_suspended: false, theme: Theme::Dark, nsfw_blur_enabled: true, + must_change_password: false, + deletion_scheduled_at: None, }; let body = layout(Some(&user), "Home Feed", "

    body

    ", "My Microblog"); @@ -3133,6 +3445,8 @@ mod tests { is_suspended: false, theme: Theme::Light, nsfw_blur_enabled: true, + must_change_password: false, + deletion_scheduled_at: None, }; let body = layout_with_csrf( Some(&user), @@ -3169,6 +3483,8 @@ mod tests { is_suspended: false, theme: Theme::Light, nsfw_blur_enabled: true, + must_change_password: false, + deletion_scheduled_at: None, }; let body = layout_with_context( Some(&user), @@ -3466,6 +3782,8 @@ mod tests { is_suspended: false, theme: Theme::Light, nsfw_blur_enabled: true, + must_change_password: false, + deletion_scheduled_at: None, }; let mut post = test_post(); post.user_id = Some(2); @@ -3500,6 +3818,8 @@ mod tests { is_suspended: false, theme: Theme::Light, nsfw_blur_enabled: true, + must_change_password: false, + deletion_scheduled_at: None, }; let mut post = test_post(); post.user_id = Some(2); diff --git a/src/runtime.rs b/src/runtime.rs index 1edc78a..54a7a51 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -1,10 +1,47 @@ use std::env; use std::fs; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; use anyhow::Context as _; use tracing::{info, warn}; +/// Number of in-flight operations that stage files under the runtime temp +/// directory (media uploads, account archive export/import, restore uploads). +/// File cleanup is skipped while any operation is active so a slow but healthy +/// operation can never have its staging directory removed underneath it. +static ACTIVE_TEMP_OPERATIONS: AtomicUsize = AtomicUsize::new(0); + +/// Maximum directory entries inspected in one cleanup pass. Cleanup is +/// opportunistic; bounding each pass keeps a pathological temp directory from +/// stalling the maintenance scheduler. +const MAX_CLEANUP_SCAN_ENTRIES: usize = 10_000; + +/// Marks one long-running operation that stages files under +/// [`RuntimePaths::tmp_dir`]. The guard must live for the whole operation. +#[must_use] +pub struct TempOperationGuard { + _private: (), +} + +/// Begins a temp-staging operation. See [`TempOperationGuard`]. +pub fn begin_temp_operation() -> TempOperationGuard { + ACTIVE_TEMP_OPERATIONS.fetch_add(1, Ordering::SeqCst); + TempOperationGuard { _private: () } +} + +impl Drop for TempOperationGuard { + fn drop(&mut self) { + ACTIVE_TEMP_OPERATIONS.fetch_sub(1, Ordering::SeqCst); + } +} + +/// Whether any operation is currently staging files under `tmp_dir`. +#[must_use] +pub fn temp_operations_active() -> bool { + ACTIVE_TEMP_OPERATIONS.load(Ordering::SeqCst) > 0 +} + #[derive(Debug, Clone)] pub struct RuntimePaths { pub data_dir: PathBuf, @@ -193,10 +230,24 @@ impl RuntimePaths { self.db_dir.join(format!("rustpost.sqlite3-{suffix}")) } - /// Removes leftover upload staging files and abandoned restore uploads - /// older than `max_age`. Called with a zero age during startup, where no - /// request can be in flight, and with a grace period while running. + /// Removes leftover upload staging files, abandoned restore uploads, and + /// staged account export/import archives older than `max_age`. Called with + /// a zero age during startup, where no request can be in flight, and with + /// a grace period while running. + /// + /// Safety properties: + /// * Returns without touching anything while a staged operation is active, + /// so in-flight uploads/imports/exports are never removed. + /// * Only entries directly inside `tmp_dir` whose names match the known + /// staging prefixes are candidates; symlinks are unlinked, never + /// followed, and directories are removed only with `remove_dir_all` + /// after confirming they are not symlinks. + /// * Each pass inspects at most [`MAX_CLEANUP_SCAN_ENTRIES`] entries. pub fn cleanup_stale_temp_files(&self, max_age: std::time::Duration) -> anyhow::Result { + if temp_operations_active() { + tracing::debug!("skipping temp cleanup while a staged operation is active"); + return Ok(0); + } let mut removed = 0usize; let entries = match fs::read_dir(&self.tmp_dir) { Ok(entries) => entries, @@ -207,27 +258,53 @@ impl RuntimePaths { }); } }; - for entry in entries { + for (scanned, entry) in entries.enumerate() { + if scanned >= MAX_CLEANUP_SCAN_ENTRIES { + tracing::warn!( + limit = MAX_CLEANUP_SCAN_ENTRIES, + "temp cleanup stopped early; too many entries in the runtime temp directory" + ); + break; + } let entry = entry?; let path = entry.path(); - if !path.is_file() { + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + let is_staging_name = name.ends_with(".upload") + || Path::new(name) + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("tmp")) + || name.starts_with("restore-upload-") + || name.starts_with(crate::portability::EXPORT_TMP_PREFIX) + || name.starts_with(crate::portability::IMPORT_TMP_PREFIX); + if !is_staging_name { continue; } - let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + // Use `symlink_metadata` so a symlink is treated as a link (never + // followed) and only real directories are recursively removed. + let Ok(metadata) = fs::symlink_metadata(&path) else { continue; }; - if !(name.ends_with(".upload") || name.starts_with("restore-upload-")) { + let is_symlink = metadata.file_type().is_symlink(); + if !is_symlink && !metadata.is_file() && !metadata.is_dir() { continue; } - let stale = match fs::metadata(&path).and_then(|metadata| metadata.modified()) { - Ok(modified) => modified - .elapsed() - .map(|age| age >= max_age) - .unwrap_or(false), - Err(_error) => false, + let stale = metadata + .modified() + .is_ok_and(|modified| modified.elapsed().is_ok_and(|age| age >= max_age)); + if !stale { + continue; + } + let removed_entry = if is_symlink || metadata.is_file() { + fs::remove_file(&path).is_ok() + } else { + fs::remove_dir_all(&path).is_ok() }; - if stale && fs::remove_file(&path).is_ok() { + if removed_entry { removed += 1; + } else { + tracing::warn!(path = %path.display(), "failed to remove stale temp entry"); } } Ok(removed) @@ -396,4 +473,145 @@ mod tests { b"new wal" ); } + + fn staging_paths() -> (tempfile::TempDir, RuntimePaths) { + let temp = tempfile::tempdir().expect("temp dir"); + let paths = RuntimePaths::from_data_dir(temp.path().join("data")); + paths.ensure().expect("ensure paths"); + (temp, paths) + } + + fn age_file(path: &Path, age: std::time::Duration) { + let modified = std::time::SystemTime::now() + .checked_sub(age) + .expect("system time supports subtraction"); + let file = fs::File::options() + .write(true) + .open(path) + .expect("open for mtime"); + file.set_modified(modified).expect("set modified"); + } + + #[test] + fn cleanup_removes_stale_known_staging_entries_only() { + let (_temp, paths) = staging_paths(); + let stale = std::time::Duration::from_hours(2); + let staging_file = paths.tmp_dir.join("account-import-abc.tar.gz"); + let export_file = paths.tmp_dir.join("account-export-def.tar.gz"); + let restore_file = paths.tmp_dir.join("restore-upload-1.tar"); + let upload_file = paths.tmp_dir.join("staged.upload"); + let tmp_file = paths.tmp_dir.join("scratch.tmp"); + let unrelated = paths.tmp_dir.join("keep.txt"); + for file in [ + &staging_file, + &export_file, + &restore_file, + &upload_file, + &tmp_file, + &unrelated, + ] { + fs::write(file, b"staged").expect("write staging file"); + age_file(file, stale); + } + + let removed = paths + .cleanup_stale_temp_files(std::time::Duration::from_hours(1)) + .expect("cleanup"); + + assert_eq!(removed, 5); + assert!(!staging_file.exists()); + assert!(!export_file.exists()); + assert!(!restore_file.exists()); + assert!(!upload_file.exists()); + assert!(!tmp_file.exists()); + assert!(unrelated.exists(), "unrelated files must be preserved"); + } + + #[test] + fn cleanup_removes_abandoned_import_staging_directories() { + let (_temp, paths) = staging_paths(); + let staging_dir = paths.tmp_dir.join("account-import-extract"); + fs::create_dir_all(&staging_dir).expect("staging dir"); + fs::write(staging_dir.join("media.bin"), b"staged").expect("staged media"); + let unrelated_dir = paths.tmp_dir.join("keep-dir"); + fs::create_dir_all(&unrelated_dir).expect("unrelated dir"); + + let removed = paths + .cleanup_stale_temp_files(std::time::Duration::ZERO) + .expect("startup cleanup"); + + assert_eq!(removed, 1); + assert!(!staging_dir.exists()); + assert!(unrelated_dir.is_dir(), "unrelated directories must remain"); + } + + #[test] + fn cleanup_respects_the_age_threshold() { + let (_temp, paths) = staging_paths(); + let fresh = paths.tmp_dir.join("account-import-fresh.tar.gz"); + fs::write(&fresh, b"fresh").expect("staging file"); + + let removed = paths + .cleanup_stale_temp_files(std::time::Duration::from_hours(1)) + .expect("cleanup"); + assert_eq!(removed, 0); + assert!(fresh.exists(), "a fresh upload must not be removed"); + + let removed = paths + .cleanup_stale_temp_files(std::time::Duration::ZERO) + .expect("startup cleanup"); + assert_eq!(removed, 1); + assert!(!fresh.exists()); + } + + #[test] + fn cleanup_skips_while_a_staged_operation_is_active() { + let (_temp, paths) = staging_paths(); + let stale = paths.tmp_dir.join("account-import-active.tar.gz"); + fs::write(&stale, b"active").expect("staging file"); + age_file(&stale, std::time::Duration::from_hours(4)); + + let operation = begin_temp_operation(); + assert!(temp_operations_active()); + let removed = paths + .cleanup_stale_temp_files(std::time::Duration::ZERO) + .expect("cleanup"); + assert_eq!(removed, 0); + assert!( + stale.exists(), + "an active operation's staging file must survive cleanup" + ); + + drop(operation); + assert!(!temp_operations_active()); + let removed = paths + .cleanup_stale_temp_files(std::time::Duration::from_hours(1)) + .expect("cleanup"); + assert_eq!(removed, 1); + assert!(!stale.exists()); + } + + #[cfg(unix)] + #[test] + fn cleanup_never_follows_staging_symlinks() { + let (_temp, paths) = staging_paths(); + let target_dir = paths.data_dir.join("outside"); + fs::create_dir_all(&target_dir).expect("target dir"); + let target_file = target_dir.join("important.bin"); + fs::write(&target_file, b"important").expect("target file"); + let link = paths.tmp_dir.join("account-import-link"); + std::os::unix::fs::symlink(&target_dir, &link).expect("symlink"); + + let removed = paths + .cleanup_stale_temp_files(std::time::Duration::ZERO) + .expect("cleanup"); + + assert_eq!(removed, 1); + assert!(!link.exists()); + assert!(target_dir.is_dir(), "symlink target directory must remain"); + assert_eq!( + fs::read(&target_file).expect("target file survives"), + b"important" + ); + } } diff --git a/src/server.rs b/src/server.rs index 4f28272..0edc54e 100644 --- a/src/server.rs +++ b/src/server.rs @@ -27,7 +27,10 @@ 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}; +use crate::{ + account, admin, backup, csrf, favicon, identity, instance, media, portability, rate_limit, + render, social, +}; const CSRF_TOKEN_HISTORY_LIMIT: usize = 32; @@ -45,7 +48,7 @@ pub struct AppState { pub nsfw_blur_default: Arc, /// Set after an in-process restore swaps the runtime directories. The /// running process still holds the previous SQLite connection, so writes - /// are refused until the operator restarts RustPost. + /// are refused until the operator restarts `RustPost`. pub restart_required: Arc, } @@ -101,6 +104,183 @@ async fn restart_required_guard( next.run(request).await } +/// Paths that stay reachable while an account is restricted to a forced +/// password change. Static assets, logout, the password form, account +/// deletion, and data export must remain available. +fn password_change_exempt(path: &str) -> bool { + if path.starts_with("/assets/") || path.starts_with("/uploads/") { + return true; + } + matches!( + path, + "/login" + | "/settings/password" + | "/logout" + | "/favicon.ico" + | "/local" + | "/settings/delete" + | "/settings/delete/confirm" + | "/settings/delete/cancel" + | "/settings/export" + | "/account-deleted" + ) +} + +/// State-changing paths still allowed while an account is pending deletion. +/// Everything else is read-only until the deletion is cancelled. +fn pending_deletion_exempt(path: &str) -> bool { + matches!( + path, + "/logout" | "/settings/password" | "/settings/delete/cancel" | "/settings/delete/confirm" + ) +} + +fn is_state_changing(method: &axum::http::Method) -> bool { + !matches!( + *method, + axum::http::Method::GET + | axum::http::Method::HEAD + | axum::http::Method::OPTIONS + | axum::http::Method::TRACE + ) +} + +/// Enforces forced-password-reset and pending-deletion restrictions for the +/// authenticated viewer on every route, so a long-lived session cannot bypass +/// either account state. +async fn account_state_guard( + State(state): State>, + request: axum::extract::Request, + next: middleware::Next, +) -> Response { + let guarded_path = request.uri().path(); + if guarded_path.starts_with("/assets/") + || guarded_path.starts_with("/uploads/") + || guarded_path == "/favicon.ico" + { + return next.run(request).await; + } + if auth::session_cookie(request.headers()).is_none() { + return next.run(request).await; + } + let user = match auth::current_user(&state.pool, request.headers()).await { + Ok(Some(user)) => user, + Ok(None) => return next.run(request).await, + Err(error) => { + // Fail closed: an authenticated request whose account state cannot + // be verified must not bypass forced password or deletion checks. + tracing::warn!(error = %error, "account state lookup failed"); + return ( + StatusCode::SERVICE_UNAVAILABLE, + Html(render::error_page( + StatusCode::SERVICE_UNAVAILABLE, + "Your account state could not be verified. Try again shortly.", + )), + ) + .into_response(); + } + }; + let path = request.uri().path(); + if user.must_change_password && !password_change_exempt(path) { + return Redirect::to("/settings/password?required=1").into_response(); + } + if user.deletion_scheduled_at.is_some() + && is_state_changing(request.method()) + && !pending_deletion_exempt(path) + { + return ( + StatusCode::FORBIDDEN, + Html(render::error_page( + StatusCode::FORBIDDEN, + "This account is scheduled for deletion. Cancel the deletion in account settings to keep using it.", + )), + ) + .into_response(); + } + next.run(request).await +} + +/// Central maintenance-mode policy for mutating routes. +/// +/// Only routes that publish or rewrite public content are candidates. Reads, +/// authentication, account/security operations, social graph actions, and the +/// admin UI stay available while maintenance mode is on. Registration is +/// blocked for everyone; publish routes are blocked for non-administrators so +/// administrators can verify the instance and later disable maintenance. +/// +/// Keep this table authoritative: any new route that publishes content +/// (creates or rewrites posts/replies/quotes/reposts, or imports archives that +/// publish posts) must be added here, with a matching table-driven test. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MaintenancePolicy { + /// Always reachable. + Allowed, + /// Blocked for every request. + Blocked, + /// Blocked for anonymous users and non-administrators. + BlockedUnlessAdmin, +} + +fn maintenance_policy(method: &axum::http::Method, path: &str) -> MaintenancePolicy { + if *method != axum::http::Method::POST { + return MaintenancePolicy::Allowed; + } + match path { + "/register" => MaintenancePolicy::Blocked, + "/posts" | "/settings/import" => MaintenancePolicy::BlockedUnlessAdmin, + _ if path.starts_with("/posts/") + && (path.ends_with("/quote") + || path.ends_with("/repost") + || path.ends_with("/edit")) => + { + MaintenancePolicy::BlockedUnlessAdmin + } + _ => MaintenancePolicy::Allowed, + } +} + +async fn maintenance_guard( + State(state): State>, + request: axum::extract::Request, + next: middleware::Next, +) -> Response { + let policy = maintenance_policy(request.method(), request.uri().path()); + if policy == MaintenancePolicy::Allowed { + return next.run(request).await; + } + let settings = match instance::load(&state.pool).await { + Ok(settings) => settings, + Err(error) => { + tracing::warn!(error = %error, "failed to load instance settings; refusing state change"); + return ( + StatusCode::SERVICE_UNAVAILABLE, + Html(render::maintenance_page( + &state.settings.site.name, + instance::DEFAULT_MAINTENANCE_MESSAGE, + )), + ) + .into_response(); + } + }; + if !settings.maintenance_mode { + return next.run(request).await; + } + if policy == MaintenancePolicy::BlockedUnlessAdmin + && let Ok(Some(user)) = auth::current_user(&state.pool, request.headers()).await + && user.is_admin + { + return next.run(request).await; + } + let notice = settings + .maintenance_notice() + .unwrap_or(instance::DEFAULT_MAINTENANCE_MESSAGE); + ( + StatusCode::SERVICE_UNAVAILABLE, + Html(render::maintenance_page(&state.settings.site.name, notice)), + ) + .into_response() +} + /// Periodically prunes expired operational rows and abandoned temp files. /// /// Startup performs an aggressive temp cleanup (nothing can be in flight yet); @@ -113,13 +293,23 @@ pub fn spawn_maintenance_scheduler( tokio::spawn(async move { // The longest configured rate-limit window is one hour; keep two hours. const RATE_LIMIT_RETENTION_SECS: i64 = 2 * 60 * 60; - const MAINTENANCE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30 * 60); + const MAINTENANCE_INTERVAL: std::time::Duration = std::time::Duration::from_mins(30); let mut first_cycle = true; loop { + match crate::account::recover_pending_media_deletions(&paths).await { + Ok(0) => {} + Ok(recovered) => tracing::info!( + recovered, + "recovered interrupted account deletion media cleanup" + ), + Err(error) => { + tracing::warn!(error = %error, "pending media deletion recovery failed"); + } + } let max_temp_age = if first_cycle { std::time::Duration::ZERO } else { - std::time::Duration::from_secs(60 * 60) + std::time::Duration::from_hours(1) }; match paths.cleanup_stale_temp_files(max_temp_age) { Ok(0) => {} @@ -135,6 +325,13 @@ pub fn spawn_maintenance_scheduler( Ok(removed) => tracing::info!(removed, "removed stale sessions"), Err(error) => tracing::warn!(error = %error, "session pruning failed"), } + match crate::account::finalize_due_deletions(&pool, &paths).await { + Ok(0) => {} + Ok(removed) => tracing::info!(removed, "finalized scheduled account deletions"), + Err(error) => { + tracing::warn!(error = %error, "scheduled account deletion finalization failed"); + } + } first_cycle = false; tokio::select! { changed = shutdown_rx.changed() => { @@ -148,8 +345,13 @@ pub fn spawn_maintenance_scheduler( }); } +#[expect( + clippy::too_many_lines, + reason = "the route table is one flat registration list, matching the original layout" +)] pub fn router(state: Arc) -> Router { let upload_body_limit = upload_body_limit(&state.settings); + let import_body_limit = import_upload_body_limit(&state.settings); Router::new() .route("/", get(home)) .route("/assets/rustpost-boot.js", get(client_boot_script)) @@ -175,7 +377,11 @@ pub fn router(state: Arc) -> Router { .route("/users/{username}/followers", get(profile_followers)) .route("/users/{username}/following", get(profile_following)) .route("/users/{id}/follow", post(follow)) + .route("/users/{id}/follow/approve", post(approve_follow_request)) + .route("/users/{id}/follow/reject", post(reject_follow_request)) + .route("/users/{id}/follow/cancel", post(cancel_follow_request)) .route("/users/{id}/unfollow", post(unfollow)) + .route("/follow-requests", get(follow_requests)) .route("/users/{id}/block", post(block)) .route("/users/{id}/unblock", post(unblock)) .route("/users/{id}/mute", post(mute)) @@ -183,7 +389,19 @@ pub fn router(state: Arc) -> Router { .route("/settings", get(settings_form).post(settings_update)) .route("/settings/muted-words", post(add_muted_word)) .route("/settings/muted-words/{id}/remove", post(remove_muted_word)) - .route("/settings/password", post(change_password)) + .route( + "/settings/password", + get(password_change_page).post(change_password), + ) + .route("/settings/username", post(change_username)) + .route("/settings/export", get(export_account)) + .route( + "/settings/import", + get(import_account_form) + .post(import_account) + .layer(DefaultBodyLimit::max(import_body_limit)), + ) + .route("/settings/delete/cancel", post(cancel_deletion)) .route("/settings/delete", get(delete_account_warning)) .route( "/settings/delete/confirm", @@ -201,6 +419,16 @@ pub fn router(state: Arc) -> Router { .route("/admin", get(admin_dashboard)) .route("/admin/users", get(admin_users)) .route("/admin/users/{id}/suspend", post(admin_suspend)) + .route( + "/admin/users/{id}/require-password-reset", + post(admin_require_password_reset), + ) + .route( + "/admin/users/{id}/revoke-sessions", + post(admin_revoke_sessions), + ) + .route("/admin/announcement", post(admin_update_announcement)) + .route("/admin/maintenance", post(admin_update_maintenance)) .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)) @@ -239,6 +467,14 @@ pub fn router(state: Arc) -> Router { ServeDir::new(state.paths.uploads_thumbs.clone()), ) .layer(DefaultBodyLimit::max(upload_body_limit)) + .layer(middleware::from_fn_with_state( + state.clone(), + account_state_guard, + )) + .layer(middleware::from_fn_with_state( + state.clone(), + maintenance_guard, + )) .layer(middleware::from_fn_with_state( state.clone(), restart_required_guard, @@ -258,6 +494,20 @@ fn upload_body_limit(settings: &Settings) -> usize { } } +/// Body limit for `POST /settings/import`. The configured compressed archive +/// ceiling plus a small multipart/framing allowance so an oversized archive is +/// rejected by the streaming handler with a clear 413 page instead of a +/// transport-level body limit failure. The global media body limit stays +/// independent of archive imports. +fn import_upload_body_limit(settings: &Settings) -> usize { + const MULTIPART_SLACK_BYTES: u64 = 1024 * 1024; + let limit = settings + .accounts + .max_archive_upload_bytes + .saturating_add(MULTIPART_SLACK_BYTES); + usize::try_from(limit).unwrap_or(usize::MAX) +} + fn upload_payload_limit(settings: &Settings) -> u64 { let max_media = settings.posts.max_media_per_post; if settings.media.max_video_size >= settings.media.max_image_size { @@ -396,6 +646,7 @@ struct AdminUsersQuery { #[derive(Deserialize)] struct SettingsQuery { saved: Option, + required: Option, } #[derive(Deserialize)] @@ -436,6 +687,7 @@ struct ParsedProfileUpdate { delete_banner: bool, nsfw_blur_enabled: bool, liked_posts_public: bool, + follow_approval_required: bool, profile_picture_media_id: Option, banner_media_id: Option, } @@ -491,6 +743,8 @@ struct FollowActionResponse { kind: &'static str, user_id: i64, following: bool, + /// A pending follow request exists (button shows "Requested"). + requested: bool, followers: i64, following_count: i64, action: String, @@ -529,7 +783,9 @@ async fn home(State(state): State>, headers: HeaderMap) -> AppResu let user = current(&state, &headers).await?; let posts = social::timeline(&state.pool, user.as_ref().map(|u| u.id), "local", None).await?; let csrf = form_csrf(&state, &headers).await; - let composer = if user.is_some() || state.settings.accounts.anonymous_mode_enabled { + let composer = if (user.is_some() || state.settings.accounts.anonymous_mode_enabled) + && posting_enabled_for(&state, user.as_ref()).await? + { render::composer(csrf.as_deref(), None, state.settings.posts.max_text_chars) } else { String::new() @@ -555,14 +811,19 @@ async fn layout_context( state: &AppState, user: Option<&CurrentUser>, ) -> AppResult { - let (counts, notification_unread_count) = if let Some(user) = user { + let (counts, notification_unread_count, pending_follow_requests) = if let Some(user) = user { ( Some(social::follow_counts(&state.pool, user.id).await?), Some(social::unread_notification_count(&state.pool, user.id).await?), + Some(social::pending_follow_request_count(&state.pool, user.id).await?), ) } else { - (None, None) + (None, None, None) }; + let instance = instance::load(&state.pool).await.unwrap_or_else(|error| { + tracing::warn!(error = %error, "failed to load instance settings"); + instance::InstanceSettings::default() + }); Ok(render::LayoutContext { anonymous_mode_enabled: state.settings.accounts.anonymous_mode_enabled, tor_onion_address: state.tor.onion_address(), @@ -570,6 +831,14 @@ async fn layout_context( following_count: counts.map(|(_followers, following)| following), notification_unread_count, favicon_content_type: favicon::current(&state.paths).content_type(), + announcement: instance.announcement_text().map(ToOwned::to_owned), + maintenance_notice: instance.maintenance_notice().map(ToOwned::to_owned), + account_notice: user.and_then(|user| { + user.deletion_scheduled_at + .as_ref() + .map(|deadline| format!("This account is scheduled for deletion on {deadline}.")) + }), + pending_follow_requests, }) } @@ -591,6 +860,13 @@ async fn page_layout( )) } +/// Whether the viewer may publish content right now. Maintenance mode blocks +/// posting for everyone except administrators. +async fn posting_enabled_for(state: &AppState, user: Option<&CurrentUser>) -> AppResult { + let instance = instance::load(&state.pool).await?; + Ok(!instance.maintenance_mode || user.is_some_and(|user| user.is_admin)) +} + fn blur_nsfw_media(state: &AppState, user: Option<&CurrentUser>) -> bool { let global_blur = state .nsfw_blur_default @@ -656,7 +932,20 @@ async fn register_form(State(state): State>) -> AppResult

    Registration is currently closed.

    {}"#, + render::notice( + "info", + instance + .maintenance_notice() + .unwrap_or(instance::DEFAULT_MAINTENANCE_MESSAGE) + ) + ) + } else { + register_form_body(&state, None).await? + }; Ok(Html( page_layout(&state, None, None, "Register", &body).await?, )) @@ -672,6 +961,16 @@ async fn register( if !state.settings.accounts.registration_enabled { return Err(AppError::Forbidden); } + if instance::load(&state.pool).await?.maintenance_mode { + return Ok(( + StatusCode::SERVICE_UNAVAILABLE, + Html(render::maintenance_page( + &state.settings.site.name, + instance::DEFAULT_MAINTENANCE_MESSAGE, + )), + ) + .into_response()); + } let Some(confirm_password) = form.confirm_password.as_deref() else { return Err(AppError::BadRequest( "please confirm your password".to_owned(), @@ -1327,7 +1626,9 @@ async fn thread( return Err(AppError::NotFound); } let csrf = form_csrf(&state, &headers).await; - let composer = if user.is_some() || state.settings.accounts.anonymous_mode_enabled { + let composer = if (user.is_some() || state.settings.accounts.anonymous_mode_enabled) + && posting_enabled_for(&state, user.as_ref()).await? + { render::composer( csrf.as_deref(), Some(id), @@ -1638,6 +1939,36 @@ async fn reply_redirect(Path(id): Path) -> Redirect { Redirect::to(&format!("/posts/{id}")) } +/// Renders the page for a handle no account currently owns. +/// +/// When the handle appears in `username_history`, the page lists the accounts +/// that previously held it instead of silently resolving to a different +/// person. Unknown handles keep the normal 404 response. +async fn historical_username_page( + state: &AppState, + user: Option<&CurrentUser>, + headers: &HeaderMap, + requested: &str, +) -> AppResult> { + let normalized = requested.trim().to_ascii_lowercase(); + let holders = identity::historical_username_holders(&state.pool, &normalized).await?; + if holders.is_empty() { + if instance::username_was_released(&state.pool, &normalized).await? { + let csrf = form_csrf(state, headers).await; + let body = render::released_username_page(&normalized); + return Ok(Html( + page_layout(state, user, csrf.as_deref(), "Released username", &body).await?, + )); + } + return Err(AppError::NotFound); + } + let csrf = form_csrf(state, headers).await; + let body = render::historical_username_page(&normalized, &holders); + Ok(Html( + page_layout(state, user, csrf.as_deref(), "Username history", &body).await?, + )) +} + fn profile_tab_from_query(tab: Option<&str>) -> social::ProfileTimelineTab { match tab { Some("replies") => social::ProfileTimelineTab::Replies, @@ -1677,6 +2008,7 @@ async fn profile( ) -> AppResult> { let user = current(&state, &headers).await?; let active_tab = profile_tab_from_query(query.tab.as_deref()); + let requested_username = username.clone(); let profile = state .pool .call(move |conn| { @@ -1725,7 +2057,8 @@ async fn profile( is_suspended, )) = profile else { - return Err(AppError::NotFound); + return historical_username_page(&state, user.as_ref(), &headers, &requested_username) + .await; }; let csrf = form_csrf(&state, &headers).await; let viewer_id = user.as_ref().map(|u| u.id); @@ -1791,6 +2124,11 @@ async fn profile( }; let profile_state_note = profile_state_note(is_suspended, owner_or_admin, relationship, activity_visible); + let mut username_history = + render::username_history_note(&identity::username_history(&state.pool, profile_id).await?); + if instance::username_was_released(&state.pool, &profile_username.to_ascii_lowercase()).await? { + username_history.push_str(&render::released_username_profile_note()); + } let counts = format!( r#"

    {}{}

    "#, html_escape::encode_double_quoted_attribute(&profile_username), @@ -1835,13 +2173,14 @@ async fn profile( ) }; let body = format!( - r#"
    {}
    {}

    {}

    @{}

    {}
    {}{}{}{}
    {}{}{}"#, + r#"
    {}
    {}

    {}

    @{}

    {}
    {}{}{}{}{}
    {}{}{}"#, banner, picture, html_escape::encode_text(display_name.as_str()), html_escape::encode_text(profile_username.as_str()), controls, counts, + username_history, bio_line, location_line, website_link, @@ -2071,7 +2410,11 @@ fn profile_controls( ) ); } - let follow_action = render::follow_form(profile_id, csrf, relationship.following); + let follow_action = render::follow_form( + profile_id, + csrf, + render::FollowButtonState::from_flags(relationship.following, relationship.requested), + ); let mute_action = if relationship.muted { small_form( &format!("/users/{profile_id}/unmute"), @@ -2117,6 +2460,65 @@ async fn follow( Ok(Redirect::to(&account_action_return(&state.pool, &headers, id).await?).into_response()) } +async fn approve_follow_request( + State(state): State>, + headers: HeaderMap, + Path(id): Path, + Form(form): Form, +) -> AppResult { + let user = require_active_user(&state, &headers).await?; + validate_csrf(&state.pool, &headers, &form.csrf).await?; + social::approve_follow_request(&state.pool, user.id, id) + .await + .map_err(|err| AppError::BadRequest(err.to_string()))?; + Ok(Redirect::to("/follow-requests").into_response()) +} + +async fn reject_follow_request( + State(state): State>, + headers: HeaderMap, + Path(id): Path, + Form(form): Form, +) -> AppResult { + let user = require_active_user(&state, &headers).await?; + validate_csrf(&state.pool, &headers, &form.csrf).await?; + social::reject_follow_request(&state.pool, user.id, id) + .await + .map_err(|err| AppError::BadRequest(err.to_string()))?; + Ok(Redirect::to("/follow-requests").into_response()) +} + +async fn cancel_follow_request( + State(state): State>, + headers: HeaderMap, + Path(id): Path, + Form(form): Form, +) -> AppResult { + let user = require_active_user(&state, &headers).await?; + validate_csrf(&state.pool, &headers, &form.csrf).await?; + social::cancel_follow_request(&state.pool, user.id, id) + .await + .map_err(|err| AppError::BadRequest(err.to_string()))?; + if enhanced_request(&headers) { + return Ok(Json(follow_action_response(&state.pool, user.id, id).await?).into_response()); + } + Ok(Redirect::to("/follow-requests").into_response()) +} + +async fn follow_requests( + State(state): State>, + headers: HeaderMap, +) -> AppResult> { + let user = require_active_user(&state, &headers).await?; + let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); + let incoming = social::incoming_follow_requests(&state.pool, user.id).await?; + let outgoing = social::outgoing_follow_requests(&state.pool, user.id).await?; + let body = render::follow_requests_page(&incoming, &outgoing, &csrf); + Ok(Html( + page_layout(&state, Some(&user), Some(&csrf), "Follow requests", &body).await?, + )) +} + async fn unfollow( State(state): State>, headers: HeaderMap, @@ -2167,10 +2569,69 @@ fn settings_query_notice(saved: Option<&str>) -> Option<(&'static str, &'static Some("muted-word") => Some(("success", "Muted word saved.")), Some("muted-word-removed") => Some(("success", "Muted word removed.")), Some("password") => Some(("success", "Password changed.")), + Some("username") => Some(("success", "Username changed.")), + Some("delete-requested") => Some(( + "success", + "Account deletion requested. You can cancel it until the deadline.", + )), + Some("delete-cancelled") => Some(("success", "Account deletion cancelled.")), _ => None, } } +/// Username change form plus the account's recorded previous handles. +fn settings_username_panel( + username: &str, + history: &[identity::UsernameHistoryEntry], + csrf: &str, +) -> String { + let history_html = if history.is_empty() { + r#"

    No previous usernames.

    "#.to_owned() + } else { + let rows = history + .iter() + .map(|entry| { + format!( + r#"
  • @{} changed {}
  • "#, + html_escape::encode_text(&entry.username), + html_escape::encode_text(&entry.changed_at) + ) + }) + .collect::>() + .join(""); + format!( + r#"

    Previous usernames

    Handles you used before stay reserved so nobody else can claim them. You can always switch back to one of them.

      {rows}
    "# + ) + }; + format!( + r#"

    Username

    Your public @handle is used in profile links and mentions.

    {history_html}
    "#, + csrf = html_escape::encode_double_quoted_attribute(csrf), + username = html_escape::encode_double_quoted_attribute(username), + ) +} + +/// Export and import entry points for portable account archives. +fn settings_account_data_panel() -> String { + r#"

    Account data

    Export your posts, profile, media, follows, and settings as a portable archive, or import an archive exported from another RustPost instance.

    Export account archive Import account archive

    Archives never include your password, sessions, tokens, or administrator access.

    "# + .to_owned() +} + +/// Danger panel for requesting or cancelling account deletion. +fn settings_delete_panel(deletion: Option<&account::DeletionRequest>, csrf: &str) -> String { + deletion.map_or_else( + || { + r#"

    Delete account

    Deleting your account starts a countdown before permanent removal.

    Start delete account flow

    "#.to_owned() + }, + |deletion| { + format!( + r#"

    Delete account

    This account is scheduled for permanent deletion on {}.

    You can cancel the deletion until then. Until it completes, this account keeps your posts but cannot publish or change account data.

    "#, + html_escape::encode_text(&deletion.scheduled_at), + html_escape::encode_double_quoted_attribute(csrf), + ) + }, + ) +} + struct SettingsProfile { display_name: String, bio: String, @@ -2179,6 +2640,7 @@ struct SettingsProfile { theme: String, nsfw_blur_enabled: bool, liked_posts_public: bool, + follow_approval_required: bool, picture_path: Option, banner_path: Option, } @@ -2188,7 +2650,7 @@ async fn settings_profile(pool: &SqlitePool, user_id: i64) -> AppResult AppResult(5)? != 0, liked_posts_public: row.get::<_, i64>(6)? != 0, - picture_path: row.get(7)?, - banner_path: row.get(8)?, + follow_approval_required: row.get::<_, i64>(7)? != 0, + picture_path: row.get(8)?, + banner_path: row.get(9)?, }) }, ) @@ -2470,6 +2933,10 @@ async fn settings_form( Ok(Html(settings_page(&state, &user, &csrf, notice).await?)) } +#[expect( + clippy::too_many_lines, + reason = "the settings page template is assembled in one place so panels keep a consistent layout" +)] async fn settings_page( state: &AppState, user: &CurrentUser, @@ -2480,6 +2947,10 @@ async fn settings_page( 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 username_history = identity::username_history(&state.pool, user.id).await?; + let deletion = account::deletion_status(&state.pool, user.id).await?; + let username_panel = settings_username_panel(&user.username, &username_history, csrf); + let delete_panel = settings_delete_panel(deletion.as_ref(), csrf); let notice_html = notice.map_or_else(String::new, |(kind, message)| render::notice(kind, message)); let password_hint = if state.settings.accounts.min_password_length == 0 { @@ -2530,10 +3001,16 @@ async fn settings_page( "Make liked posts public", "Allow other people to view your Likes tab on your profile.", ), + settings_switch( + "follow_approval_required", + profile.follow_approval_required, + "Require approval for new followers", + "New followers must be approved before they can follow you. Existing followers are kept, and pending requests are not affected.", + ), ] .join(""); let body = format!( - r#"{notice_html}

    Account settings

    Profile, privacy, media, and account controls.

    Profile media

    Avatar and banner images for your profile header.

    {}

    Profile

    Your name, bio, location, and link as shown on your profile.

    {}

    Preferences and privacy

    Control your display theme, NSFW media blur, and profile activity visibility.

    {}

    Blocked users

    Blocked accounts cannot follow or interact with you.

    {}

    Muted users

    Muted accounts stay hidden from your views.

    {}

    Muted words

    Hide posts containing specific words or phrases.

    {}

    Change password

    Update the password used to sign in to this account.

    {}

    {}

    Delete account

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

    Start delete account flow

    "#, + r#"{notice_html}

    Account settings

    Profile, privacy, media, and account controls.

    Profile media

    Avatar and banner images for your profile header.

    {}

    Profile

    Your name, bio, location, and link as shown on your profile.

    {}

    Preferences and privacy

    Control your display theme, NSFW media blur, and profile activity visibility.

    {}

    Blocked users

    Blocked accounts cannot follow or interact with you.

    {}

    Muted users

    Muted accounts stay hidden from your views.

    {}

    Muted words

    Hide posts containing specific words or phrases.

    {}

    Change password

    Update the password used to sign in to this account.

    {}

    {}

    {username_panel}{account_data_panel}{delete_panel}"#, html_escape::encode_double_quoted_attribute(&csrf), profile_media, profile_fields, @@ -2561,6 +3038,9 @@ async fn settings_page( new_password_attrs, html_escape::encode_text(&password_hint), confirm_new_password_attrs, + username_panel = username_panel, + account_data_panel = settings_account_data_panel(), + delete_panel = delete_panel, ); page_layout(state, Some(user), Some(csrf), "Settings", &body).await } @@ -2608,12 +3088,13 @@ async fn apply_profile_update( let theme = form.theme.as_str().to_owned(); let nsfw_blur_enabled = i64::from(form.nsfw_blur_enabled); let liked_posts_public = i64::from(form.liked_posts_public); + let follow_approval_required = i64::from(form.follow_approval_required); let user_id = user.id; state .pool .call(move |conn| { conn.execute( - "UPDATE users SET display_name = ?, bio = ?, location = ?, website = ?, theme = ?, nsfw_blur_enabled = ?, liked_posts_public = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + "UPDATE users SET display_name = ?, bio = ?, location = ?, website = ?, theme = ?, nsfw_blur_enabled = ?, liked_posts_public = ?, follow_approval_required = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", params![ display_name, bio, @@ -2622,6 +3103,7 @@ async fn apply_profile_update( theme, nsfw_blur_enabled, liked_posts_public, + follow_approval_required, user_id ], )?; @@ -2725,7 +3207,7 @@ async fn change_password( { Ok(()) => Ok(Redirect::to("/settings?saved=password").into_response()), Err(err) => { - settings_response( + password_response( &state, &user, &headers, @@ -2738,6 +3220,32 @@ async fn change_password( } } +/// Renders password-change failures on the restricted page while a forced +/// reset is active, and on the full settings page otherwise. +async fn password_response( + state: &AppState, + user: &CurrentUser, + headers: &HeaderMap, + status: StatusCode, + kind: &'static str, + message: &str, +) -> AppResult { + if user.must_change_password { + let csrf = form_csrf(state, headers).await.unwrap_or_default(); + return Ok(( + status, + Html(password_change_page_html( + state, + user, + &csrf, + Some((kind, message)), + )), + ) + .into_response()); + } + settings_response(state, user, headers, status, kind, message).await +} + async fn settings_response( state: &AppState, user: &CurrentUser, @@ -2760,7 +3268,11 @@ async fn delete_account_warning( ) -> AppResult> { let user = require_active_user(&state, &headers).await?; let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); - let body = render_delete_account_warning(); + let deletion = account::deletion_status(&state.pool, user.id).await?; + let body = deletion.map_or_else( + || render_delete_account_warning(state.settings.accounts.deletion_grace_period_days), + |deletion| render_delete_account_pending(&deletion, &csrf), + ); Ok(Html( page_layout(&state, Some(&user), Some(&csrf), "Delete account", &body).await?, )) @@ -2810,6 +3322,33 @@ async fn delete_account_final( ) .await; } + if state.settings.accounts.deletion_grace_period_days > 0 { + let password_ok = auth::verify_user_password(&state.pool, user.id, &form.password).await?; + if !password_ok { + return delete_account_final_response( + &state, + &user, + &headers, + StatusCode::UNAUTHORIZED, + "Password is incorrect.", + ) + .await; + } + return match account::request_deletion(&state.pool, &state.settings, user.id).await { + Ok(_request) => Ok(Redirect::to("/settings?saved=delete-requested").into_response()), + Err(err) => { + tracing::warn!(user_id = user.id, error = %err, "account deletion request failed"); + delete_account_final_response( + &state, + &user, + &headers, + StatusCode::BAD_REQUEST, + "Account deletion could not be scheduled. Try again later.", + ) + .await + } + }; + } match account::delete_account(&state.pool, &state.paths, user.id, &form.password).await { Ok(_summary) => { let mut response = Redirect::to("/account-deleted").into_response(); @@ -2948,9 +3487,28 @@ async fn account_deleted(State(state): State>) -> AppResult String { - r#""# - .to_owned() +fn render_delete_account_warning(grace_days: u64) -> String { + let grace = if grace_days == 0 { + "With the current configuration the account is removed immediately after password confirmation." + .to_owned() + } else { + format!( + "After you confirm with your password, RustPost starts a {grace_days}-day countdown. You can cancel any time before the deadline; permanent removal happens after it." + ) + }; + format!( + r#""#, + html_escape::encode_text(&grace), + ) +} + +fn render_delete_account_pending(deletion: &account::DeletionRequest, csrf: &str) -> String { + format!( + r#""#, + html_escape::encode_text(&deletion.scheduled_at), + html_escape::encode_double_quoted_attribute(csrf), + html_escape::encode_text(&deletion.requested_at), + ) } fn render_delete_account_final_warning( @@ -2966,431 +3524,906 @@ fn render_delete_account_final_warning( ) } -// Multipart parsing is kept in one place so uploaded profile media and text -// fields share one validation path before any database updates happen. -async fn parse_profile_update( - state: &AppState, - user_id: i64, - multipart: Multipart, -) -> AppResult { - let mut form = ParsedProfileUpdate { - csrf_token: String::new(), - display_name: String::new(), - bio: String::new(), - location: String::new(), - website: String::new(), - theme: Theme::Light, - delete_profile_picture: false, - delete_banner: false, - nsfw_blur_enabled: false, - liked_posts_public: false, - profile_picture_media_id: None, - banner_media_id: None, +/// Standalone password form used for the forced-password-reset flow and +/// direct visits to `/settings/password`. +async fn password_change_page( + State(state): State>, + headers: HeaderMap, + Query(query): Query, +) -> AppResult> { + let user = require_user(&state, &headers).await?; + let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); + let notice = if query.required.as_deref() == Some("1") { + Some(( + "error", + "You must change your password before using the rest of your account.", + )) + } else { + settings_query_notice(query.saved.as_deref()) }; - let outcome = fill_profile_update_form(state, user_id, multipart, &mut form).await; - if outcome.is_err() { - cleanup_profile_uploads(state, &form).await; - } - outcome.map(|()| form) + Ok(Html(password_change_page_html( + &state, &user, &csrf, notice, + ))) } -async fn fill_profile_update_form( +fn password_change_page_html( state: &AppState, - user_id: i64, - mut multipart: Multipart, - form: &mut ParsedProfileUpdate, -) -> AppResult<()> { - while let Some(field) = multipart - .next_field() - .await - .map_err(|err| AppError::BadRequest(err.to_string()))? + user: &CurrentUser, + csrf: &str, + notice: Option<(&str, &str)>, +) -> String { + let notice_html = + notice.map_or_else(String::new, |(kind, message)| render::notice(kind, message)); + let hint = password_hint(state); + let new_password_attrs = render::password_length_attrs( + state.settings.accounts.min_password_length, + "new-password-requirement", + ); + let confirm_new_password_attrs = render::password_length_attrs( + state.settings.accounts.min_password_length, + "confirm-new-password-requirement", + ); + let logout = small_form("/logout", csrf, "Log out", "Log out of this session"); + let restricted = if user.must_change_password { + r#"

    An administrator requires this account to set a new password before it can be used again.

    "# + } else { + "" + }; + format!( + r#"{notice_html}

    Change password

    Update the password used to sign in to this account.

    {restricted}{}
    {logout}
    "#, + password_form_html( + csrf, + &hint, + &new_password_attrs, + &confirm_new_password_attrs + ), + ) +} + +fn password_hint(state: &AppState) -> String { + if state.settings.accounts.min_password_length == 0 { + "No minimum password length is currently required.".to_owned() + } else { + format!( + "Password must be at least {} characters.", + state.settings.accounts.min_password_length + ) + } +} + +fn password_form_html( + csrf: &str, + hint: &str, + new_password_attrs: &str, + confirm_new_password_attrs: &str, +) -> String { + format!( + r#"

    {}

    {}

    "#, + html_escape::encode_double_quoted_attribute(csrf), + html_escape::encode_text(hint), + new_password_attrs, + html_escape::encode_text(hint), + confirm_new_password_attrs, + ) +} + +#[derive(Deserialize)] +struct UsernameChangeForm { + csrf: String, + new_username: String, + password: String, +} + +async fn change_username( + State(state): State>, + headers: HeaderMap, + Form(form): Form, +) -> AppResult { + let user = require_active_user(&state, &headers).await?; + validate_csrf(&state.pool, &headers, &form.csrf).await?; + let password_ok = auth::verify_user_password(&state.pool, user.id, &form.password).await?; + if !password_ok { + return settings_response( + &state, + &user, + &headers, + StatusCode::UNAUTHORIZED, + "error", + "Password is incorrect.", + ) + .await; + } + match identity::change_username(&state.pool, &state.settings, user.id, &form.new_username).await { - let Some(name) = field.name().map(ToOwned::to_owned) else { - continue; - }; - match name.as_str() { - "csrf" => { - form.csrf_token = field - .text() - .await - .map_err(|err| AppError::BadRequest(err.to_string()))?; - } - "display_name" => { - form.display_name = field - .text() - .await - .map_err(|err| AppError::BadRequest(err.to_string()))?; - } - "bio" => { - form.bio = field - .text() - .await - .map_err(|err| AppError::BadRequest(err.to_string()))?; - } - "location" => { - form.location = field - .text() - .await - .map_err(|err| AppError::BadRequest(err.to_string()))?; - } - "website" => { - form.website = field - .text() - .await - .map_err(|err| AppError::BadRequest(err.to_string()))?; - } - "dark_mode" => { - form.theme = Theme::Dark; - } - "nsfw_blur_enabled" => { - form.nsfw_blur_enabled = true; - } - "liked_posts_public" => { - form.liked_posts_public = true; - } - "delete_profile_picture" => { - form.delete_profile_picture = true; - } - "delete_banner" => { - form.delete_banner = true; - } - "profile_picture" if field.file_name().is_some() => { - if !state.settings.accounts.allow_profile_pictures { - return Err(AppError::Forbidden); - } - if field.file_name().is_none_or(|name| name.trim().is_empty()) { - continue; - } - form.profile_picture_media_id = Some( - media::save_profile_picture_upload( - &state.pool, - &state.settings, - &state.paths, - &state.ffmpeg, - user_id, - field, - ) - .await - .map_err(|err| { - tracing::warn!(error = %err, "profile picture upload rejected"); - AppError::BadRequest(err.to_string()) - })?, - ); - } - "banner" if field.file_name().is_some() => { - if !state.settings.accounts.allow_profile_banners { - return Err(AppError::Forbidden); - } - if field.file_name().is_none_or(|name| name.trim().is_empty()) { - continue; - } - form.banner_media_id = Some( - media::save_banner_upload( - &state.pool, - &state.settings, - &state.paths, - &state.ffmpeg, - user_id, - field, - ) - .await - .map_err(|err| { - tracing::warn!(error = %err, "profile banner upload rejected"); - AppError::BadRequest(err.to_string()) - })?, - ); - } - _ => {} + Ok(_change) => Ok(Redirect::to("/settings?saved=username").into_response()), + Err(err) => { + settings_response( + &state, + &user, + &headers, + StatusCode::BAD_REQUEST, + "error", + &err.to_string(), + ) + .await } } - Ok(()) } -/// Deletes profile media that was uploaded for a settings save that did not -/// complete, so rejected saves do not leave orphaned uploads behind. -async fn cleanup_profile_uploads(state: &AppState, form: &ParsedProfileUpdate) { - for media_id in [form.profile_picture_media_id, form.banner_media_id] - .into_iter() - .flatten() +async fn cancel_deletion( + State(state): State>, + headers: HeaderMap, + Form(form): Form, +) -> AppResult { + let user = require_active_user(&state, &headers).await?; + validate_csrf(&state.pool, &headers, &form.csrf).await?; + account::cancel_deletion(&state.pool, user.id).await?; + Ok(Redirect::to("/settings?saved=delete-cancelled").into_response()) +} + +async fn export_account( + State(state): State>, + headers: HeaderMap, +) -> AppResult { + let user = require_active_user(&state, &headers).await?; + // The guard keeps runtime temp cleanup from removing the staged export + // archive while it is being written. + let _operation = crate::runtime::begin_temp_operation(); + let filename = format!( + "rustpost-account-{}-{}.tar.gz", + safe_filename_component(&user.username), + Uuid::new_v4().simple() + ); + let destination = + state + .paths + .tmp_dir + .join(format!("{}{}", portability::EXPORT_TMP_PREFIX, filename)); + let export = match portability::export_account( + &state.pool, + &state.paths, + user.id, + &destination, + portability::ArchiveLimits::from_settings(&state.settings), + ) + .await { - if let Err(error) = media::delete_media(&state.pool, &state.paths, media_id).await { - tracing::warn!( - media_id, - error = %error, - "failed to clean up media uploaded for a rejected settings save" - ); + Ok(export) => export, + Err(err) => { + tracing::warn!(user_id = user.id, error = %err, "account export failed"); + let _ = tokio::fs::remove_file(&destination).await; + return Err(AppError::BadRequest(err.to_string())); } - } + }; + stream_staged_file(&export.archive_path, &filename, "application/gzip").await } -async fn ensure_parent_post_exists(pool: &SqlitePool, parent_id: i64) -> AppResult<()> { - let exists = pool - .call(move |conn| { - Ok(conn - .query_row( - "SELECT 1 FROM posts WHERE id = ? AND is_deleted = 0", - [parent_id], - |_| Ok(()), - ) - .optional()? - .is_some()) - }) - .await?; - if exists { - Ok(()) - } else { - Err(AppError::BadRequest( - "reply target was not found; it may have been deleted".to_owned(), - )) - } +/// Validates a value for use inside an attachment filename. +fn safe_filename_component(value: &str) -> String { + value + .chars() + .filter(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_')) + .take(32) + .collect() } -async fn delete_preview( - pool: &SqlitePool, - actor_id: i64, - is_admin: bool, - post_id: i64, -) -> AppResult { - let preview = pool - .call(move |conn| { - conn.query_row( - r#" - SELECT p.user_id, p.text, u.username, u.display_name, p.parent_post_id - FROM posts p - LEFT JOIN users u ON u.id = p.user_id - WHERE p.id = ? AND p.is_deleted = 0 - "#, - [post_id], - |row| { - Ok(( - row.get::<_, Option>(0)?, - row.get::<_, String>(1)?, - row.get::<_, Option>(2)?, - row.get::<_, Option>(3)?, - row.get::<_, Option>(4)?, +async fn stream_staged_file( + path: &std::path::Path, + download_name: &str, + content_type: &'static str, +) -> AppResult { + let metadata = tokio::fs::metadata(path).await?; + let file = tokio::fs::File::open(path).await?; + let cleanup_path = path.to_owned(); + let stream = futures_util::stream::unfold( + (file, cleanup_path), + |(mut file, cleanup_path)| async move { + let mut buffer = vec![0u8; 64 * 1024]; + match file.read(&mut buffer).await { + Ok(0) => { + let _ = tokio::fs::remove_file(&cleanup_path).await; + None + } + Ok(read) => { + buffer.truncate(read); + Some(( + Ok::(Bytes::from(buffer)), + (file, cleanup_path), )) - }, - ) - .optional() - .map_err(Into::into) - }) - .await?; - let Some((owner, text, username, display_name, parent_post_id)) = preview else { - return Err(AppError::NotFound); - }; - if !is_admin && owner != Some(actor_id) { - return Err(AppError::Forbidden); - } - Ok(DeletePreview { - text, - username, - display_name, - parent_post_id, - }) + } + Err(error) => { + let _ = tokio::fs::remove_file(&cleanup_path).await; + Some((Err(error), (file, cleanup_path))) + } + } + }, + ); + let disposition = format!( + "attachment; filename=\"{}\"", + download_name.replace('"', "") + ); + let mut response = Body::from_stream(stream).into_response(); + response + .headers_mut() + .insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); + response.headers_mut().insert( + header::CONTENT_LENGTH, + HeaderValue::from_str(&metadata.len().to_string()) + .map_err(|err| AppError::BadRequest(err.to_string()))?, + ); + response.headers_mut().insert( + header::CONTENT_DISPOSITION, + HeaderValue::from_str(&disposition).map_err(|err| AppError::BadRequest(err.to_string()))?, + ); + Ok(response) } -async fn edit_preview( - pool: &SqlitePool, - actor_id: i64, - post_id: i64, - edit_window_seconds: u64, -) -> AppResult { - let edit_window_modifier = edit_window_modifier(edit_window_seconds); - let preview = pool - .call(move |conn| { - conn.query_row( - r#" - SELECT p.user_id, p.text, p.parent_post_id, - CASE - WHEN ? IS NULL THEN 0 - ELSE p.created_at >= datetime('now', ?) - END AS within_window - FROM posts p - WHERE p.id = ? AND p.is_deleted = 0 - "#, - params![edit_window_modifier.clone(), edit_window_modifier, post_id], - |row| { - Ok(( - row.get::<_, Option>(0)?, - row.get::<_, String>(1)?, - row.get::<_, Option>(2)?, - row.get::<_, i64>(3)? != 0, - )) - }, +async fn import_account_form( + State(state): State>, + headers: HeaderMap, +) -> AppResult> { + let user = require_active_user(&state, &headers).await?; + let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); + let instance = instance::load(&state.pool).await?; + let body = if instance.maintenance_mode { + format!( + r#"

    Import account archive

    {}
    "#, + render::notice( + "error", + instance + .maintenance_notice() + .unwrap_or(instance::DEFAULT_MAINTENANCE_MESSAGE) ) - .optional() - .map_err(Into::into) - }) - .await?; - let Some((owner, text, parent_post_id, within_window)) = preview else { - return Err(AppError::NotFound); + ) + } else { + render_import_account_form( + &csrf, + user.must_change_password, + state.settings.accounts.max_archive_upload_bytes, + state.settings.accounts.max_archive_expanded_bytes, + ) }; - if owner != Some(actor_id) { - return Err(AppError::Forbidden); - } - if !within_window { - return Err(AppError::BadRequest( - "the edit window for this post has expired".to_owned(), - )); + Ok(Html( + page_layout(&state, Some(&user), Some(&csrf), "Import account", &body).await?, + )) +} + +fn render_import_account_form( + csrf: &str, + forced_password_change: bool, + max_upload_bytes: u64, + max_expanded_bytes: u64, +) -> String { + let disabled = if forced_password_change { + r#"

    Finish the required password change before importing an archive.

    "# + } else { + "" + }; + format!( + r#"

    Import account archive

    Import a RustPost account archive that you exported from another instance. The archive adds posts, media, muted words, and outgoing follows to your account. Usernames, passwords, administrator flags, and sessions in an archive are ignored.

    {}{disabled}

    Maximum compressed archive size: {}. Maximum expanded media size: {}. Limits are enforced while the archive is streamed.

    Back to settings

    "#, + render::notice( + "info", + "Follows to protected accounts become pending requests and still require approval.", + ), + html_escape::encode_double_quoted_attribute(csrf), + format_bytes(max_upload_bytes), + format_bytes(max_expanded_bytes), + ) +} + +async fn import_account( + State(state): State>, + headers: HeaderMap, + multipart: Multipart, +) -> AppResult { + let user = require_active_user(&state, &headers).await?; + // The guard keeps runtime temp cleanup from removing this operation's + // staged upload or export archive while it is in flight. + let _operation = crate::runtime::begin_temp_operation(); + // Imports publish content, so they share the post rate-limit budget. + rate_limit::check_and_record( + &state.pool, + rate_limit::Scope::Post, + &user_actor(user.id), + state.settings.moderation.posts_per_minute, + 60, + ) + .await + .map_err(|err| AppError::RateLimited(err.to_string()))?; + let staged = stage_account_import(&state, &headers, multipart).await?; + let report = + portability::import_account(&state.pool, &state.paths, &state.settings, user.id, &staged) + .await; + let _ = tokio::fs::remove_file(&staged).await; + match report { + Ok(report) => { + let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); + let body = render_import_report(&report, &csrf); + Ok( + Html( + page_layout(&state, Some(&user), Some(&csrf), "Import complete", &body).await?, + ) + .into_response(), + ) + } + Err(err) => { + tracing::warn!(user_id = user.id, error = %err, "account import failed"); + let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); + let body = format!( + r#"{}{}"#, + render::notice("error", &err.to_string()), + render_import_account_form( + &csrf, + user.must_change_password, + state.settings.accounts.max_archive_upload_bytes, + state.settings.accounts.max_archive_expanded_bytes, + ) + ); + Ok(( + StatusCode::BAD_REQUEST, + Html(page_layout(&state, Some(&user), Some(&csrf), "Import account", &body).await?), + ) + .into_response()) + } } - Ok(EditPreview { - text, - parent_post_id, - }) } -fn edit_window_modifier(seconds: u64) -> Option { - if seconds == 0 { - return None; +async fn stage_account_import( + state: &AppState, + headers: &HeaderMap, + mut multipart: Multipart, +) -> AppResult { + /// Smallest accepted `csrf` field; larger values are rejected so a + /// malicious request cannot make the server buffer an arbitrary string. + const MAX_CSRF_FIELD_BYTES: u64 = 4 * 1024; + /// Bound for unrelated multipart fields that are drained and ignored. + const MAX_IGNORED_FIELD_BYTES: u64 = 64 * 1024; + + let mut csrf: Option = None; + let mut staged: Option = None; + while let Some(mut field) = multipart + .next_field() + .await + .map_err(|err| AppError::BadRequest(err.to_string()))? + { + let name = field.name().map(ToOwned::to_owned).unwrap_or_default(); + if name == "csrf" { + csrf = Some(read_limited_text_field(&mut field, MAX_CSRF_FIELD_BYTES).await?); + continue; + } + if name != "archive" || field.file_name().is_none() { + drain_limited_field(&mut field, MAX_IGNORED_FIELD_BYTES).await?; + continue; + } + let csrf_token = csrf.clone().ok_or(AppError::Forbidden)?; + validate_csrf(&state.pool, headers, &csrf_token).await?; + if staged.is_some() { + return Err(AppError::BadRequest( + "only one archive can be imported at a time".to_owned(), + )); + } + let path = state.paths.tmp_dir.join(format!( + "{}{}.tar.gz", + portability::IMPORT_TMP_PREFIX, + Uuid::new_v4().simple() + )); + let written = write_multipart_field_to_file( + field, + &path, + Some(state.settings.accounts.max_archive_upload_bytes), + ) + .await; + match written { + Ok(_bytes) => staged = Some(path), + Err(err) => { + let _ = tokio::fs::remove_file(&path).await; + return Err(err); + } + } } - i64::try_from(seconds) - .ok() - .map(|seconds| format!("-{seconds} seconds")) + staged.ok_or_else(|| AppError::BadRequest("choose a RustPost account archive".to_owned())) } -async fn post_is_reply(pool: &SqlitePool, post_id: i64) -> AppResult { - Ok(pool - .call(move |conn| { - conn.query_row( - "SELECT parent_post_id IS NOT NULL FROM posts WHERE id = ? AND is_deleted = 0", - [post_id], - |row| row.get::<_, i64>(0), - ) - .optional() - .map(|value| value.unwrap_or(0) != 0) - .map_err(Into::into) - }) - .await?) +/// Reads a text multipart field with a byte cap so an oversized field cannot +/// force unbounded buffering. +async fn read_limited_text_field( + field: &mut axum::extract::multipart::Field<'_>, + max_bytes: u64, +) -> AppResult { + let mut bytes = Vec::new(); + while let Some(chunk) = field + .chunk() + .await + .map_err(|err| AppError::BadRequest(err.to_string()))? + { + if u64::try_from(bytes.len() + chunk.len()).unwrap_or(u64::MAX) > max_bytes { + return Err(AppError::BadRequest( + "a multipart form field is too large".to_owned(), + )); + } + bytes.extend_from_slice(&chunk); + } + String::from_utf8(bytes).map_err(|_utf8| AppError::BadRequest("invalid form value".to_owned())) } -fn redirect_to_post_anchor(headers: &HeaderMap, post_id: i64, is_reply: bool) -> Redirect { - let target = anchored_return(headers, post_id, is_reply, "/home"); - Redirect::to(&target) +/// Drains an ignored multipart field while enforcing a small cap. +async fn drain_limited_field( + field: &mut axum::extract::multipart::Field<'_>, + max_bytes: u64, +) -> AppResult<()> { + let mut read = 0u64; + while let Some(chunk) = field + .chunk() + .await + .map_err(|err| AppError::BadRequest(err.to_string()))? + { + read = read.saturating_add(u64::try_from(chunk.len()).unwrap_or(u64::MAX)); + if read > max_bytes { + return Err(AppError::BadRequest( + "a multipart form field is too large".to_owned(), + )); + } + } + Ok(()) } -fn enhanced_request(headers: &HeaderMap) -> bool { - headers - .get("x-rustpost-enhance") - .and_then(|value| value.to_str().ok()) - .is_some_and(|value| value == "1") +fn render_import_report(report: &portability::ImportReport, csrf: &str) -> String { + format!( + r#"

    Import complete

    Archive account
    @{}
    Posts imported
    {}
    Media imported
    {}
    Follows added
    {}
    Follow requests pending approval
    {}
    Follows skipped
    {}
    Post references unresolved
    {}
    Muted words imported
    {}
    Profile fields applied
    {}
    Profile fields kept
    {}

    Back to settings

    {}
    "#, + html_escape::encode_text(&report.archive_username), + report.posts_imported, + report.media_imported, + report.follows_imported, + report.follows_pending, + report.follows_skipped, + report.post_references_dropped, + report.muted_words_imported, + report.profile_fields_applied, + report.profile_fields_skipped, + small_form( + "/settings/import", + csrf, + "Import another archive", + "Import another account archive" + ), + ) } -async fn follow_action_response( - pool: &SqlitePool, - viewer_id: i64, - profile_id: i64, -) -> AppResult { - let following = social::is_following(pool, viewer_id, profile_id).await?; - let (followers, following_count) = social::follow_counts(pool, profile_id).await?; - Ok(FollowActionResponse { - kind: "follow", - user_id: profile_id, - following, - followers, - following_count, - action: if following { - format!("/users/{profile_id}/unfollow") - } else { - format!("/users/{profile_id}/follow") - }, - }) +// Multipart parsing is kept in one place so uploaded profile media and text +// fields share one validation path before any database updates happen. +async fn parse_profile_update( + state: &AppState, + user_id: i64, + multipart: Multipart, +) -> AppResult { + let mut form = ParsedProfileUpdate { + csrf_token: String::new(), + display_name: String::new(), + bio: String::new(), + location: String::new(), + website: String::new(), + theme: Theme::Light, + delete_profile_picture: false, + delete_banner: false, + nsfw_blur_enabled: false, + liked_posts_public: false, + follow_approval_required: false, + profile_picture_media_id: None, + banner_media_id: None, + }; + let outcome = fill_profile_update_form(state, user_id, multipart, &mut form).await; + if outcome.is_err() { + cleanup_profile_uploads(state, &form).await; + } + outcome.map(|()| form) } -async fn post_action_response( - pool: &SqlitePool, - viewer_id: i64, - post_id: i64, -) -> AppResult { - let state = pool - .call(move |conn| { - conn.query_row( - r#" - SELECT - ( - SELECT COUNT(*) - FROM likes l - JOIN users u ON u.id = l.user_id AND u.is_deleted = 0 - WHERE l.post_id = p.id AND (u.liked_posts_public != 0 OR l.user_id = ?) - ), - ((SELECT COUNT(*) FROM reposts WHERE post_id = p.id) + - (SELECT COUNT(*) FROM posts qp WHERE qp.quote_post_id = p.id AND qp.is_deleted = 0)), - (SELECT COUNT(*) FROM posts replies WHERE replies.parent_post_id = p.id AND replies.is_deleted = 0), - EXISTS(SELECT 1 FROM likes WHERE user_id = ? AND post_id = p.id), - EXISTS(SELECT 1 FROM bookmarks WHERE user_id = ? AND post_id = p.id), - EXISTS(SELECT 1 FROM reposts WHERE user_id = ? AND post_id = p.id) +async fn fill_profile_update_form( + state: &AppState, + user_id: i64, + mut multipart: Multipart, + form: &mut ParsedProfileUpdate, +) -> AppResult<()> { + while let Some(field) = multipart + .next_field() + .await + .map_err(|err| AppError::BadRequest(err.to_string()))? + { + let Some(name) = field.name().map(ToOwned::to_owned) else { + continue; + }; + match name.as_str() { + "csrf" | "display_name" | "bio" | "location" | "website" => { + fill_profile_text_field(form, &name, field).await?; + } + "dark_mode" => form.theme = Theme::Dark, + "nsfw_blur_enabled" => form.nsfw_blur_enabled = true, + "liked_posts_public" => form.liked_posts_public = true, + "follow_approval_required" => form.follow_approval_required = true, + "delete_profile_picture" => form.delete_profile_picture = true, + "delete_banner" => form.delete_banner = true, + "profile_picture" if field.file_name().is_some() => { + fill_profile_picture_upload(state, user_id, form, field).await?; + } + "banner" if field.file_name().is_some() => { + fill_banner_upload(state, user_id, form, field).await?; + } + _ => {} + } + } + Ok(()) +} + +/// Reads one of the plain text profile fields into the parsed form. +async fn fill_profile_text_field( + form: &mut ParsedProfileUpdate, + name: &str, + field: axum::extract::multipart::Field<'_>, +) -> AppResult<()> { + let text = field + .text() + .await + .map_err(|err| AppError::BadRequest(err.to_string()))?; + match name { + "csrf" => form.csrf_token = text, + "display_name" => form.display_name = text, + "bio" => form.bio = text, + "location" => form.location = text, + "website" => form.website = text, + _ => {} + } + Ok(()) +} + +async fn fill_profile_picture_upload( + state: &AppState, + user_id: i64, + form: &mut ParsedProfileUpdate, + field: axum::extract::multipart::Field<'_>, +) -> AppResult<()> { + if !state.settings.accounts.allow_profile_pictures { + return Err(AppError::Forbidden); + } + if field.file_name().is_none_or(|name| name.trim().is_empty()) { + return Ok(()); + } + form.profile_picture_media_id = Some( + media::save_profile_picture_upload( + &state.pool, + &state.settings, + &state.paths, + &state.ffmpeg, + user_id, + field, + ) + .await + .map_err(|err| { + tracing::warn!(error = %err, "profile picture upload rejected"); + AppError::BadRequest(err.to_string()) + })?, + ); + Ok(()) +} + +async fn fill_banner_upload( + state: &AppState, + user_id: i64, + form: &mut ParsedProfileUpdate, + field: axum::extract::multipart::Field<'_>, +) -> AppResult<()> { + if !state.settings.accounts.allow_profile_banners { + return Err(AppError::Forbidden); + } + if field.file_name().is_none_or(|name| name.trim().is_empty()) { + return Ok(()); + } + form.banner_media_id = Some( + media::save_banner_upload( + &state.pool, + &state.settings, + &state.paths, + &state.ffmpeg, + user_id, + field, + ) + .await + .map_err(|err| { + tracing::warn!(error = %err, "profile banner upload rejected"); + AppError::BadRequest(err.to_string()) + })?, + ); + Ok(()) +} + +/// Deletes profile media that was uploaded for a settings save that did not +/// complete, so rejected saves do not leave orphaned uploads behind. +async fn cleanup_profile_uploads(state: &AppState, form: &ParsedProfileUpdate) { + for media_id in [form.profile_picture_media_id, form.banner_media_id] + .into_iter() + .flatten() + { + if let Err(error) = media::delete_media(&state.pool, &state.paths, media_id).await { + tracing::warn!( + media_id, + error = %error, + "failed to clean up media uploaded for a rejected settings save" + ); + } + } +} + +async fn ensure_parent_post_exists(pool: &SqlitePool, parent_id: i64) -> AppResult<()> { + let exists = pool + .call(move |conn| { + Ok(conn + .query_row( + "SELECT 1 FROM posts WHERE id = ? AND is_deleted = 0", + [parent_id], + |_| Ok(()), + ) + .optional()? + .is_some()) + }) + .await?; + if exists { + Ok(()) + } else { + Err(AppError::BadRequest( + "reply target was not found; it may have been deleted".to_owned(), + )) + } +} + +async fn delete_preview( + pool: &SqlitePool, + actor_id: i64, + is_admin: bool, + post_id: i64, +) -> AppResult { + let preview = pool + .call(move |conn| { + conn.query_row( + r#" + SELECT p.user_id, p.text, u.username, u.display_name, p.parent_post_id FROM posts p - LEFT JOIN users author ON author.id = p.user_id + LEFT JOIN users u ON u.id = p.user_id WHERE p.id = ? AND p.is_deleted = 0 - AND (p.user_id IS NULL OR (author.is_deleted = 0 AND author.is_suspended = 0)) - AND ( - p.user_id IS NULL - OR p.user_id = ? - OR ( - p.user_id NOT IN (SELECT blocked_id FROM blocks WHERE blocker_id = ?) - AND NOT EXISTS (SELECT 1 FROM blocks WHERE blocker_id = p.user_id AND blocked_id = ?) - ) - ) "#, - params![ - viewer_id, viewer_id, viewer_id, viewer_id, post_id, viewer_id, viewer_id, - viewer_id - ], + [post_id], |row| { - Ok(PostActionResponse { - kind: "post-action", - post_id, - likes: row.get(0)?, - reposts: row.get(1)?, - replies: row.get(2)?, - liked: row.get::<_, i64>(3)? != 0, - bookmarked: row.get::<_, i64>(4)? != 0, - reposted: row.get::<_, i64>(5)? != 0, - }) + Ok(( + row.get::<_, Option>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, Option>(4)?, + )) }, ) .optional() .map_err(Into::into) }) .await?; - state.ok_or(AppError::NotFound) + let Some((owner, text, username, display_name, parent_post_id)) = preview else { + return Err(AppError::NotFound); + }; + if !is_admin && owner != Some(actor_id) { + return Err(AppError::Forbidden); + } + Ok(DeletePreview { + text, + username, + display_name, + parent_post_id, + }) } -async fn account_action_return( +async fn edit_preview( pool: &SqlitePool, - headers: &HeaderMap, - profile_id: i64, -) -> AppResult { - if let Some(target) = referer_target(headers) - .as_deref() - .and_then(safe_return_target) - { - return Ok(target); + actor_id: i64, + post_id: i64, + edit_window_seconds: u64, +) -> AppResult { + let edit_window_modifier = edit_window_modifier(edit_window_seconds); + let preview = pool + .call(move |conn| { + conn.query_row( + r#" + SELECT p.user_id, p.text, p.parent_post_id, + CASE + WHEN ? IS NULL THEN 0 + ELSE p.created_at >= datetime('now', ?) + END AS within_window + FROM posts p + WHERE p.id = ? AND p.is_deleted = 0 + "#, + params![edit_window_modifier.clone(), edit_window_modifier, post_id], + |row| { + Ok(( + row.get::<_, Option>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, i64>(3)? != 0, + )) + }, + ) + .optional() + .map_err(Into::into) + }) + .await?; + let Some((owner, text, parent_post_id, within_window)) = preview else { + return Err(AppError::NotFound); + }; + if owner != Some(actor_id) { + return Err(AppError::Forbidden); } - Ok(user_profile_path(pool, profile_id) - .await? - .unwrap_or_else(|| "/home".to_owned())) + if !within_window { + return Err(AppError::BadRequest( + "the edit window for this post has expired".to_owned(), + )); + } + Ok(EditPreview { + text, + parent_post_id, + }) } -async fn user_profile_path(pool: &SqlitePool, user_id: i64) -> AppResult> { +fn edit_window_modifier(seconds: u64) -> Option { + if seconds == 0 { + return None; + } + i64::try_from(seconds) + .ok() + .map(|seconds| format!("-{seconds} seconds")) +} + +async fn post_is_reply(pool: &SqlitePool, post_id: i64) -> AppResult { Ok(pool .call(move |conn| { conn.query_row( - "SELECT username FROM users WHERE id = ? AND is_deleted = 0", - [user_id], - |row| row.get::<_, String>(0), + "SELECT parent_post_id IS NOT NULL FROM posts WHERE id = ? AND is_deleted = 0", + [post_id], + |row| row.get::<_, i64>(0), ) .optional() + .map(|value| value.unwrap_or(0) != 0) .map_err(Into::into) }) - .await? - .map(|username| format!("/users/{username}"))) + .await?) } -fn anchored_return(headers: &HeaderMap, post_id: i64, is_reply: bool, fallback: &str) -> String { - let anchor = if is_reply { - format!("reply-{post_id}") - } else { - format!("post-{post_id}") +fn redirect_to_post_anchor(headers: &HeaderMap, post_id: i64, is_reply: bool) -> Redirect { + let target = anchored_return(headers, post_id, is_reply, "/home"); + Redirect::to(&target) +} + +fn enhanced_request(headers: &HeaderMap) -> bool { + headers + .get("x-rustpost-enhance") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == "1") +} + +async fn follow_action_response( + pool: &SqlitePool, + viewer_id: i64, + profile_id: i64, +) -> AppResult { + let relationship = social::profile_relationship(pool, Some(viewer_id), profile_id).await?; + let (followers, following_count) = social::follow_counts(pool, profile_id).await?; + let action = if relationship.following { + format!("/users/{profile_id}/unfollow") + } else if relationship.requested { + format!("/users/{profile_id}/follow/cancel") + } else { + format!("/users/{profile_id}/follow") + }; + Ok(FollowActionResponse { + kind: "follow", + user_id: profile_id, + following: relationship.following, + requested: relationship.requested, + followers, + following_count, + action, + }) +} + +async fn post_action_response( + pool: &SqlitePool, + viewer_id: i64, + post_id: i64, +) -> AppResult { + let state = pool + .call(move |conn| { + conn.query_row( + r#" + SELECT + ( + SELECT COUNT(*) + FROM likes l + JOIN users u ON u.id = l.user_id AND u.is_deleted = 0 + WHERE l.post_id = p.id AND (u.liked_posts_public != 0 OR l.user_id = ?) + ), + ((SELECT COUNT(*) FROM reposts WHERE post_id = p.id) + + (SELECT COUNT(*) FROM posts qp WHERE qp.quote_post_id = p.id AND qp.is_deleted = 0)), + (SELECT COUNT(*) FROM posts replies WHERE replies.parent_post_id = p.id AND replies.is_deleted = 0), + EXISTS(SELECT 1 FROM likes WHERE user_id = ? AND post_id = p.id), + EXISTS(SELECT 1 FROM bookmarks WHERE user_id = ? AND post_id = p.id), + EXISTS(SELECT 1 FROM reposts WHERE user_id = ? AND post_id = p.id) + FROM posts p + LEFT JOIN users author ON author.id = p.user_id + WHERE p.id = ? AND p.is_deleted = 0 + AND (p.user_id IS NULL OR (author.is_deleted = 0 AND author.is_suspended = 0)) + AND ( + p.user_id IS NULL + OR p.user_id = ? + OR ( + p.user_id NOT IN (SELECT blocked_id FROM blocks WHERE blocker_id = ?) + AND NOT EXISTS (SELECT 1 FROM blocks WHERE blocker_id = p.user_id AND blocked_id = ?) + ) + ) + "#, + params![ + viewer_id, viewer_id, viewer_id, viewer_id, post_id, viewer_id, viewer_id, + viewer_id + ], + |row| { + Ok(PostActionResponse { + kind: "post-action", + post_id, + likes: row.get(0)?, + reposts: row.get(1)?, + replies: row.get(2)?, + liked: row.get::<_, i64>(3)? != 0, + bookmarked: row.get::<_, i64>(4)? != 0, + reposted: row.get::<_, i64>(5)? != 0, + }) + }, + ) + .optional() + .map_err(Into::into) + }) + .await?; + state.ok_or(AppError::NotFound) +} + +async fn account_action_return( + pool: &SqlitePool, + headers: &HeaderMap, + profile_id: i64, +) -> AppResult { + if let Some(target) = referer_target(headers) + .as_deref() + .and_then(safe_return_target) + { + return Ok(target); + } + Ok(user_profile_path(pool, profile_id) + .await? + .unwrap_or_else(|| "/home".to_owned())) +} + +async fn user_profile_path(pool: &SqlitePool, user_id: i64) -> AppResult> { + Ok(pool + .call(move |conn| { + conn.query_row( + "SELECT username FROM users WHERE id = ? AND is_deleted = 0", + [user_id], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(Into::into) + }) + .await? + .map(|username| format!("/users/{username}"))) +} + +fn anchored_return(headers: &HeaderMap, post_id: i64, is_reply: bool, fallback: &str) -> String { + let anchor = if is_reply { + format!("reply-{post_id}") + } else { + format!("post-{post_id}") }; let base = referer_target(headers) .as_deref() @@ -3735,12 +4768,24 @@ async fn tag( .await } +#[derive(Deserialize)] +struct AdminDashboardQuery { + saved: Option, +} + async fn admin_dashboard( 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 saved_notice = match query.saved.as_deref() { + Some("announcement") => Some(render::notice("success", "Announcement updated.")), + Some("maintenance") => Some(render::notice("success", "Maintenance setting updated.")), + _ => None, + } + .unwrap_or_default(); let favicon_asset = favicon::current(&state.paths); let remove_form = if favicon_asset.is_custom() { small_form( @@ -3758,13 +4803,17 @@ async fn admin_dashboard( html_escape::encode_double_quoted_attribute(&csrf), remove_form ); + let instance = instance::load(&state.pool).await?; + let instance_panels = admin_instance_panels(&instance, &csrf); let body = format!( - "{}{}{}", + "{}{}{}{}{}", render::page_header( "Admin", "Manage site health, users, media jobs, settings, and backups." ), + saved_notice, r#"
    Site healthUsersMedia jobsDeep server settingsBackups
    "#, + instance_panels, favicon_panel ); Ok(Html( @@ -3772,6 +4821,165 @@ async fn admin_dashboard( )) } +/// Announcement and maintenance controls, persisted in `instance_settings`. +fn admin_instance_panels(instance: &instance::InstanceSettings, csrf: &str) -> String { + let announcement_enabled = if instance.announcement_enabled { + " checked" + } else { + "" + }; + let maintenance_enabled = if instance.maintenance_mode { + " checked" + } else { + "" + }; + let announcement_status = if instance.announcement_text().is_some() { + "Currently visible in the top bar." + } else { + "Not currently shown." + }; + let maintenance_status = if instance.maintenance_mode { + "Maintenance mode is ON. Posting and registration are disabled." + } else { + "Maintenance mode is off. The site is fully available." + }; + format!( + r#"

    Announcement

    {announcement_status}

    Maintenance mode

    {maintenance_status}

    While maintenance mode is on, registration and post creation (posts, replies, quotes, and reposts) are blocked. Administrators can still post, and the site stays readable.

    "#, + csrf = html_escape::encode_double_quoted_attribute(csrf), + announcement = html_escape::encode_text(&instance.announcement), + maintenance_message = html_escape::encode_text(&instance.maintenance_message), + announcement_status = html_escape::encode_text(announcement_status), + maintenance_status = html_escape::encode_text(maintenance_status), + ) +} + +#[derive(Deserialize)] +struct AnnouncementForm { + csrf: String, + announcement: Option, + enabled: Option, + intent: Option, +} + +async fn admin_update_announcement( + State(state): State>, + headers: HeaderMap, + Form(form): Form, +) -> AppResult { + let user = require_admin(&state, &headers).await?; + validate_csrf(&state.pool, &headers, &form.csrf).await?; + if form.intent.as_deref() == Some("clear") { + instance::save_announcement(&state.pool, "", false).await?; + admin::audit( + &state.pool, + user.id, + "update_announcement", + "instance_settings", + ) + .await?; + return Ok(Redirect::to("/admin?saved=announcement").into_response()); + } + let text = form.announcement.unwrap_or_default(); + let enabled = form.enabled.as_deref() == Some("true"); + match instance::save_announcement(&state.pool, &text, enabled).await { + Ok(()) => { + admin::audit( + &state.pool, + user.id, + "update_announcement", + "instance_settings", + ) + .await?; + Ok(Redirect::to("/admin?saved=announcement").into_response()) + } + Err(err) => admin_panel_error(&state, &user, &headers, &err.to_string()).await, + } +} + +#[derive(Deserialize)] +struct MaintenanceForm { + csrf: String, + message: Option, + enabled: Option, +} + +async fn admin_update_maintenance( + State(state): State>, + headers: HeaderMap, + Form(form): Form, +) -> AppResult { + let user = require_admin(&state, &headers).await?; + validate_csrf(&state.pool, &headers, &form.csrf).await?; + let enabled = form.enabled.as_deref() == Some("true"); + let message = form.message.unwrap_or_default(); + match instance::save_maintenance(&state.pool, enabled, &message).await { + Ok(()) => { + admin::audit( + &state.pool, + user.id, + if enabled { + "enable_maintenance" + } else { + "disable_maintenance" + }, + "instance_settings", + ) + .await?; + Ok(Redirect::to("/admin?saved=maintenance").into_response()) + } + Err(err) => admin_panel_error(&state, &user, &headers, &err.to_string()).await, + } +} + +async fn admin_panel_error( + state: &AppState, + user: &CurrentUser, + headers: &HeaderMap, + message: &str, +) -> AppResult { + let csrf = form_csrf(state, headers).await.unwrap_or_default(); + let instance = instance::load(&state.pool).await?; + let body = format!( + "{}{}{}", + render::notice("error", message), + admin_instance_panels(&instance, &csrf), + r#"

    Back to admin

    "# + ); + Ok(( + StatusCode::BAD_REQUEST, + Html(page_layout(state, Some(user), Some(&csrf), "Admin", &body).await?), + ) + .into_response()) +} + +async fn admin_require_password_reset( + 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?; + identity::require_password_reset(&state.pool, user.id, id) + .await + .map_err(|err| AppError::BadRequest(err.to_string()))?; + Ok(Redirect::to("/admin/users").into_response()) +} + +async fn admin_revoke_sessions( + 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?; + identity::revoke_user_sessions(&state.pool, user.id, id) + .await + .map_err(|err| AppError::BadRequest(err.to_string()))?; + Ok(Redirect::to("/admin/users").into_response()) +} + async fn admin_favicon_upload( State(state): State>, headers: HeaderMap, @@ -4006,6 +5214,19 @@ fn admin_user_row(row: &admin::AdminUserInvestigation, csrf: &str, searched: boo "Suspend this account" }, ); + let reset_password = small_form( + &format!("/admin/users/{}/require-password-reset", row.id), + csrf, + "Require password reset", + "Require this account to choose a new password before using the site again", + ); + let revoke_sessions = small_form( + &format!("/admin/users/{}/revoke-sessions", row.id), + csrf, + "Log out sessions", + "Revoke every active session for this account", + ); + let actions = format!("{action}{reset_password}{revoke_sessions}"); 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), @@ -4024,7 +5245,7 @@ fn admin_user_row(row: &admin::AdminUserInvestigation, csrf: &str, searched: boo row.moderation_action_count, row.matching_post_count, preview, - action + actions ) } @@ -4633,6 +5854,9 @@ async fn admin_restore_backup( ) -> AppResult> { let user = require_admin(&state, &headers).await?; let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); + // Staged restore uploads live under the runtime temp directory; keep + // cleanup from removing them while the restore is in flight. + let _operation = crate::runtime::begin_temp_operation(); let upload = match parse_restore_upload(&state, &headers, multipart).await { Ok(upload) => upload, Err(err) => { @@ -4756,7 +5980,7 @@ async fn parse_restore_upload( .paths .tmp_dir .join(format!("restore-upload-{}.tar", Uuid::new_v4().simple())); - write_multipart_field_to_file(field, &path).await?; + write_multipart_field_to_file(field, &path, None).await?; archive_path = Some(path); } _ => {} @@ -4775,23 +5999,43 @@ async fn parse_restore_upload( }) } +/// Streams one multipart field to `path` and returns the bytes written. +/// +/// When `max_bytes` is set the limit is enforced for every chunk as it +/// arrives, so an oversized upload is rejected before the whole body is +/// buffered or stored. Uploads must never be buffered in memory. async fn write_multipart_field_to_file( mut field: axum::extract::multipart::Field<'_>, path: &std::path::Path, -) -> AppResult<()> { + max_bytes: Option, +) -> AppResult { if let Some(parent) = path.parent() { tokio::fs::create_dir_all(parent).await?; } let mut output = tokio::fs::File::create(path).await?; + let mut written = 0u64; while let Some(chunk) = field .chunk() .await .map_err(|err| AppError::BadRequest(err.to_string()))? { + written = written.saturating_add(u64::try_from(chunk.len()).unwrap_or(u64::MAX)); + if let Some(limit) = max_bytes + && written > limit + { + return Err(upload_limit_error(limit)); + } output.write_all(&chunk).await?; } output.flush().await?; - Ok(()) + Ok(written) +} + +fn upload_limit_error(limit: u64) -> AppError { + AppError::PayloadTooLarge(format!( + "the uploaded archive is larger than the configured {} MiB limit", + limit / (1024 * 1024) + )) } async fn backups_page( @@ -9217,1126 +10461,3336 @@ mod tests { Vec::new(), ) .await; - let csrf = csrf_token(&home.body); + let csrf = csrf_token(&home.body); + + let deleted = request( + &server.base_url, + "POST", + "/settings/delete/confirm", + &[ + ("cookie", &cookie), + ("content-type", "application/x-www-form-urlencoded"), + ], + format!("csrf={csrf}&password=very%20secure%20password").into_bytes(), + ) + .await; + + assert_eq!(deleted.status, 400); + assert!(deleted.body.contains("Delete confirmation expired")); + let login = request( + &server.base_url, + "POST", + "/login", + &[("content-type", "application/x-www-form-urlencoded")], + b"username=alice&password=very%20secure%20password".to_vec(), + ) + .await; + assert_eq!(login.status, 303); + } + + #[tokio::test] + async fn delete_account_intent_and_password_control_final_delete() { + // Immediate deletion keeps this test focused on intent and password control. + let server = spawn_test_server_without_deletion_grace().await; + let registered = request( + &server.base_url, + "POST", + "/register", + &[("content-type", "application/x-www-form-urlencoded")], + b"username=alice&password=very%20secure%20password&confirm_password=very%20secure%20password".to_vec(), + ) + .await; + assert_eq!(registered.status, 303); + let cookie = session_cookie(®istered); + let confirm = request( + &server.base_url, + "GET", + "/settings/delete/confirm", + &[("cookie", &cookie)], + Vec::new(), + ) + .await; + assert_eq!(confirm.status, 200); + let csrf = csrf_token(&confirm.body); + let delete_intent = hidden_value(&confirm.body, "delete_intent"); + + let wrong_password = request( + &server.base_url, + "POST", + "/settings/delete/confirm", + &[ + ("cookie", &cookie), + ("content-type", "application/x-www-form-urlencoded"), + ], + format!("csrf={csrf}&delete_intent={delete_intent}&password=wrong").into_bytes(), + ) + .await; + assert_eq!(wrong_password.status, 401); + assert!(wrong_password.body.contains("Password is incorrect.")); + + let reused_intent = request( + &server.base_url, + "POST", + "/settings/delete/confirm", + &[ + ("cookie", &cookie), + ("content-type", "application/x-www-form-urlencoded"), + ], + format!("csrf={csrf}&delete_intent={delete_intent}&password=very%20secure%20password") + .into_bytes(), + ) + .await; + assert_eq!(reused_intent.status, 400); + assert!(reused_intent.body.contains("Delete confirmation expired")); + + let confirm = request( + &server.base_url, + "GET", + "/settings/delete/confirm", + &[("cookie", &cookie)], + Vec::new(), + ) + .await; + assert_eq!(confirm.status, 200); + let csrf = csrf_token(&confirm.body); + let delete_intent = hidden_value(&confirm.body, "delete_intent"); + let deleted = request( + &server.base_url, + "POST", + "/settings/delete/confirm", + &[ + ("cookie", &cookie), + ("content-type", "application/x-www-form-urlencoded"), + ], + format!("csrf={csrf}&delete_intent={delete_intent}&password=very%20secure%20password") + .into_bytes(), + ) + .await; + + assert_eq!(deleted.status, 303); + assert_eq!(location(&deleted), "/account-deleted"); + assert!( + deleted + .headers + .iter() + .any(|(name, value)| name == "set-cookie" && value.contains("Max-Age=0")) + ); + let login = request( + &server.base_url, + "POST", + "/login", + &[("content-type", "application/x-www-form-urlencoded")], + b"username=alice&password=very%20secure%20password".to_vec(), + ) + .await; + assert_eq!(login.status, 401); + } + + #[tokio::test] + async fn delete_account_route_succeeds_after_file_cleanup_failure() { + let server = spawn_test_server_without_deletion_grace().await; + let registered = request( + &server.base_url, + "POST", + "/register", + &[("content-type", "application/x-www-form-urlencoded")], + b"username=alice&password=very%20secure%20password&confirm_password=very%20secure%20password".to_vec(), + ) + .await; + assert_eq!(registered.status, 303); + let cookie = session_cookie(®istered); + let paths = RuntimePaths::from_data_dir(server.data_dir.clone()); + let media_path = paths.uploads_images.join("directory-media"); + std::fs::create_dir(&media_path).expect("media directory"); + let media_path_string = media_path.to_string_lossy().to_string(); + let conn = rusqlite::Connection::open(&paths.database_path).expect("open db"); + let alice: i64 = conn + .query_row( + "SELECT id FROM users WHERE normalized_username = 'alice'", + [], + |row| row.get(0), + ) + .expect("alice id"); + conn.execute( + "INSERT INTO media (owner_user_id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len) VALUES (?, 'directory-media', ?, '/uploads/images/directory-media', 'image/webp', 'image', 1)", + params![alice, media_path_string], + ) + .expect("media row"); + drop(conn); + let confirm = request( + &server.base_url, + "GET", + "/settings/delete/confirm", + &[("cookie", &cookie)], + Vec::new(), + ) + .await; + assert_eq!(confirm.status, 200); + let csrf = csrf_token(&confirm.body); + let delete_intent = hidden_value(&confirm.body, "delete_intent"); + + let deleted = request( + &server.base_url, + "POST", + "/settings/delete/confirm", + &[ + ("cookie", &cookie), + ("content-type", "application/x-www-form-urlencoded"), + ], + format!("csrf={csrf}&delete_intent={delete_intent}&password=very%20secure%20password") + .into_bytes(), + ) + .await; + + assert_eq!(deleted.status, 303); + assert_eq!(location(&deleted), "/account-deleted"); + assert!(media_path.exists()); + let conn = rusqlite::Connection::open(&paths.database_path).expect("open db"); + let users: i64 = conn + .query_row("SELECT COUNT(*) FROM users", [], |row| row.get(0)) + .expect("users count"); + assert_eq!(users, 0); + } + + async fn user_id_for(server: &TestServer, username: &str) -> i64 { + let username = username.to_owned(); + server + .pool + .call(move |conn| { + conn.query_row( + "SELECT id FROM users WHERE normalized_username = ?", + [username], + |row| row.get(0), + ) + .map_err(Into::into) + }) + .await + .expect("user id") + } + + /// End-to-end backup/restore: creates representative data through the HTTP + /// API, snapshots it, restores into a fresh data directory, and verifies + /// the restored database and media files. + #[tokio::test] + #[expect( + clippy::too_many_lines, + reason = "one end-to-end backup/restore scenario keeps setup, restore, and assertions in order" + )] + async fn backup_restore_round_trips_accounts_graph_muted_words_and_media() { + let server = spawn_test_server().await; + let alice = register_test_user(&server, "alice").await; + let bob = register_test_user(&server, "bob").await; + let _carol = register_test_user(&server, "carol").await; + + create_text_post(&server, &alice, "alice first post").await; + create_text_post(&server, &bob, "bob first post").await; + + // Media upload from bob. + let bob_csrf = csrf_token(&get_with_cookie(&server, "/home", &bob).await.body); + let upload = request( + &server.base_url, + "POST", + "/posts", + &[ + ("cookie", bob.as_str()), + ( + "content-type", + "multipart/form-data; boundary=media-boundary", + ), + ], + multipart_body_with_file( + "media-boundary", + &[("csrf", bob_csrf.as_str()), ("text", "bob photo post")], + "media", + "holiday.png", + "image/png", + &tiny_png_bytes(), + ), + ) + .await; + assert_eq!(upload.status, 303); + + // Alice follows bob; bob blocks carol; alice mutes a word. + let bob_id = user_id_for(&server, "bob").await; + let carol_id = user_id_for(&server, "carol").await; + let alice_csrf = csrf_token(&get_with_cookie(&server, "/home", &alice).await.body); + let followed = post_form_with_cookie( + &server, + &format!("/users/{bob_id}/follow"), + &alice, + &format!("csrf={alice_csrf}"), + ) + .await; + assert_eq!(followed.status, 303); + let bob_settings_csrf = csrf_token(&get_with_cookie(&server, "/settings", &bob).await.body); + let blocked = post_form_with_cookie( + &server, + &format!("/users/{carol_id}/block"), + &bob, + &format!("csrf={bob_settings_csrf}"), + ) + .await; + assert_eq!(blocked.status, 303); + let alice_settings_csrf = + csrf_token(&get_with_cookie(&server, "/settings", &alice).await.body); + let muted = post_form_with_cookie( + &server, + "/settings/muted-words", + &alice, + &format!("csrf={alice_settings_csrf}&term=spoilers"), + ) + .await; + assert_eq!(muted.status, 303); + + let source_paths = RuntimePaths::from_data_dir(server.data_dir.clone()); + let archive = backup::create_backup(&source_paths, false).expect("create backup"); + assert!(archive.is_file()); + + let target_temp = tempfile::tempdir().expect("target temp dir"); + let target_paths = RuntimePaths::from_data_dir(target_temp.path().to_path_buf()); + target_paths.ensure().expect("target paths"); + let report = + backup::restore_backup(&target_paths, &archive, false).expect("restore backup"); + assert!(report.pre_restore_backup.is_some()); + + let restored = + rusqlite::Connection::open(&target_paths.database_path).expect("restored database"); + let count = |table: &str| -> i64 { + restored + .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| { + row.get(0) + }) + .expect("count") + }; + assert_eq!(count("users"), 3); + assert_eq!(count("posts"), 3); + assert_eq!(count("follows"), 1); + assert_eq!(count("blocks"), 1); + assert_eq!(count("muted_words"), 1); + assert_eq!(count("media"), 1); + let (stored_path, public_path): (String, String) = restored + .query_row( + "SELECT stored_path, public_path FROM media LIMIT 1", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .expect("media row"); + // Restore rewrites absolute media paths for the destination data dir. + let target_prefix = target_paths.data_dir.to_string_lossy().to_string(); + assert!( + stored_path.starts_with(&target_prefix), + "restored media path {stored_path} still points outside {target_prefix}" + ); + let file_name = std::path::Path::new(&stored_path) + .file_name() + .expect("media file name"); + assert!( + target_paths.uploads_originals.join(file_name).is_file(), + "restored media file is missing for {public_path}" + ); + assert!(target_paths.settings_path.is_file()); + crate::db::validate_schema(&restored).expect("restored schema validates"); + let restored_post_id: i64 = restored + .query_row("SELECT post_id FROM post_media LIMIT 1", [], |row| { + row.get(0) + }) + .expect("post media"); + drop(restored); + + // Media cleanup must work against the restored paths. + let restored_pool = crate::db::connect(&target_paths.database_path) + .await + .expect("restored pool"); + crate::media::delete_post_media(&restored_pool, &target_paths, restored_post_id) + .await + .expect("delete restored media"); + assert!(!target_paths.uploads_originals.join(file_name).exists()); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_page_loads_each_render_a_csrf_token() { + let server = spawn_test_server().await; + let cookie = register_test_user(&server, "alice").await; + let mut handles = Vec::new(); + for _ in 0..8 { + let base_url = server.base_url.clone(); + let cookie = cookie.clone(); + handles.push(tokio::spawn(async move { + request( + &base_url, + "GET", + "/home", + &[("cookie", &cookie)], + Vec::new(), + ) + .await + })); + } + for handle in handles { + let response = handle.await.expect("task"); + assert_eq!(response.status, 200); + assert!( + response.body.contains(r#"name="csrf""#), + "concurrent page load rendered a form without a CSRF token" + ); + } + } + + #[tokio::test] + async fn oversized_image_headers_are_rejected_before_conversion() { + let server = spawn_test_server().await; + let cookie = register_test_user(&server, "alice").await; + let home = get_with_cookie(&server, "/home", &cookie).await; + let csrf = csrf_token(&home.body); + + let mut oversized = tiny_png_bytes(); + oversized[16..20].copy_from_slice(&40_000_u32.to_be_bytes()); + oversized[20..24].copy_from_slice(&40_000_u32.to_be_bytes()); + let body = multipart_body_with_file( + "post-boundary", + &[("csrf", csrf.as_str()), ("text", "huge image")], + "media", + "huge.png", + "image/png", + &oversized, + ); + let response = request( + &server.base_url, + "POST", + "/posts", + &[ + ("cookie", cookie.as_str()), + ( + "content-type", + "multipart/form-data; boundary=post-boundary", + ), + ], + body, + ) + .await; + assert_eq!(response.status, 400); + assert!(response.body.contains("dimensions")); + let media = media_row_count(&server).await; + assert_eq!(media, 0, "rejected image must not leave a media row"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_post_submissions_each_create_exactly_one_post() { + let mut settings = Settings::default(); + settings.moderation.posts_per_minute = 50; + let server = spawn_test_server_with_settings(settings).await; + let cookie = register_test_user(&server, "alice").await; + let home = get_with_cookie(&server, "/home", &cookie).await; + let csrf = csrf_token(&home.body); + + let mut handles = Vec::new(); + for index in 0..10 { + let base_url = server.base_url.clone(); + let cookie = cookie.clone(); + let csrf = csrf.clone(); + handles.push(tokio::spawn(async move { + request( + &base_url, + "POST", + "/posts", + &[ + ("cookie", cookie.as_str()), + ( + "content-type", + "multipart/form-data; boundary=post-boundary", + ), + ], + multipart_body( + "post-boundary", + &[ + ("csrf", csrf.as_str()), + ("text", &format!("concurrent submission {index}")), + ], + false, + ), + ) + .await + })); + } + for handle in handles { + assert_eq!(handle.await.expect("task").status, 303); + } + + let (count, distinct): (i64, i64) = server + .pool + .call(|conn| { + Ok(( + conn.query_row( + "SELECT COUNT(*) FROM posts WHERE is_deleted = 0", + [], + |row| row.get(0), + )?, + conn.query_row( + "SELECT COUNT(DISTINCT text) FROM posts WHERE is_deleted = 0", + [], + |row| row.get(0), + )?, + )) + }) + .await + .expect("post stats"); + assert_eq!(count, 10); + assert_eq!(distinct, 10); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_identical_media_uploads_keep_files_intact() { + let mut settings = Settings::default(); + settings.moderation.posts_per_minute = 50; + let server = spawn_test_server_with_settings(settings).await; + let cookie = register_test_user(&server, "alice").await; + let home = get_with_cookie(&server, "/home", &cookie).await; + let csrf = csrf_token(&home.body); + + let mut handles = Vec::new(); + for index in 0..6 { + let base_url = server.base_url.clone(); + let cookie = cookie.clone(); + let csrf = csrf.clone(); + handles.push(tokio::spawn(async move { + let body = multipart_body_with_file( + "media-boundary", + &[ + ("csrf", csrf.as_str()), + ("text", &format!("concurrent photo {index}")), + ], + "media", + "same.png", + "image/png", + &tiny_png_bytes(), + ); + request( + &base_url, + "POST", + "/posts", + &[ + ("cookie", cookie.as_str()), + ( + "content-type", + "multipart/form-data; boundary=media-boundary", + ), + ], + body, + ) + .await + })); + } + for handle in handles { + let response = handle.await.expect("task"); + assert_eq!(response.status, 303); + } + + let rows: Vec<(String, Option)> = server + .pool + .call(|conn| { + let mut stmt = conn.prepare("SELECT stored_path, canonical_media_id FROM media")?; + let rows = stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? + .collect::, _>>()?; + Ok(rows) + }) + .await + .expect("media rows"); + assert_eq!(rows.len(), 6); + assert_eq!( + rows.iter() + .filter(|(_, canonical)| canonical.is_none()) + .count(), + 1, + "identical uploads must share one canonical row" + ); + let mut distinct_paths = std::collections::BTreeSet::new(); + for (stored_path, _canonical) in &rows { + let path = std::path::Path::new(stored_path); + assert!(path.is_file(), "missing media file {stored_path}"); + assert_eq!( + std::fs::read(path).expect("read media"), + tiny_png_bytes(), + "media file content changed under concurrent uploads" + ); + distinct_paths.insert(stored_path.clone()); + } + assert_eq!(distinct_paths.len(), 1); + } + + fn multipart_body( + boundary: &str, + fields: &[(&str, &str)], + include_empty_media: bool, + ) -> Vec { + let mut body = String::new(); + for (name, value) in fields { + body.push_str(&format!("--{boundary}\r\n")); + body.push_str(&format!( + "Content-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n" + )); + } + if include_empty_media { + body.push_str(&format!("--{boundary}\r\n")); + body.push_str("Content-Disposition: form-data; name=\"media\"; filename=\"\"\r\n"); + body.push_str("Content-Type: application/octet-stream\r\n\r\n\r\n"); + } + body.push_str(&format!("--{boundary}--\r\n")); + body.into_bytes() + } + + fn multipart_body_with_file( + boundary: &str, + fields: &[(&str, &str)], + file_field: &str, + filename: &str, + content_type: &str, + file: &[u8], + ) -> Vec { + let mut body = multipart_body_without_close(boundary, fields); + body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); + body.extend_from_slice( + format!( + "Content-Disposition: form-data; name=\"{file_field}\"; filename=\"{filename}\"\r\n" + ) + .as_bytes(), + ); + body.extend_from_slice(format!("Content-Type: {content_type}\r\n\r\n").as_bytes()); + body.extend_from_slice(file); + body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes()); + body + } + + fn multipart_body_without_close(boundary: &str, fields: &[(&str, &str)]) -> Vec { + let mut body = Vec::new(); + for (name, value) in fields { + body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); + body.extend_from_slice( + format!("Content-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n") + .as_bytes(), + ); + } + body + } + + fn tiny_png_bytes() -> Vec { + vec![ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, + 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, + 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, + 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x04, 0x00, 0x01, 0xfe, 0xa7, 0x69, 0x9d, + 0x16, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, + ] + } + + 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 media_row_count(server: &TestServer) -> i64 { + server + .pool + .call(|conn| Ok(conn.query_row("SELECT COUNT(*) FROM media", [], |row| row.get(0))?)) + .await + .expect("media count") + } + + async fn pinned_post_id_for_user(server: &TestServer, username: &str) -> Option { + let username = username.to_owned(); + server + .pool + .call(move |conn| { + conn.query_row( + "SELECT pinned_post_id FROM users WHERE normalized_username = ?", + [username], + |row| row.get(0), + ) + .map_err(Into::into) + }) + .await + .expect("pinned post") + } + + async fn register_test_user(server: &TestServer, username: &str) -> String { + let body = format!( + "username={}&password=very%20secure%20password&confirm_password=very%20secure%20password", + form_encode(username) + ); + let response = request( + &server.base_url, + "POST", + "/register", + &[("content-type", "application/x-www-form-urlencoded")], + body.into_bytes(), + ) + .await; + assert_eq!(response.status, 303); + session_cookie(&response) + } + + async fn get_with_cookie(server: &TestServer, path: &str, cookie: &str) -> TestResponse { + request( + &server.base_url, + "GET", + path, + &[("cookie", cookie)], + Vec::new(), + ) + .await + } + + async fn liked_profile_fixture() -> (TestServer, String, String) { + let server = spawn_test_server().await; + let alice_cookie = register_test_user(&server, "alice").await; + create_text_post(&server, &alice_cookie, "privacy target post").await; + let bob_cookie = register_test_user(&server, "bob").await; + create_text_post(&server, &bob_cookie, "bob fallback post").await; + let bob_home = get_with_cookie(&server, "/home", &bob_cookie).await; + let csrf = csrf_token(&bob_home.body); + let liked = post_form_with_cookie( + &server, + "/posts/1/like", + &bob_cookie, + &format!("csrf={csrf}"), + ) + .await; + assert_eq!(liked.status, 303); + (server, alice_cookie, bob_cookie) + } + + async fn post_form_with_cookie( + server: &TestServer, + path: &str, + cookie: &str, + body: &str, + ) -> TestResponse { + request( + &server.base_url, + "POST", + path, + &[ + ("cookie", cookie), + ("content-type", "application/x-www-form-urlencoded"), + ], + body.as_bytes().to_vec(), + ) + .await + } + + async fn save_profile_settings( + server: &TestServer, + cookie: &str, + fields: &[(&str, &str)], + ) -> TestResponse { + let settings = get_with_cookie(server, "/settings", cookie).await; + let csrf = csrf_token(&settings.body); + let mut all_fields = Vec::with_capacity(fields.len() + 1); + all_fields.push(("csrf", csrf.as_str())); + all_fields.extend_from_slice(fields); + request( + &server.base_url, + "POST", + "/settings", + &[ + ("cookie", cookie), + ( + "content-type", + "multipart/form-data; boundary=settings-boundary", + ), + ], + multipart_body("settings-boundary", &all_fields, false), + ) + .await + } + + async fn user_settings_state( + server: &TestServer, + username: &str, + ) -> (String, i64, i64, String, String, String, String) { + let username = username.to_owned(); + server + .pool + .call(move |conn| { + Ok(conn.query_row( + r#" + SELECT theme, nsfw_blur_enabled, liked_posts_public, + display_name, bio, location, website + FROM users WHERE normalized_username = ? + "#, + [username], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + )) + }, + )?) + }) + .await + .expect("user settings state") + } + + async fn create_text_post(server: &TestServer, cookie: &str, text: &str) { + let home = get_with_cookie(server, "/home", cookie).await; + let csrf = csrf_token(&home.body); + let posted = request( + &server.base_url, + "POST", + "/posts", + &[ + ("cookie", cookie), + ( + "content-type", + "multipart/form-data; boundary=post-boundary", + ), + ], + multipart_body( + "post-boundary", + &[("csrf", csrf.as_str()), ("text", text)], + false, + ), + ) + .await; + assert_eq!(posted.status, 303); + } + + async fn set_post_created_seconds_ago(server: &TestServer, post_id: i64, seconds: i64) { + let modifier = format!("-{seconds} seconds"); + server + .pool + .call(move |conn| { + conn.execute( + "UPDATE posts SET created_at = datetime('now', ?), edited_at = NULL WHERE id = ?", + params![modifier, post_id], + )?; + Ok(()) + }) + .await + .expect("set post age"); + } + + fn assert_populated_notifications_page(body: &str) { + assert!(body.contains("4 unread notifications")); + assert!( + body.contains( + r#"4"# + ) + ); + assert!(body.contains(r#"

    New

    "#)); + assert_eq!( + body.matches(r#"class="notification-row unread""#).count(), + 4 + ); + assert!(body.contains(">bob @bob")); + assert!(body.contains("replied to your post")); + assert!(body.contains("liked your post")); + assert!(body.contains("reposted your post")); + assert!(body.contains("followed you")); + assert!(body.contains("alice original post")); + assert!(body.contains(r#"data-card-form="notification-open-"#)); + assert!(body.contains(r#"name="return_to" value="/posts/1""#)); + assert!(body.contains(r#"name="return_to" value="/posts/2""#)); + assert!(body.contains(r#"name="return_to" value="/users/bob""#)); + } + + fn assert_empty_state(body: &str, title: &str, message: &str) { + assert!(body.contains(r#"class="empty-state" data-testid="empty-state""#)); + assert!(body.contains(&format!("

    {title}

    "))); + if message.is_empty() { + assert!(!body.contains(r#"

    "#)); + } else { + assert!(body.contains(&format!("

    {message}

    "))); + } + } + + fn quote_form_body(response: &TestResponse, text: &str) -> String { + let csrf = csrf_token(&response.body); + format!("csrf={}&text={}", form_encode(&csrf), form_encode(text)) + } + + async fn spawn_test_server() -> TestServer { + spawn_test_server_inner(false, Settings::default()).await + } + + async fn spawn_test_server_with_admin() -> TestServer { + spawn_test_server_inner(true, Settings::default()).await + } + + async fn spawn_test_server_with_settings(settings: Settings) -> TestServer { + spawn_test_server_inner(false, settings).await + } + + /// A server configured for immediate account deletion after password + /// confirmation, matching the pre-grace-period behavior. + async fn spawn_test_server_without_deletion_grace() -> TestServer { + let mut settings = Settings::default(); + settings.accounts.deletion_grace_period_days = 0; + spawn_test_server_with_settings(settings).await + } + + async fn spawn_test_server_inner(create_admin: bool, settings: Settings) -> TestServer { + let temp = tempfile::tempdir().expect("temp dir"); + let paths = RuntimePaths::from_data_dir(temp.path().to_path_buf()) + .with_tor_data_dir(&settings.tor.data_dir) + .with_backup_dir(&settings.backup.backup_dir); + paths.ensure().expect("paths"); + crate::config::write_default_if_missing(&paths.settings_path).expect("settings"); + std::fs::write( + &paths.settings_path, + toml::to_string(&settings).expect("serialize settings"), + ) + .expect("write test settings"); + let data_dir = paths.data_dir.clone(); + let pool = crate::db::connect(&paths.database_path) + .await + .expect("connect"); + crate::db::migrate(&pool).await.expect("migrate"); + if create_admin { + crate::admin::create_admin(&pool, &settings, "siteowner", "very secure password") + .await + .expect("admin"); + } + let ffmpeg = FfmpegStatus { + available: false, + version: String::new(), + supports_webp: false, + supports_vp9: false, + error: Some("disabled in tests".to_owned()), + }; + let tor = crate::tor::validate_startup(&settings.tor); + let state = AppState::new(pool.clone(), settings, paths, ffmpeg, tor); + let registration_captcha = state.registration_captcha.clone(); + let app = router(state); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let addr = listener.local_addr().expect("addr"); + let task = tokio::spawn(async move { + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await + .expect("serve"); + }); + TestServer { + base_url: format!("127.0.0.1:{}", addr.port()), + data_dir, + pool, + registration_captcha, + _task: task, + _temp: temp, + } + } + + async fn admin_session_cookie(server: &TestServer) -> String { + let login = request( + &server.base_url, + "POST", + "/login", + &[("content-type", "application/x-www-form-urlencoded")], + b"username=siteowner&password=very%20secure%20password".to_vec(), + ) + .await; + assert_eq!(login.status, 303); + session_cookie(&login) + } + + async fn request( + base_url: &str, + method: &str, + path: &str, + headers: &[(&str, &str)], + body: Vec, + ) -> TestResponse { + let mut stream = tokio::net::TcpStream::connect(base_url) + .await + .expect("connect"); + let mut request = format!( + "{method} {path} HTTP/1.1\r\nHost: {base_url}\r\nConnection: close\r\nContent-Length: {}\r\n", + body.len() + ); + for (name, value) in headers { + request.push_str(name); + request.push_str(": "); + request.push_str(value); + request.push_str("\r\n"); + } + request.push_str("\r\n"); + stream + .write_all(request.as_bytes()) + .await + .expect("write headers"); + stream.write_all(&body).await.expect("write body"); + let mut bytes = Vec::new(); + stream.read_to_end(&mut bytes).await.expect("read"); + parse_response(&bytes) + } + + fn parse_response(bytes: &[u8]) -> TestResponse { + let split = bytes + .windows(4) + .position(|window| window == b"\r\n\r\n") + .expect("response split"); + let head = String::from_utf8_lossy(&bytes[..split]); + let body_bytes = bytes[split + 4..].to_vec(); + let body = String::from_utf8_lossy(&body_bytes).into_owned(); + let mut lines = head.lines(); + let status = lines + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .and_then(|value| value.parse::().ok()) + .expect("status"); + let headers = lines + .filter_map(|line| { + let (name, value) = line.split_once(':')?; + Some((name.to_ascii_lowercase(), value.trim().to_owned())) + }) + .collect(); + TestResponse { + status, + headers, + body_bytes, + body, + } + } + + fn session_cookie(response: &TestResponse) -> String { + response + .headers + .iter() + .find(|(name, _)| name == "set-cookie") + .map(|(_, value)| value.split(';').next().unwrap_or_default().to_owned()) + .expect("session cookie") + } + + fn location(response: &TestResponse) -> &str { + response + .headers + .iter() + .find(|(name, _)| name == "location") + .map(|(_, value)| value.as_str()) + .expect("location") + } + + fn assert_header(response: &TestResponse, name: &str, expected: &str) { + assert_eq!(header_value(response, name), Some(expected)); + } + + fn assert_no_header(response: &TestResponse, name: &str) { + assert_eq!(header_value(response, name), None); + } + + fn assert_vary_contains_accept_encoding(response: &TestResponse) { + let vary = header_value(response, "vary").expect("vary header"); + assert!( + vary.split(',') + .any(|part| part.trim().eq_ignore_ascii_case("accept-encoding")), + "Vary header should include Accept-Encoding: {vary}" + ); + } + + fn header_value<'a>(response: &'a TestResponse, name: &str) -> Option<&'a str> { + response + .headers + .iter() + .find(|(header_name, _)| header_name == name) + .map(|(_, value)| value.as_str()) + } + + fn content_length(response: &TestResponse) -> Option { + header_value(response, "content-length")?.parse().ok() + } + + fn gzip_decode(bytes: &[u8]) -> String { + let mut decoder = GzDecoder::new(bytes); + let mut output = String::new(); + decoder.read_to_string(&mut output).expect("gzip body"); + output + } + + fn deep_settings_form_body(csrf: &str, intent: &str, overrides: &[(&str, &str)]) -> Vec { + let values = crate::admin::DeepSettingsValues::from_settings(&Settings::default()); + let mut pairs = vec![ + ("csrf".to_owned(), csrf.to_owned()), + ("intent".to_owned(), intent.to_owned()), + ]; + for field in crate::admin::DeepSettingsField::ALL { + let value = overrides + .iter() + .find(|(name, _)| *name == field.form_name()) + .map_or_else( + || values.form_value(field), + |(_, value)| (*value).to_owned(), + ); + pairs.push((field.form_name().to_owned(), value)); + } + pairs + .iter() + .map(|(name, value)| format!("{}={}", form_encode(name), form_encode(value))) + .collect::>() + .join("&") + .into_bytes() + } + + fn registration_body( + username: &str, + captcha_token: Option<&str>, + captcha_answer: Option<&str>, + ) -> Vec { + let mut pairs = vec![ + ("username", username), + ("password", "very secure password"), + ("confirm_password", "very secure password"), + ]; + if let Some(token) = captcha_token { + pairs.push(("captcha_token", token)); + } + if let Some(answer) = captcha_answer { + pairs.push(("captcha_answer", answer)); + } + pairs + .iter() + .map(|(name, value)| format!("{}={}", form_encode(name), form_encode(value))) + .collect::>() + .join("&") + .into_bytes() + } + + fn form_encode(value: &str) -> String { + let mut encoded = String::new(); + for byte in value.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + encoded.push(char::from(byte)); + } + b' ' => encoded.push('+'), + _ => { + let _ = write!(encoded, "%{byte:02X}"); + } + } + } + encoded + } + + fn csrf_token(body: &str) -> String { + let marker = r#"name="csrf" value=""#; + let start = body.find(marker).expect("csrf marker") + marker.len(); + let end = body[start..].find('"').expect("csrf end") + start; + body[start..end].to_owned() + } + + fn hidden_value(body: &str, name: &str) -> String { + let marker = format!(r#"name="{name}" value=""#); + let start = body.find(&marker).expect("hidden marker") + marker.len(); + let end = body[start..].find('"').expect("hidden end") + start; + body[start..end].to_owned() + } + + async fn protected_account_fixture() -> (TestServer, String, String, i64) { + let server = spawn_test_server().await; + let alice_cookie = register_test_user(&server, "alice").await; + let bob_cookie = register_test_user(&server, "bob").await; + let alice_id = user_id_for(&server, "alice").await; + let saved = save_profile_settings( + &server, + &alice_cookie, + &[("follow_approval_required", "true")], + ) + .await; + assert_eq!(saved.status, 303); + (server, alice_cookie, bob_cookie, alice_id) + } - let deleted = request( + async fn post_multipart_with_cookie( + server: &TestServer, + path: &str, + cookie: &str, + boundary: &str, + fields: &[(&str, &str)], + ) -> TestResponse { + let content_type = format!("multipart/form-data; boundary={boundary}"); + request( &server.base_url, "POST", - "/settings/delete/confirm", - &[ - ("cookie", &cookie), - ("content-type", "application/x-www-form-urlencoded"), - ], - format!("csrf={csrf}&password=very%20secure%20password").into_bytes(), + path, + &[("cookie", cookie), ("content-type", &content_type)], + multipart_body(boundary, fields, false), + ) + .await + } + + async fn count_rows(server: &TestServer, sql: &'static str) -> i64 { + server + .pool + .call(move |conn| Ok(conn.query_row(sql, [], |row| row.get::<_, i64>(0))?)) + .await + .expect("row count") + } + + #[tokio::test] + async fn protected_account_follow_requests_need_approval_before_following() { + let (server, alice_cookie, bob_cookie, alice_id) = protected_account_fixture().await; + let bob_id = user_id_for(&server, "bob").await; + + let before = get_with_cookie(&server, "/users/alice", &bob_cookie).await; + let csrf = csrf_token(&before.body); + let requested = post_form_with_cookie( + &server, + &format!("/users/{alice_id}/follow"), + &bob_cookie, + &format!("csrf={csrf}"), ) .await; + assert_eq!(requested.status, 303); - assert_eq!(deleted.status, 400); - assert!(deleted.body.contains("Delete confirmation expired")); - let login = request( - &server.base_url, - "POST", - "/login", - &[("content-type", "application/x-www-form-urlencoded")], - b"username=alice&password=very%20secure%20password".to_vec(), + assert_eq!(count_rows(&server, "SELECT COUNT(*) FROM follows").await, 0); + assert_eq!( + count_rows(&server, "SELECT COUNT(*) FROM follow_requests").await, + 1 + ); + let profile = get_with_cookie(&server, "/users/alice", &bob_cookie).await; + assert!(profile.body.contains(">Requested")); + assert!(profile.body.contains(&format!( + r#"data-profile-followers="{alice_id}">0 followers"# + ))); + + let requests = get_with_cookie(&server, "/follow-requests", &alice_cookie).await; + assert_eq!(requests.status, 200); + assert!(requests.body.contains("@bob")); + assert!( + requests + .body + .contains(&format!("/users/{bob_id}/follow/approve")) + ); + assert!( + requests + .body + .contains(&format!("/users/{bob_id}/follow/reject")) + ); + let notifications = get_with_cookie(&server, "/notifications", &alice_cookie).await; + assert!(notifications.body.contains("requested to follow you")); + + let csrf = csrf_token(&requests.body); + let approved = post_form_with_cookie( + &server, + &format!("/users/{bob_id}/follow/approve"), + &alice_cookie, + &format!("csrf={csrf}"), ) .await; - assert_eq!(login.status, 303); + assert_eq!(approved.status, 303); + assert_eq!(count_rows(&server, "SELECT COUNT(*) FROM follows").await, 1); + assert_eq!( + count_rows(&server, "SELECT COUNT(*) FROM follow_requests").await, + 0 + ); + let bob_notifications = get_with_cookie(&server, "/notifications", &bob_cookie).await; + assert!( + bob_notifications + .body + .contains("approved your follow request") + ); + let profile = get_with_cookie(&server, "/users/alice", &bob_cookie).await; + assert!(profile.body.contains(">Following")); } #[tokio::test] - async fn delete_account_intent_and_password_control_final_delete() { - let server = spawn_test_server().await; - let registered = request( - &server.base_url, - "POST", - "/register", - &[("content-type", "application/x-www-form-urlencoded")], - b"username=alice&password=very%20secure%20password&confirm_password=very%20secure%20password".to_vec(), + async fn follow_requests_can_be_rejected_cancelled_and_blocks_clear_them() { + let (server, alice_cookie, bob_cookie, alice_id) = protected_account_fixture().await; + let bob_id = user_id_for(&server, "bob").await; + + // Cancel a pending request from the requester's own view. + let before = get_with_cookie(&server, "/users/alice", &bob_cookie).await; + let csrf = csrf_token(&before.body); + post_form_with_cookie( + &server, + &format!("/users/{alice_id}/follow"), + &bob_cookie, + &format!("csrf={csrf}"), ) .await; - assert_eq!(registered.status, 303); - let cookie = session_cookie(®istered); - let confirm = request( - &server.base_url, - "GET", - "/settings/delete/confirm", - &[("cookie", &cookie)], - Vec::new(), + let sent = get_with_cookie(&server, "/follow-requests", &bob_cookie).await; + assert!(sent.body.contains("@alice")); + let csrf = csrf_token(&sent.body); + let cancelled = post_form_with_cookie( + &server, + &format!("/users/{alice_id}/follow/cancel"), + &bob_cookie, + &format!("csrf={csrf}"), ) .await; - assert_eq!(confirm.status, 200); - let csrf = csrf_token(&confirm.body); - let delete_intent = hidden_value(&confirm.body, "delete_intent"); + assert_eq!(cancelled.status, 303); + assert_eq!( + count_rows(&server, "SELECT COUNT(*) FROM follow_requests").await, + 0 + ); - let wrong_password = request( - &server.base_url, - "POST", - "/settings/delete/confirm", - &[ - ("cookie", &cookie), - ("content-type", "application/x-www-form-urlencoded"), - ], - format!("csrf={csrf}&delete_intent={delete_intent}&password=wrong").into_bytes(), + // Reject a fresh request. + let before = get_with_cookie(&server, "/users/alice", &bob_cookie).await; + let csrf = csrf_token(&before.body); + post_form_with_cookie( + &server, + &format!("/users/{alice_id}/follow"), + &bob_cookie, + &format!("csrf={csrf}"), ) .await; - assert_eq!(wrong_password.status, 401); - assert!(wrong_password.body.contains("Password is incorrect.")); - - let reused_intent = request( - &server.base_url, - "POST", - "/settings/delete/confirm", - &[ - ("cookie", &cookie), - ("content-type", "application/x-www-form-urlencoded"), - ], - format!("csrf={csrf}&delete_intent={delete_intent}&password=very%20secure%20password") - .into_bytes(), + let requests = get_with_cookie(&server, "/follow-requests", &alice_cookie).await; + let csrf = csrf_token(&requests.body); + let rejected = post_form_with_cookie( + &server, + &format!("/users/{bob_id}/follow/reject"), + &alice_cookie, + &format!("csrf={csrf}"), ) .await; - assert_eq!(reused_intent.status, 400); - assert!(reused_intent.body.contains("Delete confirmation expired")); + assert_eq!(rejected.status, 303); + assert_eq!( + count_rows(&server, "SELECT COUNT(*) FROM follow_requests").await, + 0 + ); + assert_eq!(count_rows(&server, "SELECT COUNT(*) FROM follows").await, 0); - let confirm = request( - &server.base_url, - "GET", - "/settings/delete/confirm", - &[("cookie", &cookie)], - Vec::new(), + // A pending request disappears when the target blocks the requester. + let before = get_with_cookie(&server, "/users/alice", &bob_cookie).await; + let csrf = csrf_token(&before.body); + post_form_with_cookie( + &server, + &format!("/users/{alice_id}/follow"), + &bob_cookie, + &format!("csrf={csrf}"), ) .await; - assert_eq!(confirm.status, 200); - let csrf = csrf_token(&confirm.body); - let delete_intent = hidden_value(&confirm.body, "delete_intent"); - let deleted = request( - &server.base_url, - "POST", - "/settings/delete/confirm", - &[ - ("cookie", &cookie), - ("content-type", "application/x-www-form-urlencoded"), - ], - format!("csrf={csrf}&delete_intent={delete_intent}&password=very%20secure%20password") - .into_bytes(), + let profile = get_with_cookie(&server, "/users/bob", &alice_cookie).await; + let csrf = csrf_token(&profile.body); + let blocked = post_form_with_cookie( + &server, + &format!("/users/{bob_id}/block"), + &alice_cookie, + &format!("csrf={csrf}"), ) .await; - - assert_eq!(deleted.status, 303); - assert_eq!(location(&deleted), "/account-deleted"); - assert!( - deleted - .headers - .iter() - .any(|(name, value)| name == "set-cookie" && value.contains("Max-Age=0")) + assert_eq!(blocked.status, 303); + assert_eq!( + count_rows(&server, "SELECT COUNT(*) FROM follow_requests").await, + 0 ); - let login = request( - &server.base_url, - "POST", - "/login", - &[("content-type", "application/x-www-form-urlencoded")], - b"username=alice&password=very%20secure%20password".to_vec(), + let blocked_request = get_with_cookie(&server, "/users/alice", &bob_cookie).await; + let csrf = csrf_token(&blocked_request.body); + let refused = post_form_with_cookie( + &server, + &format!("/users/{alice_id}/follow"), + &bob_cookie, + &format!("csrf={csrf}"), ) .await; - assert_eq!(login.status, 401); + assert_eq!(refused.status, 400); } #[tokio::test] - async fn delete_account_route_succeeds_after_file_cleanup_failure() { - let server = spawn_test_server().await; - let registered = request( - &server.base_url, - "POST", - "/register", - &[("content-type", "application/x-www-form-urlencoded")], - b"username=alice&password=very%20secure%20password&confirm_password=very%20secure%20password".to_vec(), - ) - .await; - assert_eq!(registered.status, 303); - let cookie = session_cookie(®istered); - let paths = RuntimePaths::from_data_dir(server.data_dir.clone()); - let media_path = paths.uploads_images.join("directory-media"); - std::fs::create_dir(&media_path).expect("media directory"); - let media_path_string = media_path.to_string_lossy().to_string(); - let conn = rusqlite::Connection::open(&paths.database_path).expect("open db"); - let alice: i64 = conn - .query_row( - "SELECT id FROM users WHERE normalized_username = 'alice'", - [], - |row| row.get(0), - ) - .expect("alice id"); - conn.execute( - "INSERT INTO media (owner_user_id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len) VALUES (?, 'directory-media', ?, '/uploads/images/directory-media', 'image/webp', 'image', 1)", - params![alice, media_path_string], - ) - .expect("media row"); - drop(conn); - let confirm = request( - &server.base_url, - "GET", - "/settings/delete/confirm", - &[("cookie", &cookie)], - Vec::new(), - ) - .await; - assert_eq!(confirm.status, 200); - let csrf = csrf_token(&confirm.body); - let delete_intent = hidden_value(&confirm.body, "delete_intent"); - - let deleted = request( - &server.base_url, - "POST", - "/settings/delete/confirm", - &[ - ("cookie", &cookie), - ("content-type", "application/x-www-form-urlencoded"), - ], - format!("csrf={csrf}&delete_intent={delete_intent}&password=very%20secure%20password") - .into_bytes(), + async fn disabling_follow_approval_keeps_pending_requests_pending() { + let (server, alice_cookie, bob_cookie, alice_id) = protected_account_fixture().await; + let before = get_with_cookie(&server, "/users/alice", &bob_cookie).await; + let csrf = csrf_token(&before.body); + post_form_with_cookie( + &server, + &format!("/users/{alice_id}/follow"), + &bob_cookie, + &format!("csrf={csrf}"), ) .await; - assert_eq!(deleted.status, 303); - assert_eq!(location(&deleted), "/account-deleted"); - assert!(media_path.exists()); - let conn = rusqlite::Connection::open(&paths.database_path).expect("open db"); - let users: i64 = conn - .query_row("SELECT COUNT(*) FROM users", [], |row| row.get(0)) - .expect("users count"); - assert_eq!(users, 0); - } + let saved = + save_profile_settings(&server, &alice_cookie, &[("display_name", "Alice")]).await; + assert_eq!(saved.status, 303); + assert_eq!( + count_rows(&server, "SELECT COUNT(*) FROM follow_requests").await, + 1 + ); - async fn user_id_for(server: &TestServer, username: &str) -> i64 { - let username = username.to_owned(); - server - .pool - .call(move |conn| { - conn.query_row( - "SELECT id FROM users WHERE normalized_username = ?", - [username], - |row| row.get(0), - ) - .map_err(Into::into) - }) - .await - .expect("user id") + let carol_cookie = register_test_user(&server, "carol").await; + let carol_view = get_with_cookie(&server, "/users/alice", &carol_cookie).await; + let csrf = csrf_token(&carol_view.body); + let followed = post_form_with_cookie( + &server, + &format!("/users/{alice_id}/follow"), + &carol_cookie, + &format!("csrf={csrf}"), + ) + .await; + assert_eq!(followed.status, 303); + assert_eq!(count_rows(&server, "SELECT COUNT(*) FROM follows").await, 1); + assert_eq!( + count_rows(&server, "SELECT COUNT(*) FROM follow_requests").await, + 1 + ); } - /// End-to-end backup/restore: creates representative data through the HTTP - /// API, snapshots it, restores into a fresh data directory, and verifies - /// the restored database and media files. #[tokio::test] - async fn backup_restore_round_trips_accounts_graph_muted_words_and_media() { - let server = spawn_test_server().await; - let alice = register_test_user(&server, "alice").await; - let bob = register_test_user(&server, "bob").await; - let _carol = register_test_user(&server, "carol").await; + async fn admin_announcement_renders_next_to_site_name_and_escapes_text() { + let server = spawn_test_server_with_admin().await; + let admin_cookie = admin_session_cookie(&server).await; + let dashboard = get_with_cookie(&server, "/admin", &admin_cookie).await; + let csrf = csrf_token(&dashboard.body); + let updated = post_form_with_cookie( + &server, + "/admin/announcement", + &admin_cookie, + &format!( + "csrf={}&announcement={}&enabled=true&intent=save", + form_encode(&csrf), + form_encode("Window at 20:00") + ), + ) + .await; + assert_eq!(updated.status, 303); - create_text_post(&server, &alice, "alice first post").await; - create_text_post(&server, &bob, "bob first post").await; + let home = request(&server.base_url, "GET", "/home", &[], Vec::new()).await; + assert_eq!(home.status, 200); + assert!(home.body.contains(r#"data-testid="announcement""#)); + assert!(home.body.contains("<b>Window</b> at 20:00")); + assert!(!home.body.contains("Window at 20:00")); - // Media upload from bob. - let bob_csrf = csrf_token(&get_with_cookie(&server, "/home", &bob).await.body); - let upload = request( - &server.base_url, - "POST", - "/posts", - &[ - ("cookie", bob.as_str()), - ( - "content-type", - "multipart/form-data; boundary=media-boundary", - ), - ], - multipart_body_with_file( - "media-boundary", - &[("csrf", bob_csrf.as_str()), ("text", "bob photo post")], - "media", - "holiday.png", - "image/png", - &tiny_png_bytes(), + let member_cookie = register_test_user(&server, "member").await; + let member_home = get_with_cookie(&server, "/home", &member_cookie).await; + let member_csrf = csrf_token(&member_home.body); + let forbidden = post_form_with_cookie( + &server, + "/admin/announcement", + &member_cookie, + &format!( + "csrf={}&announcement=hi&enabled=true&intent=save", + form_encode(&member_csrf) ), ) .await; - assert_eq!(upload.status, 303); + assert_eq!(forbidden.status, 403); - // Alice follows bob; bob blocks carol; alice mutes a word. - let bob_id = user_id_for(&server, "bob").await; - let carol_id = user_id_for(&server, "carol").await; - let alice_csrf = csrf_token(&get_with_cookie(&server, "/home", &alice).await.body); - let followed = post_form_with_cookie( + let dashboard = get_with_cookie(&server, "/admin", &admin_cookie).await; + let csrf = csrf_token(&dashboard.body); + let cleared = post_form_with_cookie( &server, - &format!("/users/{bob_id}/follow"), - &alice, - &format!("csrf={alice_csrf}"), + "/admin/announcement", + &admin_cookie, + &format!("csrf={}&intent=clear", form_encode(&csrf)), ) .await; - assert_eq!(followed.status, 303); - let bob_settings_csrf = csrf_token(&get_with_cookie(&server, "/settings", &bob).await.body); - let blocked = post_form_with_cookie( + assert_eq!(cleared.status, 303); + let home = request(&server.base_url, "GET", "/home", &[], Vec::new()).await; + assert!(!home.body.contains(r#"data-testid="announcement""#)); + + let dashboard = get_with_cookie(&server, "/admin", &admin_cookie).await; + let csrf = csrf_token(&dashboard.body); + let too_long = post_form_with_cookie( &server, - &format!("/users/{carol_id}/block"), - &bob, - &format!("csrf={bob_settings_csrf}"), + "/admin/announcement", + &admin_cookie, + &format!( + "csrf={}&announcement={}&enabled=true&intent=save", + form_encode(&csrf), + form_encode(&"x".repeat(281)) + ), ) .await; - assert_eq!(blocked.status, 303); - let alice_settings_csrf = - csrf_token(&get_with_cookie(&server, "/settings", &alice).await.body); - let muted = post_form_with_cookie( + assert_eq!(too_long.status, 400); + assert!(too_long.body.contains("announcement is too long")); + } + + #[tokio::test] + async fn maintenance_mode_blocks_registration_and_posting_but_stays_readable() { + let server = spawn_test_server_with_admin().await; + let member_cookie = register_test_user(&server, "member").await; + create_text_post(&server, &member_cookie, "before maintenance").await; + let admin_cookie = admin_session_cookie(&server).await; + let dashboard = get_with_cookie(&server, "/admin", &admin_cookie).await; + let csrf = csrf_token(&dashboard.body); + let enabled = post_form_with_cookie( &server, - "/settings/muted-words", - &alice, - &format!("csrf={alice_settings_csrf}&term=spoilers"), + "/admin/maintenance", + &admin_cookie, + &format!( + "csrf={}&enabled=true&message={}", + form_encode(&csrf), + form_encode("Back at noon") + ), ) .await; - assert_eq!(muted.status, 303); - - let source_paths = RuntimePaths::from_data_dir(server.data_dir.clone()); - let archive = backup::create_backup(&source_paths, false).expect("create backup"); - assert!(archive.is_file()); + assert_eq!(enabled.status, 303); - let target_temp = tempfile::tempdir().expect("target temp dir"); - let target_paths = RuntimePaths::from_data_dir(target_temp.path().to_path_buf()); - target_paths.ensure().expect("target paths"); - let report = - backup::restore_backup(&target_paths, &archive, false).expect("restore backup"); - assert!(report.pre_restore_backup.is_some()); + let home = get_with_cookie(&server, "/home", &member_cookie).await; + assert_eq!(home.status, 200); + assert!(home.body.contains(r#"data-testid="maintenance-notice""#)); + assert!(home.body.contains("Back at noon")); + assert!(!home.body.contains(r#"id="post-text""#)); - let restored = - rusqlite::Connection::open(&target_paths.database_path).expect("restored database"); - let count = |table: &str| -> i64 { - restored - .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| { - row.get(0) - }) - .expect("count") - }; - assert_eq!(count("users"), 3); - assert_eq!(count("posts"), 3); - assert_eq!(count("follows"), 1); - assert_eq!(count("blocks"), 1); - assert_eq!(count("muted_words"), 1); - assert_eq!(count("media"), 1); - let (stored_path, public_path): (String, String) = restored - .query_row( - "SELECT stored_path, public_path FROM media LIMIT 1", - [], - |row| Ok((row.get(0)?, row.get(1)?)), + let csrf = csrf_token(&home.body); + let blocked = post_multipart_with_cookie( + &server, + "/posts", + &member_cookie, + "maintenance-post", + &[("csrf", csrf.as_str()), ("text", "should not publish")], + ) + .await; + assert_eq!(blocked.status, 503); + assert_eq!( + count_rows( + &server, + "SELECT COUNT(*) FROM posts WHERE text = 'should not publish'" ) - .expect("media row"); - // Restore rewrites absolute media paths for the destination data dir. - let target_prefix = target_paths.data_dir.to_string_lossy().to_string(); - assert!( - stored_path.starts_with(&target_prefix), - "restored media path {stored_path} still points outside {target_prefix}" + .await, + 0 ); - let file_name = std::path::Path::new(&stored_path) - .file_name() - .expect("media file name"); + + let register = request( + &server.base_url, + "POST", + "/register", + &[("content-type", "application/x-www-form-urlencoded")], + b"username=late&password=very%20secure%20password&confirm_password=very%20secure%20password" + .to_vec(), + ) + .await; + assert_eq!(register.status, 503); + let register_page = request(&server.base_url, "GET", "/register", &[], Vec::new()).await; + assert_eq!(register_page.status, 200); assert!( - target_paths.uploads_originals.join(file_name).is_file(), - "restored media file is missing for {public_path}" + register_page + .body + .contains("Registration is currently closed.") ); - assert!(target_paths.settings_path.is_file()); - crate::db::validate_schema(&restored).expect("restored schema validates"); - let restored_post_id: i64 = restored - .query_row("SELECT post_id FROM post_media LIMIT 1", [], |row| { - row.get(0) - }) - .expect("post media"); - drop(restored); - // Media cleanup must work against the restored paths. - let restored_pool = crate::db::connect(&target_paths.database_path) - .await - .expect("restored pool"); - crate::media::delete_post_media(&restored_pool, &target_paths, restored_post_id) - .await - .expect("delete restored media"); - assert!(!target_paths.uploads_originals.join(file_name).exists()); - } + // Administrators keep posting so they can verify the instance. + let admin_home = get_with_cookie(&server, "/home", &admin_cookie).await; + assert!(admin_home.body.contains(r#"id="post-text""#)); + let admin_csrf = csrf_token(&admin_home.body); + let admin_post = post_multipart_with_cookie( + &server, + "/posts", + &admin_cookie, + "maintenance-admin-post", + &[ + ("csrf", admin_csrf.as_str()), + ("text", "admin verification post"), + ], + ) + .await; + assert_eq!(admin_post.status, 303); - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn concurrent_page_loads_each_render_a_csrf_token() { - let server = spawn_test_server().await; - let cookie = register_test_user(&server, "alice").await; - let mut handles = Vec::new(); - for _ in 0..8 { - let base_url = server.base_url.clone(); - let cookie = cookie.clone(); - handles.push(tokio::spawn(async move { - request( - &base_url, - "GET", - "/home", - &[("cookie", &cookie)], - Vec::new(), - ) - .await - })); - } - for handle in handles { - let response = handle.await.expect("task"); - assert_eq!(response.status, 200); - assert!( - response.body.contains(r#"name="csrf""#), - "concurrent page load rendered a form without a CSRF token" - ); - } + let dashboard = get_with_cookie(&server, "/admin", &admin_cookie).await; + let csrf = csrf_token(&dashboard.body); + let disabled = post_form_with_cookie( + &server, + "/admin/maintenance", + &admin_cookie, + &format!("csrf={}&intent=save", form_encode(&csrf)), + ) + .await; + assert_eq!(disabled.status, 303); + create_text_post(&server, &member_cookie, "after maintenance").await; } #[tokio::test] - async fn oversized_image_headers_are_rejected_before_conversion() { - let server = spawn_test_server().await; - let cookie = register_test_user(&server, "alice").await; - let home = get_with_cookie(&server, "/home", &cookie).await; - let csrf = csrf_token(&home.body); + async fn admin_forced_password_reset_restricts_account_until_password_changes() { + let server = spawn_test_server_with_admin().await; + let member_cookie = register_test_user(&server, "member").await; + create_text_post(&server, &member_cookie, "before forced reset").await; + let member_id = user_id_for(&server, "member").await; + let admin_cookie = admin_session_cookie(&server).await; + let users_page = get_with_cookie(&server, "/admin/users", &admin_cookie).await; + let csrf = csrf_token(&users_page.body); + let reset = post_form_with_cookie( + &server, + &format!("/admin/users/{member_id}/require-password-reset"), + &admin_cookie, + &format!("csrf={csrf}"), + ) + .await; + assert_eq!(reset.status, 303); + assert_eq!( + count_rows( + &server, + "SELECT COUNT(*) FROM admin_audit_log WHERE action = 'require_password_reset'" + ) + .await, + 1 + ); - let mut oversized = tiny_png_bytes(); - oversized[16..20].copy_from_slice(&40_000_u32.to_be_bytes()); - oversized[20..24].copy_from_slice(&40_000_u32.to_be_bytes()); - let body = multipart_body_with_file( - "post-boundary", - &[("csrf", csrf.as_str()), ("text", "huge image")], - "media", - "huge.png", - "image/png", - &oversized, + let home = get_with_cookie(&server, "/home", &member_cookie).await; + assert_eq!(home.status, 303); + assert!(location(&home).starts_with("/settings/password")); + + let page = get_with_cookie(&server, "/settings/password?required=1", &member_cookie).await; + assert_eq!(page.status, 200); + assert!(page.body.contains("You must change your password")); + assert!(!page.body.contains(r#"id="profile-settings-form""#)); + + let csrf = csrf_token(&page.body); + let blocked_post = post_multipart_with_cookie( + &server, + "/posts", + &member_cookie, + "reset-post", + &[("csrf", csrf.as_str()), ("text", "blocked by reset")], + ) + .await; + assert_eq!(blocked_post.status, 303); + assert_eq!( + count_rows( + &server, + "SELECT COUNT(*) FROM posts WHERE text = 'blocked by reset'" + ) + .await, + 0 ); - let response = request( + + let changed = post_form_with_cookie( + &server, + "/settings/password", + &member_cookie, + &format!( + "csrf={}¤t_password=very%20secure%20password&new_password=brand%20new%20password&confirm_new_password=brand%20new%20password", + form_encode(&csrf) + ), + ) + .await; + assert_eq!(changed.status, 303); + let home = get_with_cookie(&server, "/home", &member_cookie).await; + assert_eq!(home.status, 200); + create_text_post(&server, &member_cookie, "after password reset").await; + + let old_login = request( + &server.base_url, + "POST", + "/login", + &[("content-type", "application/x-www-form-urlencoded")], + b"username=member&password=very%20secure%20password".to_vec(), + ) + .await; + assert_eq!(old_login.status, 401); + let new_login = request( &server.base_url, "POST", - "/posts", - &[ - ("cookie", cookie.as_str()), - ( - "content-type", - "multipart/form-data; boundary=post-boundary", - ), - ], - body, + "/login", + &[("content-type", "application/x-www-form-urlencoded")], + b"username=member&password=brand%20new%20password".to_vec(), ) .await; - assert_eq!(response.status, 400); - assert!(response.body.contains("dimensions")); - let media = media_row_count(&server).await; - assert_eq!(media, 0, "rejected image must not leave a media row"); + assert_eq!(new_login.status, 303); } - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn concurrent_post_submissions_each_create_exactly_one_post() { - let mut settings = Settings::default(); - settings.moderation.posts_per_minute = 50; - let server = spawn_test_server_with_settings(settings).await; - let cookie = register_test_user(&server, "alice").await; - let home = get_with_cookie(&server, "/home", &cookie).await; - let csrf = csrf_token(&home.body); + #[tokio::test] + async fn admin_forced_logout_revokes_only_that_accounts_sessions() { + let server = spawn_test_server_with_admin().await; + let member_cookie = register_test_user(&server, "member").await; + let second_login = request( + &server.base_url, + "POST", + "/login", + &[("content-type", "application/x-www-form-urlencoded")], + b"username=member&password=very%20secure%20password".to_vec(), + ) + .await; + assert_eq!(second_login.status, 303); + let second_cookie = session_cookie(&second_login); + let bystander_cookie = register_test_user(&server, "bystander").await; + let member_id = user_id_for(&server, "member").await; + let admin_cookie = admin_session_cookie(&server).await; - let mut handles = Vec::new(); - for index in 0..10 { - let base_url = server.base_url.clone(); - let cookie = cookie.clone(); - let csrf = csrf.clone(); - handles.push(tokio::spawn(async move { - request( - &base_url, - "POST", - "/posts", - &[ - ("cookie", cookie.as_str()), - ( - "content-type", - "multipart/form-data; boundary=post-boundary", - ), - ], - multipart_body( - "post-boundary", - &[ - ("csrf", csrf.as_str()), - ("text", &format!("concurrent submission {index}")), - ], - false, - ), - ) - .await - })); - } - for handle in handles { - assert_eq!(handle.await.expect("task").status, 303); + for _ in 0..2 { + let users_page = get_with_cookie(&server, "/admin/users", &admin_cookie).await; + let csrf = csrf_token(&users_page.body); + let revoked = post_form_with_cookie( + &server, + &format!("/admin/users/{member_id}/revoke-sessions"), + &admin_cookie, + &format!("csrf={csrf}"), + ) + .await; + assert_eq!(revoked.status, 303); } - let (count, distinct): (i64, i64) = server - .pool - .call(|conn| { - Ok(( - conn.query_row( - "SELECT COUNT(*) FROM posts WHERE is_deleted = 0", - [], - |row| row.get(0), - )?, - conn.query_row( - "SELECT COUNT(DISTINCT text) FROM posts WHERE is_deleted = 0", - [], - |row| row.get(0), - )?, - )) - }) - .await - .expect("post stats"); - assert_eq!(count, 10); - assert_eq!(distinct, 10); + for cookie in [&member_cookie, &second_cookie] { + let settings = get_with_cookie(&server, "/settings", cookie).await; + assert_eq!(settings.status, 401); + } + let bystander = get_with_cookie(&server, "/settings", &bystander_cookie).await; + assert_eq!(bystander.status, 200); + let login_again = request( + &server.base_url, + "POST", + "/login", + &[("content-type", "application/x-www-form-urlencoded")], + b"username=member&password=very%20secure%20password".to_vec(), + ) + .await; + assert_eq!(login_again.status, 303); } - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn concurrent_identical_media_uploads_keep_files_intact() { - let mut settings = Settings::default(); - settings.moderation.posts_per_minute = 50; - let server = spawn_test_server_with_settings(settings).await; - let cookie = register_test_user(&server, "alice").await; - let home = get_with_cookie(&server, "/home", &cookie).await; - let csrf = csrf_token(&home.body); + #[tokio::test] + async fn forced_logout_does_not_clear_a_forced_password_reset() { + let server = spawn_test_server_with_admin().await; + let cookie = register_test_user(&server, "member").await; + let member_id = user_id_for(&server, "member").await; + let admin_cookie = admin_session_cookie(&server).await; - let mut handles = Vec::new(); - for index in 0..6 { - let base_url = server.base_url.clone(); - let cookie = cookie.clone(); - let csrf = csrf.clone(); - handles.push(tokio::spawn(async move { - let body = multipart_body_with_file( - "media-boundary", - &[ - ("csrf", csrf.as_str()), - ("text", &format!("concurrent photo {index}")), - ], - "media", - "same.png", - "image/png", - &tiny_png_bytes(), - ); - request( - &base_url, - "POST", - "/posts", - &[ - ("cookie", cookie.as_str()), - ( - "content-type", - "multipart/form-data; boundary=media-boundary", - ), - ], - body, - ) - .await - })); - } - for handle in handles { - let response = handle.await.expect("task"); - assert_eq!(response.status, 303); - } + let users_page = get_with_cookie(&server, "/admin/users", &admin_cookie).await; + let csrf = csrf_token(&users_page.body); + let reset = post_form_with_cookie( + &server, + &format!("/admin/users/{member_id}/require-password-reset"), + &admin_cookie, + &format!("csrf={csrf}"), + ) + .await; + assert_eq!(reset.status, 303); - let rows: Vec<(String, Option)> = server - .pool - .call(|conn| { - let mut stmt = conn.prepare("SELECT stored_path, canonical_media_id FROM media")?; - let rows = stmt - .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? - .collect::, _>>()?; - Ok(rows) - }) - .await - .expect("media rows"); - assert_eq!(rows.len(), 6); - assert_eq!( - rows.iter() - .filter(|(_, canonical)| canonical.is_none()) - .count(), - 1, - "identical uploads must share one canonical row" - ); - let mut distinct_paths = std::collections::BTreeSet::new(); - for (stored_path, _canonical) in &rows { - let path = std::path::Path::new(stored_path); - assert!(path.is_file(), "missing media file {stored_path}"); - assert_eq!( - std::fs::read(path).expect("read media"), - tiny_png_bytes(), - "media file content changed under concurrent uploads" - ); - distinct_paths.insert(stored_path.clone()); - } - assert_eq!(distinct_paths.len(), 1); - } + let users_page = get_with_cookie(&server, "/admin/users", &admin_cookie).await; + let csrf = csrf_token(&users_page.body); + let revoked = post_form_with_cookie( + &server, + &format!("/admin/users/{member_id}/revoke-sessions"), + &admin_cookie, + &format!("csrf={csrf}"), + ) + .await; + assert_eq!(revoked.status, 303); + let old_session = get_with_cookie(&server, "/settings", &cookie).await; + assert_eq!(old_session.status, 401); - fn multipart_body( - boundary: &str, - fields: &[(&str, &str)], - include_empty_media: bool, - ) -> Vec { - let mut body = String::new(); - for (name, value) in fields { - body.push_str(&format!("--{boundary}\r\n")); - body.push_str(&format!( - "Content-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n" - )); - } - if include_empty_media { - body.push_str(&format!("--{boundary}\r\n")); - body.push_str("Content-Disposition: form-data; name=\"media\"; filename=\"\"\r\n"); - body.push_str("Content-Type: application/octet-stream\r\n\r\n\r\n"); - } - body.push_str(&format!("--{boundary}--\r\n")); - body.into_bytes() + let login = request( + &server.base_url, + "POST", + "/login", + &[("content-type", "application/x-www-form-urlencoded")], + b"username=member&password=very%20secure%20password".to_vec(), + ) + .await; + assert_eq!(login.status, 303); + let fresh_cookie = session_cookie(&login); + let home = get_with_cookie(&server, "/home", &fresh_cookie).await; + assert_eq!(home.status, 303); + assert!(location(&home).starts_with("/settings/password")); } - fn multipart_body_with_file( - boundary: &str, - fields: &[(&str, &str)], - file_field: &str, - filename: &str, - content_type: &str, - file: &[u8], - ) -> Vec { - let mut body = multipart_body_without_close(boundary, fields); - body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); - body.extend_from_slice( - format!( - "Content-Disposition: form-data; name=\"{file_field}\"; filename=\"{filename}\"\r\n" + #[tokio::test] + async fn deletion_grace_period_blocks_writes_and_can_be_cancelled() { + let server = spawn_test_server().await; + let cookie = register_test_user(&server, "deleter").await; + create_text_post(&server, &cookie, "post before deletion").await; + + let confirm_page = get_with_cookie(&server, "/settings/delete/confirm", &cookie).await; + let csrf = csrf_token(&confirm_page.body); + let intent = hidden_value(&confirm_page.body, "delete_intent"); + let requested = post_form_with_cookie( + &server, + "/settings/delete/confirm", + &cookie, + &format!("csrf={csrf}&delete_intent={intent}&password=very%20secure%20password"), + ) + .await; + assert_eq!(requested.status, 303); + assert_eq!(location(&requested), "/settings?saved=delete-requested"); + assert_eq!( + count_rows( + &server, + "SELECT COUNT(*) FROM users WHERE deletion_scheduled_at IS NOT NULL" ) - .as_bytes(), + .await, + 1 ); - body.extend_from_slice(format!("Content-Type: {content_type}\r\n\r\n").as_bytes()); - body.extend_from_slice(file); - body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes()); - body - } - - fn multipart_body_without_close(boundary: &str, fields: &[(&str, &str)]) -> Vec { - let mut body = Vec::new(); - for (name, value) in fields { - body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); - body.extend_from_slice( - format!("Content-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n") - .as_bytes(), - ); - } - body - } - fn tiny_png_bytes() -> Vec { - vec![ - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, - 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, - 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, - 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x04, 0x00, 0x01, 0xfe, 0xa7, 0x69, 0x9d, - 0x16, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, - ] - } + let home = get_with_cookie(&server, "/home", &cookie).await; + assert_eq!(home.status, 200); + assert!(home.body.contains(r#"data-testid="account-notice""#)); - 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 - } + let csrf = csrf_token(&home.body); + let blocked = post_multipart_with_cookie( + &server, + "/posts", + &cookie, + "deletion-post", + &[("csrf", csrf.as_str()), ("text", "should not publish")], + ) + .await; + assert_eq!(blocked.status, 403); - async fn media_row_count(server: &TestServer) -> i64 { - server - .pool - .call(|conn| Ok(conn.query_row("SELECT COUNT(*) FROM media", [], |row| row.get(0))?)) - .await - .expect("media count") + let settings = get_with_cookie(&server, "/settings", &cookie).await; + assert!(settings.body.contains(r#"data-testid="deletion-deadline""#)); + let csrf = csrf_token(&settings.body); + let cancelled = post_form_with_cookie( + &server, + "/settings/delete/cancel", + &cookie, + &format!("csrf={csrf}"), + ) + .await; + assert_eq!(cancelled.status, 303); + assert_eq!( + count_rows( + &server, + "SELECT COUNT(*) FROM users WHERE deletion_scheduled_at IS NULL" + ) + .await, + 1 + ); + create_text_post(&server, &cookie, "post after cancellation").await; } - async fn pinned_post_id_for_user(server: &TestServer, username: &str) -> Option { - let username = username.to_owned(); + #[tokio::test] + async fn matched_deletion_deadlines_finalize_idempotently() { + let server = spawn_test_server().await; + let cookie = register_test_user(&server, "expiring").await; + create_text_post(&server, &cookie, "expiring post").await; + let user_id = user_id_for(&server, "expiring").await; + let paths = RuntimePaths::from_data_dir(server.data_dir.clone()); server .pool .call(move |conn| { - conn.query_row( - "SELECT pinned_post_id FROM users WHERE normalized_username = ?", - [username], - |row| row.get(0), - ) - .map_err(Into::into) + conn.execute( + "UPDATE users SET deletion_requested_at = datetime('now','-31 days'), deletion_scheduled_at = datetime('now','-1 day') WHERE id = ?", + [user_id], + )?; + Ok(()) }) .await - .expect("pinned post") + .expect("schedule deletion"); + + let removed = crate::account::finalize_due_deletions(&server.pool, &paths) + .await + .expect("finalize"); + assert_eq!(removed, 1); + assert_eq!( + count_rows(&server, "SELECT COUNT(*) FROM users WHERE id = 1").await, + 0 + ); + assert_eq!(count_rows(&server, "SELECT COUNT(*) FROM posts").await, 0); + let session = get_with_cookie(&server, "/home", &cookie).await; + assert_eq!(session.status, 200); + assert!(!session.body.contains(r#"data-testid="account-notice""#)); + let removed_again = crate::account::finalize_due_deletions(&server.pool, &paths) + .await + .expect("finalize again"); + assert_eq!(removed_again, 0); } - async fn register_test_user(server: &TestServer, username: &str) -> String { - let body = format!( - "username={}&password=very%20secure%20password&confirm_password=very%20secure%20password", - form_encode(username) + #[tokio::test] + async fn zero_grace_period_deletes_immediately_after_confirmation() { + let mut settings = Settings::default(); + settings.accounts.deletion_grace_period_days = 0; + let server = spawn_test_server_with_settings(settings).await; + let cookie = register_test_user(&server, "instant").await; + create_text_post(&server, &cookie, "instant post").await; + + let confirm_page = get_with_cookie(&server, "/settings/delete/confirm", &cookie).await; + let csrf = csrf_token(&confirm_page.body); + let intent = hidden_value(&confirm_page.body, "delete_intent"); + let deleted = post_form_with_cookie( + &server, + "/settings/delete/confirm", + &cookie, + &format!("csrf={csrf}&delete_intent={intent}&password=very%20secure%20password"), + ) + .await; + assert_eq!(deleted.status, 303); + assert_eq!(location(&deleted), "/account-deleted"); + assert_eq!(count_rows(&server, "SELECT COUNT(*) FROM users").await, 0); + } + + #[tokio::test] + async fn username_changes_keep_identity_and_reserve_previous_handles() { + let server = spawn_test_server().await; + let cookie = register_test_user(&server, "original").await; + create_text_post(&server, &cookie, "post from original").await; + + let settings = get_with_cookie(&server, "/settings", &cookie).await; + let csrf = csrf_token(&settings.body); + let wrong_password = post_form_with_cookie( + &server, + "/settings/username", + &cookie, + &format!("csrf={csrf}&new_username=renamed&password=not%20the%20password"), + ) + .await; + assert_eq!(wrong_password.status, 401); + + let changed = post_form_with_cookie( + &server, + "/settings/username", + &cookie, + &format!( + "csrf={csrf}&new_username={}&password=very%20secure%20password", + form_encode("ReNamed") + ), + ) + .await; + assert_eq!(changed.status, 303); + assert_eq!(location(&changed), "/settings?saved=username"); + assert_eq!( + count_rows( + &server, + "SELECT COUNT(*) FROM username_history WHERE username = 'original'" + ) + .await, + 1 ); - let response = request( + + let profile = get_with_cookie(&server, "/users/renamed", &cookie).await; + assert_eq!(profile.status, 200); + assert!(profile.body.contains("Previously known as @original")); + assert!(profile.body.contains("post from original")); + + let old_url = request(&server.base_url, "GET", "/users/original", &[], Vec::new()).await; + assert_eq!(old_url.status, 200); + assert!(old_url.body.contains("No account uses @original")); + assert!(old_url.body.contains(r#"href="/users/ReNamed""#)); + + let duplicate = request( &server.base_url, "POST", "/register", &[("content-type", "application/x-www-form-urlencoded")], - body.into_bytes(), + b"username=original&password=very%20secure%20password&confirm_password=very%20secure%20password".to_vec(), ) .await; - assert_eq!(response.status, 303); - session_cookie(&response) + assert_eq!(duplicate.status, 400); + assert!(duplicate.body.contains("That username is already taken.")); + + let taken = post_form_with_cookie( + &server, + "/settings/username", + &cookie, + &format!("csrf={csrf}&new_username=settings&password=very%20secure%20password"), + ) + .await; + assert_eq!(taken.status, 400); + + let revert = post_form_with_cookie( + &server, + "/settings/username", + &cookie, + &format!("csrf={csrf}&new_username=original&password=very%20secure%20password"), + ) + .await; + assert_eq!(revert.status, 303); + let profile = get_with_cookie(&server, "/users/original", &cookie).await; + assert!(profile.body.contains("Previously known as @ReNamed")); + assert_eq!( + count_rows( + &server, + "SELECT COUNT(*) FROM username_history WHERE normalized_username = 'original'" + ) + .await, + 0 + ); } - async fn get_with_cookie(server: &TestServer, path: &str, cookie: &str) -> TestResponse { - request( - &server.base_url, - "GET", - path, - &[("cookie", cookie)], - Vec::new(), + #[expect( + clippy::too_many_lines, + reason = "one end-to-end scenario: export from one disposable instance and import into another" + )] + #[tokio::test] + async fn account_archives_round_trip_over_http_and_respect_protected_follows() { + let source = spawn_test_server().await; + let source_cookie = register_test_user(&source, "mover").await; + create_text_post(&source, &source_cookie, "portable post body").await; + let _protector_cookie = register_test_user(&source, "protector").await; + let protector_id = user_id_for(&source, "protector").await; + let protector_view = get_with_cookie(&source, "/users/protector", &source_cookie).await; + let csrf = csrf_token(&protector_view.body); + let followed = post_form_with_cookie( + &source, + &format!("/users/{protector_id}/follow"), + &source_cookie, + &format!("csrf={csrf}"), ) - .await + .await; + assert_eq!(followed.status, 303); + + let export = get_with_cookie(&source, "/settings/export", &source_cookie).await; + assert_eq!(export.status, 200); + assert!( + header_value(&export, "content-disposition") + .expect("disposition") + .contains("attachment") + ); + assert_eq!(&export.body_bytes[..2], &[0x1f, 0x8b]); + + let destination = spawn_test_server().await; + let receiver_cookie = register_test_user(&destination, "receiver").await; + let protector_cookie = register_test_user(&destination, "protector").await; + let saved = save_profile_settings( + &destination, + &protector_cookie, + &[("follow_approval_required", "true")], + ) + .await; + assert_eq!(saved.status, 303); + + let import_page = get_with_cookie(&destination, "/settings/import", &receiver_cookie).await; + assert!(import_page.body.contains(r#"name="archive""#)); + let csrf = csrf_token(&import_page.body); + let body = multipart_body_with_file( + "archive-boundary", + &[("csrf", csrf.as_str())], + "archive", + "account.tar.gz", + "application/gzip", + &export.body_bytes, + ); + let imported = request( + &destination.base_url, + "POST", + "/settings/import", + &[ + ("cookie", receiver_cookie.as_str()), + ( + "content-type", + "multipart/form-data; boundary=archive-boundary", + ), + ], + body, + ) + .await; + assert_eq!(imported.status, 200); + assert!(imported.body.contains("Import complete")); + assert_eq!( + count_rows( + &destination, + "SELECT COUNT(*) FROM posts WHERE text = 'portable post body'" + ) + .await, + 1 + ); + assert_eq!( + count_rows(&destination, "SELECT COUNT(*) FROM follow_requests").await, + 1, + "protected accounts must receive a pending request instead of a follow" + ); + assert_eq!( + count_rows(&destination, "SELECT COUNT(*) FROM follows").await, + 0 + ); + + let import_page = get_with_cookie(&destination, "/settings/import", &receiver_cookie).await; + let csrf = csrf_token(&import_page.body); + let body = multipart_body_with_file( + "archive-boundary", + &[("csrf", csrf.as_str())], + "archive", + "account.tar.gz", + "application/gzip", + &export.body_bytes, + ); + let repeated = request( + &destination.base_url, + "POST", + "/settings/import", + &[ + ("cookie", receiver_cookie.as_str()), + ( + "content-type", + "multipart/form-data; boundary=archive-boundary", + ), + ], + body, + ) + .await; + assert_eq!(repeated.status, 400); + assert!(repeated.body.contains("already been imported")); } - async fn liked_profile_fixture() -> (TestServer, String, String) { - let server = spawn_test_server().await; - let alice_cookie = register_test_user(&server, "alice").await; - create_text_post(&server, &alice_cookie, "privacy target post").await; - let bob_cookie = register_test_user(&server, "bob").await; - create_text_post(&server, &bob_cookie, "bob fallback post").await; - let bob_home = get_with_cookie(&server, "/home", &bob_cookie).await; - let csrf = csrf_token(&bob_home.body); - let liked = post_form_with_cookie( + #[tokio::test] + async fn renames_and_deletions_keep_follow_requests_consistent() { + let (server, alice_cookie, bob_cookie, alice_id) = protected_account_fixture().await; + let bob_id = user_id_for(&server, "bob").await; + + let before = get_with_cookie(&server, "/users/alice", &bob_cookie).await; + let csrf = csrf_token(&before.body); + post_form_with_cookie( &server, - "/posts/1/like", + &format!("/users/{alice_id}/follow"), &bob_cookie, &format!("csrf={csrf}"), ) .await; - assert_eq!(liked.status, 303); - (server, alice_cookie, bob_cookie) - } - async fn post_form_with_cookie( - server: &TestServer, - path: &str, - cookie: &str, - body: &str, - ) -> TestResponse { - request( + // Renaming the requester keeps the request addressed to the same account. + let settings = get_with_cookie(&server, "/settings", &bob_cookie).await; + let csrf = csrf_token(&settings.body); + let renamed = post_form_with_cookie( + &server, + "/settings/username", + &bob_cookie, + &format!("csrf={csrf}&new_username=robert&password=very%20secure%20password"), + ) + .await; + assert_eq!(renamed.status, 303); + let requests = get_with_cookie(&server, "/follow-requests", &alice_cookie).await; + assert!(requests.body.contains("@robert")); + assert!(!requests.body.contains("@bob")); + let csrf = csrf_token(&requests.body); + let approved = post_form_with_cookie( + &server, + &format!("/users/{bob_id}/follow/approve"), + &alice_cookie, + &format!("csrf={csrf}"), + ) + .await; + assert_eq!(approved.status, 303); + assert_eq!(count_rows(&server, "SELECT COUNT(*) FROM follows").await, 1); + + // Deleting the requester removes their pending requests and history. + server + .pool + .call(move |conn| { + conn.execute( + "UPDATE users SET deletion_requested_at = datetime('now','-31 days'), deletion_scheduled_at = datetime('now','-1 day') WHERE id = ?", + [bob_id], + )?; + conn.execute( + "INSERT INTO follow_requests (requester_id, target_id) VALUES (?, ?)", + params![bob_id, alice_id], + )?; + Ok(()) + }) + .await + .expect("schedule requester deletion"); + let paths = RuntimePaths::from_data_dir(server.data_dir.clone()); + let removed = crate::account::finalize_due_deletions(&server.pool, &paths) + .await + .expect("finalize"); + assert_eq!(removed, 1); + assert_eq!( + count_rows(&server, "SELECT COUNT(*) FROM follow_requests").await, + 0 + ); + assert_eq!( + count_rows( + &server, + "SELECT COUNT(*) FROM username_history WHERE normalized_username IN ('bob', 'robert')" + ) + .await, + 0 + ); + let reused = request( &server.base_url, "POST", - path, - &[ - ("cookie", cookie), - ("content-type", "application/x-www-form-urlencoded"), - ], - body.as_bytes().to_vec(), + "/register", + &[("content-type", "application/x-www-form-urlencoded")], + b"username=robert&password=very%20secure%20password&confirm_password=very%20secure%20password".to_vec(), ) - .await + .await; + assert_eq!(reused.status, 303); } - async fn save_profile_settings( - server: &TestServer, - cookie: &str, - fields: &[(&str, &str)], - ) -> TestResponse { - let settings = get_with_cookie(server, "/settings", cookie).await; - let csrf = csrf_token(&settings.body); - let mut all_fields = Vec::with_capacity(fields.len() + 1); - all_fields.push(("csrf", csrf.as_str())); - all_fields.extend_from_slice(fields); - request( + #[tokio::test] + async fn maintenance_mode_blocks_account_imports() { + let server = spawn_test_server_with_admin().await; + let member_cookie = register_test_user(&server, "member").await; + let admin_cookie = admin_session_cookie(&server).await; + let dashboard = get_with_cookie(&server, "/admin", &admin_cookie).await; + let csrf = csrf_token(&dashboard.body); + post_form_with_cookie( + &server, + "/admin/maintenance", + &admin_cookie, + &format!("csrf={}&enabled=true", form_encode(&csrf)), + ) + .await; + + let page = get_with_cookie(&server, "/settings/import", &member_cookie).await; + assert_eq!(page.status, 200); + assert!(page.body.contains("maintenance mode")); + + let csrf = csrf_token(&page.body); + let body = multipart_body_with_file( + "import-blocked", + &[("csrf", csrf.as_str())], + "archive", + "account.tar.gz", + "application/gzip", + &[0x1f, 0x8b, 0x08, 0x00], + ); + let blocked = request( &server.base_url, "POST", - "/settings", + "/settings/import", &[ - ("cookie", cookie), + ("cookie", member_cookie.as_str()), ( "content-type", - "multipart/form-data; boundary=settings-boundary", + "multipart/form-data; boundary=import-blocked", ), ], - multipart_body("settings-boundary", &all_fields, false), + body, ) - .await + .await; + assert_eq!(blocked.status, 503); } - async fn user_settings_state( - server: &TestServer, - username: &str, - ) -> (String, i64, i64, String, String, String, String) { - let username = username.to_owned(); - server - .pool - .call(move |conn| { - Ok(conn.query_row( - r#" - SELECT theme, nsfw_blur_enabled, liked_posts_public, - display_name, bio, location, website - FROM users WHERE normalized_username = ? - "#, - [username], - |row| { - Ok(( - row.get(0)?, - row.get(1)?, - row.get(2)?, - row.get(3)?, - row.get(4)?, - row.get(5)?, - row.get(6)?, - )) - }, - )?) - }) - .await - .expect("user settings state") + async fn enable_maintenance(server: &TestServer, admin_cookie: &str, message: &str) { + let dashboard = get_with_cookie(server, "/admin", admin_cookie).await; + assert_eq!(dashboard.status, 200); + let csrf = csrf_token(&dashboard.body); + let body = format!( + "csrf={}&enabled=true&message={}", + form_encode(&csrf), + form_encode(message) + ); + let enabled = + post_form_with_cookie(server, "/admin/maintenance", admin_cookie, &body).await; + assert_eq!(enabled.status, 303); } - async fn create_text_post(server: &TestServer, cookie: &str, text: &str) { - let home = get_with_cookie(server, "/home", cookie).await; - let csrf = csrf_token(&home.body); - let posted = request( + async fn disable_maintenance(server: &TestServer, admin_cookie: &str) { + let dashboard = get_with_cookie(server, "/admin", admin_cookie).await; + let csrf = csrf_token(&dashboard.body); + let body = format!("csrf={}&intent=save", form_encode(&csrf)); + let disabled = + post_form_with_cookie(server, "/admin/maintenance", admin_cookie, &body).await; + assert_eq!(disabled.status, 303); + } + + async fn login_test_user(server: &TestServer, username: &str, password: &str) -> TestResponse { + request( &server.base_url, "POST", - "/posts", - &[ - ("cookie", cookie), - ( - "content-type", - "multipart/form-data; boundary=post-boundary", - ), - ], - multipart_body( - "post-boundary", - &[("csrf", csrf.as_str()), ("text", text)], - false, - ), + "/login", + &[("content-type", "application/x-www-form-urlencoded")], + format!( + "username={}&password={}", + form_encode(username), + form_encode(password) + ) + .into_bytes(), + ) + .await + } + + async fn flag_password_reset(server: &TestServer, admin_cookie: &str, user_id: i64) { + let users = get_with_cookie(server, "/admin/users", admin_cookie).await; + let csrf = csrf_token(&users.body); + let reset = post_form_with_cookie( + server, + &format!("/admin/users/{user_id}/require-password-reset"), + admin_cookie, + &format!("csrf={csrf}"), ) .await; - assert_eq!(posted.status, 303); + assert_eq!(reset.status, 303); } - async fn set_post_created_seconds_ago(server: &TestServer, post_id: i64, seconds: i64) { - let modifier = format!("-{seconds} seconds"); - server - .pool - .call(move |conn| { - conn.execute( - "UPDATE posts SET created_at = datetime('now', ?), edited_at = NULL WHERE id = ?", - params![modifier, post_id], - )?; - Ok(()) - }) - .await - .expect("set post age"); + #[test] + fn maintenance_policy_covers_every_publish_route() { + use axum::http::Method; + + let cases = [ + (Method::GET, "/home", MaintenancePolicy::Allowed), + (Method::GET, "/posts/1", MaintenancePolicy::Allowed), + (Method::GET, "/settings/import", MaintenancePolicy::Allowed), + (Method::POST, "/register", MaintenancePolicy::Blocked), + ( + Method::POST, + "/posts", + MaintenancePolicy::BlockedUnlessAdmin, + ), + ( + Method::POST, + "/settings/import", + MaintenancePolicy::BlockedUnlessAdmin, + ), + ( + Method::POST, + "/posts/1/quote", + MaintenancePolicy::BlockedUnlessAdmin, + ), + ( + Method::POST, + "/posts/1/repost", + MaintenancePolicy::BlockedUnlessAdmin, + ), + ( + Method::POST, + "/posts/1/edit", + MaintenancePolicy::BlockedUnlessAdmin, + ), + (Method::POST, "/posts/1/delete", MaintenancePolicy::Allowed), + (Method::POST, "/posts/1/like", MaintenancePolicy::Allowed), + ( + Method::POST, + "/posts/1/bookmark", + MaintenancePolicy::Allowed, + ), + (Method::POST, "/posts/1/pin", MaintenancePolicy::Allowed), + (Method::POST, "/posts/1/reply", MaintenancePolicy::Allowed), + (Method::POST, "/users/1/follow", MaintenancePolicy::Allowed), + (Method::POST, "/users/1/block", MaintenancePolicy::Allowed), + (Method::POST, "/users/1/mute", MaintenancePolicy::Allowed), + (Method::POST, "/settings", MaintenancePolicy::Allowed), + ( + Method::POST, + "/settings/password", + MaintenancePolicy::Allowed, + ), + ( + Method::POST, + "/settings/username", + MaintenancePolicy::Allowed, + ), + ( + Method::POST, + "/settings/delete/confirm", + MaintenancePolicy::Allowed, + ), + ( + Method::POST, + "/settings/delete/cancel", + MaintenancePolicy::Allowed, + ), + (Method::POST, "/logout", MaintenancePolicy::Allowed), + ( + Method::POST, + "/notifications/read", + MaintenancePolicy::Allowed, + ), + ( + Method::POST, + "/notifications/open", + MaintenancePolicy::Allowed, + ), + ( + Method::POST, + "/admin/maintenance", + MaintenancePolicy::Allowed, + ), + ( + Method::POST, + "/admin/users/1/revoke-sessions", + MaintenancePolicy::Allowed, + ), + (Method::POST, "/onboarding", MaintenancePolicy::Allowed), + ]; + for (method, path, expected) in cases { + assert_eq!( + maintenance_policy(&method, path), + expected, + "unexpected policy for {method} {path}" + ); + } } - fn assert_populated_notifications_page(body: &str) { - assert!(body.contains("4 unread notifications")); - assert!( - body.contains( - r#"4"# + #[tokio::test] + async fn maintenance_mode_matrix_blocks_publishing_and_keeps_account_operations() { + let server = spawn_test_server_with_admin().await; + let member_cookie = register_test_user(&server, "member").await; + create_text_post(&server, &member_cookie, "member original post").await; + let admin_cookie = admin_session_cookie(&server).await; + create_text_post(&server, &admin_cookie, "admin original post").await; + + enable_maintenance(&server, &admin_cookie, "Matrix maintenance").await; + + // Publish mutations are refused for members and never touch the data. + let home = get_with_cookie(&server, "/home", &member_cookie).await; + let csrf = csrf_token(&home.body); + for path in ["/posts/1/edit", "/posts/1/quote", "/posts/1/repost"] { + let blocked = post_multipart_with_cookie( + &server, + path, + &member_cookie, + "maintenance-matrix", + &[ + ("csrf", csrf.as_str()), + ("text", "blocked during maintenance"), + ], + ) + .await; + assert_eq!(blocked.status, 503, "{path} should be blocked"); + assert!(blocked.body.contains("Matrix maintenance")); + } + assert_eq!( + count_rows( + &server, + "SELECT COUNT(*) FROM posts WHERE text = 'blocked during maintenance'" ) + .await, + 0 ); - assert!(body.contains(r#"

    New

    "#)); assert_eq!( - body.matches(r#"class="notification-row unread""#).count(), - 4 + count_rows( + &server, + "SELECT COUNT(*) FROM posts WHERE id = 1 AND text = 'member original post'" + ) + .await, + 1, + "the edit must not have changed the stored post" ); - assert!(body.contains(">bob @bob")); - assert!(body.contains("replied to your post")); - assert!(body.contains("liked your post")); - assert!(body.contains("reposted your post")); - assert!(body.contains("followed you")); - assert!(body.contains("alice original post")); - assert!(body.contains(r#"data-card-form="notification-open-"#)); - assert!(body.contains(r#"name="return_to" value="/posts/1""#)); - assert!(body.contains(r#"name="return_to" value="/posts/2""#)); - assert!(body.contains(r#"name="return_to" value="/users/bob""#)); - } - fn assert_empty_state(body: &str, title: &str, message: &str) { - assert!(body.contains(r#"class="empty-state" data-testid="empty-state""#)); - assert!(body.contains(&format!("

    {title}

    "))); - if message.is_empty() { - assert!(!body.contains(r#"

    "#)); - } else { - assert!(body.contains(&format!("

    {message}

    "))); + // Account and security operations keep working for members. + let saved = + save_profile_settings(&server, &member_cookie, &[("display_name", "Member")]).await; + assert_eq!(saved.status, 303); + let settings = get_with_cookie(&server, "/settings", &member_cookie).await; + let csrf = csrf_token(&settings.body); + let renamed = post_form_with_cookie( + &server, + "/settings/username", + &member_cookie, + &format!("csrf={csrf}&new_username=member_renamed&password=very%20secure%20password"), + ) + .await; + assert_eq!(renamed.status, 303); + let export = get_with_cookie(&server, "/settings/export", &member_cookie).await; + assert_eq!(export.status, 200); + let settings = get_with_cookie(&server, "/settings", &member_cookie).await; + let csrf = csrf_token(&settings.body); + let cancelled = post_form_with_cookie( + &server, + "/settings/delete/cancel", + &member_cookie, + &format!("csrf={csrf}"), + ) + .await; + assert_eq!(cancelled.status, 303); + for path in ["/posts/1/like", "/posts/1/bookmark"] { + let response = + post_form_with_cookie(&server, path, &member_cookie, &format!("csrf={csrf}")).await; + assert_eq!(response.status, 303, "{path} should stay available"); } - } + let reply_redirect = post_form_with_cookie( + &server, + "/posts/1/reply", + &member_cookie, + &format!("csrf={csrf}"), + ) + .await; + assert_eq!(reply_redirect.status, 303); - fn quote_form_body(response: &TestResponse, text: &str) -> String { - let csrf = csrf_token(&response.body); - format!("csrf={}&text={}", form_encode(&csrf), form_encode(text)) + // Administrators keep publishing so they can verify the instance. + let admin_csrf = csrf_token(&get_with_cookie(&server, "/home", &admin_cookie).await.body); + let admin_edit = post_form_with_cookie( + &server, + "/posts/2/edit", + &admin_cookie, + &format!( + "csrf={admin_csrf}&text={}", + form_encode("admin edit during maintenance") + ), + ) + .await; + assert_eq!(admin_edit.status, 303); + let admin_repost = post_form_with_cookie( + &server, + "/posts/1/repost", + &admin_cookie, + &format!("csrf={admin_csrf}"), + ) + .await; + assert_eq!(admin_repost.status, 303); + + disable_maintenance(&server, &admin_cookie).await; + create_text_post(&server, &member_cookie, "member post after maintenance").await; } - async fn spawn_test_server() -> TestServer { - spawn_test_server_inner(false, Settings::default()).await + #[tokio::test] + #[expect( + clippy::too_many_lines, + reason = "one table-driven forced-reset route matrix keeps every protected and exempt route visible" + )] + async fn forced_password_reset_blocks_protected_routes_and_keeps_exempt_flows() { + let server = spawn_test_server_with_admin().await; + let member_cookie = register_test_user(&server, "member").await; + create_text_post(&server, &member_cookie, "before forced reset").await; + let member_id = user_id_for(&server, "member").await; + let second_cookie = + session_cookie(&login_test_user(&server, "member", "very secure password").await); + let stale_cookie = + session_cookie(&login_test_user(&server, "member", "very secure password").await); + let admin_cookie = admin_session_cookie(&server).await; + flag_password_reset(&server, &admin_cookie, member_id).await; + + // Every protected GET route redirects to the password page. + for path in [ + "/home", + "/settings", + "/notifications", + "/following", + "/bookmarks", + "/mentions", + "/search?q=reset", + "/tags/reset", + "/posts/1", + "/users/member", + "/follow-requests", + "/settings/import", + "/onboarding", + "/admin", + "/admin/users", + ] { + for cookie in [&member_cookie, &second_cookie] { + let response = get_with_cookie(&server, path, cookie).await; + assert_eq!(response.status, 303, "GET {path} should redirect"); + assert!( + location(&response).starts_with("/settings/password?required=1"), + "GET {path} redirected to {}", + location(&response) + ); + } + } + + // Protected state-changing routes are intercepted before their handlers. + for path in [ + "/settings", + "/settings/username", + "/settings/muted-words", + "/posts/1/edit", + "/posts/1/delete", + "/posts/1/like", + "/posts/1/bookmark", + "/posts/1/pin", + "/posts/1/repost", + "/posts/1/quote", + "/users/1/follow", + "/users/1/block", + "/users/1/mute", + "/notifications/read", + "/notifications/open", + "/settings/import", + ] { + let response = post_form_with_cookie(&server, path, &member_cookie, "csrf=bogus").await; + assert_eq!(response.status, 303, "POST {path} should redirect"); + assert!(location(&response).starts_with("/settings/password?required=1")); + } + + // Exempt account flows stay reachable. + let password_page = get_with_cookie(&server, "/settings/password", &member_cookie).await; + assert_eq!(password_page.status, 200); + assert!(password_page.body.contains(r#"id="current_password""#)); + assert_eq!( + get_with_cookie(&server, "/settings/delete", &member_cookie) + .await + .status, + 200 + ); + assert_eq!( + get_with_cookie(&server, "/settings/delete/confirm", &member_cookie) + .await + .status, + 200 + ); + assert_eq!( + get_with_cookie(&server, "/account-deleted", &member_cookie) + .await + .status, + 200 + ); + assert_eq!( + get_with_cookie(&server, "/settings/export", &second_cookie) + .await + .status, + 200 + ); + assert_eq!( + request( + &server.base_url, + "GET", + "/assets/rustpost.js", + &[], + Vec::new() + ) + .await + .status, + 200 + ); + assert_eq!( + request(&server.base_url, "GET", "/favicon.ico", &[], Vec::new()) + .await + .status, + 200 + ); + + // A stale cookie is treated as anonymous, not as a bypass. + let stale = get_with_cookie(&server, "/home", "rustpost_session=bogus").await; + assert_eq!(stale.status, 200); + + // Logout stays reachable; it revokes only the session it is sent with. + let member_password_page = + get_with_cookie(&server, "/settings/password", &member_cookie).await; + let member_csrf = csrf_token(&member_password_page.body); + let logout = post_form_with_cookie( + &server, + "/logout", + &member_cookie, + &format!("csrf={member_csrf}"), + ) + .await; + assert_eq!(logout.status, 303); + + // Changing the password clears the flag atomically and revokes the + // other sessions; the session that changed the password survives. + let page = get_with_cookie(&server, "/settings/password", &second_cookie).await; + let csrf = csrf_token(&page.body); + let changed = post_form_with_cookie( + &server, + "/settings/password", + &second_cookie, + &format!( + "csrf={csrf}¤t_password=very%20secure%20password&new_password=brand%20new%20password&confirm_new_password=brand%20new%20password" + ), + ) + .await; + assert_eq!(changed.status, 303); + assert_eq!(location(&changed), "/settings?saved=password"); + assert_eq!( + count_rows( + &server, + "SELECT COUNT(*) FROM users WHERE must_change_password = 1" + ) + .await, + 0 + ); + assert_eq!( + get_with_cookie(&server, "/home", &second_cookie) + .await + .status, + 200 + ); + let third_cookie = + session_cookie(&login_test_user(&server, "member", "brand new password").await); + assert_eq!( + get_with_cookie(&server, "/settings", &third_cookie) + .await + .status, + 200 + ); + // Sessions created before the password change are gone. + let revoked = get_with_cookie(&server, "/settings", &stale_cookie).await; + assert_eq!(revoked.status, 401); } - async fn spawn_test_server_with_admin() -> TestServer { - spawn_test_server_inner(true, Settings::default()).await - } + #[tokio::test] + async fn forced_logout_revokes_all_sessions_and_allows_relogin() { + let server = spawn_test_server_with_admin().await; + let first_cookie = register_test_user(&server, "victim").await; + let second_cookie = + session_cookie(&login_test_user(&server, "victim", "very secure password").await); + let victim_id = user_id_for(&server, "victim").await; + let other_cookie = register_test_user(&server, "bystander").await; - async fn spawn_test_server_with_settings(settings: Settings) -> TestServer { - spawn_test_server_inner(false, settings).await - } + assert_eq!( + get_with_cookie(&server, "/settings", &second_cookie) + .await + .status, + 200 + ); - async fn spawn_test_server_inner(create_admin: bool, settings: Settings) -> TestServer { - let temp = tempfile::tempdir().expect("temp dir"); - let paths = RuntimePaths::from_data_dir(temp.path().to_path_buf()) - .with_tor_data_dir(&settings.tor.data_dir) - .with_backup_dir(&settings.backup.backup_dir); - paths.ensure().expect("paths"); - crate::config::write_default_if_missing(&paths.settings_path).expect("settings"); - std::fs::write( - &paths.settings_path, - toml::to_string(&settings).expect("serialize settings"), + let admin_cookie = admin_session_cookie(&server).await; + let users = get_with_cookie(&server, "/admin/users", &admin_cookie).await; + let csrf = csrf_token(&users.body); + let revoked = post_form_with_cookie( + &server, + &format!("/admin/users/{victim_id}/revoke-sessions"), + &admin_cookie, + &format!("csrf={csrf}"), ) - .expect("write test settings"); - let data_dir = paths.data_dir.clone(); - let pool = crate::db::connect(&paths.database_path) - .await - .expect("connect"); - crate::db::migrate(&pool).await.expect("migrate"); - if create_admin { - crate::admin::create_admin(&pool, &settings, "siteowner", "very secure password") - .await - .expect("admin"); + .await; + assert_eq!(revoked.status, 303); + + for cookie in [&first_cookie, &second_cookie] { + let response = get_with_cookie(&server, "/settings", cookie).await; + assert_eq!( + response.status, 401, + "revoked session must not authenticate" + ); } - let ffmpeg = FfmpegStatus { - available: false, - version: String::new(), - supports_webp: false, - supports_vp9: false, - error: Some("disabled in tests".to_owned()), + assert_eq!( + get_with_cookie(&server, "/settings", &other_cookie) + .await + .status, + 200, + "other accounts keep their sessions" + ); + let victim_sessions = { + server + .pool + .call(move |conn| { + Ok(conn.query_row( + "SELECT COUNT(*) FROM sessions WHERE user_id = ? AND revoked_at IS NULL", + [victim_id], + |row| row.get::<_, i64>(0), + )?) + }) + .await + .expect("victim sessions") }; - let tor = crate::tor::validate_startup(&settings.tor); - let state = AppState::new(pool.clone(), settings, paths, ffmpeg, tor); - let registration_captcha = state.registration_captcha.clone(); - let app = router(state); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind"); - let addr = listener.local_addr().expect("addr"); - let task = tokio::spawn(async move { - axum::serve( - listener, - app.into_make_service_with_connect_info::(), - ) - .await - .expect("serve"); - }); - TestServer { - base_url: format!("127.0.0.1:{}", addr.port()), - data_dir, - pool, - registration_captcha, - _task: task, - _temp: temp, - } - } + assert_eq!(victim_sessions, 0); - async fn admin_session_cookie(server: &TestServer) -> String { - let login = request( - &server.base_url, - "POST", - "/login", - &[("content-type", "application/x-www-form-urlencoded")], - b"username=siteowner&password=very%20secure%20password".to_vec(), + // The account can log in again and a repeated forced logout is safe. + let third_cookie = + session_cookie(&login_test_user(&server, "victim", "very secure password").await); + assert_eq!( + get_with_cookie(&server, "/settings", &third_cookie) + .await + .status, + 200 + ); + let users = get_with_cookie(&server, "/admin/users", &admin_cookie).await; + let csrf = csrf_token(&users.body); + let repeated = post_form_with_cookie( + &server, + &format!("/admin/users/{victim_id}/revoke-sessions"), + &admin_cookie, + &format!("csrf={csrf}"), ) .await; - assert_eq!(login.status, 303); - session_cookie(&login) + assert_eq!(repeated.status, 303); + assert_eq!( + get_with_cookie(&server, "/settings", &third_cookie) + .await + .status, + 401 + ); + + // Missing accounts are rejected without touching sessions. + let missing = post_form_with_cookie( + &server, + "/admin/users/9999/revoke-sessions", + &admin_cookie, + &format!("csrf={csrf}"), + ) + .await; + assert_eq!(missing.status, 400); } - async fn request( - base_url: &str, - method: &str, - path: &str, - headers: &[(&str, &str)], - body: Vec, - ) -> TestResponse { - let mut stream = tokio::net::TcpStream::connect(base_url) - .await - .expect("connect"); - let mut request = format!( - "{method} {path} HTTP/1.1\r\nHost: {base_url}\r\nConnection: close\r\nContent-Length: {}\r\n", - body.len() + #[tokio::test] + #[expect( + clippy::too_many_lines, + reason = "one transition scenario walks public/protected toggles, pending requests, and follower counts" + )] + async fn protected_account_toggles_never_auto_approve_pending_requests() { + let mut settings = Settings::default(); + settings.moderation.account_creations_per_ip_per_day = 20; + let server = spawn_test_server_with_settings(settings).await; + let alice_cookie = register_test_user(&server, "alice").await; + let alice_id = user_id_for(&server, "alice").await; + let saved = save_profile_settings( + &server, + &alice_cookie, + &[("follow_approval_required", "true")], + ) + .await; + assert_eq!(saved.status, 303); + let bob_cookie = register_test_user(&server, "bob").await; + let bob_id = user_id_for(&server, "bob").await; + let carol_cookie = register_test_user(&server, "carol").await; + + // Bob requests; the pending request is not a follower and shows a badge. + let profile = get_with_cookie(&server, "/users/alice", &bob_cookie).await; + let csrf = csrf_token(&profile.body); + let requested = post_form_with_cookie( + &server, + &format!("/users/{alice_id}/follow"), + &bob_cookie, + &format!("csrf={csrf}"), + ) + .await; + assert_eq!(requested.status, 303); + assert_eq!(count_rows(&server, "SELECT COUNT(*) FROM follows").await, 0); + let alice_home = get_with_cookie(&server, "/home", &alice_cookie).await; + assert!( + alice_home + .body + .contains(r#"aria-label="1 pending follow requests""#), + "the pending request must show in the navigation badge" ); - for (name, value) in headers { - request.push_str(name); - request.push_str(": "); - request.push_str(value); - request.push_str("\r\n"); - } - request.push_str("\r\n"); - stream - .write_all(request.as_bytes()) - .await - .expect("write headers"); - stream.write_all(&body).await.expect("write body"); - let mut bytes = Vec::new(); - stream.read_to_end(&mut bytes).await.expect("read"); - parse_response(&bytes) - } + let alice_profile = get_with_cookie(&server, "/users/alice", &carol_cookie).await; + assert!(alice_profile.body.contains("0 followers")); - fn parse_response(bytes: &[u8]) -> TestResponse { - let split = bytes - .windows(4) - .position(|window| window == b"\r\n\r\n") - .expect("response split"); - let head = String::from_utf8_lossy(&bytes[..split]); - let body_bytes = bytes[split + 4..].to_vec(); - let body = String::from_utf8_lossy(&body_bytes).into_owned(); - let mut lines = head.lines(); - let status = lines - .next() - .and_then(|line| line.split_whitespace().nth(1)) - .and_then(|value| value.parse::().ok()) - .expect("status"); - let headers = lines - .filter_map(|line| { - let (name, value) = line.split_once(':')?; - Some((name.to_ascii_lowercase(), value.trim().to_owned())) - }) - .collect(); - TestResponse { - status, - headers, - body_bytes, - body, - } - } + // Disabling protection keeps the pending request pending and lets new + // follows through immediately. Nothing is auto-approved. + let saved = save_profile_settings( + &server, + &alice_cookie, + &[("display_name", "Alice Unprotected")], + ) + .await; + assert_eq!(saved.status, 303); + assert_eq!( + count_rows(&server, "SELECT COUNT(*) FROM follow_requests").await, + 1 + ); + assert_eq!(count_rows(&server, "SELECT COUNT(*) FROM follows").await, 0); + let carol_view = get_with_cookie(&server, "/users/alice", &carol_cookie).await; + let csrf = csrf_token(&carol_view.body); + let carol_follow = post_form_with_cookie( + &server, + &format!("/users/{alice_id}/follow"), + &carol_cookie, + &format!("csrf={csrf}"), + ) + .await; + assert_eq!(carol_follow.status, 303); + assert_eq!(count_rows(&server, "SELECT COUNT(*) FROM follows").await, 1); + assert_eq!( + count_rows(&server, "SELECT COUNT(*) FROM follow_requests").await, + 1, + "the pending request must survive the privacy change" + ); - fn session_cookie(response: &TestResponse) -> String { - response - .headers - .iter() - .find(|(name, _)| name == "set-cookie") - .map(|(_, value)| value.split(';').next().unwrap_or_default().to_owned()) - .expect("session cookie") - } + // Re-enabling protection keeps the existing follower and the pending + // request; a new account still needs approval. + let saved = save_profile_settings( + &server, + &alice_cookie, + &[ + ("display_name", "Alice Protected"), + ("follow_approval_required", "true"), + ], + ) + .await; + assert_eq!(saved.status, 303); + assert_eq!(count_rows(&server, "SELECT COUNT(*) FROM follows").await, 1); + assert_eq!( + count_rows(&server, "SELECT COUNT(*) FROM follow_requests").await, + 1 + ); + let dave_cookie = register_test_user(&server, "dave").await; + let dave_view = get_with_cookie(&server, "/users/alice", &dave_cookie).await; + let csrf = csrf_token(&dave_view.body); + let dave_follow = post_form_with_cookie( + &server, + &format!("/users/{alice_id}/follow"), + &dave_cookie, + &format!("csrf={csrf}"), + ) + .await; + assert_eq!(dave_follow.status, 303); + assert_eq!(count_rows(&server, "SELECT COUNT(*) FROM follows").await, 1); + assert_eq!( + count_rows(&server, "SELECT COUNT(*) FROM follow_requests").await, + 2 + ); - fn location(response: &TestResponse) -> &str { - response - .headers - .iter() - .find(|(name, _)| name == "location") - .map(|(_, value)| value.as_str()) - .expect("location") + // Approving Bob after all toggles creates exactly one follow. + let requests = get_with_cookie(&server, "/follow-requests", &alice_cookie).await; + let csrf = csrf_token(&requests.body); + let approved = post_form_with_cookie( + &server, + &format!("/users/{bob_id}/follow/approve"), + &alice_cookie, + &format!("csrf={csrf}"), + ) + .await; + assert_eq!(approved.status, 303); + assert_eq!(count_rows(&server, "SELECT COUNT(*) FROM follows").await, 2); + assert_eq!( + count_rows(&server, "SELECT COUNT(*) FROM follow_requests").await, + 1 + ); + let alice_profile = get_with_cookie(&server, "/users/alice", &carol_cookie).await; + assert!(alice_profile.body.contains("2 followers")); } - fn assert_header(response: &TestResponse, name: &str, expected: &str) { - assert_eq!(header_value(response, name), Some(expected)); - } + #[tokio::test] + async fn released_username_tombstones_cover_old_urls_before_and_after_reuse() { + let server = spawn_test_server_without_deletion_grace().await; + let alice_cookie = register_test_user(&server, "tombstone_alice").await; + create_text_post(&server, &alice_cookie, "alice tombstone post").await; - fn assert_no_header(response: &TestResponse, name: &str) { - assert_eq!(header_value(response, name), None); - } + let settings = get_with_cookie(&server, "/settings", &alice_cookie).await; + let csrf = csrf_token(&settings.body); + let renamed = post_form_with_cookie( + &server, + "/settings/username", + &alice_cookie, + &format!( + "csrf={csrf}&new_username=tombstone_alice_new&password=very%20secure%20password" + ), + ) + .await; + assert_eq!(renamed.status, 303); - fn assert_vary_contains_accept_encoding(response: &TestResponse) { - let vary = header_value(response, "vary").expect("vary header"); + // While the account is alive the old URL is a history page. + let old_url = request( + &server.base_url, + "GET", + "/users/tombstone_alice", + &[], + Vec::new(), + ) + .await; + assert_eq!(old_url.status, 200); + assert!(old_url.body.contains("No account uses @tombstone_alice")); assert!( - vary.split(',') - .any(|part| part.trim().eq_ignore_ascii_case("accept-encoding")), - "Vary header should include Accept-Encoding: {vary}" + old_url + .body + .contains(r#"href="/users/tombstone_alice_new""#) ); - } - fn header_value<'a>(response: &'a TestResponse, name: &str) -> Option<&'a str> { - response - .headers - .iter() - .find(|(header_name, _)| header_name == name) - .map(|(_, value)| value.as_str()) - } + // Immediate deletion releases the handles and records tombstones. + let confirm = get_with_cookie(&server, "/settings/delete/confirm", &alice_cookie).await; + let csrf = csrf_token(&confirm.body); + let intent = hidden_value(&confirm.body, "delete_intent"); + let deleted = post_form_with_cookie( + &server, + "/settings/delete/confirm", + &alice_cookie, + &format!("csrf={csrf}&delete_intent={intent}&password=very%20secure%20password"), + ) + .await; + assert_eq!(deleted.status, 303); + assert_eq!(location(&deleted), "/account-deleted"); - fn content_length(response: &TestResponse) -> Option { - header_value(response, "content-length")?.parse().ok() + for handle in ["tombstone_alice", "tombstone_alice_new"] { + let page = request( + &server.base_url, + "GET", + &format!("/users/{handle}"), + &[], + Vec::new(), + ) + .await; + assert_eq!(page.status, 200, "{handle} should render a tombstone"); + assert!( + page.body.contains(r#"data-testid="released-username""#), + "{handle} should not silently disappear" + ); + assert!(!page.body.contains("alice tombstone post")); + } + + // A new account can claim the released handle, and the old URL then + // clearly marks it as a different account. + let bob_cookie = register_test_user(&server, "tombstone_alice_new").await; + let profile = get_with_cookie(&server, "/users/tombstone_alice_new", &bob_cookie).await; + assert_eq!(profile.status, 200); + assert!( + profile + .body + .contains(r#"data-testid="released-username-note""#), + "a reclaimed handle must disclose the earlier deleted account" + ); + assert!(!profile.body.contains("alice tombstone post")); + let other_handle = request( + &server.base_url, + "GET", + "/users/tombstone_alice", + &[], + Vec::new(), + ) + .await; + assert!( + other_handle + .body + .contains(r#"data-testid="released-username""#), + "the unclaimed old handle stays a tombstone" + ); } - fn gzip_decode(bytes: &[u8]) -> String { - let mut decoder = GzDecoder::new(bytes); - let mut output = String::new(); - decoder.read_to_string(&mut output).expect("gzip body"); - output + #[tokio::test] + async fn oversized_archive_uploads_are_rejected_cleanly_and_valid_ones_still_import() { + let mut settings = Settings::default(); + settings.accounts.max_archive_upload_bytes = 64 * 1024; + let server = spawn_test_server_with_settings(settings).await; + let uploader_cookie = register_test_user(&server, "uploader").await; + create_text_post(&server, &uploader_cookie, "uploader archive post").await; + let paths = RuntimePaths::from_data_dir(server.data_dir.clone()); + + let import_page = get_with_cookie(&server, "/settings/import", &uploader_cookie).await; + let csrf = csrf_token(&import_page.body); + let mut oversized = vec![0u8; 64 * 1024 + 1]; + oversized[..4].copy_from_slice(&[0x1f, 0x8b, 0x08, 0x00]); + let body = multipart_body_with_file( + "oversized-archive", + &[("csrf", csrf.as_str())], + "archive", + "oversized.tar.gz", + "application/gzip", + &oversized, + ); + let rejected = request( + &server.base_url, + "POST", + "/settings/import", + &[ + ("cookie", uploader_cookie.as_str()), + ( + "content-type", + "multipart/form-data; boundary=oversized-archive", + ), + ], + body, + ) + .await; + assert_eq!(rejected.status, 413); + assert!( + rejected.body.contains("larger than the configured"), + "oversized uploads need a clear error: {}", + rejected.body + ); + assert_eq!(count_rows(&server, "SELECT COUNT(*) FROM posts").await, 1); + assert_eq!( + count_rows(&server, "SELECT COUNT(*) FROM account_imports").await, + 0 + ); + let leftovers = std::fs::read_dir(&paths.tmp_dir) + .expect("tmp dir") + .filter_map(Result::ok) + .filter(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with(crate::portability::IMPORT_TMP_PREFIX) + }) + .count(); + assert_eq!( + leftovers, 0, + "rejected uploads must not leave staging files" + ); + + // A small, valid archive still imports under the same limit. + let export = get_with_cookie(&server, "/settings/export", &uploader_cookie).await; + assert_eq!(export.status, 200); + assert!(export.body_bytes.len() < 64 * 1024); + let receiver_cookie = register_test_user(&server, "receiver").await; + let import_page = get_with_cookie(&server, "/settings/import", &receiver_cookie).await; + let csrf = csrf_token(&import_page.body); + let body = multipart_body_with_file( + "valid-archive", + &[("csrf", csrf.as_str())], + "archive", + "account.tar.gz", + "application/gzip", + &export.body_bytes, + ); + let imported = request( + &server.base_url, + "POST", + "/settings/import", + &[ + ("cookie", receiver_cookie.as_str()), + ( + "content-type", + "multipart/form-data; boundary=valid-archive", + ), + ], + body, + ) + .await; + assert_eq!(imported.status, 200); + assert!(imported.body.contains("Import complete")); + assert_eq!( + count_rows( + &server, + "SELECT COUNT(*) FROM posts WHERE text = 'uploader archive post'" + ) + .await, + 2, + "the original post plus the imported copy" + ); } - fn deep_settings_form_body(csrf: &str, intent: &str, overrides: &[(&str, &str)]) -> Vec { - let values = crate::admin::DeepSettingsValues::from_settings(&Settings::default()); - let mut pairs = vec![ - ("csrf".to_owned(), csrf.to_owned()), - ("intent".to_owned(), intent.to_owned()), - ]; - for field in crate::admin::DeepSettingsField::ALL { - let value = overrides - .iter() - .find(|(name, _)| *name == field.form_name()) - .map_or_else( - || values.form_value(field), - |(_, value)| (*value).to_owned(), - ); - pairs.push((field.form_name().to_owned(), value)); + #[tokio::test] + #[expect( + clippy::too_many_lines, + reason = "one scenario proves exclusions, tamper resistance, and non-mutation together" + )] + async fn account_archives_exclude_nonportable_state_and_reject_privilege_fields() { + let server = spawn_test_server().await; + let mover_cookie = register_test_user(&server, "archive_mover").await; + create_text_post(&server, &mover_cookie, "portable content only").await; + let onlooker_cookie = register_test_user(&server, "archive_onlooker").await; + create_text_post(&server, &onlooker_cookie, "other account content").await; + let mover_id = user_id_for(&server, "archive_mover").await; + + let onlooker_home = get_with_cookie(&server, "/home", &onlooker_cookie).await; + let csrf = csrf_token(&onlooker_home.body); + for path in ["/posts/1/like", "/posts/1/bookmark", "/posts/1/repost"] { + let response = + post_form_with_cookie(&server, path, &onlooker_cookie, &format!("csrf={csrf}")) + .await; + assert_eq!(response.status, 303, "{path}"); } - pairs + let follow = post_form_with_cookie( + &server, + &format!("/users/{mover_id}/follow"), + &onlooker_cookie, + &format!("csrf={csrf}"), + ) + .await; + assert_eq!(follow.status, 303); + + let export = get_with_cookie(&server, "/settings/export", &mover_cookie).await; + assert_eq!(export.status, 200); + let entries = read_tar_gz_entries(&export.body_bytes); + let names = entries .iter() - .map(|(name, value)| format!("{}={}", form_encode(name), form_encode(value))) - .collect::>() - .join("&") - .into_bytes() - } + .map(|(name, _bytes)| name.clone()) + .collect::>(); + assert_eq!( + names, + [ + "manifest.json", + "profile.json", + "posts.json", + "media.json", + "follows.json", + "settings.json" + ] + .map(str::to_owned) + ); + let posts_json = String::from_utf8( + entries + .iter() + .find(|(name, _)| name == "posts.json") + .expect("posts document") + .1 + .clone(), + ) + .expect("posts utf8"); + assert!(posts_json.contains("portable content only")); + assert!(!posts_json.contains("other account content")); - fn registration_body( - username: &str, - captcha_token: Option<&str>, - captcha_answer: Option<&str>, - ) -> Vec { - let mut pairs = vec![ - ("username", username), - ("password", "very secure password"), - ("confirm_password", "very secure password"), - ]; - if let Some(token) = captcha_token { - pairs.push(("captcha_token", token)); - } - if let Some(answer) = captcha_answer { - pairs.push(("captcha_answer", answer)); - } - pairs + let all_text = entries .iter() - .map(|(name, value)| format!("{}={}", form_encode(name), form_encode(value))) - .collect::>() - .join("&") - .into_bytes() - } + .map(|(_name, bytes)| String::from_utf8_lossy(bytes).into_owned()) + .collect::(); + for secret in [ + "password_hash", + "token_hash", + "csrf", + "is_admin", + "is_suspended", + "must_change_password", + "deletion_scheduled_at", + "notification", + "bookmark", + "repost", + ] { + assert!( + !all_text.contains(secret), + "archives must not carry {secret}" + ); + } - fn form_encode(value: &str) -> String { - let mut encoded = String::new(); - for byte in value.bytes() { - match byte { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - encoded.push(char::from(byte)); + // Tampering with the profile document cannot synthesize privileged or + // restricted account state on the destination. + let tampered = entries + .iter() + .map(|(name, bytes)| { + if name != "profile.json" { + return (name.clone(), bytes.clone()); } - b' ' => encoded.push('+'), - _ => { - let _ = write!(encoded, "%{byte:02X}"); + let mut profile: serde_json::Value = + serde_json::from_slice(bytes).expect("profile json"); + let object = profile.as_object_mut().expect("profile object"); + for (key, value) in [ + ("is_admin", serde_json::Value::Bool(true)), + ("is_suspended", serde_json::Value::Bool(true)), + ("must_change_password", serde_json::Value::Bool(true)), + ( + "deletion_scheduled_at", + serde_json::Value::String("2000-01-01 00:00:00".to_owned()), + ), + ( + "password_hash", + serde_json::Value::String("$argon2id$forged".to_owned()), + ), + ] { + object.insert(key.to_owned(), value); } - } - } - encoded - } + ( + name.clone(), + serde_json::to_vec(&profile).expect("tampered profile"), + ) + }) + .collect::>(); + let tampered_bytes = write_tar_gz_entries(&tampered); - fn csrf_token(body: &str) -> String { - let marker = r#"name="csrf" value=""#; - let start = body.find(marker).expect("csrf marker") + marker.len(); - let end = body[start..].find('"').expect("csrf end") + start; - body[start..end].to_owned() + let receiver_cookie = register_test_user(&server, "archive_receiver").await; + let import_page = get_with_cookie(&server, "/settings/import", &receiver_cookie).await; + let csrf = csrf_token(&import_page.body); + let body = multipart_body_with_file( + "tampered-archive", + &[("csrf", csrf.as_str())], + "archive", + "tampered.tar.gz", + "application/gzip", + &tampered_bytes, + ); + let imported = request( + &server.base_url, + "POST", + "/settings/import", + &[ + ("cookie", receiver_cookie.as_str()), + ( + "content-type", + "multipart/form-data; boundary=tampered-archive", + ), + ], + body, + ) + .await; + assert_eq!(imported.status, 200, "{}", imported.body); + assert!(imported.body.contains("Import complete")); + assert_eq!( + count_rows( + &server, + "SELECT COUNT(*) FROM posts WHERE text = 'portable content only'" + ) + .await, + 2, + "the original post plus the imported copy" + ); + let receiver_id = user_id_for(&server, "archive_receiver").await; + let state = { + server + .pool + .call(move |conn| { + Ok(conn.query_row( + "SELECT is_admin, is_suspended, must_change_password, deletion_scheduled_at FROM users WHERE id = ?", + [receiver_id], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, Option>(3)?, + )) + }, + )?) + }) + .await + .expect("receiver state") + }; + assert_eq!(state, (0, 0, 0, None)); + let relogin = login_test_user(&server, "archive_receiver", "very secure password").await; + assert_eq!(relogin.status, 303, "the destination password is untouched"); } - fn hidden_value(body: &str, name: &str) -> String { - let marker = format!(r#"name="{name}" value=""#); - let start = body.find(&marker).expect("hidden marker") + marker.len(); - let end = body[start..].find('"').expect("hidden end") + start; - body[start..end].to_owned() + #[tokio::test] + async fn deletion_finalization_invalidates_sessions_and_releases_with_tombstones() { + let server = spawn_test_server().await; + let cookie = register_test_user(&server, "scheduler_victim").await; + create_text_post(&server, &cookie, "scheduled deletion post").await; + let user_id = user_id_for(&server, "scheduler_victim").await; + let second_cookie = session_cookie( + &login_test_user(&server, "scheduler_victim", "very secure password").await, + ); + server + .pool + .call(move |conn| { + conn.execute( + "UPDATE users SET deletion_requested_at = datetime('now','-31 days'), deletion_scheduled_at = datetime('now','-1 day') WHERE id = ?", + [user_id], + )?; + Ok(()) + }) + .await + .expect("schedule deletion"); + + // The scheduler can race a request from the account it is finalizing. + let paths = RuntimePaths::from_data_dir(server.data_dir.clone()); + let (removed, racing_request) = tokio::join!( + crate::account::finalize_due_deletions(&server.pool, &paths), + get_with_cookie(&server, "/settings", &second_cookie) + ); + assert_eq!(removed.expect("finalize"), 1); + assert!( + matches!(racing_request.status, 200 | 401), + "a racing request must see either the account or a cleanly signed-out state, got {}", + racing_request.status + ); + assert_eq!( + get_with_cookie(&server, "/settings", &second_cookie) + .await + .status, + 401, + "sessions must be invalidated by finalization" + ); + let live_sessions = { + server + .pool + .call(move |conn| { + Ok(conn.query_row( + "SELECT COUNT(*) FROM sessions WHERE user_id = ?", + [user_id], + |row| row.get::<_, i64>(0), + )?) + }) + .await + .expect("sessions") + }; + assert_eq!(live_sessions, 0); + + // The handle is released and tombstoned rather than silently 404ing. + let page = request( + &server.base_url, + "GET", + "/users/scheduler_victim", + &[], + Vec::new(), + ) + .await; + assert_eq!(page.status, 200); + assert!(page.body.contains(r#"data-testid="released-username""#)); + let reused_cookie = register_test_user(&server, "scheduler_victim").await; + let reused = get_with_cookie(&server, "/users/scheduler_victim", &reused_cookie).await; + assert!( + reused + .body + .contains(r#"data-testid="released-username-note""#) + ); + assert!(!reused.body.contains("scheduled deletion post")); + } + + fn read_tar_gz_entries(bytes: &[u8]) -> Vec<(String, Vec)> { + use flate2::read::GzDecoder; + use std::io::Read as _; + + let decoder = GzDecoder::new(bytes); + let mut archive = tar::Archive::new(decoder); + let mut entries = Vec::new(); + for entry in archive.entries().expect("tar entries") { + let mut entry = entry.expect("tar entry"); + let name = entry + .path() + .expect("entry path") + .to_string_lossy() + .to_string(); + let mut buffer = Vec::new(); + entry.read_to_end(&mut buffer).expect("entry bytes"); + entries.push((name, buffer)); + } + entries + } + + fn write_tar_gz_entries(entries: &[(String, Vec)]) -> Vec { + use flate2::write::GzEncoder; + + let encoder = GzEncoder::new(Vec::new(), flate2::Compression::default()); + let mut builder = tar::Builder::new(encoder); + for (name, bytes) in entries { + let mut header = tar::Header::new_ustar(); + header.set_path(name).expect("entry path"); + header.set_entry_type(tar::EntryType::Regular); + header.set_size(u64::try_from(bytes.len()).expect("entry size")); + header.set_mode(0o600); + header.set_cksum(); + builder.append(&header, bytes.as_slice()).expect("append"); + } + let encoder = builder.into_inner().expect("tar finish"); + encoder.finish().expect("gzip finish") } } diff --git a/src/social.rs b/src/social.rs index ac55132..dda70d1 100644 --- a/src/social.rs +++ b/src/social.rs @@ -744,27 +744,95 @@ pub async fn unrepost(pool: &SqlitePool, user_id: i64, post_id: i64) -> anyhow:: .await } -pub async fn follow(pool: &SqlitePool, follower_id: i64, followed_id: i64) -> anyhow::Result { +/// Result of asking to follow an account. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FollowOutcome { + /// A follow row was created immediately. + Followed, + /// The target requires approval and a pending request was recorded. + Requested, + /// The viewer already follows the target. + AlreadyFollowing, + /// A pending request already exists for this pair. + AlreadyRequested, +} + +/// Follows an account, or records a request when the target requires approval. +/// +/// Targets with `users.follow_approval_required = 1` receive a +/// `follow_request` notification and must approve the request before the +/// follow counts or grants follower privileges. Existing followers are never +/// removed when an account enables protection. Repeated and concurrent +/// requests are idempotent, and blocks in either direction reject the action. +/// Deleted or suspended accounts can neither follow nor be followed. +pub async fn follow( + pool: &SqlitePool, + follower_id: i64, + followed_id: i64, +) -> anyhow::Result { if follower_id == followed_id { anyhow::bail!("cannot follow yourself"); } pool.call(move |conn| { let tx = conn.transaction()?; - let target_available = tx + let actor_available = tx .query_row( "SELECT is_deleted = 0 AND is_suspended = 0 FROM users WHERE id = ?", - [followed_id], + [follower_id], |row| row.get::<_, i64>(0), ) .optional()? .unwrap_or(0) != 0; - if !target_available { - anyhow::bail!("account cannot be followed"); + if !actor_available { + anyhow::bail!("account cannot follow"); } - if blocks_exist_tx(&tx, follower_id, followed_id)? { + let target = tx + .query_row( + "SELECT is_deleted = 0 AND is_suspended = 0, follow_approval_required FROM users WHERE id = ?", + [followed_id], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)), + ) + .optional()?; + let Some((available, approval_required)) = target else { + anyhow::bail!("account cannot be followed"); + }; + if available == 0 || blocks_exist_tx(&tx, follower_id, followed_id)? { anyhow::bail!("account cannot be followed"); } + if follow_exists_tx(&tx, follower_id, followed_id)? { + tx.commit()?; + return Ok(FollowOutcome::AlreadyFollowing); + } + // Pending requests stay pending even after the target disables + // protection: the owner can still approve or reject them, and asking + // again does not create a second request. + if follow_request_exists_tx(&tx, follower_id, followed_id)? { + tx.commit()?; + return Ok(FollowOutcome::AlreadyRequested); + } + if approval_required != 0 { + let changed = tx.execute( + "INSERT OR IGNORE INTO follow_requests (requester_id, target_id) VALUES (?, ?)", + params![follower_id, followed_id], + )?; + if changed > 0 { + create_notification_tx( + &tx, + followed_id, + Some(follower_id), + None, + "follow_request", + "requested to follow you", + )?; + } + tx.commit()?; + return Ok(if changed > 0 { + FollowOutcome::Requested + } else { + FollowOutcome::AlreadyRequested + }); + } let changed = tx.execute( "INSERT OR IGNORE INTO follows (follower_id, followed_id) VALUES (?, ?)", params![follower_id, followed_id], @@ -780,11 +848,259 @@ pub async fn follow(pool: &SqlitePool, follower_id: i64, followed_id: i64) -> an )?; } tx.commit()?; + Ok(if changed > 0 { + FollowOutcome::Followed + } else { + FollowOutcome::AlreadyFollowing + }) + }) + .await +} + +fn follow_exists_tx( + tx: &rusqlite::Transaction<'_>, + follower_id: i64, + followed_id: i64, +) -> anyhow::Result { + Ok(tx + .query_row( + "SELECT 1 FROM follows WHERE follower_id = ? AND followed_id = ?", + params![follower_id, followed_id], + |_| Ok(()), + ) + .optional()? + .is_some()) +} + +fn follow_request_exists_tx( + tx: &rusqlite::Transaction<'_>, + requester_id: i64, + target_id: i64, +) -> anyhow::Result { + Ok(tx + .query_row( + "SELECT 1 FROM follow_requests WHERE requester_id = ? AND target_id = ?", + params![requester_id, target_id], + |_| Ok(()), + ) + .optional()? + .is_some()) +} + +/// An account involved in a pending follow request. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FollowRequestView { + pub user_id: i64, + pub username: String, + pub display_name: String, + pub bio: String, + pub profile_picture_path: Option, + pub created_at: String, +} + +/// Approves a pending request, creating the follow and notifying the requester. +/// +/// The request row is deleted first and only a successful delete allows the +/// follow to be created. Requests from deleted, suspended, or blocked +/// accounts are discarded without creating a follow or notification. +/// Returns `true` when a pending request was consumed; when the follow already +/// exists the request is still consumed, but the follow row and the approval +/// notification are never duplicated. +pub async fn approve_follow_request( + pool: &SqlitePool, + target_id: i64, + requester_id: i64, +) -> anyhow::Result { + pool.call(move |conn| { + let tx = conn.transaction()?; + let changed = tx.execute( + "DELETE FROM follow_requests WHERE requester_id = ? AND target_id = ?", + params![requester_id, target_id], + )?; + if changed == 0 { + tx.commit()?; + return Ok(false); + } + let requester_available = tx + .query_row( + "SELECT is_deleted = 0 AND is_suspended = 0 FROM users WHERE id = ?", + [requester_id], + |row| row.get::<_, i64>(0), + ) + .optional()? + .unwrap_or(0) + != 0; + if !requester_available || blocks_exist_tx(&tx, requester_id, target_id)? { + tx.commit()?; + return Ok(false); + } + let inserted = tx.execute( + "INSERT OR IGNORE INTO follows (follower_id, followed_id) VALUES (?, ?)", + params![requester_id, target_id], + )?; + if inserted > 0 { + create_notification_tx( + &tx, + requester_id, + Some(target_id), + None, + "follow_request_approved", + "approved your follow request", + )?; + } + tx.commit()?; + Ok(true) + }) + .await +} + +/// Rejects a pending request. Returns `true` when a request existed. +pub async fn reject_follow_request( + pool: &SqlitePool, + target_id: i64, + requester_id: i64, +) -> anyhow::Result { + pool.call(move |conn| { + let changed = conn.execute( + "DELETE FROM follow_requests WHERE requester_id = ? AND target_id = ?", + params![requester_id, target_id], + )?; + Ok(changed > 0) + }) + .await +} + +/// Cancels the viewer's own pending request to another account. +pub async fn cancel_follow_request( + pool: &SqlitePool, + requester_id: i64, + target_id: i64, +) -> anyhow::Result { + pool.call(move |conn| { + let changed = conn.execute( + "DELETE FROM follow_requests WHERE requester_id = ? AND target_id = ?", + params![requester_id, target_id], + )?; Ok(changed > 0) }) .await } +/// Pending incoming requests awaiting `user_id`'s approval, oldest first. +/// +/// Blocked pairs are hidden in both directions. Suspended requesters are not +/// filtered here because approval re-validates the requester and discards +/// requests that can no longer be approved. +pub async fn incoming_follow_requests( + pool: &SqlitePool, + user_id: i64, +) -> anyhow::Result> { + pool.call(move |conn| { + let mut stmt = conn.prepare( + r#" + SELECT u.id, u.username, u.display_name, u.bio, + COALESCE(pic.thumbnail_public_path, pic.public_path), + r.created_at + FROM follow_requests r + JOIN users u ON u.id = r.requester_id + LEFT JOIN media pic ON pic.id = u.profile_picture_media_id + WHERE r.target_id = ? AND u.is_deleted = 0 + AND NOT EXISTS (SELECT 1 FROM blocks WHERE blocker_id = ? AND blocked_id = u.id) + AND NOT EXISTS (SELECT 1 FROM blocks WHERE blocker_id = u.id AND blocked_id = ?) + ORDER BY r.created_at ASC, r.requester_id ASC + "#, + )?; + let rows = stmt + .query_map(params![user_id, user_id, user_id], map_follow_request_row)? + .collect::, _>>()?; + Ok(rows) + }) + .await +} + +/// Pending outgoing requests created by `user_id`, oldest first. +/// +/// The returned accounts are the request targets, so the UI can link to them. +pub async fn outgoing_follow_requests( + pool: &SqlitePool, + user_id: i64, +) -> anyhow::Result> { + pool.call(move |conn| { + let mut stmt = conn.prepare( + r#" + SELECT u.id, u.username, u.display_name, u.bio, + COALESCE(pic.thumbnail_public_path, pic.public_path), + r.created_at + FROM follow_requests r + JOIN users u ON u.id = r.target_id + LEFT JOIN media pic ON pic.id = u.profile_picture_media_id + WHERE r.requester_id = ? AND u.is_deleted = 0 AND u.is_suspended = 0 + AND NOT EXISTS (SELECT 1 FROM blocks WHERE blocker_id = ? AND blocked_id = u.id) + AND NOT EXISTS (SELECT 1 FROM blocks WHERE blocker_id = u.id AND blocked_id = ?) + ORDER BY r.created_at ASC, r.target_id ASC + "#, + )?; + let rows = stmt + .query_map(params![user_id, user_id, user_id], map_follow_request_row)? + .collect::, _>>()?; + Ok(rows) + }) + .await +} + +fn map_follow_request_row(row: &Row<'_>) -> rusqlite::Result { + Ok(FollowRequestView { + user_id: row.get(0)?, + username: row.get(1)?, + display_name: row.get(2)?, + bio: row.get(3)?, + profile_picture_path: row.get(4)?, + created_at: row.get(5)?, + }) +} + +/// Number of pending incoming requests for `user_id`. +pub async fn pending_follow_request_count(pool: &SqlitePool, user_id: i64) -> anyhow::Result { + pool.call(move |conn| { + Ok(conn.query_row( + r#" + SELECT COUNT(*) + FROM follow_requests r + JOIN users u ON u.id = r.requester_id + WHERE r.target_id = ? AND u.is_deleted = 0 + AND NOT EXISTS (SELECT 1 FROM blocks WHERE blocker_id = ? AND blocked_id = u.id) + AND NOT EXISTS (SELECT 1 FROM blocks WHERE blocker_id = u.id AND blocked_id = ?) + "#, + params![user_id, user_id, user_id], + |row| row.get(0), + )?) + }) + .await +} + +/// Enables or disables follow approval for an account. +/// +/// Enabling protection never removes existing follows. Disabling protection +/// leaves existing pending requests pending: the owner can still approve or +/// reject them, and new follows are accepted directly. +pub async fn set_follow_approval_required( + pool: &SqlitePool, + user_id: i64, + required: bool, +) -> anyhow::Result<()> { + pool.call(move |conn| { + let changed = conn.execute( + "UPDATE users SET follow_approval_required = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND is_deleted = 0", + params![i64::from(required), user_id], + )?; + if changed == 0 { + anyhow::bail!("account not found"); + } + Ok(()) + }) + .await +} + pub async fn active_follow_targets(pool: &SqlitePool, ids: &[i64]) -> anyhow::Result> { if ids.is_empty() { return Ok(Vec::new()); @@ -815,16 +1131,26 @@ pub async fn active_follow_targets(pool: &SqlitePool, ids: &[i64]) -> anyhow::Re .collect()) } +/// Removes a follow, or cancels the viewer's pending request in that direction. +/// +/// Returns `true` only when a follow row was deleted; cancelling a pending +/// request is a side effect so the button works in the requested state too. pub async fn unfollow( pool: &SqlitePool, follower_id: i64, followed_id: i64, ) -> anyhow::Result { pool.call(move |conn| { - let changed = conn.execute( + let tx = conn.transaction()?; + let changed = tx.execute( "DELETE FROM follows WHERE follower_id = ? AND followed_id = ?", params![follower_id, followed_id], )?; + tx.execute( + "DELETE FROM follow_requests WHERE requester_id = ? AND target_id = ?", + params![follower_id, followed_id], + )?; + tx.commit()?; Ok(changed > 0) }) .await @@ -835,6 +1161,8 @@ pub async fn unfollow( #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct ProfileRelationship { pub following: bool, + /// The viewer has a pending follow request to this profile. + pub requested: bool, pub blocked: bool, pub muted: bool, pub blocks_viewer: bool, @@ -865,11 +1193,12 @@ pub async fn profile_relationship( EXISTS(SELECT 1 FROM follows WHERE follower_id = ? AND followed_id = ?), EXISTS(SELECT 1 FROM blocks WHERE blocker_id = ? AND blocked_id = ?), EXISTS(SELECT 1 FROM mutes WHERE muter_id = ? AND muted_id = ?), - EXISTS(SELECT 1 FROM blocks WHERE blocker_id = ? AND blocked_id = ?) + EXISTS(SELECT 1 FROM blocks WHERE blocker_id = ? AND blocked_id = ?), + EXISTS(SELECT 1 FROM follow_requests WHERE requester_id = ? AND target_id = ?) "#, params![ viewer_id, profile_id, viewer_id, profile_id, viewer_id, profile_id, profile_id, - viewer_id + viewer_id, viewer_id, profile_id ], |row| { Ok(ProfileRelationship { @@ -877,6 +1206,7 @@ pub async fn profile_relationship( blocked: row.get::<_, i64>(1)? != 0, muted: row.get::<_, i64>(2)? != 0, blocks_viewer: row.get::<_, i64>(3)? != 0, + requested: row.get::<_, i64>(4)? != 0, }) }, )?) @@ -1115,6 +1445,10 @@ pub async fn block(pool: &SqlitePool, blocker_id: i64, blocked_id: i64) -> anyho "DELETE FROM follows WHERE (follower_id = ? AND followed_id = ?) OR (follower_id = ? AND followed_id = ?)", params![blocker_id, blocked_id, blocked_id, blocker_id], )?; + tx.execute( + "DELETE FROM follow_requests WHERE (requester_id = ? AND target_id = ?) OR (requester_id = ? AND target_id = ?)", + params![blocker_id, blocked_id, blocked_id, blocker_id], + )?; tx.commit()?; Ok(()) }) @@ -4255,6 +4589,66 @@ mod tests { .await } + async fn notification_message( + pool: &SqlitePool, + user_id: i64, + kind: &str, + ) -> anyhow::Result)>> { + let kind = kind.to_owned(); + pool.call(move |conn| { + Ok(conn + .query_row( + "SELECT message, actor_user_id FROM notifications WHERE user_id = ? AND kind = ? ORDER BY id DESC LIMIT 1", + params![user_id, kind], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option>(1)?)), + ) + .optional()?) + }) + .await + } + + async fn follow_request_count(pool: &SqlitePool, requester_id: i64, target_id: i64) -> i64 { + pool.call(move |conn| { + Ok(conn.query_row( + "SELECT COUNT(*) FROM follow_requests WHERE requester_id = ? AND target_id = ?", + params![requester_id, target_id], + |row| row.get(0), + )?) + }) + .await + .expect("follow request count") + } + + async fn follow_row_count(pool: &SqlitePool, follower_id: i64, followed_id: i64) -> i64 { + pool.call(move |conn| { + Ok(conn.query_row( + "SELECT COUNT(*) FROM follows WHERE follower_id = ? AND followed_id = ?", + params![follower_id, followed_id], + |row| row.get(0), + )?) + }) + .await + .expect("follow row count") + } + + async fn register_account(pool: &SqlitePool, settings: &Settings, username: &str) -> i64 { + auth::register_user(pool, settings, username, "very secure password", false) + .await + .expect("register account") + } + + async fn set_account_flags(pool: &SqlitePool, user_id: i64, deleted: bool, suspended: bool) { + pool.call(move |conn| { + conn.execute( + "UPDATE users SET is_deleted = ?, is_suspended = ? WHERE id = ?", + params![i64::from(deleted), i64::from(suspended), user_id], + )?; + Ok(()) + }) + .await + .expect("account flags"); + } + #[tokio::test] async fn repost_notification_created_only_once() { let (pool, settings, alice, bob) = fixture().await; @@ -4798,8 +5192,14 @@ mod tests { async fn follow_unfollow_is_idempotent_and_counts_once() { let (pool, _settings, alice, bob) = fixture().await; - assert!(follow(&pool, bob, alice).await.expect("first follow")); - assert!(!follow(&pool, bob, alice).await.expect("duplicate follow")); + assert_eq!( + follow(&pool, bob, alice).await.expect("first follow"), + FollowOutcome::Followed + ); + assert_eq!( + follow(&pool, bob, alice).await.expect("duplicate follow"), + FollowOutcome::AlreadyFollowing + ); assert!(is_following(&pool, bob, alice).await.expect("is following")); assert_eq!( follow_counts(&pool, alice).await.expect("alice counts"), @@ -4833,331 +5233,1906 @@ mod tests { } #[tokio::test] - async fn follow_rejects_deleted_or_suspended_targets() { - let (pool, settings, alice, bob) = fixture().await; - let carol = auth::register_user(&pool, &settings, "carol", "very secure password", false) + async fn protected_account_records_follow_request() { + let (pool, _settings, alice, bob) = fixture().await; + set_profile_picture( + &pool, + alice, + "/uploads/images/alice.webp", + Some("/uploads/thumbs/alice-profile.webp"), + ) + .await; + set_profile_picture(&pool, bob, "/uploads/images/bob.webp", None).await; + set_follow_approval_required(&pool, alice, true) .await - .expect("carol"); - pool.call(move |conn| { - conn.execute("UPDATE users SET is_suspended = 1 WHERE id = ?", [bob])?; - conn.execute("UPDATE users SET is_deleted = 1 WHERE id = ?", [carol])?; - Ok(()) - }) - .await - .expect("mark unavailable"); - - let suspended = follow(&pool, alice, bob).await.expect_err("suspended"); - let deleted = follow(&pool, alice, carol).await.expect_err("deleted"); + .expect("protect alice"); - assert_eq!(suspended.to_string(), "account cannot be followed"); - assert_eq!(deleted.to_string(), "account cannot be followed"); + assert_eq!( + follow(&pool, bob, alice).await.expect("request follow"), + FollowOutcome::Requested + ); + assert_eq!(follow_request_count(&pool, bob, alice).await, 1); assert!( - !is_following(&pool, alice, bob) + !is_following(&pool, bob, alice) .await .expect("not following") ); + assert_eq!( + follow_counts(&pool, alice).await.expect("alice counts"), + (0, 0) + ); assert!( - !is_following(&pool, alice, carol) + followers_accounts(&pool, alice, Some(bob), None) .await - .expect("not following") + .expect("followers list") + .0 + .is_empty() + ); + assert!( + following_accounts(&pool, bob, None) + .await + .expect("bob following list") + .0 + .is_empty() ); - } - #[tokio::test] - async fn following_accounts_returns_only_viewer_follows() { - let (pool, settings, alice, bob) = fixture().await; - let carol = auth::register_user(&pool, &settings, "carol", "very secure password", false) + let relationship = profile_relationship(&pool, Some(bob), alice) .await - .expect("carol"); + .expect("relationship"); + assert!(!relationship.following); + assert!(relationship.requested); - follow(&pool, alice, bob).await.expect("alice follows bob"); - follow(&pool, carol, alice) + let incoming = incoming_follow_requests(&pool, alice) .await - .expect("carol follows alice"); + .expect("incoming requests"); + assert_eq!(incoming.len(), 1); + assert_eq!(incoming[0].user_id, bob); + assert_eq!(incoming[0].username, "bob"); + assert_eq!( + incoming[0].profile_picture_path.as_deref(), + Some("/uploads/images/bob.webp") + ); + let outgoing = outgoing_follow_requests(&pool, bob) + .await + .expect("outgoing requests"); + assert_eq!(outgoing.len(), 1); + assert_eq!(outgoing[0].user_id, alice); + assert_eq!(outgoing[0].username, "alice"); + assert_eq!( + outgoing[0].profile_picture_path.as_deref(), + Some("/uploads/thumbs/alice-profile.webp") + ); + assert_eq!( + pending_follow_request_count(&pool, alice) + .await + .expect("alice pending count"), + 1 + ); + assert_eq!( + pending_follow_request_count(&pool, bob) + .await + .expect("bob pending count"), + 0 + ); - let (accounts, _) = following_accounts(&pool, alice, None) + let (message, actor) = notification_message(&pool, alice, "follow_request") .await - .expect("following accounts"); - assert_eq!(accounts.len(), 1); - assert_eq!(accounts[0].id, bob); - assert_eq!(accounts[0].username, "bob"); - assert!(accounts[0].viewer_following); + .expect("lookup") + .expect("follow_request notification"); + assert_eq!(message, "requested to follow you"); + assert_eq!(actor, Some(bob)); + + assert_eq!( + follow(&pool, bob, alice).await.expect("duplicate request"), + FollowOutcome::AlreadyRequested + ); + assert_eq!(follow_request_count(&pool, bob, alice).await, 1); } - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn concurrent_posts_all_commit_with_unique_ids() { - let (pool, settings, alice, bob) = fixture().await; - let mut handles = Vec::new(); - for index in 0..24 { - let pool = pool.clone(); - let settings = settings.clone(); - let author = if index % 2 == 0 { alice } else { bob }; - handles.push(tokio::spawn(async move { - create_post( - &pool, - &settings, - Some(author), - &format!("concurrent post {index}"), - None, - &[], - ) + #[tokio::test] + async fn approving_request_creates_follow_and_notifies_requester() { + let (pool, _settings, alice, bob) = fixture().await; + set_follow_approval_required(&pool, alice, true) + .await + .expect("protect alice"); + assert_eq!( + follow(&pool, bob, alice).await.expect("request follow"), + FollowOutcome::Requested + ); + + assert!( + approve_follow_request(&pool, alice, bob) .await - })); - } - let mut ids = BTreeSet::new(); - for handle in handles { - let id = handle.await.expect("task").expect("concurrent post"); - ids.insert(id); - } - assert_eq!(ids.len(), 24); - let count: i64 = pool - .call(|conn| { - Ok(conn.query_row( - "SELECT COUNT(*) FROM posts WHERE is_deleted = 0", - [], - |row| row.get(0), - )?) - }) + .expect("approve") + ); + assert!( + !approve_follow_request(&pool, alice, bob) + .await + .expect("re-approve") + ); + assert_eq!(follow_request_count(&pool, bob, alice).await, 0); + assert!(is_following(&pool, bob, alice).await.expect("following")); + assert_eq!( + follow_counts(&pool, alice).await.expect("alice counts"), + (1, 0) + ); + let relationship = profile_relationship(&pool, Some(bob), alice) .await - .expect("post count"); - assert_eq!(count, 24); + .expect("relationship after approval"); + assert!(relationship.following); + assert!(!relationship.requested); - // All 24 posts share one-second timestamps; the merged timeline must - // still be ordered by numeric id, not by the "p:" event string. - let timeline = timeline(&pool, None, "local", None) + let (message, actor) = notification_message(&pool, bob, "follow_request_approved") .await - .expect("timeline"); - let ids = timeline.iter().map(|post| post.id).collect::>(); - let mut newest_first = ids.clone(); - newest_first.sort_unstable_by(|left, right| right.cmp(left)); - assert_eq!(ids, newest_first); + .expect("lookup") + .expect("approval notification"); + assert_eq!(message, "approved your follow request"); + assert_eq!(actor, Some(alice)); } - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn concurrent_rate_limit_checks_record_at_most_the_limit() { - let (pool, _settings, alice, _bob) = fixture().await; - let actor = format!("user:{alice}"); - let mut handles = Vec::new(); - for _ in 0..16 { - let pool = pool.clone(); - let actor = actor.clone(); - handles.push(tokio::spawn(async move { - crate::rate_limit::check_and_record( - &pool, - crate::rate_limit::Scope::Post, - &actor, - 3, - 60, - ) + #[tokio::test] + async fn follow_request_reject_and_cancel_are_direction_sensitive() { + let (pool, _settings, alice, bob) = fixture().await; + set_follow_approval_required(&pool, alice, true) + .await + .expect("protect alice"); + assert_eq!( + follow(&pool, bob, alice).await.expect("request"), + FollowOutcome::Requested + ); + + // Only the requester may cancel and only the target may approve/reject. + assert!( + !approve_follow_request(&pool, bob, alice) .await - })); - } - let mut accepted = 0; - for handle in handles { - if handle.await.expect("task").is_ok() { - accepted += 1; - } - } - assert_eq!(accepted, 3); + .expect("wrong-direction approve") + ); + assert!( + !reject_follow_request(&pool, bob, alice) + .await + .expect("wrong-direction reject") + ); + assert!( + !cancel_follow_request(&pool, alice, bob) + .await + .expect("wrong-direction cancel") + ); + assert_eq!(follow_request_count(&pool, bob, alice).await, 1); + + assert!( + reject_follow_request(&pool, alice, bob) + .await + .expect("reject") + ); + assert!( + !reject_follow_request(&pool, alice, bob) + .await + .expect("re-reject") + ); + assert_eq!(follow_request_count(&pool, bob, alice).await, 0); + assert!(!is_following(&pool, bob, alice).await.expect("no follow")); + assert_eq!( + follow_counts(&pool, alice).await.expect("alice counts"), + (0, 0) + ); + assert!( + notification_message(&pool, bob, "follow_request_approved") + .await + .expect("lookup") + .is_none() + ); + + assert_eq!( + follow(&pool, bob, alice).await.expect("request again"), + FollowOutcome::Requested + ); + assert!( + cancel_follow_request(&pool, bob, alice) + .await + .expect("cancel") + ); + assert!( + !cancel_follow_request(&pool, bob, alice) + .await + .expect("re-cancel") + ); + assert_eq!(follow_request_count(&pool, bob, alice).await, 0); + assert!( + !is_following(&pool, bob, alice) + .await + .expect("still no follow") + ); } - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn concurrent_follows_create_one_relationship() { - let (pool, _settings, alice, bob) = fixture().await; - let mut handles = Vec::new(); - for _ in 0..12 { - let pool = pool.clone(); - handles.push(tokio::spawn(async move { follow(&pool, alice, bob).await })); - } - for handle in handles { - handle.await.expect("task").expect("follow"); - } - let rows: i64 = pool - .call(move |conn| { - Ok(conn.query_row( - "SELECT COUNT(*) FROM follows WHERE follower_id = ? AND followed_id = ?", - params![alice, bob], - |row| row.get(0), - )?) - }) + #[tokio::test] + async fn block_removes_pending_follow_requests_in_both_directions() { + let (pool, settings, alice, bob) = fixture().await; + let carol = auth::register_user(&pool, &settings, "carol", "very secure password", false) .await - .expect("follow count"); - assert_eq!(rows, 1); + .expect("carol"); + + // Blocking removes the blocker's own outgoing request. + set_follow_approval_required(&pool, alice, true) + .await + .expect("protect alice"); + assert_eq!( + follow(&pool, bob, alice).await.expect("request"), + FollowOutcome::Requested + ); + block(&pool, bob, alice).await.expect("bob blocks alice"); + assert_eq!(follow_request_count(&pool, bob, alice).await, 0); + + // Blocking also removes requests targeting the blocker. + set_follow_approval_required(&pool, carol, true) + .await + .expect("protect carol"); + assert_eq!( + follow(&pool, alice, carol).await.expect("request"), + FollowOutcome::Requested + ); + block(&pool, carol, alice) + .await + .expect("carol blocks alice"); + assert_eq!(follow_request_count(&pool, alice, carol).await, 0); + + // A block in either direction rejects new follows. + assert!(follow(&pool, alice, bob).await.is_err()); + assert!(follow(&pool, alice, carol).await.is_err()); } - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn concurrent_block_and_unblock_leave_consistent_state() { + #[tokio::test] + async fn unfollow_cancels_a_pending_follow_request() { let (pool, _settings, alice, bob) = fixture().await; - follow(&pool, alice, bob).await.expect("follow"); - let mut handles = Vec::new(); - for index in 0..20 { - let pool = pool.clone(); - handles.push(tokio::spawn(async move { - if index % 2 == 0 { - block(&pool, alice, bob).await - } else { - unblock(&pool, alice, bob).await - } - })); - } - for handle in handles { - handle.await.expect("task").expect("block toggle"); - } - let (blocks, follows): (i64, i64) = pool - .call(move |conn| { - Ok(( - conn.query_row( - "SELECT COUNT(*) FROM blocks WHERE blocker_id = ? AND blocked_id = ?", - params![alice, bob], - |row| row.get(0), - )?, - conn.query_row( - "SELECT COUNT(*) FROM follows WHERE (follower_id = ? AND followed_id = ?) OR (follower_id = ? AND followed_id = ?)", - params![alice, bob, bob, alice], - |row| row.get(0), - )?, - )) - }) + set_follow_approval_required(&pool, alice, true) .await - .expect("state"); - // A block must never coexist with a follow relationship. + .expect("protect alice"); + assert_eq!( + follow(&pool, bob, alice).await.expect("request"), + FollowOutcome::Requested + ); + + assert!( + !unfollow(&pool, bob, alice) + .await + .expect("unfollow cancels request") + ); + assert_eq!(follow_request_count(&pool, bob, alice).await, 0); + assert!( + outgoing_follow_requests(&pool, bob) + .await + .expect("outgoing") + .is_empty() + ); + assert_eq!( + pending_follow_request_count(&pool, alice) + .await + .expect("pending count"), + 0 + ); + } + + #[tokio::test] + async fn approval_toggle_keeps_followers_and_pending_requests() { + let (pool, settings, alice, bob) = fixture().await; + let carol = auth::register_user(&pool, &settings, "carol", "very secure password", false) + .await + .expect("carol"); + let dave = auth::register_user(&pool, &settings, "dave", "very secure password", false) + .await + .expect("dave"); + + assert_eq!( + follow(&pool, bob, alice).await.expect("bob follows"), + FollowOutcome::Followed + ); + set_follow_approval_required(&pool, alice, true) + .await + .expect("enable protection"); + assert!( + is_following(&pool, bob, alice) + .await + .expect("existing follow kept") + ); + assert_eq!( + follow(&pool, bob, alice).await.expect("bob again"), + FollowOutcome::AlreadyFollowing + ); + assert_eq!( + follow(&pool, carol, alice).await.expect("carol requests"), + FollowOutcome::Requested + ); + + set_follow_approval_required(&pool, alice, false) + .await + .expect("disable protection"); + assert_eq!(follow_request_count(&pool, carol, alice).await, 1); + assert_eq!( + pending_follow_request_count(&pool, alice) + .await + .expect("pending"), + 1 + ); + assert_eq!( + follow(&pool, dave, alice).await.expect("dave follows"), + FollowOutcome::Followed + ); + assert_eq!(follow_counts(&pool, alice).await.expect("counts"), (2, 0)); + assert_eq!( + pending_follow_request_count(&pool, alice) + .await + .expect("pending"), + 1 + ); + + // The owner can still approve the request made while protection was on. + assert!( + approve_follow_request(&pool, alice, carol) + .await + .expect("approve") + ); + assert!( + is_following(&pool, carol, alice) + .await + .expect("carol follow") + ); + assert_eq!(follow_counts(&pool, alice).await.expect("counts"), (3, 0)); + assert_eq!( + pending_follow_request_count(&pool, alice) + .await + .expect("pending"), + 0 + ); + + set_follow_approval_required(&pool, alice, false) + .await + .expect("disable again"); + assert_eq!( + set_follow_approval_required(&pool, dave + 10_000, true) + .await + .expect_err("missing account") + .to_string(), + "account not found" + ); + } + + #[tokio::test] + async fn follow_request_lists_apply_visibility_filters() { + let (pool, settings, alice, bob) = fixture().await; + let carol = auth::register_user(&pool, &settings, "carol", "very secure password", false) + .await + .expect("carol"); + let dave = auth::register_user(&pool, &settings, "dave", "very secure password", false) + .await + .expect("dave"); + + set_follow_approval_required(&pool, alice, true) + .await + .expect("protect alice"); + for requester in [bob, carol, dave] { + assert_eq!( + follow(&pool, requester, alice).await.expect("request"), + FollowOutcome::Requested + ); + } + // These states cannot be produced by block()/account deletion without + // deleting the request, so insert them directly to exercise the + // list/count visibility filters. + pool.call(move |conn| { + conn.execute("UPDATE users SET is_deleted = 1 WHERE id = ?", [dave])?; + conn.execute( + "INSERT INTO blocks (blocker_id, blocked_id) VALUES (?, ?)", + params![bob, alice], + )?; + conn.execute( + "INSERT INTO blocks (blocker_id, blocked_id) VALUES (?, ?)", + params![alice, carol], + )?; + Ok(()) + }) + .await + .expect("mark states"); + + assert!( + incoming_follow_requests(&pool, alice) + .await + .expect("incoming") + .is_empty() + ); + assert_eq!( + pending_follow_request_count(&pool, alice) + .await + .expect("pending count"), + 0 + ); + assert!( + outgoing_follow_requests(&pool, bob) + .await + .expect("bob outgoing") + .is_empty() + ); + assert!( + outgoing_follow_requests(&pool, carol) + .await + .expect("carol outgoing") + .is_empty() + ); + } + + #[tokio::test] + async fn follow_request_notifications_are_grouped_and_markable() { + let (pool, _settings, alice, bob) = fixture().await; + set_follow_approval_required(&pool, alice, true) + .await + .expect("protect alice"); + assert_eq!( + follow(&pool, bob, alice).await.expect("request"), + FollowOutcome::Requested + ); + + assert_eq!( + unread_notification_count(&pool, alice) + .await + .expect("unread"), + 1 + ); + let groups = notification_groups(&pool, alice).await.expect("groups"); + let request_group = groups + .iter() + .find(|group| group.kind == "follow_request") + .expect("follow_request group"); + assert_eq!(request_group.total_count, 1); + assert_eq!(request_group.unread_count, 1); + assert_eq!(request_group.actors.len(), 1); + assert_eq!(request_group.actors[0].username.as_deref(), Some("bob")); + + // Non-post notifications are marked read through the ids-based path. + mark_notification_ids_read(&pool, alice, &request_group.notification_ids) + .await + .expect("mark request read"); + assert_eq!( + unread_notification_count(&pool, alice) + .await + .expect("unread"), + 0 + ); + + assert!( + approve_follow_request(&pool, alice, bob) + .await + .expect("approve") + ); + assert_eq!( + unread_notification_count(&pool, bob) + .await + .expect("bob unread"), + 1 + ); + let groups = notification_groups(&pool, bob).await.expect("bob groups"); + let approved_group = groups + .iter() + .find(|group| group.kind == "follow_request_approved") + .expect("approval group"); + assert_eq!(approved_group.total_count, 1); + assert_eq!(approved_group.actors.len(), 1); + assert_eq!(approved_group.actors[0].username.as_deref(), Some("alice")); + mark_notification_ids_read(&pool, bob, &approved_group.notification_ids) + .await + .expect("mark approval read"); + assert_eq!( + unread_notification_count(&pool, bob) + .await + .expect("bob unread"), + 0 + ); + } + + #[tokio::test] + async fn follow_rejects_deleted_or_suspended_targets() { + let (pool, settings, alice, bob) = fixture().await; + let carol = auth::register_user(&pool, &settings, "carol", "very secure password", false) + .await + .expect("carol"); + pool.call(move |conn| { + conn.execute("UPDATE users SET is_suspended = 1 WHERE id = ?", [bob])?; + conn.execute("UPDATE users SET is_deleted = 1 WHERE id = ?", [carol])?; + Ok(()) + }) + .await + .expect("mark unavailable"); + + let suspended = follow(&pool, alice, bob).await.expect_err("suspended"); + let deleted = follow(&pool, alice, carol).await.expect_err("deleted"); + + assert_eq!(suspended.to_string(), "account cannot be followed"); + assert_eq!(deleted.to_string(), "account cannot be followed"); + assert!( + !is_following(&pool, alice, bob) + .await + .expect("not following") + ); + assert!( + !is_following(&pool, alice, carol) + .await + .expect("not following") + ); + } + + #[tokio::test] + async fn following_accounts_returns_only_viewer_follows() { + let (pool, settings, alice, bob) = fixture().await; + let carol = auth::register_user(&pool, &settings, "carol", "very secure password", false) + .await + .expect("carol"); + + follow(&pool, alice, bob).await.expect("alice follows bob"); + follow(&pool, carol, alice) + .await + .expect("carol follows alice"); + + let (accounts, _) = following_accounts(&pool, alice, None) + .await + .expect("following accounts"); + assert_eq!(accounts.len(), 1); + assert_eq!(accounts[0].id, bob); + assert_eq!(accounts[0].username, "bob"); + assert!(accounts[0].viewer_following); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_posts_all_commit_with_unique_ids() { + let (pool, settings, alice, bob) = fixture().await; + let mut handles = Vec::new(); + for index in 0..24 { + let pool = pool.clone(); + let settings = settings.clone(); + let author = if index % 2 == 0 { alice } else { bob }; + handles.push(tokio::spawn(async move { + create_post( + &pool, + &settings, + Some(author), + &format!("concurrent post {index}"), + None, + &[], + ) + .await + })); + } + let mut ids = BTreeSet::new(); + for handle in handles { + let id = handle.await.expect("task").expect("concurrent post"); + ids.insert(id); + } + assert_eq!(ids.len(), 24); + let count: i64 = pool + .call(|conn| { + Ok(conn.query_row( + "SELECT COUNT(*) FROM posts WHERE is_deleted = 0", + [], + |row| row.get(0), + )?) + }) + .await + .expect("post count"); + assert_eq!(count, 24); + + // All 24 posts share one-second timestamps; the merged timeline must + // still be ordered by numeric id, not by the "p:" event string. + let timeline = timeline(&pool, None, "local", None) + .await + .expect("timeline"); + let ids = timeline.iter().map(|post| post.id).collect::>(); + let mut newest_first = ids.clone(); + newest_first.sort_unstable_by(|left, right| right.cmp(left)); + assert_eq!(ids, newest_first); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_rate_limit_checks_record_at_most_the_limit() { + let (pool, _settings, alice, _bob) = fixture().await; + let actor = format!("user:{alice}"); + let mut handles = Vec::new(); + for _ in 0..16 { + let pool = pool.clone(); + let actor = actor.clone(); + handles.push(tokio::spawn(async move { + crate::rate_limit::check_and_record( + &pool, + crate::rate_limit::Scope::Post, + &actor, + 3, + 60, + ) + .await + })); + } + let mut accepted = 0; + for handle in handles { + if handle.await.expect("task").is_ok() { + accepted += 1; + } + } + assert_eq!(accepted, 3); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_follows_create_one_relationship() { + let (pool, _settings, alice, bob) = fixture().await; + let mut handles = Vec::new(); + for _ in 0..12 { + let pool = pool.clone(); + handles.push(tokio::spawn(async move { follow(&pool, alice, bob).await })); + } + for handle in handles { + let outcome = handle.await.expect("task").expect("follow"); + assert!(matches!( + outcome, + FollowOutcome::Followed | FollowOutcome::AlreadyFollowing + )); + } + let rows: i64 = pool + .call(move |conn| { + Ok(conn.query_row( + "SELECT COUNT(*) FROM follows WHERE follower_id = ? AND followed_id = ?", + params![alice, bob], + |row| row.get(0), + )?) + }) + .await + .expect("follow count"); + assert_eq!(rows, 1); + assert_eq!( + notification_count(&pool, bob, "follow") + .await + .expect("follow notifications"), + 1 + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_follow_requests_create_one_row_and_notification() { + let (pool, _settings, alice, bob) = fixture().await; + set_follow_approval_required(&pool, alice, true) + .await + .expect("protect alice"); + let mut handles = Vec::new(); + for _ in 0..12 { + let pool = pool.clone(); + handles.push(tokio::spawn(async move { follow(&pool, bob, alice).await })); + } + for handle in handles { + let outcome = handle.await.expect("task").expect("follow"); + assert!(matches!( + outcome, + FollowOutcome::Requested | FollowOutcome::AlreadyRequested + )); + } + let (requests, follows): (i64, i64) = pool + .call(move |conn| { + Ok(( + conn.query_row( + "SELECT COUNT(*) FROM follow_requests WHERE requester_id = ? AND target_id = ?", + params![bob, alice], + |row| row.get(0), + )?, + conn.query_row( + "SELECT COUNT(*) FROM follows WHERE follower_id = ? AND followed_id = ?", + params![bob, alice], + |row| row.get(0), + )?, + )) + }) + .await + .expect("request state"); + assert_eq!(requests, 1); + assert_eq!(follows, 0); + assert_eq!( + notification_count(&pool, alice, "follow_request") + .await + .expect("request notifications"), + 1 + ); + assert_eq!( + notification_count(&pool, bob, "follow_request_approved") + .await + .expect("approval notifications"), + 0 + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_block_and_unblock_leave_consistent_state() { + let (pool, _settings, alice, bob) = fixture().await; + follow(&pool, alice, bob).await.expect("follow"); + let mut handles = Vec::new(); + for index in 0..20 { + let pool = pool.clone(); + handles.push(tokio::spawn(async move { + if index % 2 == 0 { + block(&pool, alice, bob).await + } else { + unblock(&pool, alice, bob).await + } + })); + } + for handle in handles { + handle.await.expect("task").expect("block toggle"); + } + let (blocks, follows): (i64, i64) = pool + .call(move |conn| { + Ok(( + conn.query_row( + "SELECT COUNT(*) FROM blocks WHERE blocker_id = ? AND blocked_id = ?", + params![alice, bob], + |row| row.get(0), + )?, + conn.query_row( + "SELECT COUNT(*) FROM follows WHERE (follower_id = ? AND followed_id = ?) OR (follower_id = ? AND followed_id = ?)", + params![alice, bob, bob, alice], + |row| row.get(0), + )?, + )) + }) + .await + .expect("state"); + // A block must never coexist with a follow relationship. + assert!( + blocks == 0 || follows == 0, + "blocks={blocks} follows={follows}" + ); + } + + #[tokio::test] + async fn muted_words_are_case_insensitive_unicode_and_exempt_own_posts() { + let (pool, settings, alice, bob) = fixture().await; + add_muted_word(&pool, alice, "CAFÉ").await.expect("mute"); + add_muted_word(&pool, alice, "breaking news") + .await + .expect("mute phrase"); + add_muted_word(&pool, alice, "cat").await.expect("mute"); + // Duplicates (case-insensitive) collapse to one row. + add_muted_word(&pool, alice, "café") + .await + .expect("dup mute"); + let stored = muted_words(&pool, alice).await.expect("list"); + assert_eq!(stored.len(), 3); + for invalid in ["", " ", "\n"] { + assert!(add_muted_word(&pool, alice, invalid).await.is_err()); + } + assert!( + add_muted_word(&pool, alice, &"x".repeat(101)) + .await + .is_err() + ); + + let hidden = [ + "CAFÉ is open", + "a café visit", + "Breaking NEWS today", + "concatenate the strings", + "the cat sat down", + ]; + for text in hidden { + create_post(&pool, &settings, Some(bob), text, None, &[]) + .await + .expect("hidden post"); + } + create_post(&pool, &settings, Some(bob), "a dog appears", None, &[]) + .await + .expect("visible post"); + create_post(&pool, &settings, Some(alice), "my own CAFÉ cat", None, &[]) + .await + .expect("own post"); + + let timeline = timeline(&pool, Some(alice), "local", None) + .await + .expect("timeline"); + let texts = timeline + .iter() + .map(|post| post.text.as_str()) + .collect::>(); + assert!(texts.contains(&"a dog appears")); + assert!(texts.contains(&"my own CAFÉ cat")); + assert_eq!(timeline.len(), 2, "unexpected timeline: {texts:?}"); + + let (_, search_posts) = search(&pool, Some(alice), "concatenate") + .await + .expect("search"); + assert!(search_posts.is_empty()); + + remove_muted_word(&pool, alice, stored[0].id) + .await + .expect("remove"); + assert_eq!(muted_words(&pool, alice).await.expect("list").len(), 2); + } + + #[tokio::test] + async fn notifications_hide_blocked_muted_and_muted_word_content() { + let (pool, settings, alice, bob) = fixture().await; + create_post( + &pool, + &settings, + Some(bob), + "spoilers ahead @alice", + None, + &[], + ) + .await + .expect("mention post"); + assert_eq!( + notifications(&pool, alice) + .await + .expect("notifications") + .len(), + 1 + ); + + add_muted_word(&pool, alice, "spoilers") + .await + .expect("mute word"); + assert!( + notifications(&pool, alice) + .await + .expect("notifications") + .is_empty() + ); + assert_eq!( + unread_notification_count(&pool, alice) + .await + .expect("unread"), + 0 + ); + + remove_muted_word( + &pool, + alice, + muted_words(&pool, alice).await.expect("list")[0].id, + ) + .await + .expect("unmute word"); + follow(&pool, bob, alice).await.expect("follow"); + assert_eq!( + notifications(&pool, alice) + .await + .expect("notifications") + .len(), + 2 + ); + + block(&pool, alice, bob).await.expect("block"); + assert!( + notifications(&pool, alice) + .await + .expect("notifications") + .is_empty() + ); + unblock(&pool, alice, bob).await.expect("unblock"); + assert_eq!( + notifications(&pool, alice) + .await + .expect("notifications") + .len(), + 2 + ); + } + + #[tokio::test] + async fn enabling_protection_keeps_followers_and_only_requests_new_follows() { + let (pool, settings, alice, bob) = fixture().await; + let carol = register_account(&pool, &settings, "carol").await; + + assert_eq!( + follow(&pool, bob, alice).await.expect("bob follows"), + FollowOutcome::Followed + ); + assert_eq!(follow_counts(&pool, alice).await.expect("counts"), (1, 0)); + assert!( + followers_accounts(&pool, alice, None, None) + .await + .expect("followers") + .0 + .iter() + .any(|account| account.id == bob) + ); + assert!( + following_accounts(&pool, bob, None) + .await + .expect("bob following") + .0 + .iter() + .any(|account| account.id == alice) + ); + + set_follow_approval_required(&pool, alice, true) + .await + .expect("protect alice"); + + // Existing followers survive the toggle untouched. + assert!(is_following(&pool, bob, alice).await.expect("kept follow")); + assert_eq!(follow_counts(&pool, alice).await.expect("counts"), (1, 0)); + assert!( + followers_accounts(&pool, alice, None, None) + .await + .expect("followers") + .0 + .iter() + .any(|account| account.id == bob) + ); + assert_eq!( + follow(&pool, bob, alice).await.expect("bob repeats"), + FollowOutcome::AlreadyFollowing + ); + + // New follows become pending requests only. + assert_eq!( + follow(&pool, carol, alice).await.expect("carol requests"), + FollowOutcome::Requested + ); + assert_eq!(follow_row_count(&pool, carol, alice).await, 0); + assert!( + !is_following(&pool, carol, alice) + .await + .expect("not following") + ); + assert_eq!(follow_counts(&pool, alice).await.expect("counts"), (1, 0)); + assert!( + followers_accounts(&pool, alice, None, None) + .await + .expect("followers") + .0 + .iter() + .all(|account| account.id != carol) + ); + assert!( + following_accounts(&pool, carol, None) + .await + .expect("carol following") + .0 + .is_empty() + ); + assert_eq!( + pending_follow_request_count(&pool, alice) + .await + .expect("pending"), + 1 + ); + assert_eq!(follow_request_count(&pool, carol, alice).await, 1); + let relationship = profile_relationship(&pool, Some(carol), alice) + .await + .expect("relationship"); + assert!(!relationship.following); + assert!(relationship.requested); + assert_eq!( + notification_count(&pool, alice, "follow_request") + .await + .expect("notification"), + 1 + ); + let (message, actor) = notification_message(&pool, alice, "follow_request") + .await + .expect("lookup") + .expect("notification"); + assert_eq!(message, "requested to follow you"); + assert_eq!(actor, Some(carol)); + } + + #[tokio::test] + async fn disabling_protection_keeps_pending_requests_until_approved() { + let (pool, settings, alice, _bob) = fixture().await; + let carol = register_account(&pool, &settings, "carol").await; + let dave = register_account(&pool, &settings, "dave").await; + + set_follow_approval_required(&pool, alice, true) + .await + .expect("protect alice"); + assert_eq!( + follow(&pool, carol, alice).await.expect("request"), + FollowOutcome::Requested + ); + set_follow_approval_required(&pool, alice, false) + .await + .expect("unprotect alice"); + + // Disabling protection neither approves nor deletes the pending request. + assert_eq!(follow_request_count(&pool, carol, alice).await, 1); + assert_eq!( + pending_follow_request_count(&pool, alice) + .await + .expect("pending"), + 1 + ); + assert_eq!(follow_row_count(&pool, carol, alice).await, 0); + assert!( + !is_following(&pool, carol, alice) + .await + .expect("no auto follow") + ); + assert_eq!(follow_counts(&pool, alice).await.expect("counts"), (0, 0)); + + // New follows are immediate while the old request stays pending. + assert_eq!( + follow(&pool, dave, alice).await.expect("dave"), + FollowOutcome::Followed + ); + assert_eq!(follow_counts(&pool, alice).await.expect("counts"), (1, 0)); + assert_eq!(follow_request_count(&pool, carol, alice).await, 1); + assert_eq!( + pending_follow_request_count(&pool, alice) + .await + .expect("pending"), + 1 + ); + + // The owner can still approve the request created while protected. + assert!( + approve_follow_request(&pool, alice, carol) + .await + .expect("approve") + ); + assert!(is_following(&pool, carol, alice).await.expect("approved")); + assert_eq!(follow_request_count(&pool, carol, alice).await, 0); + assert_eq!( + pending_follow_request_count(&pool, alice) + .await + .expect("pending"), + 0 + ); + assert_eq!(follow_counts(&pool, alice).await.expect("counts"), (2, 0)); + assert_eq!(follow_row_count(&pool, carol, alice).await, 1); + assert_eq!( + notification_count(&pool, carol, "follow_request_approved") + .await + .expect("notification"), + 1 + ); + let (message, actor) = notification_message(&pool, carol, "follow_request_approved") + .await + .expect("lookup") + .expect("notification"); + assert_eq!(message, "approved your follow request"); + assert_eq!(actor, Some(alice)); + + // Re-approving consumes nothing and duplicates nothing. + assert!( + !approve_follow_request(&pool, alice, carol) + .await + .expect("re-approve") + ); + assert_eq!(follow_row_count(&pool, carol, alice).await, 1); + assert_eq!(follow_counts(&pool, alice).await.expect("counts"), (2, 0)); + assert_eq!( + notification_count(&pool, carol, "follow_request_approved") + .await + .expect("notification"), + 1 + ); + } + + #[tokio::test] + #[expect( + clippy::too_many_lines, + reason = "one scenario proves pending requests survive repeated enable/disable cycles without auto-approval or duplicates" + )] + async fn repeated_protection_toggles_never_approve_or_duplicate_pending_requests() { + let (pool, _settings, alice, bob) = fixture().await; + set_follow_approval_required(&pool, alice, true) + .await + .expect("protect alice"); + assert_eq!( + follow(&pool, bob, alice).await.expect("request"), + FollowOutcome::Requested + ); + + for round in 0..3 { + set_follow_approval_required(&pool, alice, false) + .await + .expect("unprotect alice"); + assert_eq!( + follow_request_count(&pool, bob, alice).await, + 1, + "round {round}" + ); + assert_eq!( + pending_follow_request_count(&pool, alice) + .await + .expect("pending"), + 1, + "round {round}" + ); + assert_eq!( + follow_row_count(&pool, bob, alice).await, + 0, + "round {round}" + ); + assert_eq!( + follow(&pool, bob, alice).await.expect("repeat"), + FollowOutcome::AlreadyRequested, + "round {round}" + ); + + set_follow_approval_required(&pool, alice, true) + .await + .expect("protect alice"); + assert_eq!( + follow_request_count(&pool, bob, alice).await, + 1, + "round {round}" + ); + assert_eq!( + pending_follow_request_count(&pool, alice) + .await + .expect("pending"), + 1, + "round {round}" + ); + assert_eq!( + follow_row_count(&pool, bob, alice).await, + 0, + "round {round}" + ); + assert_eq!( + follow(&pool, bob, alice).await.expect("repeat"), + FollowOutcome::AlreadyRequested, + "round {round}" + ); + } + + assert_eq!(follow_counts(&pool, alice).await.expect("counts"), (0, 0)); + assert_eq!( + notification_count(&pool, alice, "follow_request") + .await + .expect("notification"), + 1 + ); + assert_eq!( + notification_count(&pool, alice, "follow") + .await + .expect("notification"), + 0 + ); + assert_eq!( + notification_count(&pool, bob, "follow_request_approved") + .await + .expect("notification"), + 0 + ); + + set_follow_approval_required(&pool, alice, false) + .await + .expect("unprotect alice"); + assert!( + approve_follow_request(&pool, alice, bob) + .await + .expect("approve") + ); + assert_eq!(follow_row_count(&pool, bob, alice).await, 1); + assert_eq!(follow_counts(&pool, alice).await.expect("counts"), (1, 0)); + assert_eq!( + pending_follow_request_count(&pool, alice) + .await + .expect("pending"), + 0 + ); + assert_eq!( + notification_count(&pool, bob, "follow_request_approved") + .await + .expect("notification"), + 1 + ); + } + + #[tokio::test] + async fn pending_requests_are_visible_only_to_requester_and_target() { + let (pool, settings, alice, bob) = fixture().await; + let carol = register_account(&pool, &settings, "carol").await; + let dave = register_account(&pool, &settings, "dave").await; + + set_follow_approval_required(&pool, alice, true) + .await + .expect("protect alice"); + assert_eq!( + follow(&pool, bob, alice).await.expect("request"), + FollowOutcome::Requested + ); + + let incoming = incoming_follow_requests(&pool, alice) + .await + .expect("incoming"); + assert_eq!(incoming.len(), 1); + assert_eq!(incoming[0].user_id, bob); + assert!( + incoming_follow_requests(&pool, bob) + .await + .expect("bob") + .is_empty() + ); + assert!( + incoming_follow_requests(&pool, carol) + .await + .expect("carol") + .is_empty() + ); + assert!( + incoming_follow_requests(&pool, dave) + .await + .expect("dave") + .is_empty() + ); + + let outgoing = outgoing_follow_requests(&pool, bob) + .await + .expect("outgoing"); + assert_eq!(outgoing.len(), 1); + assert_eq!(outgoing[0].user_id, alice); + assert!( + outgoing_follow_requests(&pool, alice) + .await + .expect("alice") + .is_empty() + ); + assert!( + outgoing_follow_requests(&pool, carol) + .await + .expect("carol") + .is_empty() + ); + assert!( + outgoing_follow_requests(&pool, dave) + .await + .expect("dave") + .is_empty() + ); + + assert_eq!( + pending_follow_request_count(&pool, alice) + .await + .expect("pending"), + 1 + ); + for unrelated in [bob, carol, dave] { + assert_eq!( + pending_follow_request_count(&pool, unrelated) + .await + .expect("pending"), + 0 + ); + } + } + + #[tokio::test] + async fn simultaneous_request_resolution_picks_exactly_one_winner() { + let (pool, settings, alice, bob) = fixture().await; + let carol = register_account(&pool, &settings, "carol").await; + let dave = register_account(&pool, &settings, "dave").await; + set_follow_approval_required(&pool, alice, true) + .await + .expect("protect alice"); + + assert_eq!( + follow(&pool, bob, alice).await.expect("request"), + FollowOutcome::Requested + ); + let (approved, cancelled) = tokio::join!( + approve_follow_request(&pool, alice, bob), + cancel_follow_request(&pool, bob, alice) + ); + let approved = approved.expect("approve"); + let cancelled = cancelled.expect("cancel"); + assert_ne!(approved, cancelled, "exactly one must resolve the request"); + assert_eq!(follow_request_count(&pool, bob, alice).await, 0); + assert_eq!( + pending_follow_request_count(&pool, alice) + .await + .expect("pending"), + 0 + ); + let expected_rows = i64::from(approved); + let expected_notifications = i64::from(approved); + assert_eq!(follow_row_count(&pool, bob, alice).await, expected_rows); + assert_eq!( + notification_count(&pool, bob, "follow_request_approved") + .await + .expect("notification"), + expected_notifications + ); + + assert_eq!( + follow(&pool, carol, alice).await.expect("request"), + FollowOutcome::Requested + ); + let (approved, rejected) = tokio::join!( + approve_follow_request(&pool, alice, carol), + reject_follow_request(&pool, alice, carol) + ); + let approved = approved.expect("approve"); + let rejected = rejected.expect("reject"); + assert_ne!(approved, rejected, "exactly one must resolve the request"); + assert_eq!(follow_request_count(&pool, carol, alice).await, 0); + assert_eq!( + follow_row_count(&pool, carol, alice).await, + i64::from(approved) + ); + assert_eq!( + notification_count(&pool, carol, "follow_request_approved") + .await + .expect("notification"), + i64::from(approved) + ); + + // Cancelling and approving in the same tick resolves the request once. + assert_eq!( + follow(&pool, dave, alice).await.expect("request"), + FollowOutcome::Requested + ); + let (cancelled, approved) = tokio::join!( + cancel_follow_request(&pool, dave, alice), + approve_follow_request(&pool, alice, dave) + ); + let cancelled = cancelled.expect("cancel"); + let approved = approved.expect("approve"); + assert_ne!(cancelled, approved, "exactly one must resolve the request"); + assert_eq!(follow_request_count(&pool, dave, alice).await, 0); + assert_eq!( + follow_row_count(&pool, dave, alice).await, + i64::from(approved) + ); + assert_eq!( + notification_count(&pool, dave, "follow_request_approved") + .await + .expect("notification"), + i64::from(approved) + ); + } + + #[tokio::test] + async fn blocks_clear_pending_requests_and_unblock_does_not_restore_them() { + let (pool, settings, alice, bob) = fixture().await; + let carol = register_account(&pool, &settings, "carol").await; + set_follow_approval_required(&pool, alice, true) + .await + .expect("protect alice"); + + // The requester blocking the target clears the pending request. + assert_eq!( + follow(&pool, bob, alice).await.expect("request"), + FollowOutcome::Requested + ); + assert_eq!( + pending_follow_request_count(&pool, alice) + .await + .expect("pending"), + 1 + ); + block(&pool, bob, alice).await.expect("bob blocks alice"); + assert_eq!(follow_request_count(&pool, bob, alice).await, 0); + assert_eq!( + pending_follow_request_count(&pool, alice) + .await + .expect("pending"), + 0 + ); + assert!(follow(&pool, bob, alice).await.is_err()); + assert!( + !approve_follow_request(&pool, alice, bob) + .await + .expect("approve") + ); + assert!( + !reject_follow_request(&pool, alice, bob) + .await + .expect("reject") + ); + assert!( + !cancel_follow_request(&pool, bob, alice) + .await + .expect("cancel") + ); + assert_eq!(follow_row_count(&pool, bob, alice).await, 0); + + // Unblocking leaves the cleared request gone; a new one is a fresh row. + unblock(&pool, bob, alice).await.expect("unblock"); + assert_eq!(follow_request_count(&pool, bob, alice).await, 0); + assert_eq!( + pending_follow_request_count(&pool, alice) + .await + .expect("pending"), + 0 + ); + assert_eq!( + follow(&pool, bob, alice).await.expect("re-request"), + FollowOutcome::Requested + ); + assert_eq!(follow_request_count(&pool, bob, alice).await, 1); + + // The target blocking the requester clears it from the other direction. + block(&pool, alice, bob).await.expect("alice blocks bob"); + assert_eq!(follow_request_count(&pool, bob, alice).await, 0); + assert_eq!( + pending_follow_request_count(&pool, alice) + .await + .expect("pending"), + 0 + ); + assert!(follow(&pool, bob, alice).await.is_err()); + unblock(&pool, alice, bob).await.expect("unblock"); + assert_eq!(follow_request_count(&pool, bob, alice).await, 0); + + // A third account's block also prevents new pending requests. + set_follow_approval_required(&pool, carol, true) + .await + .expect("protect carol"); + block(&pool, carol, bob).await.expect("carol blocks bob"); + assert!(follow(&pool, bob, carol).await.is_err()); + assert_eq!(follow_request_count(&pool, bob, carol).await, 0); + assert_eq!(follow_row_count(&pool, bob, carol).await, 0); + } + + #[tokio::test] + #[expect( + clippy::too_many_lines, + reason = "one scenario covers deleted and suspended targets, requesters, and pending requests in sequence" + )] + async fn follow_transitions_skip_deleted_and_suspended_accounts() { + let (pool, settings, alice, bob) = fixture().await; + let carol = register_account(&pool, &settings, "carol").await; + let dave = register_account(&pool, &settings, "dave").await; + let grace = register_account(&pool, &settings, "grace").await; + set_account_flags(&pool, carol, true, false).await; + set_account_flags(&pool, dave, false, true).await; + + // Unavailable targets cannot be followed... + assert_eq!( + follow(&pool, alice, carol) + .await + .expect_err("deleted target") + .to_string(), + "account cannot be followed" + ); + assert_eq!( + follow(&pool, alice, dave) + .await + .expect_err("suspended target") + .to_string(), + "account cannot be followed" + ); + // ...and unavailable accounts cannot follow or request either. + assert_eq!( + follow(&pool, carol, alice) + .await + .expect_err("deleted requester") + .to_string(), + "account cannot follow" + ); + assert_eq!( + follow(&pool, dave, alice) + .await + .expect_err("suspended requester") + .to_string(), + "account cannot follow" + ); + assert_eq!(follow_row_count(&pool, carol, alice).await, 0); + assert_eq!(follow_row_count(&pool, dave, alice).await, 0); + assert_eq!(follow_request_count(&pool, carol, alice).await, 0); + assert_eq!(follow_request_count(&pool, dave, alice).await, 0); + assert_eq!( + notification_count(&pool, alice, "follow_request") + .await + .expect("notification"), + 0 + ); + + // A suspended requester's pending request is discarded on approval. + set_follow_approval_required(&pool, alice, true) + .await + .expect("protect alice"); + assert_eq!( + follow(&pool, bob, alice).await.expect("request"), + FollowOutcome::Requested + ); + assert_eq!( + pending_follow_request_count(&pool, alice) + .await + .expect("pending"), + 1 + ); + set_account_flags(&pool, bob, false, true).await; + assert!( + !approve_follow_request(&pool, alice, bob) + .await + .expect("approve") + ); + assert_eq!(follow_request_count(&pool, bob, alice).await, 0); + assert_eq!(follow_row_count(&pool, bob, alice).await, 0); + assert_eq!(follow_counts(&pool, alice).await.expect("counts"), (0, 0)); + assert_eq!( + pending_follow_request_count(&pool, alice) + .await + .expect("pending"), + 0 + ); + assert_eq!( + notification_count(&pool, bob, "follow_request_approved") + .await + .expect("notification"), + 0 + ); + + // A deleted requester's request is hidden and discarded the same way. + assert_eq!( + follow(&pool, grace, alice).await.expect("request"), + FollowOutcome::Requested + ); + set_account_flags(&pool, grace, true, false).await; + assert!( + !incoming_follow_requests(&pool, alice) + .await + .expect("incoming") + .iter() + .any(|request| request.user_id == grace) + ); + assert_eq!( + pending_follow_request_count(&pool, alice) + .await + .expect("pending"), + 0 + ); assert!( - blocks == 0 || follows == 0, - "blocks={blocks} follows={follows}" + !approve_follow_request(&pool, alice, grace) + .await + .expect("approve") ); + assert_eq!(follow_request_count(&pool, grace, alice).await, 0); + assert_eq!(follow_row_count(&pool, grace, alice).await, 0); } #[tokio::test] - async fn muted_words_are_case_insensitive_unicode_and_exempt_own_posts() { - let (pool, settings, alice, bob) = fixture().await; - add_muted_word(&pool, alice, "CAFÉ").await.expect("mute"); - add_muted_word(&pool, alice, "breaking news") - .await - .expect("mute phrase"); - add_muted_word(&pool, alice, "cat").await.expect("mute"); - // Duplicates (case-insensitive) collapse to one row. - add_muted_word(&pool, alice, "café") + async fn duplicate_follow_requests_create_exactly_one_row_and_notification() { + let (pool, _settings, alice, bob) = fixture().await; + set_follow_approval_required(&pool, alice, true) .await - .expect("dup mute"); - let stored = muted_words(&pool, alice).await.expect("list"); - assert_eq!(stored.len(), 3); - for invalid in ["", " ", "\n"] { - assert!(add_muted_word(&pool, alice, invalid).await.is_err()); + .expect("protect alice"); + assert_eq!( + follow(&pool, bob, alice).await.expect("request"), + FollowOutcome::Requested + ); + for _ in 0..5 { + assert_eq!( + follow(&pool, bob, alice).await.expect("duplicate"), + FollowOutcome::AlreadyRequested + ); } + assert_eq!(follow_request_count(&pool, bob, alice).await, 1); + assert_eq!( + pending_follow_request_count(&pool, alice) + .await + .expect("pending"), + 1 + ); + assert_eq!( + notification_count(&pool, alice, "follow_request") + .await + .expect("notification"), + 1 + ); + assert_eq!( + incoming_follow_requests(&pool, alice) + .await + .expect("incoming") + .len(), + 1 + ); + assert_eq!( + outgoing_follow_requests(&pool, bob) + .await + .expect("outgoing") + .len(), + 1 + ); + } + + #[tokio::test] + async fn follow_counts_and_pending_counts_stay_consistent_across_states() { + let (pool, settings, alice, bob) = fixture().await; + let carol = register_account(&pool, &settings, "carol").await; + let dave = register_account(&pool, &settings, "dave").await; + set_follow_approval_required(&pool, alice, true) + .await + .expect("protect alice"); + + assert_eq!(follow_counts(&pool, alice).await.expect("counts"), (0, 0)); + assert_eq!( + pending_follow_request_count(&pool, alice) + .await + .expect("pending"), + 0 + ); + + assert_eq!( + follow(&pool, bob, alice).await.expect("request"), + FollowOutcome::Requested + ); + assert_eq!( + follow(&pool, carol, alice).await.expect("request"), + FollowOutcome::Requested + ); + assert_eq!(follow_counts(&pool, alice).await.expect("counts"), (0, 0)); + assert_eq!( + pending_follow_request_count(&pool, alice) + .await + .expect("pending"), + 2 + ); + assert_eq!( + incoming_follow_requests(&pool, alice) + .await + .expect("incoming") + .len(), + 2 + ); assert!( - add_muted_word(&pool, alice, &"x".repeat(101)) + followers_accounts(&pool, alice, None, None) .await - .is_err() + .expect("followers") + .0 + .is_empty() ); - let hidden = [ - "CAFÉ is open", - "a café visit", - "Breaking NEWS today", - "concatenate the strings", - "the cat sat down", - ]; - for text in hidden { - create_post(&pool, &settings, Some(bob), text, None, &[]) + assert!( + approve_follow_request(&pool, alice, bob) .await - .expect("hidden post"); - } - create_post(&pool, &settings, Some(bob), "a dog appears", None, &[]) - .await - .expect("visible post"); - create_post(&pool, &settings, Some(alice), "my own CAFÉ cat", None, &[]) - .await - .expect("own post"); + .expect("approve") + ); + assert_eq!(follow_counts(&pool, alice).await.expect("counts"), (1, 0)); + assert_eq!( + pending_follow_request_count(&pool, alice) + .await + .expect("pending"), + 1 + ); + assert_eq!( + incoming_follow_requests(&pool, alice) + .await + .expect("incoming") + .len(), + 1 + ); + assert_eq!( + following_accounts(&pool, bob, None) + .await + .expect("bob following") + .0 + .len(), + 1 + ); - let timeline = timeline(&pool, Some(alice), "local", None) - .await - .expect("timeline"); - let texts = timeline - .iter() - .map(|post| post.text.as_str()) - .collect::>(); - assert!(texts.contains(&"a dog appears")); - assert!(texts.contains(&"my own CAFÉ cat")); - assert_eq!(timeline.len(), 2, "unexpected timeline: {texts:?}"); + assert!( + reject_follow_request(&pool, alice, carol) + .await + .expect("reject") + ); + assert_eq!(follow_counts(&pool, alice).await.expect("counts"), (1, 0)); + assert_eq!( + pending_follow_request_count(&pool, alice) + .await + .expect("pending"), + 0 + ); - let (_, search_posts) = search(&pool, Some(alice), "concatenate") + set_follow_approval_required(&pool, alice, false) .await - .expect("search"); - assert!(search_posts.is_empty()); + .expect("unprotect alice"); + assert_eq!( + follow(&pool, dave, alice).await.expect("follow"), + FollowOutcome::Followed + ); + assert_eq!(follow_counts(&pool, alice).await.expect("counts"), (2, 0)); + assert_eq!( + follow_counts(&pool, dave).await.expect("dave counts"), + (0, 1) + ); - remove_muted_word(&pool, alice, stored[0].id) + assert!(unfollow(&pool, dave, alice).await.expect("unfollow")); + assert_eq!(follow_counts(&pool, alice).await.expect("counts"), (1, 0)); + assert_eq!( + follow_counts(&pool, dave).await.expect("dave counts"), + (0, 0) + ); + } + + #[tokio::test] + async fn approving_a_request_whose_follow_already_exists_is_a_no_op() { + let (pool, _settings, alice, bob) = fixture().await; + set_follow_approval_required(&pool, alice, true) .await - .expect("remove"); - assert_eq!(muted_words(&pool, alice).await.expect("list").len(), 2); + .expect("protect alice"); + assert_eq!( + follow(&pool, bob, alice).await.expect("request"), + FollowOutcome::Requested + ); + // Simulate a racing approval that already created the follow row. + pool.call(move |conn| { + conn.execute( + "INSERT INTO follows (follower_id, followed_id) VALUES (?, ?)", + params![bob, alice], + )?; + Ok(()) + }) + .await + .expect("seed follow"); + + assert!( + approve_follow_request(&pool, alice, bob) + .await + .expect("approve") + ); + assert_eq!(follow_request_count(&pool, bob, alice).await, 0); + assert_eq!(follow_row_count(&pool, bob, alice).await, 1); + assert_eq!(follow_counts(&pool, alice).await.expect("counts"), (1, 0)); + assert_eq!( + notification_count(&pool, bob, "follow_request_approved") + .await + .expect("notification"), + 0 + ); + + // Approving, rejecting, or cancelling a resolved request is a no-op. + assert!( + !approve_follow_request(&pool, alice, bob) + .await + .expect("re-approve") + ); + assert!( + !reject_follow_request(&pool, alice, bob) + .await + .expect("reject") + ); + assert!( + !cancel_follow_request(&pool, bob, alice) + .await + .expect("cancel") + ); + assert_eq!(follow_row_count(&pool, bob, alice).await, 1); + assert_eq!( + notification_count(&pool, bob, "follow_request_approved") + .await + .expect("notification"), + 0 + ); } #[tokio::test] - async fn notifications_hide_blocked_muted_and_muted_word_content() { + async fn follow_request_lifecycle_creates_expected_notifications_only() { let (pool, settings, alice, bob) = fixture().await; - create_post( - &pool, - &settings, - Some(bob), - "spoilers ahead @alice", - None, - &[], - ) - .await - .expect("mention post"); + let carol = register_account(&pool, &settings, "carol").await; + let dave = register_account(&pool, &settings, "dave").await; + set_follow_approval_required(&pool, alice, true) + .await + .expect("protect alice"); + assert_eq!( - notifications(&pool, alice) + follow(&pool, bob, alice).await.expect("request"), + FollowOutcome::Requested + ); + assert_eq!( + notification_count(&pool, alice, "follow_request") .await - .expect("notifications") - .len(), + .expect("notification"), + 1 + ); + assert!( + approve_follow_request(&pool, alice, bob) + .await + .expect("approve") + ); + assert_eq!( + notification_count(&pool, bob, "follow_request_approved") + .await + .expect("notification"), 1 ); - add_muted_word(&pool, alice, "spoilers") - .await - .expect("mute word"); + assert_eq!( + follow(&pool, carol, alice).await.expect("request"), + FollowOutcome::Requested + ); assert!( - notifications(&pool, alice) + reject_follow_request(&pool, alice, carol) .await - .expect("notifications") - .is_empty() + .expect("reject") ); assert_eq!( - unread_notification_count(&pool, alice) + notification_count(&pool, carol, "follow_request_approved") .await - .expect("unread"), + .expect("notification"), 0 ); - remove_muted_word( - &pool, - alice, - muted_words(&pool, alice).await.expect("list")[0].id, - ) - .await - .expect("unmute word"); - follow(&pool, bob, alice).await.expect("follow"); assert_eq!( - notifications(&pool, alice) + follow(&pool, dave, alice).await.expect("request"), + FollowOutcome::Requested + ); + assert!( + cancel_follow_request(&pool, dave, alice) .await - .expect("notifications") - .len(), - 2 + .expect("cancel") + ); + assert_eq!( + notification_count(&pool, dave, "follow_request_approved") + .await + .expect("notification"), + 0 ); - block(&pool, alice, bob).await.expect("block"); + assert_eq!( + notification_count(&pool, alice, "follow_request") + .await + .expect("notification"), + 3 + ); + assert_eq!( + notification_count(&pool, alice, "follow") + .await + .expect("notification"), + 0 + ); + } + + #[tokio::test] + async fn disabling_protection_never_promotes_pending_requests() { + let (pool, _settings, alice, bob) = fixture().await; + set_follow_approval_required(&pool, alice, true) + .await + .expect("protect alice"); + assert_eq!( + follow(&pool, bob, alice).await.expect("request"), + FollowOutcome::Requested + ); + + for _ in 0..3 { + set_follow_approval_required(&pool, alice, false) + .await + .expect("unprotect alice"); + assert_eq!(follow_row_count(&pool, bob, alice).await, 0); + set_follow_approval_required(&pool, alice, true) + .await + .expect("protect alice"); + assert_eq!(follow_row_count(&pool, bob, alice).await, 0); + } assert!( - notifications(&pool, alice) + !is_following(&pool, bob, alice) .await - .expect("notifications") - .is_empty() + .expect("not following") ); - unblock(&pool, alice, bob).await.expect("unblock"); + assert_eq!(follow_counts(&pool, alice).await.expect("counts"), (0, 0)); + assert_eq!(follow_request_count(&pool, bob, alice).await, 1); assert_eq!( - notifications(&pool, alice) + pending_follow_request_count(&pool, alice) .await - .expect("notifications") - .len(), - 2 + .expect("pending"), + 1 + ); + assert_eq!( + notification_count(&pool, bob, "follow_request_approved") + .await + .expect("notification"), + 0 + ); + let relationship = profile_relationship(&pool, Some(bob), alice) + .await + .expect("relationship"); + assert!(!relationship.following); + assert!(relationship.requested); + + // Only explicit approval promotes the request to a real follow. + set_follow_approval_required(&pool, alice, false) + .await + .expect("unprotect alice"); + assert!( + approve_follow_request(&pool, alice, bob) + .await + .expect("approve") ); + assert_eq!(follow_row_count(&pool, bob, alice).await, 1); + assert_eq!(follow_counts(&pool, alice).await.expect("counts"), (1, 0)); } } From af74eac357aa45361fab1988a8f23abe26be078c Mon Sep 17 00:00:00 2001 From: csd113 Date: Tue, 29 Sep 2026 07:59:55 -0700 Subject: [PATCH 18/21] Upgrade Arti and Tor crates to 0.46.0 --- CHANGELOG.md | 4 +- Cargo.lock | 206 ++++++++++++++++++++++++++++++++------------------- Cargo.toml | 10 +-- README.md | 12 +-- 4 files changed, 141 insertions(+), 91 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 931ff50..2395c3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,9 +20,9 @@ ### Runtime and Dependency Baseline - Raised the minimum supported Rust version to `1.91` and updated local, CI, and release-build documentation accordingly. -- Upgraded the embedded Arti and Tor crate family to `0.45.0`, including the onion-stream API migration required by that release. +- Upgraded the embedded Arti and Tor crate family to `0.46.0`, including the onion-stream API migration from the earlier pre-release baseline. - Updated direct dependencies to their latest compatible releases, including `base64 0.23`, `tower-http 0.7`, `infer 0.22`, and `ureq 3.4`. -- Kept `rusqlite` at `0.36` so Cargo resolves a single compatible `libsqlite3-sys` version with Arti `0.45.0`. +- Kept `rusqlite` at `0.36` so Cargo resolves a single compatible `libsqlite3-sys` version with Arti `0.46.0`. ### Release Blockers Fixed - Standardized the release artifact, install docs, CI, and operator commands on the single `rustpost-cli` binary. diff --git a/Cargo.lock b/Cargo.lock index 2175921..d7eb54f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -160,9 +160,9 @@ checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "arti-client" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18e7e5f663c4a5db15ba9377b98b3fff2190f64c02e608beb1292dd67b30f199" +checksum = "aafcbc506657d886b27ef2d8e06a99edd9014b28130c798a3013935b46983097" dependencies = [ "async-trait", "cfg-if", @@ -673,6 +673,12 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "coarsetime" version = "0.1.38" @@ -842,6 +848,15 @@ dependencies = [ "cipher 0.5.2", ] +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -1726,6 +1741,12 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + [[package]] name = "hashbrown" version = "0.17.1" @@ -2895,6 +2916,17 @@ dependencies = [ "miniz_oxide 0.8.9", ] +[[package]] +name = "polyval" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" +dependencies = [ + "cpubits", + "cpufeatures 0.3.1", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -4261,9 +4293,9 @@ checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tor-async-utils" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0969a2e97472807009ea516a0b98541048d59b21afad98e1da9c577cd93d5db9" +checksum = "31d9f28de36ed7646f0f03835ce2e306e2c5df8a1784a2cac27bd549cdc07599" dependencies = [ "cfg-if", "derive-deftly", @@ -4273,6 +4305,7 @@ dependencies = [ "oneshot-fused-workaround", "pin-project", "postage", + "slotmap-careful", "sync_wrapper", "thiserror 2.0.21", "tokio", @@ -4286,9 +4319,9 @@ dependencies = [ [[package]] name = "tor-basic-utils" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70c40efc7fdfd91d8e774cccaff5d8218cc6b914a5c65b04ca5719416427c9f8" +checksum = "1a2bd42110ae0faea1d446b35899aa5c3591cd31a94b593aa9264f00baba41da" dependencies = [ "derive-deftly", "derive_more", @@ -4312,9 +4345,9 @@ dependencies = [ [[package]] name = "tor-bytes" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c23ad66d11cdb8052cd52f6f54cd14cc8e060fb49284a717246213500e6e911f" +checksum = "73de2ba54a125c27a440616f2d806a171bcb4e30013de166c7dc0a79bde44ab4" dependencies = [ "bytes", "derive-deftly", @@ -4330,9 +4363,9 @@ dependencies = [ [[package]] name = "tor-cell" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8bb1363898e3a5eebc3fd0bff63e00449810d4ad96699b685a8537b92929958" +checksum = "118f2b6a56605bedd077003ee5678fda215965d550dd0aef21ce8e10dd082750" dependencies = [ "amplify", "bitflags 2.13.2", @@ -4361,9 +4394,9 @@ dependencies = [ [[package]] name = "tor-cert" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12a760ed5a0177a49d405c9ff53a17cf0348b3a297f8c228e860e880a878649c" +checksum = "b2cec6ae3abafc382e126a26e74f000bffbbf2c9b1a8c9ec26bb84463f0120b4" dependencies = [ "caret", "derive_builder_fork_arti", @@ -4380,9 +4413,9 @@ dependencies = [ [[package]] name = "tor-chanmgr" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e6be3bca876eda4aa9a47e77f803f2914f4c882f9affde994219146b6a88e8c" +checksum = "6cef673b170f10790ff81d2f190e0a8150e5c9683c2c8f4c09652a6954c0e5ab" dependencies = [ "async-trait", "base64ct", @@ -4423,9 +4456,9 @@ dependencies = [ [[package]] name = "tor-checkable" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcdb5c50f28a24a4048a5ff8db5089eb1de204de80dc5ef40e04188ba66c8025" +checksum = "d3872a5bda49647ad4c4a822c58774995ad3acd02327f1cabe6cfc019d714535" dependencies = [ "humantime", "itertools", @@ -4437,9 +4470,9 @@ dependencies = [ [[package]] name = "tor-circmgr" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2ababd68e4bdbc4da874eef6938fdb8792f387d1722f53574a88e84a08a54af" +checksum = "2cf1eb6c2e3184c9e51c746df81f1a890ba7b663ac418debe2dac3756d840c8a" dependencies = [ "amplify", "async-trait", @@ -4487,9 +4520,9 @@ dependencies = [ [[package]] name = "tor-config" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ecacf2afa11dc25841514d69f0a770abf3b2c0da792ef47e5bdfdfe07bf67e" +checksum = "dd8ca5684100c9223590c11685f3f39c4e4c0037c65daddc26927b3ae4cc893d" dependencies = [ "amplify", "cfg-if", @@ -4522,9 +4555,9 @@ dependencies = [ [[package]] name = "tor-config-path" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52de46b996c648c3880ae0cc0cf8dd2cf9be4466be65e24df39790bd6e673204" +checksum = "cbed06099bacbdbb1d91cfff1032863896d6097c33eece067ab20756e4a6fa2b" dependencies = [ "directories", "serde", @@ -4536,9 +4569,9 @@ dependencies = [ [[package]] name = "tor-consdiff" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "243ef0da7444be7f8f15ba17e3eb7c9e4359e9f93ddb68abd33984874e39cf6e" +checksum = "9760d0051159a257b1d0065615ad6bc023da4e178d41ff5640222a29852de848" dependencies = [ "derive_more", "digest 0.10.7", @@ -4553,9 +4586,9 @@ dependencies = [ [[package]] name = "tor-dirclient" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c62385daf1ec5a8a7132c9da0baf5121169316d2fc3a40886a26a6074fa635" +checksum = "403a79a0d00de6c78f15773a284e790459cff6983449e8578490e6b5dfb2afcd" dependencies = [ "async-compression", "base64ct", @@ -4582,9 +4615,9 @@ dependencies = [ [[package]] name = "tor-dircommon" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44132db8353536b8c79b81c9d0887053c1fe4679f0198b5f244d15a542b5bf16" +checksum = "dd5cd2a4f641ecb007cc50914bb1b46128de0975aba08baa56497c8e03c71c71" dependencies = [ "base64ct", "derive-deftly", @@ -4603,9 +4636,9 @@ dependencies = [ [[package]] name = "tor-dirmgr" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b5523974c841cf2a9f85cfae6a1345572ac6e9deda1a43c2acf5301bddf8f83" +checksum = "805da4e947311285ce0782ab129df318701c2c08046c316086cb6bb3eb7a463e" dependencies = [ "async-trait", "base64ct", @@ -4660,9 +4693,9 @@ dependencies = [ [[package]] name = "tor-error" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ac7221e0878a49f9eaa1adbfcbc15300699c7fc65644432706bef3cb99c5fc3" +checksum = "08f29e906f9f333a6e32e32afac9fb6712ac340191eb3292295b3337682b2dad" dependencies = [ "derive_more", "futures", @@ -4678,9 +4711,9 @@ dependencies = [ [[package]] name = "tor-general-addr" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b55314b3d95d4145c2d88cee46d4c623b209eceb980ac151c35ab97b2cba9de" +checksum = "7b45ecd95dbf79070f9e477e1d84b22e47f03b2ab2f853abe31887daa9090cf0" dependencies = [ "derive_more", "thiserror 2.0.21", @@ -4689,9 +4722,9 @@ dependencies = [ [[package]] name = "tor-guardmgr" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ddc6ae93e20b4e90174a915fca0be127753664886ae41722425dbd88bc4f500" +checksum = "3e84beedbf276ce4ea170206b38ca01c995dbf1b9f678e9708f50e2e71dbfef4" dependencies = [ "amplify", "base64ct", @@ -4733,9 +4766,9 @@ dependencies = [ [[package]] name = "tor-hsclient" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00a766e2a933e69f627ab112232634d1c5d92f8a24f694484aaf8b47f98e664e" +checksum = "3eba66be03b5eb899185e5b4416d8893fdb09a97b33db9e450042f06e2550f0e" dependencies = [ "async-trait", "cfg-if", @@ -4779,9 +4812,9 @@ dependencies = [ [[package]] name = "tor-hscrypto" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6438a115a512791dc1309ab8a70b969c89631914d8aa0cfa35cdb20d5d33b7" +checksum = "118ff6a69bfa111b6e8d087ffb107ea9cbcd5d4469295cd7ada1b3cf19ba5a0f" dependencies = [ "cipher 0.5.2", "data-encoding", @@ -4812,9 +4845,9 @@ dependencies = [ [[package]] name = "tor-hsservice" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3291bbf7641b457a16ef803db4d585334e669fa72fe194a276e4c6fc631f858d" +checksum = "d61a6b5217c14780f54fa1c8610514b1349c93f657304dec98bd8e3083e1a09f" dependencies = [ "amplify", "async-trait", @@ -4871,9 +4904,9 @@ dependencies = [ [[package]] name = "tor-key-forge" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "763406a26bdb2ce8d9b1bd52bc1eb5d808a9c5b4f25ed687972e2ac869be801f" +checksum = "1861b5eebab93515a0ab2401540f3d5a9fb376f1b1535d5793dcd697fb19f928" dependencies = [ "derive-deftly", "derive_more", @@ -4893,9 +4926,9 @@ dependencies = [ [[package]] name = "tor-keymgr" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43f7f56a7b14d090d29e2e3c78b2fea84a6b97ad9b4b9fc13c4e09ec5ac283fa" +checksum = "b3088a573dadebb34acacb30d686751e2a94d12ccf9d6647946ab6c6b4ad5a0e" dependencies = [ "amplify", "arrayvec", @@ -4934,9 +4967,9 @@ dependencies = [ [[package]] name = "tor-linkspec" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "608f77e1b9acba4304b45d2810e1d41c3899741dbdadf2f6e1e18baea29bbd5b" +checksum = "6d8d3a2aaf3bb67110218b9ff1718cd7d6c7e62226e2fee1a18efa6aa19ff07c" dependencies = [ "base64ct", "by_address", @@ -4961,9 +4994,9 @@ dependencies = [ [[package]] name = "tor-llcrypto" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b7b91d916be50cbbb575c468f6926cc34c3003d0162a644195bf60c00ad0247" +checksum = "4fee81755528eb34f8352ca6987d5f5c66ebba6af2e4618da4b527f17b0e912e" dependencies = [ "aes", "base64ct", @@ -5004,9 +5037,9 @@ dependencies = [ [[package]] name = "tor-log-ratelim" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "382fbfbe7b7a6e88165bc473362616b2360c5d07d2c66c9362c79b0f6da3d39c" +checksum = "2f8696c8ea9090059d91eea287628b5df0b3b4fba433179d893e348857857544" dependencies = [ "futures", "humantime", @@ -5020,9 +5053,9 @@ dependencies = [ [[package]] name = "tor-memquota" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a433d849640322ccfa68015e5ae5835471e0959e70d097833f3847f5934239" +checksum = "03786604757846a5f936285eef397e3fc0ed455893dc5012ceafdf455ada0d8d" dependencies = [ "cfg-if", "derive-deftly", @@ -5052,9 +5085,9 @@ dependencies = [ [[package]] name = "tor-memquota-cost" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93d6db1779594e2934ea62b83baedb0b2de1f436177d3b07a497b252ca7b522d" +checksum = "86d6f7e1d842e6272e414e1f866dc688306b788d368df42e47c4995e040c679c" dependencies = [ "derive-deftly", "itertools", @@ -5064,9 +5097,9 @@ dependencies = [ [[package]] name = "tor-netdir" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4214b180dae5f489253a2495322cf2c83f3bc7a4b246747cc6bf2ad1cba41ca1" +checksum = "9d3b84edfe732b77fb1420347902c06d92a1e6992e378ad19da6bdadcb8cd798" dependencies = [ "async-trait", "bitflags 2.13.2", @@ -5097,9 +5130,9 @@ dependencies = [ [[package]] name = "tor-netdoc" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f0f1ef6582bab04851c78982f7a43b7f6058498338ec9ff8f6a5e2d34471c95" +checksum = "5b6abd7aaeb2bdbb3fbf760aab9aaa4e8e1a0f8b84d93a834df5a16882faa31e" dependencies = [ "amplify", "base64ct", @@ -5150,9 +5183,9 @@ dependencies = [ [[package]] name = "tor-persist" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2718cecfe2ef009ab81f0426ce64be6aa4098c04c13d9cc116b5128376f37c2e" +checksum = "e6f2a41d13a685e181361cfc3bdf25d319b96b56f5806594b6b8ed760d0099ed" dependencies = [ "amplify", "derive-deftly", @@ -5180,10 +5213,11 @@ dependencies = [ [[package]] name = "tor-proto" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9098bb3d88ccfcfbf3b34619df8dedf850ee1ddeba8ed70b6e78e64f3349c43e" +checksum = "0ddf767335ae2e255025536d48384b92c6b4b8557e5b345be600e7ca2de1c880" dependencies = [ + "aes", "amplify", "async-trait", "asynchronous-codec", @@ -5193,6 +5227,7 @@ dependencies = [ "cfg-if", "cipher 0.5.2", "coarsetime", + "ctr", "derive-deftly", "derive_builder_fork_arti", "derive_more", @@ -5207,6 +5242,7 @@ dependencies = [ "nonany", "oneshot-fused-workaround", "pin-project", + "polyval", "postage", "rand 0.10.3", "rand_core 0.10.1", @@ -5246,9 +5282,9 @@ dependencies = [ [[package]] name = "tor-protover" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be1e76606ba190039026fe949af420db546058d64de69ebc42a454a0745607b8" +checksum = "cab080a7551561bd56c0c2e3578b7114f0853700b96e43613fabc1fe06e5d885" dependencies = [ "caret", "derive-deftly", @@ -5261,9 +5297,9 @@ dependencies = [ [[package]] name = "tor-relay-selection" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465cc59cd3713b1b1dcb3daafd21153509b49a91dfd19711df3143154806bc97" +checksum = "07a5c22b7cfd59c40e38d4e111906793c0d65cf881e31efe3a83cac42887b1c9" dependencies = [ "rand 0.10.3", "serde", @@ -5275,9 +5311,9 @@ dependencies = [ [[package]] name = "tor-rtcompat" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c0c4f9845afe4a2be175091a3610cb0e76361a445f4d849d81e5bccced827dd" +checksum = "542f4e13054bbba8d764ee24178fd889f77afb59dca7c6ae0a370604118c20b6" dependencies = [ "amplify", "async-trait", @@ -5312,9 +5348,9 @@ dependencies = [ [[package]] name = "tor-rtmock" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8b042bfc0441618a513568cc7cd7544af146c7e3841857cc6ad39caca85528f" +checksum = "04cb78b3a39634e46c1bc795f62fca17b4d8a9404fead18750592a03216e1da3" dependencies = [ "amplify", "assert_matches", @@ -5342,9 +5378,9 @@ dependencies = [ [[package]] name = "tor-socksproto" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c52feb363eefbe662c059f39969842dc001bf82dbe81849c703d5051cbd10266" +checksum = "2f96e25e5d36b4aff59af40ec86f7ebb227a32b2f726ef1bc8cccf927f8fb31a" dependencies = [ "amplify", "caret", @@ -5359,9 +5395,9 @@ dependencies = [ [[package]] name = "tor-units" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f985b939892d57ab7dfe06ad6cf54aa82808305b22b176191c54cbabab5c073d" +checksum = "9a562c031f6c1f9d22f7bbcfbe715840d0e2cfe5e59d1296cc26000c17130555" dependencies = [ "derive-deftly", "derive_more", @@ -5556,6 +5592,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -5754,9 +5800,13 @@ dependencies = [ [[package]] name = "weak-table" -version = "0.3.2" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "323f4da9523e9a669e1eaf9c6e763892769b1d38c623913647bfdc1532fe4549" +checksum = "5eba2ec791141b0bf4db542f6b038a8f9310cee33e076cb181245780b09a24ff" +dependencies = [ + "cfg-if", + "hashbrown 0.16.1", +] [[package]] name = "web-time" diff --git a/Cargo.toml b/Cargo.toml index 27d18d5..d809062 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ path = "src/bin/rustpost-cli.rs" [dependencies] anyhow = "1.0.104" -arti-client = { version = "=0.45.0", default-features = false, features = ["tokio", "rustls", "onion-service-service"] } +arti-client = { version = "=0.46.0", default-features = false, features = ["tokio", "rustls", "onion-service-service"] } argon2 = "0.5.3" axum = { version = "0.8.9", features = ["multipart"] } base64 = "0.23.1" @@ -39,10 +39,10 @@ tempfile = "3.27.0" thiserror = "2.0.20" tokio = { version = "1.53.1", features = ["macros", "rt-multi-thread", "fs", "process", "signal", "net", "time", "io-util", "sync"] } toml = "1.1.4" -tor-cell = "=0.45.0" -tor-hsservice = "=0.45.0" -tor-proto = { version = "=0.45.0", features = ["tokio"] } -tor-rtcompat = { version = "=0.45.0", default-features = false, features = ["tokio", "rustls"] } +tor-cell = "=0.46.0" +tor-hsservice = "=0.46.0" +tor-proto = { version = "=0.46.0", features = ["tokio"] } +tor-rtcompat = { version = "=0.46.0", default-features = false, features = ["tokio", "rustls"] } tower-http = { version = "0.7.0", features = ["fs", "trace"] } tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } diff --git a/README.md b/README.md index 228acfd..f21bdc2 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ [![CI](https://img.shields.io/github/actions/workflow/status/csd113/RustPost/ci.yml?branch=main&style=flat-square&label=CI&logo=github)](https://github.com/csd113/RustPost/actions) [![Rust](https://img.shields.io/badge/rust-1.91%2B-orange?style=flat-square&logo=rust)](https://www.rust-lang.org/) [![SQLite](https://img.shields.io/badge/database-SQLite-blue?style=flat-square&logo=sqlite)](https://www.sqlite.org/) -[![Embedded Arti](https://img.shields.io/badge/embedded%20Arti-0.45.0-7D4698?style=flat-square&logo=torproject)](https://www.torproject.org/) +[![Embedded Arti](https://img.shields.io/badge/embedded%20Arti-0.46.0-7D4698?style=flat-square&logo=torproject)](https://www.torproject.org/) [![License](https://img.shields.io/badge/license-see%20LICENSE-green?style=flat-square)](./LICENSE) [**Getting Started**](#-getting-started) · [**Configuration**](#-configuration) · [**CLI Reference**](#-cli-reference) · [**Security**](#-security-model) · [**Tor / Arti**](#-tor--arti) @@ -389,11 +389,11 @@ The active onion address is shown by the running server in its startup/status ou **Current pinned Arti/Tor crates in `Cargo.toml`:** ``` -arti-client = 0.45.0 # bootstraps the embedded Tor client and onion service -tor-hsservice = 0.45.0 # onion-service config, handle, and rendezvous streams -tor-proto = 0.45.0 # inspect and accept incoming onion stream requests -tor-cell = 0.45.0 # cell-level protocol handling -tor-rtcompat = 0.45.0 # Tokio-compatible Arti runtime +arti-client = 0.46.0 # bootstraps the embedded Tor client and onion service +tor-hsservice = 0.46.0 # onion-service config, handle, and rendezvous streams +tor-proto = 0.46.0 # inspect and accept incoming onion stream requests +tor-cell = 0.46.0 # cell-level protocol handling +tor-rtcompat = 0.46.0 # Tokio-compatible Arti runtime rustls = 0.23 # ring crypto provider required by Arti's rustls stack ``` From 791615b00446a26ba1a5048cee83b31156b26a2d Mon Sep 17 00:00:00 2001 From: csd113 Date: Tue, 29 Sep 2026 08:04:55 -0700 Subject: [PATCH 19/21] Fix strict Clippy on Rust 1.91 --- src/auth.rs | 2 +- src/cli.rs | 2 +- src/db.rs | 2 +- src/identity.rs | 2 +- src/portability.rs | 2 +- src/server.rs | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index 1fabffc..e8fcf3a 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -67,7 +67,7 @@ pub enum LoginFailure { pub const USERNAME_TAKEN_MESSAGE: &str = "username is already taken"; -/// SQLite extended result code for a UNIQUE constraint failure. +/// `SQLite` extended result code for a `UNIQUE` constraint failure. const SQLITE_CONSTRAINT_UNIQUE: i32 = 2067; fn is_unique_violation(error: &rusqlite::Error) -> bool { diff --git a/src/cli.rs b/src/cli.rs index e7e251e..e0d6e29 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -104,7 +104,7 @@ pub async fn run() -> anyhow::Result<()> { .with_backup_dir(&settings.backup.backup_dir); // Keep backup and restore pointed at the settings file the operator // actually loaded, including an explicit --config path. - paths.settings_path = settings_path.clone(); + paths.settings_path.clone_from(&settings_path); paths.ensure()?; info!(data_dir = %paths.data_dir.display(), settings = %settings_path.display(), "runtime paths ready"); diff --git a/src/db.rs b/src/db.rs index ac7848f..2207662 100644 --- a/src/db.rs +++ b/src/db.rs @@ -82,7 +82,7 @@ fn open_connection(path: &Path) -> anyhow::Result { /// Registers the Rust-backed SQL helpers used by application queries. /// /// `rustpost_muted_word_match(text, term)` performs a Unicode-aware, -/// case-insensitive substring test. SQLite's built-in `lower()` only folds +/// case-insensitive substring test. `SQLite`'s built-in `lower()` only folds /// ASCII characters, which made muted-word matching miss uppercase accented /// text. Registering this on the connection keeps the check inside SQL so /// muted rows are filtered before `LIMIT` is applied. diff --git a/src/identity.rs b/src/identity.rs index 0a9a8e7..34f079f 100644 --- a/src/identity.rs +++ b/src/identity.rs @@ -11,7 +11,7 @@ use crate::config::Settings; use crate::db::SqlitePool; use crate::validation; -/// SQLite extended result code for a UNIQUE constraint failure. +/// `SQLite` extended result code for a `UNIQUE` constraint failure. const SQLITE_CONSTRAINT_UNIQUE: i32 = 2067; fn is_unique_violation(error: &rusqlite::Error) -> bool { diff --git a/src/portability.rs b/src/portability.rs index 529c215..912bb40 100644 --- a/src/portability.rs +++ b/src/portability.rs @@ -161,7 +161,7 @@ const HASH_PREFIX_LEN: usize = 16; const COPY_BUFFER_BYTES: usize = 64 * 1024; const ALREADY_IMPORTED_MESSAGE: &str = "this archive has already been imported into this account"; -/// SQLite extended result codes for the `UNIQUE(user_id, archive_id)` +/// `SQLite` extended result codes for the `UNIQUE(user_id, archive_id)` /// constraint on `account_imports`. const SQLITE_CONSTRAINT_UNIQUE: i32 = 2_067; const SQLITE_CONSTRAINT_PRIMARYKEY: i32 = 1_555; diff --git a/src/server.rs b/src/server.rs index 0edc54e..9993e49 100644 --- a/src/server.rs +++ b/src/server.rs @@ -47,7 +47,7 @@ pub struct AppState { /// deep settings. pub nsfw_blur_default: Arc, /// Set after an in-process restore swaps the runtime directories. The - /// running process still holds the previous SQLite connection, so writes + /// running process still holds the previous `SQLite` connection, so writes /// are refused until the operator restarts `RustPost`. pub restart_required: Arc, } From dfee77b144042cf40813d2e605dabc07f06263bf Mon Sep 17 00:00:00 2001 From: csd113 Date: Tue, 29 Sep 2026 08:15:27 -0700 Subject: [PATCH 20/21] Remap restored media paths on Windows --- CHANGELOG.md | 1 + src/backup.rs | 18 ++++++++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2395c3d..040a199 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ - Rejected unsafe profile website URL schemes server-side and stopped rendering unsafe legacy profile website values as links. - Enforced configured per-post image and video attachment limits and raised the multipart body limit to cover valid configured media mixes. - Hardened invalid upload cleanup with a cancellation-safe staged-file guard so rejected malformed, oversized, or staging-error uploads do not leave orphaned files under `tmp/uploads`. +- Fixed restored media path remapping for Windows paths so backups remain usable after restoring into a different data directory. - Corrected generated settings copy for username, display name, and bio limits from bytes to characters. ### Release Validation diff --git a/src/backup.rs b/src/backup.rs index 712857b..7a1770e 100644 --- a/src/backup.rs +++ b/src/backup.rs @@ -895,9 +895,14 @@ fn rewrite_restored_media_paths(database_path: &Path, paths: &RuntimePaths) -> a } fn remap_media_path(path: &str, roots: &[(&str, &PathBuf)]) -> String { + let normalized = if path.contains('\\') { + std::borrow::Cow::Owned(path.replace('\\', "/")) + } else { + std::borrow::Cow::Borrowed(path) + }; for (marker, root) in roots { - if let Some(index) = path.find(marker) { - let relative = &path[index + marker.len()..]; + if let Some(index) = normalized.find(marker) { + let relative = &normalized[index + marker.len()..]; if relative.is_empty() { return path.to_owned(); } @@ -1398,14 +1403,19 @@ mod tests { fn restored_media_paths_are_remapped_to_the_destination() { let originals = PathBuf::from("/new/data/uploads/originals"); let roots: [(&str, &PathBuf); 1] = [("/uploads/originals/", &originals)]; + let expected = originals.join("a.png").to_string_lossy().to_string(); assert_eq!( remap_media_path("/old/data/uploads/originals/a.png", &roots), - "/new/data/uploads/originals/a.png" + expected + ); + assert_eq!( + remap_media_path(r"C:\old\data\uploads\originals\a.png", &roots), + expected ); assert_eq!( remap_media_path("/new/data/uploads/originals/a.png", &roots), - "/new/data/uploads/originals/a.png" + expected ); assert_eq!( remap_media_path("/unrelated/place/a.png", &roots), From e26513194baddc45a0c97b514b8293b9e5d36932 Mon Sep 17 00:00:00 2001 From: csd113 Date: Tue, 29 Sep 2026 08:22:41 -0700 Subject: [PATCH 21/21] Handle account deletion during settings load --- CHANGELOG.md | 1 + src/server.rs | 43 ++++++++++++++++++++++--------------------- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 040a199..63edd7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ - Enforced configured per-post image and video attachment limits and raised the multipart body limit to cover valid configured media mixes. - Hardened invalid upload cleanup with a cancellation-safe staged-file guard so rejected malformed, oversized, or staging-error uploads do not leave orphaned files under `tmp/uploads`. - Fixed restored media path remapping for Windows paths so backups remain usable after restoring into a different data directory. +- Return a signed-out response when account finalization races settings page loading, instead of surfacing a server error. - Corrected generated settings copy for username, display name, and bio limits from bytes to characters. ### Release Validation diff --git a/src/server.rs b/src/server.rs index 9993e49..d45065e 100644 --- a/src/server.rs +++ b/src/server.rs @@ -2647,8 +2647,9 @@ struct SettingsProfile { async fn settings_profile(pool: &SqlitePool, user_id: i64) -> AppResult { pool.call(move |conn| { - conn.query_row( - r#" + Ok(conn + .query_row( + r#" SELECT u.display_name, u.bio, u.location, u.website, u.theme, u.nsfw_blur_enabled, u.liked_posts_public, u.follow_approval_required, pic.public_path AS profile_picture_path, @@ -2658,26 +2659,26 @@ async fn settings_profile(pool: &SqlitePool, user_id: i64) -> AppResult(5)? != 0, - liked_posts_public: row.get::<_, i64>(6)? != 0, - follow_approval_required: row.get::<_, i64>(7)? != 0, - picture_path: row.get(8)?, - banner_path: row.get(9)?, - }) - }, - ) - .map_err(Into::into) + [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, + liked_posts_public: row.get::<_, i64>(6)? != 0, + follow_approval_required: row.get::<_, i64>(7)? != 0, + picture_path: row.get(8)?, + banner_path: row.get(9)?, + }) + }, + ) + .optional()?) }) - .await - .map_err(Into::into) + .await? + .ok_or(AppError::Unauthorized) } fn settings_profile_media(