From 3d092b3cee8f653a0f793403caeafb0fd6ed1b67 Mon Sep 17 00:00:00 2001 From: csd113 Date: Tue, 26 May 2026 16:59:26 -0700 Subject: [PATCH 01/12] Implement production backup and restore Add deterministic full-site backup archives with manifest.toml metadata, SQLite snapshotting, SHA-256 hashes, required runtime directories, media/assets coverage, and explicit opt-in Tor onion-service key handling. Harden restore by treating archives as hostile input: validate entry types, paths, duplicate entries, manifest contents, hashes, settings, SQLite integrity, schema compatibility, and approved runtime roots before swapping live data. Stage restores in tmp, create pre-restore safety backups, serialize operations with a lock, roll back failed installs, and restrict restored Tor key permissions. Expose the workflow through the admin Backups page with manual creation, admin-only downloads, restore-from-upload confirmation, automatic backup settings, retention controls, recent history, CSRF/admin auth, and no-JS-compatible forms. Add scheduled automatic backups disabled by default with settings.toml-compatible defaults. Update README and changelog with usage, defaults, warnings, format details, and restore procedure. Add Rust coverage for manifest/hash validation, malicious archive rejection, staging, retention, Tor key include/exclude, rollback, config compatibility, and concurrent operation handling, plus ignored Playwright coverage for backup/download, fresh and populated restore, scheduler retention, malicious upload rejection, Firefox no-JS, and WebKit smoke. Validation run: cargo fmt --all --check; 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; npx playwright test tests/playwright/backup_restore.spec.mjs --project=chromium; targeted Firefox no-JS and WebKit smoke Playwright runs. --- CHANGELOG.md | 7 +- README.md | 44 +- src/admin.rs | 203 ++- src/backup.rs | 1575 ++++++++++++++++++++-- src/cli.rs | 22 +- src/config.rs | 119 +- src/db.rs | 9 +- src/errors.rs | 4 +- src/runtime.rs | 6 + src/server.rs | 474 ++++++- tests/playwright/backup_restore.spec.mjs | 427 ++++++ 11 files changed, 2770 insertions(+), 120 deletions(-) create mode 100644 tests/playwright/backup_restore.spec.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index d78a36f..a17b706 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,8 +19,11 @@ ### Admin, Settings, and Operations - Added `posts.post_edit_window_seconds` to settings and deep server settings, including validation from `0` to `300` seconds. -- Added an admin backups page for creating backup archives from the web UI. -- Refactored the backup restore workflow around staged extraction and clearer restore handling. +- Rebuilt backup archives around deterministic tar output plus `manifest.toml` with RustPost version, DB schema version, component paths, sizes, SHA-256 hashes, timestamps, and Tor-key inclusion state. +- Added full-site backup coverage for SQLite, settings, media variants, thumbnails/transcodes, assets, required runtime directories, and opt-in Tor onion-service keys. +- Added admin backup creation, admin-only archive downloads, restore-from-upload with confirmation, automatic backup settings, safe retention controls, and recent backup history/status. +- Added scheduled automatic backups, disabled by default, with settings.toml/admin configuration and retention that only prunes automatic archives. +- Hardened restore with staged extraction, manifest/hash verification, SQLite integrity and schema checks, hostile path rejection, pre-restore safety backups, rollback on install failure, operation locking, and strict restored Tor-key permissions. ### Privacy, Security, and UI Fixes - Hardened mention suggestions so wildcard characters cannot broaden searches and unavailable or hidden users stay excluded. diff --git a/README.md b/README.md index 65bed88..b15058f 100644 --- a/README.md +++ b/README.md @@ -71,8 +71,8 @@ This repository is a **production-oriented MVP** — not a toy, but not overengi |---|---| | Database | SQLite with WAL, foreign keys, migrations, FTS5, and timeline indexes | | Rate limiting | SQLite-backed per-user and per-IP limits for all write operations | -| Backups | Tar archive of DB + settings + media + optional Tor keys | -| Restore | Path validation rejects traversal, symlinks, absolute paths, and Unicode bypass attempts | +| 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 | | 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 | @@ -360,7 +360,7 @@ rustpost restore archive.tar # rejects Tor key paths unless flag giv rustpost restore archive.tar --include-tor-keys ``` -Restore path validation rejects: absolute paths, traversal sequences, symlinks/hardlinks, Windows drive prefixes, backslash paths, encoded traversal or slash markers, and slash-like Unicode bypass characters. +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. --- @@ -385,20 +385,50 @@ Profile pictures and banners follow the same media pipeline as post uploads. ## 💾 Backup & Restore ```sh -# Create a backup (DB + settings + media) +# Create a backup (SQLite DB snapshot + settings + media/assets) rustpost-cli backup # Include Tor onion-service keys rustpost-cli backup --include-tor-keys # Restore into a fresh data directory -rustpost-cli restore rustpost-backup-2026-05-18T....tar +rustpost-cli restore rustpost-20260526T....tar # Restore including Tor keys -rustpost-cli restore rustpost-backup-2026-05-18T....tar --include-tor-keys +rustpost-cli restore rustpost-20260526T....tar --include-tor-keys ``` -> Archive names include subsecond precision to avoid same-second collisions. +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. + +Archive format: + +- Tar entries are written in deterministic order with normalized header metadata. +- `manifest.toml` records the RustPost version, DB schema version, created timestamp, included components, runtime-relative paths, file sizes, SHA-256 hashes, and whether Tor keys are included. +- The durable runtime state covered by the format is `db/rustpost.sqlite3`, `settings.toml`, `uploads/originals`, `uploads/images`, `uploads/videos`, `uploads/thumbs`, `assets`, and required empty runtime directories. +- Runtime `tmp`, `logs`, `backups`, cache junk, Playwright artifacts, symlinks, and non-durable files are not included. +- Tor onion-service private keys are excluded by default. `--include-tor-keys` or the admin checkbox is required to include or restore them. Restored Tor key files are permission-restricted on Unix. + +Restore safety: + +- Backups are treated as hostile input. RustPost validates the manifest, hashes, entry types, paths, settings file, SQLite integrity, foreign keys, and schema compatibility before touching live runtime files. +- Restore stages into `tmp/`, creates a pre-restore safety backup, then swaps approved runtime roots. On failure it rolls back moved live paths and leaves the old runtime in place. +- Concurrent backup/restore attempts are rejected with a lock under `tmp/`. +- Admin-upload restore writes the restored files for the runtime, but the already-running process keeps its existing SQLite connection. Restart RustPost after a successful admin restore so it reopens the restored database and settings. + +Automatic backups: + +```toml +[backup] +enabled = true +backup_dir = "backups" +automatic_enabled = false +automatic_interval_minutes = 1440 +retention_keep_last = 10 +retention_max_age_days = 30 +automatic_include_tor_keys = false +``` + +Automatic backups are disabled by default. Retention deletes only automatic archives (`rustpost-auto-*.tar`), always keeps the newest `retention_keep_last`, and never prunes manual or pre-restore safety backups. --- diff --git a/src/admin.rs b/src/admin.rs index 14e4649..963393a 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -7,7 +7,7 @@ use rusqlite::{Row, params, params_from_iter}; use serde::Deserialize; use crate::auth; -use crate::config::{MAX_POST_EDIT_WINDOW_SECONDS, Settings}; +use crate::config::{BackupSettings, MAX_POST_EDIT_WINDOW_SECONDS, Settings}; use crate::db::SqlitePool; const MIB: u64 = 1024 * 1024; @@ -112,6 +112,27 @@ pub struct DeepSettingsChange { pub new_value: String, } +#[derive(Debug, Clone, Deserialize)] +pub struct BackupSettingsForm { + pub csrf: String, + pub enabled: String, + pub automatic_enabled: String, + pub automatic_interval_minutes: String, + pub retention_keep_last: String, + pub retention_max_age_days: String, + pub automatic_include_tor_keys: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BackupSettingsValues { + pub enabled: bool, + pub automatic_enabled: bool, + pub automatic_interval_minutes: u64, + pub retention_keep_last: usize, + pub retention_max_age_days: u64, + pub automatic_include_tor_keys: bool, +} + impl DeepSettingsField { pub const ALL: [Self; 24] = [ Self::SiteName, @@ -442,6 +463,60 @@ impl DeepSettingsValues { } } +impl BackupSettingsValues { + #[must_use] + pub fn from_settings(settings: &Settings) -> Self { + Self { + enabled: settings.backup.enabled, + automatic_enabled: settings.backup.automatic_enabled, + automatic_interval_minutes: settings.backup.automatic_interval_minutes, + retention_keep_last: settings.backup.retention_keep_last, + retention_max_age_days: settings.backup.retention_max_age_days, + automatic_include_tor_keys: settings.backup.automatic_include_tor_keys, + } + } + + #[must_use] + pub fn apply_to(&self, current: &Settings) -> Settings { + let mut updated = current.clone(); + updated.backup = BackupSettings { + enabled: self.enabled, + backup_dir: current.backup.backup_dir.clone(), + automatic_enabled: self.automatic_enabled, + automatic_interval_minutes: self.automatic_interval_minutes, + retention_keep_last: self.retention_keep_last, + retention_max_age_days: self.retention_max_age_days, + automatic_include_tor_keys: self.automatic_include_tor_keys, + }; + updated + } +} + +pub fn parse_backup_settings_form( + form: &BackupSettingsForm, + current: &Settings, +) -> anyhow::Result { + let values = BackupSettingsValues { + enabled: parse_named_bool(&form.enabled, "Backups enabled")?, + automatic_enabled: parse_named_bool(&form.automatic_enabled, "Automatic backups enabled")?, + automatic_interval_minutes: parse_named_u64( + &form.automatic_interval_minutes, + "Automatic backup interval", + )?, + retention_keep_last: parse_named_usize(&form.retention_keep_last, "Backups to keep")?, + retention_max_age_days: parse_named_u64( + &form.retention_max_age_days, + "Maximum automatic backup age", + )?, + automatic_include_tor_keys: parse_named_bool( + &form.automatic_include_tor_keys, + "Automatic Tor key backup", + )?, + }; + values.apply_to(current).validate()?; + Ok(values) +} + pub fn parse_deep_settings_form( form: &DeepSettingsForm, current: &Settings, @@ -535,6 +610,17 @@ pub fn write_deep_settings(path: &Path, updated: &Settings) -> anyhow::Result<() write_atomic(path, rewritten.as_bytes()) } +pub fn write_backup_settings(path: &Path, updated: &Settings) -> anyhow::Result<()> { + updated.validate()?; + let raw = fs::read_to_string(path) + .with_context(|| format!("failed to read settings file {}", path.display()))?; + let rewritten = rewrite_backup_settings_toml(&raw, &updated.backup); + let parsed: Settings = toml::from_str(&rewritten) + .with_context(|| "rewritten settings.toml did not parse as settings")?; + parsed.validate()?; + write_atomic(path, rewritten.as_bytes()) +} + fn parse_bool(value: &str, field: DeepSettingsField) -> anyhow::Result { match value { "true" => Ok(true), @@ -589,6 +675,32 @@ fn parse_mb(value: &str, field: DeepSettingsField) -> anyhow::Result { Ok(mb) } +fn parse_named_bool(value: &str, label: &str) -> anyhow::Result { + match value { + "true" => Ok(true), + "false" => Ok(false), + _ => anyhow::bail!("{label} must be true or false"), + } +} + +fn parse_named_u64(value: &str, label: &str) -> anyhow::Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + anyhow::bail!("{label} is required"); + } + if trimmed.starts_with('-') { + anyhow::bail!("{label} must not be negative"); + } + trimmed + .parse::() + .with_context(|| format!("{label} must be a whole number")) +} + +fn parse_named_usize(value: &str, label: &str) -> anyhow::Result { + let parsed = parse_named_u64(value, label)?; + usize::try_from(parsed).with_context(|| format!("{label} is too large")) +} + fn bytes_to_mb(bytes: u64) -> u64 { bytes / MIB } @@ -652,6 +764,95 @@ fn rewrite_deep_settings_toml(raw: &str, settings: &Settings) -> String { rewritten } +fn rewrite_backup_settings_toml(raw: &str, settings: &BackupSettings) -> String { + let mut output = Vec::new(); + let mut current_section: Option<&str> = None; + let mut found = vec![false; BACKUP_SETTING_KEYS.len()]; + let mut backup_section_seen = false; + + for line in raw.lines() { + if let Some(section) = parse_section_header(line) { + append_missing_backup_settings(&mut output, current_section, &mut found, settings); + if section == "backup" { + backup_section_seen = true; + } + current_section = Some(section); + output.push(line.to_owned()); + continue; + } + + if current_section == Some("backup") + && let Some((index, key)) = BACKUP_SETTING_KEYS + .iter() + .copied() + .enumerate() + .find(|(_, key)| line_assigns_key(line, key)) + { + found[index] = true; + output.push(format!( + "{}{} = {}", + leading_whitespace(line), + key, + backup_toml_value(key, settings) + )); + continue; + } + + output.push(line.to_owned()); + } + + append_missing_backup_settings(&mut output, current_section, &mut found, settings); + if !backup_section_seen { + output.push(String::new()); + output.push("[backup]".to_owned()); + append_missing_backup_settings(&mut output, Some("backup"), &mut found, settings); + } + + let mut rewritten = output.join("\n"); + if raw.ends_with('\n') { + rewritten.push('\n'); + } + rewritten +} + +const BACKUP_SETTING_KEYS: [&str; 6] = [ + "enabled", + "automatic_enabled", + "automatic_interval_minutes", + "retention_keep_last", + "retention_max_age_days", + "automatic_include_tor_keys", +]; + +fn append_missing_backup_settings( + output: &mut Vec, + section: Option<&str>, + found: &mut [bool], + settings: &BackupSettings, +) { + if section != Some("backup") { + return; + } + for (index, key) in BACKUP_SETTING_KEYS.iter().copied().enumerate() { + if !found[index] { + found[index] = true; + output.push(format!("{key} = {}", backup_toml_value(key, settings))); + } + } +} + +fn backup_toml_value(key: &str, settings: &BackupSettings) -> String { + match key { + "enabled" => settings.enabled.to_string(), + "automatic_enabled" => settings.automatic_enabled.to_string(), + "automatic_interval_minutes" => settings.automatic_interval_minutes.to_string(), + "retention_keep_last" => settings.retention_keep_last.to_string(), + "retention_max_age_days" => settings.retention_max_age_days.to_string(), + "automatic_include_tor_keys" => settings.automatic_include_tor_keys.to_string(), + _ => String::new(), + } +} + fn append_missing_for_section( output: &mut Vec, section: Option<&str>, diff --git a/src/backup.rs b/src/backup.rs index 832a208..1fd2e45 100644 --- a/src/backup.rs +++ b/src/backup.rs @@ -1,124 +1,1285 @@ -use std::fs::{self, File}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read as _}; use std::path::{Component, Path, PathBuf}; +use std::time::{Duration, SystemTime}; -use chrono::Utc; -use tar::{Archive, Builder, EntryType}; +use anyhow::Context as _; +use chrono::{SecondsFormat, Utc}; +use rusqlite::{Connection, OptionalExtension as _}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; +use tar::{Archive, Builder, EntryType, Header}; +use tokio::sync::watch; +use tracing::{info, warn}; use walkdir::WalkDir; +use crate::config::{BackupSettings, Settings}; +use crate::db::CURRENT_SCHEMA_VERSION; use crate::runtime::RuntimePaths; +const FORMAT_VERSION: u16 = 1; +const MANIFEST_PATH: &str = "manifest.toml"; +const MANIFEST_MAX_BYTES: u64 = 256 * 1024; +const LOCK_DIR: &str = "backup-restore.lock"; +const AUTOMATIC_CHECK_INTERVAL_SECS: u64 = 60; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BackupKind { + Manual, + Automatic, + PreRestore, +} + +#[derive(Debug, Clone)] +struct ArchiveFile { + source: PathBuf, + archive_path: String, + component: &'static str, + size: u64, + sha256: String, + mode: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BackupManifest { + pub format_version: u16, + pub rustpost_version: String, + pub db_schema_version: Option, + pub created_at: String, + pub tor_keys_included: bool, + pub components: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ManifestEntry { + pub kind: ManifestEntryKind, + pub component: String, + pub archive_path: String, + pub runtime_path: String, + pub size: u64, + pub sha256: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ManifestEntryKind { + Directory, + File, +} + +#[derive(Debug, Clone)] +pub struct BackupArchiveInfo { + pub filename: String, + pub path: PathBuf, + pub size: u64, + pub automatic: bool, + pub created_at: Option, + pub tor_keys_included: Option, + pub manifest_valid: bool, +} + +#[derive(Debug, Clone)] +pub struct RestoreReport { + pub pre_restore_backup: Option, + pub tor_keys_restored: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ExtractedEntry { + kind: ManifestEntryKind, + size: u64, +} + pub fn create_backup(paths: &RuntimePaths, include_tor_keys: bool) -> anyhow::Result { + let _lock = OperationLock::acquire(paths)?; + create_backup_inner(paths, include_tor_keys, BackupKind::Manual, true) +} + +pub fn restore_backup( + paths: &RuntimePaths, + archive_path: &Path, + include_tor_keys: bool, +) -> anyhow::Result { + let _lock = OperationLock::acquire(paths)?; + let staging = tempfile::Builder::new() + .prefix("rustpost-restore-") + .tempdir_in(&paths.tmp_dir) + .with_context(|| "failed to create restore staging directory")?; + 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)?; + let pre_restore_backup = + create_backup_inner(paths, include_tor_keys, BackupKind::PreRestore, false) + .with_context(|| "failed to create pre-restore safety backup")?; + swap_staged_runtime(paths, staging.path(), include_tor_keys) + .with_context(|| "failed to install restored runtime state")?; + apply_restored_permissions(paths, include_tor_keys)?; + Ok(RestoreReport { + pre_restore_backup: Some(pre_restore_backup), + tor_keys_restored: include_tor_keys && staged.manifest.tor_keys_included, + }) +} + +pub fn list_backups(paths: &RuntimePaths) -> anyhow::Result> { + if !paths.backups_dir.exists() { + return Ok(Vec::new()); + } + let mut archives = Vec::new(); + for entry in fs::read_dir(&paths.backups_dir).with_context(|| { + format!( + "failed to read backup directory {}", + paths.backups_dir.display() + ) + })? { + let entry = entry?; + let path = entry.path(); + if !path.is_file() || path.extension().and_then(|ext| ext.to_str()) != Some("tar") { + continue; + } + let Some(filename) = path + .file_name() + .and_then(|name| name.to_str()) + .map(ToOwned::to_owned) + else { + continue; + }; + if !is_rustpost_backup_name(&filename) { + continue; + } + let metadata = entry.metadata()?; + let manifest = read_manifest_from_archive(&path).ok(); + let automatic = filename.starts_with("rustpost-auto-"); + archives.push(BackupArchiveInfo { + filename, + path, + size: metadata.len(), + automatic, + created_at: manifest + .as_ref() + .map(|manifest| manifest.created_at.clone()), + tor_keys_included: manifest.as_ref().map(|manifest| manifest.tor_keys_included), + manifest_valid: manifest.is_some(), + }); + } + archives.sort_by(|left, right| right.filename.cmp(&left.filename)); + Ok(archives) +} + +pub fn backup_path_for_download(paths: &RuntimePaths, filename: &str) -> anyhow::Result { + validate_backup_filename(filename)?; + let path = paths.backups_dir.join(filename); + let canonical_dir = paths + .backups_dir + .canonicalize() + .with_context(|| "backup directory does not exist")?; + let canonical_path = path + .canonicalize() + .with_context(|| "backup archive does not exist")?; + if !canonical_path.starts_with(canonical_dir) || !canonical_path.is_file() { + anyhow::bail!("backup archive does not exist"); + } + Ok(canonical_path) +} + +pub fn operation_in_progress(paths: &RuntimePaths) -> bool { + paths.tmp_dir.join(LOCK_DIR).is_dir() +} + +pub fn run_automatic_backup_if_due( + base_paths: &RuntimePaths, + settings: &Settings, +) -> anyhow::Result> { + if !settings.backup.enabled || !settings.backup.automatic_enabled { + return Ok(None); + } + let paths = RuntimePaths::from_data_dir(base_paths.data_dir.clone()) + .with_tor_data_dir(&settings.tor.data_dir) + .with_backup_dir(&settings.backup.backup_dir); + paths.ensure()?; + if !automatic_backup_due(&paths, &settings.backup)? { + apply_retention(&paths, &settings.backup)?; + return Ok(None); + } + let _lock = OperationLock::acquire(&paths)?; + let archive = create_backup_inner( + &paths, + settings.backup.automatic_include_tor_keys, + BackupKind::Automatic, + true, + )?; + apply_retention(&paths, &settings.backup)?; + Ok(Some(archive)) +} + +pub fn spawn_automatic_scheduler( + base_paths: RuntimePaths, + settings_path: PathBuf, + mut shutdown_rx: watch::Receiver, +) { + tokio::spawn(async move { + loop { + match Settings::load(&settings_path).and_then(|settings| { + settings.validate()?; + run_automatic_backup_if_due(&base_paths, &settings) + }) { + Ok(Some(path)) => info!(archive = %path.display(), "automatic backup created"), + Ok(None) => {} + Err(error) => warn!(error = %error, "automatic backup check failed"), + } + tokio::select! { + changed = shutdown_rx.changed() => { + if changed.is_err() || *shutdown_rx.borrow() { + break; + } + } + () = tokio::time::sleep(Duration::from_secs(AUTOMATIC_CHECK_INTERVAL_SECS)) => {} + } + } + }); +} + +pub fn apply_retention(paths: &RuntimePaths, settings: &BackupSettings) -> anyhow::Result { + let mut automatic = list_backups(paths)? + .into_iter() + .filter(|archive| archive.automatic) + .collect::>(); + automatic.sort_by(|left, right| right.filename.cmp(&left.filename)); + let keep_last = settings.retention_keep_last.max(1); + let cutoff = retention_cutoff(settings.retention_max_age_days); + let mut deleted = 0usize; + for (index, archive) in automatic.iter().enumerate() { + let over_count = index >= keep_last; + let over_age = cutoff.is_some_and(|cutoff| { + fs::metadata(&archive.path) + .and_then(|metadata| metadata.modified()) + .is_ok_and(|modified| modified < cutoff) + }); + if over_count || (over_age && automatic.len().saturating_sub(deleted) > keep_last) { + fs::remove_file(&archive.path) + .with_context(|| format!("failed to remove old backup {}", archive.filename))?; + deleted += 1; + } + } + Ok(deleted) +} + +pub fn read_manifest_from_archive(path: &Path) -> anyhow::Result { + let file = File::open(path)?; + let mut archive = Archive::new(file); + for entry in archive.entries()? { + let mut entry = entry?; + let archive_path = validate_entry_path(&entry, true)?; + if archive_path == MANIFEST_PATH { + return read_manifest_entry(&mut entry); + } + } + anyhow::bail!("backup manifest is missing") +} + +#[expect( + clippy::too_many_lines, + reason = "backup creation is a linear archive assembly pipeline" +)] +fn create_backup_inner( + paths: &RuntimePaths, + include_tor_keys: bool, + kind: BackupKind, + require_database: bool, +) -> anyhow::Result { fs::create_dir_all(&paths.backups_dir)?; - let archive_path = paths.backups_dir.join(format!( - "rustpost-{}.tar", - Utc::now().format("%Y%m%d%H%M%S%f") - )); - let file = File::create(&archive_path)?; - let mut builder = Builder::new(file); - append_file(&mut builder, &paths.database_path, "db/rustpost.sqlite3")?; - append_file( - &mut builder, - &paths.database_sidecar_path("wal"), - "db/rustpost.sqlite3-wal", + fs::create_dir_all(&paths.tmp_dir)?; + let staging = tempfile::Builder::new() + .prefix("rustpost-backup-") + .tempdir_in(&paths.tmp_dir) + .with_context(|| "failed to create backup staging directory")?; + + let staged_db = staging.path().join("rustpost.sqlite3"); + let db_schema_version = if paths.database_path.is_file() { + snapshot_database(&paths.database_path, &staged_db)?; + Some(database_schema_version(&staged_db)?) + } else if require_database { + anyhow::bail!( + "database {} is required for backup", + paths.database_path.display() + ); + } else { + None + }; + + let mut directories = BTreeSet::new(); + for path in required_archive_dirs(include_tor_keys) { + directories.insert(path.to_owned()); + } + + let mut files = Vec::new(); + if staged_db.is_file() { + files.push(archive_file( + staged_db, + "db/rustpost.sqlite3", + "database", + 0o600, + )?); + } + if paths.settings_path.is_file() { + files.push(archive_file( + paths.settings_path.clone(), + "settings.toml", + "settings", + 0o600, + )?); + } else if require_database { + anyhow::bail!( + "settings file {} is required for backup", + paths.settings_path.display() + ); + } + append_runtime_dir( + &mut files, + &mut directories, + &paths.uploads_originals, + "uploads/originals", + "media", )?; - append_file( - &mut builder, - &paths.database_sidecar_path("shm"), - "db/rustpost.sqlite3-shm", + append_runtime_dir( + &mut files, + &mut directories, + &paths.uploads_images, + "uploads/images", + "media", + )?; + append_runtime_dir( + &mut files, + &mut directories, + &paths.uploads_videos, + "uploads/videos", + "media", + )?; + append_runtime_dir( + &mut files, + &mut directories, + &paths.uploads_thumbs, + "uploads/thumbs", + "media", + )?; + append_runtime_dir( + &mut files, + &mut directories, + &paths.assets_dir, + "assets", + "assets", )?; - append_file(&mut builder, &paths.settings_path, "settings.toml")?; - append_dir(&mut builder, &paths.uploads_originals, "uploads/originals")?; - append_dir(&mut builder, &paths.uploads_images, "uploads/images")?; - append_dir(&mut builder, &paths.uploads_videos, "uploads/videos")?; - append_dir(&mut builder, &paths.uploads_thumbs, "uploads/thumbs")?; if include_tor_keys { - append_dir( - &mut builder, + append_runtime_dir( + &mut files, + &mut directories, &paths.tor_onion_service_dir, "tor/onion-service", + "tor_keys", )?; } + + files.sort_by(|left, right| left.archive_path.cmp(&right.archive_path)); + 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) + .with_context(|| format!("failed to create backup archive {}", archive_path.display()))?; + let mut builder = Builder::new(file); + append_bytes( + &mut builder, + MANIFEST_PATH, + manifest_toml.as_bytes(), + 0o600, + EntryType::Regular, + )?; + for directory in &directories { + append_bytes(&mut builder, directory, &[], 0o700, EntryType::Directory)?; + } + for file in &files { + append_file(&mut builder, file)?; + } builder.finish()?; Ok(archive_path) } -pub fn restore_backup( - paths: &RuntimePaths, - archive_path: &Path, +fn append_runtime_dir( + files: &mut Vec, + directories: &mut BTreeSet, + dir: &Path, + archive_root: &'static str, + component: &'static str, +) -> anyhow::Result<()> { + directories.insert(archive_root.to_owned()); + if !dir.exists() { + return Ok(()); + } + for entry in WalkDir::new(dir).follow_links(false).sort_by_file_name() { + let entry = entry?; + if entry.file_type().is_symlink() { + anyhow::bail!("cannot back up symlink {}", entry.path().display()); + } + let rel = entry.path().strip_prefix(dir)?; + if rel.as_os_str().is_empty() { + continue; + } + if excluded_runtime_artifact(rel) { + continue; + } + let archive_path = format_archive_path(archive_root, rel)?; + if entry.file_type().is_dir() { + directories.insert(archive_path); + } else if entry.file_type().is_file() { + files.push(archive_file( + entry.path().to_path_buf(), + &archive_path, + component, + 0o600, + )?); + } + } + Ok(()) +} + +fn archive_file( + source: PathBuf, + archive_path: &str, + component: &'static str, + mode: u32, +) -> anyhow::Result { + validate_archive_path_text(archive_path, false, true)?; + let metadata = fs::metadata(&source)?; + if !metadata.is_file() { + anyhow::bail!("backup source {} is not a regular file", source.display()); + } + let (size, sha256) = hash_file(&source)?; + Ok(ArchiveFile { + source, + archive_path: archive_path.to_owned(), + component, + size, + sha256, + mode, + }) +} + +fn build_manifest( + db_schema_version: Option, include_tor_keys: bool, + directories: &BTreeSet, + files: &[ArchiveFile], +) -> BackupManifest { + let mut components = Vec::with_capacity(directories.len() + files.len()); + components.extend(directories.iter().map(|path| ManifestEntry { + kind: ManifestEntryKind::Directory, + component: component_for_archive_path(path).to_owned(), + archive_path: path.clone(), + runtime_path: path.clone(), + size: 0, + sha256: None, + })); + components.extend(files.iter().map(|file| ManifestEntry { + kind: ManifestEntryKind::File, + component: file.component.to_owned(), + archive_path: file.archive_path.clone(), + runtime_path: file.archive_path.clone(), + size: file.size, + sha256: Some(file.sha256.clone()), + })); + components.sort_by(|left, right| left.archive_path.cmp(&right.archive_path)); + BackupManifest { + format_version: FORMAT_VERSION, + rustpost_version: env!("CARGO_PKG_VERSION").to_owned(), + db_schema_version, + created_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), + tor_keys_included: include_tor_keys, + components, + } +} + +fn append_file(builder: &mut Builder, file: &ArchiveFile) -> anyhow::Result<()> { + let mut source = File::open(&file.source)?; + let mut header = + deterministic_header(&file.archive_path, file.size, file.mode, EntryType::Regular)?; + builder.append(&header, &mut source)?; + header.set_cksum(); + Ok(()) +} + +fn append_bytes( + builder: &mut Builder, + archive_path: &str, + bytes: &[u8], + mode: u32, + entry_type: EntryType, ) -> anyhow::Result<()> { - let file = File::open(archive_path)?; + let mut header = deterministic_header( + archive_path, + u64::try_from(bytes.len()).unwrap_or(u64::MAX), + mode, + entry_type, + )?; + builder.append(&header, bytes)?; + header.set_cksum(); + Ok(()) +} + +fn deterministic_header( + archive_path: &str, + size: u64, + mode: u32, + entry_type: EntryType, +) -> anyhow::Result
{ + let mut header = Header::new_ustar(); + header.set_path(archive_path)?; + header.set_entry_type(entry_type); + header.set_size(size); + header.set_mode(mode); + header.set_uid(0); + header.set_gid(0); + header.set_mtime(0); + header.set_cksum(); + Ok(header) +} + +fn snapshot_database(source: &Path, destination: &Path) -> anyhow::Result<()> { + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent)?; + } + let conn = Connection::open(source) + .with_context(|| format!("failed to open SQLite database {}", source.display()))?; + conn.busy_timeout(Duration::from_secs(5))?; + let _checkpoint_result = conn.pragma_update(None, "wal_checkpoint", "FULL"); + conn.execute( + &format!("VACUUM INTO {}", sqlite_path_literal(destination)), + [], + ) + .with_context(|| "failed to snapshot SQLite database")?; + validate_sqlite_database(destination, None)?; + Ok(()) +} + +fn sqlite_path_literal(path: &Path) -> String { + let value = path.to_string_lossy().replace('\'', "''"); + format!("'{value}'") +} + +fn database_schema_version(path: &Path) -> anyhow::Result { + let conn = Connection::open(path)?; + schema_version_from_connection(&conn) +} + +fn validate_sqlite_database( + path: &Path, + expected_schema_version: Option, +) -> anyhow::Result<()> { + let conn = Connection::open(path) + .with_context(|| format!("failed to open restored database {}", path.display()))?; + let integrity: String = conn.query_row("PRAGMA integrity_check", [], |row| row.get(0))?; + if integrity != "ok" { + anyhow::bail!("restored database failed integrity_check"); + } + let foreign_key_problem: Option = conn + .query_row("PRAGMA foreign_key_check", [], |row| row.get(0)) + .optional()?; + if foreign_key_problem.is_some() { + anyhow::bail!("restored database failed foreign_key_check"); + } + let schema_version = schema_version_from_connection(&conn)?; + if schema_version > CURRENT_SCHEMA_VERSION { + anyhow::bail!( + "backup schema version {schema_version} is newer than this RustPost ({CURRENT_SCHEMA_VERSION})" + ); + } + if let Some(expected) = expected_schema_version + && expected != schema_version + { + anyhow::bail!("manifest schema version does not match restored database"); + } + Ok(()) +} + +fn schema_version_from_connection(conn: &Connection) -> anyhow::Result { + let version = conn + .query_row("SELECT MAX(version) FROM schema_migrations", [], |row| { + row.get::<_, Option>(0) + }) + .optional()? + .flatten() + .ok_or_else(|| anyhow::anyhow!("database has no schema migration version"))?; + Ok(version) +} + +fn extract_archive_to_stage( + archive_path: &Path, + staging_dir: &Path, + include_tor_keys: bool, +) -> anyhow::Result { + let file = File::open(archive_path) + .with_context(|| format!("failed to open backup archive {}", archive_path.display()))?; let mut archive = Archive::new(file); + let mut manifest = None; + let mut seen = BTreeSet::new(); + let mut entries = BTreeMap::new(); for entry in archive.entries()? { let mut entry = entry?; - let path = entry.path()?.to_path_buf(); - validate_archive_path(&path, include_tor_keys)?; + let archive_path = validate_entry_path(&entry, include_tor_keys)?; + if !seen.insert(archive_path.clone()) { + anyhow::bail!("archive contains duplicate entry {archive_path}"); + } let entry_type = entry.header().entry_type(); - if !matches!(entry_type, EntryType::Regular | EntryType::Directory) { + if archive_path == MANIFEST_PATH { + if !entry_type.is_file() { + anyhow::bail!("backup manifest must be a regular file"); + } + manifest = Some(read_manifest_entry(&mut entry)?); + continue; + } + validate_allowed_archive_path(&archive_path, include_tor_keys)?; + let target = staging_dir.join(&archive_path); + ensure_under_root(staging_dir, &target)?; + if entry_type.is_dir() { + fs::create_dir_all(&target)?; + entries.insert( + archive_path, + ExtractedEntry { + kind: ManifestEntryKind::Directory, + size: 0, + }, + ); + } else if entry_type.is_file() { + if let Some(parent) = target.parent() { + fs::create_dir_all(parent)?; + } + let mut output = OpenOptions::new() + .write(true) + .create_new(true) + .open(&target) + .with_context(|| format!("archive contains duplicate file {archive_path}"))?; + let size = io::copy(&mut entry, &mut output)?; + entries.insert( + archive_path, + ExtractedEntry { + kind: ManifestEntryKind::File, + size, + }, + ); + } else { anyhow::bail!("archive contains unsupported entry type"); } - let target = paths.data_dir.join(&path); - if let Some(parent) = target.parent() { - fs::create_dir_all(parent)?; + } + Ok(StagedBackup { + manifest: manifest.ok_or_else(|| anyhow::anyhow!("backup manifest is missing"))?, + entries, + }) +} + +fn read_manifest_entry(entry: &mut tar::Entry<'_, File>) -> anyhow::Result { + let mut limited = entry.take(MANIFEST_MAX_BYTES + 1); + let mut raw = Vec::new(); + limited.read_to_end(&mut raw)?; + if u64::try_from(raw.len()).unwrap_or(u64::MAX) > MANIFEST_MAX_BYTES { + anyhow::bail!("backup manifest is too large"); + } + toml::from_slice(&raw).with_context(|| "backup manifest is malformed") +} + +#[derive(Debug)] +struct StagedBackup { + manifest: BackupManifest, + entries: BTreeMap, +} + +fn validate_staged_backup( + staging_dir: &Path, + staged: &StagedBackup, + include_tor_keys: bool, +) -> anyhow::Result<()> { + let manifest = &staged.manifest; + if manifest.format_version != FORMAT_VERSION { + anyhow::bail!("unsupported backup manifest format version"); + } + if manifest.tor_keys_included && !include_tor_keys { + anyhow::bail!("backup contains Tor keys but restore did not opt in"); + } + let mut manifest_entries = BTreeMap::new(); + for entry in &manifest.components { + validate_manifest_entry(entry, include_tor_keys)?; + if manifest_entries + .insert(entry.archive_path.clone(), entry) + .is_some() + { + anyhow::bail!("backup manifest contains duplicate component"); } - entry.unpack(&target)?; } + let manifest_paths = manifest_entries.keys().collect::>(); + let extracted_paths = staged.entries.keys().collect::>(); + if manifest_paths != extracted_paths { + anyhow::bail!("archive entries do not match backup manifest"); + } + require_manifest_file(&manifest_entries, "db/rustpost.sqlite3")?; + require_manifest_file(&manifest_entries, "settings.toml")?; + + for (path, manifest_entry) in manifest_entries { + let extracted = staged + .entries + .get(&path) + .ok_or_else(|| anyhow::anyhow!("archive entry missing after extraction"))?; + if manifest_entry.kind != extracted.kind { + anyhow::bail!("archive entry type does not match manifest"); + } + let staged_path = staging_dir.join(path); + ensure_under_root(staging_dir, &staged_path)?; + match manifest_entry.kind { + ManifestEntryKind::Directory => { + if !staged_path.is_dir() { + anyhow::bail!("manifest directory is missing"); + } + } + ManifestEntryKind::File => { + if !staged_path.is_file() { + anyhow::bail!("manifest file is missing"); + } + let (size, hash) = hash_file(&staged_path)?; + if size != manifest_entry.size + || Some(hash.as_str()) != manifest_entry.sha256.as_deref() + { + anyhow::bail!("backup file hash validation failed"); + } + if size != extracted.size { + anyhow::bail!("archive file size does not match extracted size"); + } + } + } + } + validate_sqlite_database( + &staging_dir.join("db/rustpost.sqlite3"), + manifest.db_schema_version, + )?; + let restored_settings = fs::read_to_string(staging_dir.join("settings.toml"))?; + let settings: Settings = toml::from_str(&restored_settings) + .with_context(|| "restored settings.toml is malformed")?; + settings.validate()?; Ok(()) } -fn append_file(builder: &mut Builder, path: &Path, archive_name: &str) -> anyhow::Result<()> { - if path.exists() { - builder.append_path_with_name(path, archive_name)?; +fn validate_manifest_entry(entry: &ManifestEntry, include_tor_keys: bool) -> anyhow::Result<()> { + if entry.runtime_path != entry.archive_path { + anyhow::bail!("manifest runtime path must match archive path"); + } + validate_archive_path_text( + &entry.archive_path, + entry.kind == ManifestEntryKind::Directory, + include_tor_keys, + )?; + validate_allowed_archive_path(&entry.archive_path, include_tor_keys)?; + match entry.kind { + ManifestEntryKind::Directory => { + if entry.size != 0 || entry.sha256.is_some() { + anyhow::bail!("manifest directory entries must not include hashes"); + } + } + ManifestEntryKind::File => { + if entry.sha256.as_deref().is_none_or(|hash| hash.len() != 64) { + anyhow::bail!("manifest file entries require sha256 hashes"); + } + } } Ok(()) } -fn append_dir(builder: &mut Builder, dir: &Path, archive_name: &str) -> anyhow::Result<()> { - if !dir.exists() { - return Ok(()); +fn require_manifest_file( + manifest_entries: &BTreeMap, + path: &str, +) -> anyhow::Result<()> { + if manifest_entries + .get(path) + .is_none_or(|entry| entry.kind != ManifestEntryKind::File) + { + anyhow::bail!("backup manifest is missing required file {path}"); } - for entry in WalkDir::new(dir).follow_links(false) { - let entry = entry?; - if entry.file_type().is_symlink() || entry.file_type().is_dir() { - continue; + Ok(()) +} + +fn swap_staged_runtime( + paths: &RuntimePaths, + staging_dir: &Path, + include_tor_keys: bool, +) -> anyhow::Result<()> { + let rollback = tempfile::Builder::new() + .prefix(".rustpost-restore-rollback-") + .tempdir_in(&paths.data_dir)?; + let mut moved_live = Vec::<(PathBuf, PathBuf)>::new(); + let mut installed = Vec::::new(); + for root in restore_roots(paths, staging_dir, include_tor_keys) { + if root.live.exists() { + let rollback_path = rollback.path().join(root.relative); + if let Some(parent) = rollback_path.parent() { + fs::create_dir_all(parent)?; + } + fs::rename(&root.live, &rollback_path).with_context(|| { + format!( + "failed to move live path {} into restore rollback area", + root.live.display() + ) + })?; + moved_live.push((rollback_path, root.live.clone())); } - let rel = entry.path().strip_prefix(dir)?; - builder.append_path_with_name(entry.path(), Path::new(archive_name).join(rel))?; + let result = (|| { + if let Some(parent) = root.live.parent() { + fs::create_dir_all(parent)?; + } + if root.staged.exists() { + fs::rename(&root.staged, &root.live) + } else if root.directory { + fs::create_dir_all(&root.live) + } else { + Err(io::Error::new( + io::ErrorKind::NotFound, + "staged restore file missing", + )) + } + })(); + if let Err(error) = result { + rollback_restore(&installed, &moved_live)?; + anyhow::bail!( + "failed to install restored path {}: {error}", + root.live.display() + ); + } + installed.push(root.live); + } + Ok(()) +} + +fn rollback_restore( + installed: &[PathBuf], + moved_live: &[(PathBuf, PathBuf)], +) -> anyhow::Result<()> { + for path in installed.iter().rev() { + remove_path_if_exists(path)?; + } + for (rollback_path, live_path) in moved_live.iter().rev() { + if let Some(parent) = live_path.parent() { + fs::create_dir_all(parent)?; + } + fs::rename(rollback_path, live_path).with_context(|| { + format!( + "failed to restore live path {} after restore failure", + live_path.display() + ) + })?; } Ok(()) } +fn apply_restored_permissions(paths: &RuntimePaths, include_tor_keys: bool) -> anyhow::Result<()> { + if include_tor_keys && paths.tor_onion_service_dir.exists() { + restrict_tor_permissions(&paths.tor_onion_service_dir)?; + } + Ok(()) +} + +#[cfg(unix)] +fn restrict_tor_permissions(path: &Path) -> anyhow::Result<()> { + use std::os::unix::fs::PermissionsExt as _; + + fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + for entry in WalkDir::new(path).follow_links(false) { + let entry = entry?; + let mode = if entry.file_type().is_dir() { + 0o700 + } else { + 0o600 + }; + fs::set_permissions(entry.path(), fs::Permissions::from_mode(mode))?; + } + Ok(()) +} + +#[cfg(not(unix))] +fn restrict_tor_permissions(_path: &Path) -> anyhow::Result<()> { + Ok(()) +} + +#[derive(Debug)] +struct RestoreRoot { + relative: &'static str, + staged: PathBuf, + live: PathBuf, + directory: bool, +} + +fn restore_roots( + paths: &RuntimePaths, + staging_dir: &Path, + include_tor_keys: bool, +) -> Vec { + let mut roots = vec![ + RestoreRoot { + relative: "settings.toml", + staged: staging_dir.join("settings.toml"), + live: paths.settings_path.clone(), + directory: false, + }, + RestoreRoot { + relative: "db", + staged: staging_dir.join("db"), + live: paths.db_dir.clone(), + directory: true, + }, + RestoreRoot { + relative: "uploads/originals", + staged: staging_dir.join("uploads/originals"), + live: paths.uploads_originals.clone(), + directory: true, + }, + RestoreRoot { + relative: "uploads/images", + staged: staging_dir.join("uploads/images"), + live: paths.uploads_images.clone(), + directory: true, + }, + RestoreRoot { + relative: "uploads/videos", + staged: staging_dir.join("uploads/videos"), + live: paths.uploads_videos.clone(), + directory: true, + }, + RestoreRoot { + relative: "uploads/thumbs", + staged: staging_dir.join("uploads/thumbs"), + live: paths.uploads_thumbs.clone(), + directory: true, + }, + RestoreRoot { + relative: "assets", + staged: staging_dir.join("assets"), + live: paths.assets_dir.clone(), + directory: true, + }, + ]; + if include_tor_keys { + roots.push(RestoreRoot { + relative: "tor/onion-service", + staged: staging_dir.join("tor/onion-service"), + live: paths.tor_onion_service_dir.clone(), + directory: true, + }); + } + roots +} + +fn validate_entry_path( + entry: &tar::Entry<'_, File>, + include_tor_keys: bool, +) -> anyhow::Result { + let entry_type = entry.header().entry_type(); + if !entry_type.is_file() && !entry_type.is_dir() { + anyhow::bail!("archive contains unsupported entry type"); + } + let raw = entry.path_bytes(); + let raw = std::str::from_utf8(raw.as_ref()) + .map_err(|_utf8| anyhow::anyhow!("archive path is not valid UTF-8"))?; + validate_archive_path_text(raw, entry_type.is_dir(), include_tor_keys) +} + pub fn validate_archive_path(path: &Path, include_tor_keys: bool) -> anyhow::Result<()> { - if path.is_absolute() { - anyhow::bail!("archive path is absolute"); - } - let as_text = path.to_string_lossy(); - let lower = as_text.to_ascii_lowercase(); - if as_text.contains('\\') - || as_text.contains(':') - || lower.contains("%2e") - || lower.contains("%2f") - || lower.contains("%5c") - || lower.contains('\u{2215}') - || lower.contains('\u{2044}') + let text = path + .to_str() + .ok_or_else(|| anyhow::anyhow!("archive path is not valid UTF-8"))?; + validate_archive_path_text(text, false, include_tor_keys).map(|_| ()) +} + +fn validate_archive_path_text( + raw: &str, + is_directory: bool, + include_tor_keys: bool, +) -> anyhow::Result { + if raw.is_empty() || raw.starts_with('/') { + anyhow::bail!("archive path is absolute or empty"); + } + 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!("archive path contains unsafe characters"); } - if path - .components() - .any(|component| !matches!(component, Component::Normal(_))) + let lower = raw.to_ascii_lowercase(); + if lower.contains("%2e") || lower.contains("%2f") || lower.contains("%5c") { + anyhow::bail!("archive path contains unsafe characters"); + } + let normalized = if is_directory { + raw.strip_suffix('/').unwrap_or(raw) + } else { + raw + }; + if normalized.is_empty() || normalized.contains("//") || normalized.ends_with('/') { + anyhow::bail!("archive path contains unsafe separators"); + } + let path = Path::new(normalized); + if path.is_absolute() + || path + .components() + .any(|component| !matches!(component, Component::Normal(_))) { anyhow::bail!("archive path contains traversal"); } - if as_text.starts_with("tor/") && !include_tor_keys { + if normalized.starts_with("tor/") && !include_tor_keys { anyhow::bail!("archive contains Tor keys but restore did not opt in"); } + Ok(normalized.to_owned()) +} + +fn validate_allowed_archive_path(path: &str, include_tor_keys: bool) -> anyhow::Result<()> { + if path == MANIFEST_PATH + || path == "settings.toml" + || path == "db" + || path == "db/rustpost.sqlite3" + || path == "uploads" + || path == "uploads/originals" + || path.starts_with("uploads/originals/") + || path == "uploads/images" + || path.starts_with("uploads/images/") + || path == "uploads/videos" + || path.starts_with("uploads/videos/") + || path == "uploads/thumbs" + || path.starts_with("uploads/thumbs/") + || path == "assets" + || path.starts_with("assets/") + || (include_tor_keys + && (path == "tor" + || path == "tor/onion-service" + || path.starts_with("tor/onion-service/"))) + { + return Ok(()); + } + anyhow::bail!("archive path is outside approved runtime roots"); +} + +fn format_archive_path(root: &str, rel: &Path) -> anyhow::Result { + let rel = rel + .to_str() + .ok_or_else(|| anyhow::anyhow!("runtime path is not valid UTF-8"))? + .replace(std::path::MAIN_SEPARATOR, "/"); + let archive_path = format!("{root}/{rel}"); + validate_archive_path_text(&archive_path, false, true) +} + +fn ensure_under_root(root: &Path, target: &Path) -> anyhow::Result<()> { + let mut normalized = PathBuf::from(root); + for component in target + .strip_prefix(root) + .with_context(|| "restore target is outside staging root")? + .components() + { + match component { + Component::Normal(part) => normalized.push(part), + _ => anyhow::bail!("restore target contains traversal"), + } + } + if normalized != target { + anyhow::bail!("restore target normalization failed"); + } + Ok(()) +} + +fn required_archive_dirs(include_tor_keys: bool) -> Vec<&'static str> { + let mut dirs = vec![ + "db", + "uploads", + "uploads/originals", + "uploads/images", + "uploads/videos", + "uploads/thumbs", + "assets", + ]; + if include_tor_keys { + dirs.push("tor"); + dirs.push("tor/onion-service"); + } + dirs +} + +fn component_for_archive_path(path: &str) -> &str { + if path == "db" || path.starts_with("db/") { + "database" + } else if path == "settings.toml" { + "settings" + } else if path == "assets" || path.starts_with("assets/") { + "assets" + } else if path == "tor" || path.starts_with("tor/") { + "tor_keys" + } else { + "media" + } +} + +fn excluded_runtime_artifact(path: &Path) -> bool { + path.components().any(|component| { + let Component::Normal(value) = component else { + return true; + }; + let name = value.to_string_lossy(); + matches!( + name.as_ref(), + ".DS_Store" + | "node_modules" + | "playwright-report" + | "test-results" + | ".cache" + | "trace.zip" + ) || name.ends_with(".tmp") + || name.ends_with(".upload") + || name.ends_with(".log") + || name.ends_with(".trace") + || name.ends_with(".har") + }) +} + +fn automatic_backup_due(paths: &RuntimePaths, settings: &BackupSettings) -> anyhow::Result { + let Some(latest) = list_backups(paths)? + .into_iter() + .filter(|archive| archive.automatic) + .max_by(|left, right| left.filename.cmp(&right.filename)) + else { + return Ok(true); + }; + let modified = fs::metadata(latest.path)?.modified()?; + let interval = Duration::from_secs(settings.automatic_interval_minutes.saturating_mul(60)); + Ok(modified.elapsed().unwrap_or(Duration::ZERO) >= interval) +} + +fn retention_cutoff(days: u64) -> Option { + if days == 0 { + return None; + } + SystemTime::now().checked_sub(Duration::from_secs(days.saturating_mul(86_400))) +} + +fn unique_archive_path(backups_dir: &Path, kind: BackupKind) -> PathBuf { + let prefix = match kind { + BackupKind::Manual => "rustpost", + BackupKind::Automatic => "rustpost-auto", + BackupKind::PreRestore => "rustpost-pre-restore", + }; + let timestamp = Utc::now().format("%Y%m%dT%H%M%S%6fZ"); + backups_dir.join(format!("{prefix}-{timestamp}.tar")) +} + +fn is_rustpost_backup_name(filename: &str) -> bool { + std::path::Path::new(filename) + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("tar")) + && (filename.starts_with("rustpost-") + || filename.starts_with("rustpost-auto-") + || filename.starts_with("rustpost-pre-restore-")) +} + +fn validate_backup_filename(filename: &str) -> anyhow::Result<()> { + validate_archive_path_text(filename, false, false)?; + if filename.contains('/') || !is_rustpost_backup_name(filename) { + anyhow::bail!("invalid backup filename"); + } Ok(()) } +fn hash_file(path: &Path) -> anyhow::Result<(u64, String)> { + let mut file = File::open(path)?; + let mut hasher = Sha256::new(); + let mut total = 0u64; + let mut buffer = vec![0u8; 64 * 1024].into_boxed_slice(); + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + total = total.saturating_add(u64::try_from(read).unwrap_or(u64::MAX)); + hasher.update(&buffer[..read]); + } + Ok((total, format!("{:x}", hasher.finalize()))) +} + +fn remove_path_if_exists(path: &Path) -> anyhow::Result<()> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.is_dir() => fs::remove_dir_all(path)?, + Ok(_) => fs::remove_file(path)?, + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + Ok(()) +} + +struct OperationLock { + path: PathBuf, +} + +impl OperationLock { + fn acquire(paths: &RuntimePaths) -> anyhow::Result { + fs::create_dir_all(&paths.tmp_dir)?; + let path = paths.tmp_dir.join(LOCK_DIR); + match fs::create_dir(&path) { + Ok(()) => Ok(Self { path }), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + anyhow::bail!("another backup or restore is already running"); + } + Err(error) => Err(error).with_context(|| "failed to create backup operation lock"), + } + } +} + +impl Drop for OperationLock { + fn drop(&mut self) { + let _remove_result = fs::remove_dir(&self.path); + } +} + #[cfg(test)] mod tests { use super::*; + use std::thread; + + fn test_db(path: &Path, version: i64) { + let conn = Connection::open(path).expect("open db"); + conn.execute_batch( + r#" + CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY); + INSERT INTO schema_migrations (version) VALUES (1); + "#, + ) + .expect("schema"); + if version != 1 { + conn.execute( + "INSERT INTO schema_migrations (version) VALUES (?)", + [version], + ) + .expect("version"); + } + } + + fn test_settings(path: &Path) { + fs::write( + path, + toml::to_string(&Settings::default()).expect("settings"), + ) + .expect("write settings"); + } + + fn test_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"); + test_db(&paths.database_path, CURRENT_SCHEMA_VERSION); + test_settings(&paths.settings_path); + (temp, paths) + } #[test] fn backup_path_safety() { @@ -127,31 +1288,210 @@ mod tests { assert!(validate_archive_path(Path::new("/tmp/settings.toml"), false).is_err()); assert!(validate_archive_path(Path::new("C:/settings.toml"), false).is_err()); assert!(validate_archive_path(Path::new("uploads\\x"), false).is_err()); + assert!(validate_archive_path(Path::new("uploads//x"), false).is_err()); assert!(validate_archive_path(Path::new("uploads/%2e%2e/settings.toml"), false).is_err()); + 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()); } #[test] - fn backup_excludes_tor_by_default_and_can_include() { - let temp = tempfile::tempdir().expect("temp dir"); - let paths = RuntimePaths::from_data_dir(temp.path().join("data")); - paths.ensure().expect("ensure"); - fs::write(&paths.database_path, "db").expect("db"); - fs::write(paths.database_sidecar_path("wal"), "wal").expect("wal"); - fs::write(&paths.settings_path, "settings").expect("settings"); - fs::write(paths.tor_onion_service_dir.join("secret"), "key").expect("key"); - let no_tor = create_backup(&paths, false).expect("backup"); - let names = archive_names(&no_tor); + fn backup_manifest_records_hashes_and_excludes_tor_by_default() { + let (_temp, paths) = test_paths(); + fs::write(paths.uploads_images.join("photo.webp"), b"image").expect("image"); + fs::write(paths.tor_onion_service_dir.join("secret"), b"key").expect("key"); + + let archive = create_backup(&paths, false).expect("backup"); + let manifest = read_manifest_from_archive(&archive).expect("manifest"); + let names = archive_names(&archive); + + assert_eq!(manifest.format_version, FORMAT_VERSION); + assert_eq!(manifest.db_schema_version, Some(CURRENT_SCHEMA_VERSION)); + assert!(!manifest.tor_keys_included); + assert!(names.iter().any(|name| name == MANIFEST_PATH)); assert!(names.iter().any(|name| name == "db/rustpost.sqlite3")); - assert!(names.iter().any(|name| name == "db/rustpost.sqlite3-wal")); + assert!(names.iter().any(|name| name == "settings.toml")); + assert!(names.iter().any(|name| name == "uploads/images/photo.webp")); assert!(!names.iter().any(|name| name.contains("tor/onion-service"))); + assert!(manifest.components.iter().any(|entry| entry.archive_path + == "uploads/images/photo.webp" + && entry.sha256.is_some())); + let with_tor = create_backup(&paths, true).expect("backup"); let names = archive_names(&with_tor); + assert!(names.iter().any(|name| name == "tor/onion-service/secret")); + } + + #[test] + fn restore_validates_hashes_before_touching_live_runtime() { + 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); + fs::write(source.uploads_images.join("restored.webp"), b"restored").expect("media"); + let archive = create_backup(&source, false).expect("backup"); + + 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); + fs::write(target.uploads_images.join("old.webp"), b"old").expect("old"); + 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!(target.uploads_images.join("old.webp").is_file()); + assert!(!target.uploads_images.join("restored.webp").exists()); + } + + #[test] + fn restore_rejects_malicious_archive_entries() { + let (_temp, paths) = test_paths(); + for (name, entry_type) in [ + ("../settings.toml", EntryType::Regular), + ("/tmp/settings.toml", EntryType::Regular), + ("uploads//images/x", EntryType::Regular), + ("uploads/images/x", EntryType::Symlink), + ("uploads/images/y", EntryType::Link), + ("C:/settings.toml", EntryType::Regular), + ] { + let temp = tempfile::tempdir().expect("malicious"); + let archive = temp.path().join("bad.tar"); + write_minimal_archive(&archive, name, entry_type); + assert!( + restore_backup(&paths, &archive, false).is_err(), + "{name} should be rejected" + ); + } + } + + #[test] + fn restore_rejects_duplicate_archive_entries() { + let (_temp, paths) = test_paths(); + let archive = paths.tmp_dir.join("duplicate.tar"); + let file = File::create(&archive).expect("archive"); + let mut builder = Builder::new(file); + append_bytes( + &mut builder, + MANIFEST_PATH, + b"not toml", + 0o600, + EntryType::Regular, + ) + .expect("manifest"); + append_bytes( + &mut builder, + "settings.toml", + b"one", + 0o600, + EntryType::Regular, + ) + .expect("one"); + append_bytes( + &mut builder, + "settings.toml", + b"two", + 0o600, + EntryType::Regular, + ) + .expect("two"); + builder.finish().expect("finish"); + + let error = restore_backup(&paths, &archive, false).expect_err("duplicate"); + + assert!(error.to_string().contains("validation")); + } + + #[test] + fn restore_rolls_back_when_install_fails() { + 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); + fs::write(source.uploads_images.join("restored.webp"), b"restored").expect("media"); + let archive = create_backup(&source, false).expect("backup"); + + 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); + fs::write(&target.settings_path, "old-settings").expect("old settings"); + let old_db = fs::read(&target.database_path).expect("old db"); + fs::remove_dir_all(target.data_dir.join("uploads")).expect("remove uploads"); + fs::write(target.data_dir.join("uploads"), b"not a directory").expect("block uploads"); + + let error = restore_backup(&target, &archive, false).expect_err("install failure"); + + assert!(error.to_string().contains("install")); + assert_eq!( + fs::read_to_string(&target.settings_path).expect("settings"), + "old-settings" + ); + assert_eq!(fs::read(&target.database_path).expect("db"), old_db); + assert_eq!( + fs::read(target.data_dir.join("uploads")).expect("uploads blocker"), + b"not a directory" + ); + } + + #[test] + fn concurrent_operations_are_rejected() { + let (_temp, paths) = test_paths(); + fs::create_dir(paths.tmp_dir.join(LOCK_DIR)).expect("lock"); + + let error = create_backup(&paths, false).expect_err("locked"); + + assert!(error.to_string().contains("already running")); + } + + #[test] + fn automatic_retention_only_deletes_automatic_backups() { + let (_temp, paths) = test_paths(); + for name in [ + "rustpost-auto-20260526T000000000000Z.tar", + "rustpost-auto-20260526T000001000000Z.tar", + "rustpost-auto-20260526T000002000000Z.tar", + "rustpost-20260526T000003000000Z.tar", + ] { + fs::write(paths.backups_dir.join(name), b"x").expect("backup"); + thread::sleep(Duration::from_millis(2)); + } + let settings = BackupSettings { + retention_keep_last: 2, + retention_max_age_days: 0, + ..BackupSettings::default() + }; + + let deleted = apply_retention(&paths, &settings).expect("retention"); + + assert_eq!(deleted, 1); assert!( - names - .iter() - .any(|name| name.contains("tor/onion-service/secret")) + !paths + .backups_dir + .join("rustpost-auto-20260526T000000000000Z.tar") + .exists() + ); + assert!( + paths + .backups_dir + .join("rustpost-auto-20260526T000001000000Z.tar") + .exists() + ); + assert!( + paths + .backups_dir + .join("rustpost-auto-20260526T000002000000Z.tar") + .exists() + ); + assert!( + paths + .backups_dir + .join("rustpost-20260526T000003000000Z.tar") + .exists() ); } @@ -161,13 +1501,80 @@ mod tests { .entries() .expect("entries") .map(|entry| { - entry - .expect("entry") - .path() - .expect("path") - .to_string_lossy() - .to_string() + let entry = entry.expect("entry"); + std::str::from_utf8(entry.path_bytes().as_ref()) + .expect("utf8") + .trim_end_matches('/') + .to_owned() }) .collect() } + + fn corrupt_first_file_hash(archive: &Path, dir: &Path) -> PathBuf { + let manifest = read_manifest_from_archive(archive).expect("manifest"); + let mut manifest = manifest; + let file = manifest + .components + .iter_mut() + .find(|entry| { + entry.kind == ManifestEntryKind::File && entry.archive_path == "settings.toml" + }) + .expect("file"); + file.sha256 = Some("0".repeat(64)); + let manifest_toml = toml::to_string(&manifest).expect("manifest toml"); + let corrupted = dir.join("corrupted.tar"); + let output = File::create(&corrupted).expect("corrupted"); + let mut builder = Builder::new(output); + append_bytes( + &mut builder, + MANIFEST_PATH, + manifest_toml.as_bytes(), + 0o600, + EntryType::Regular, + ) + .expect("manifest"); + let input = File::open(archive).expect("archive"); + for entry in Archive::new(input).entries().expect("entries") { + let mut entry = entry.expect("entry"); + let path = std::str::from_utf8(entry.path_bytes().as_ref()) + .expect("utf8") + .trim_end_matches('/') + .to_owned(); + if path == MANIFEST_PATH { + continue; + } + let entry_type = entry.header().entry_type(); + if entry_type.is_dir() { + append_bytes(&mut builder, &path, &[], 0o700, EntryType::Directory).expect("dir"); + } else { + let mut bytes = Vec::new(); + entry.read_to_end(&mut bytes).expect("read"); + append_bytes(&mut builder, &path, &bytes, 0o600, EntryType::Regular).expect("file"); + } + } + builder.finish().expect("finish"); + corrupted + } + + fn write_minimal_archive(path: &Path, entry_name: &str, entry_type: EntryType) { + let file = File::create(path).expect("archive"); + let mut builder = Builder::new(file); + append_bytes( + &mut builder, + MANIFEST_PATH, + b"format_version = 1", + 0o600, + EntryType::Regular, + ) + .expect("manifest"); + let mut header = deterministic_header("placeholder", 0, 0o600, entry_type).expect("header"); + header.as_old_mut().name.fill(0); + header.as_old_mut().name[..entry_name.len()].copy_from_slice(entry_name.as_bytes()); + if entry_type == EntryType::Symlink || entry_type == EntryType::Link { + header.set_link_name("settings.toml").expect("link"); + } + header.set_cksum(); + builder.append(&header, io::empty()).expect("entry"); + builder.finish().expect("finish"); + } } diff --git a/src/cli.rs b/src/cli.rs index 568053f..d4fb03e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -100,7 +100,9 @@ pub async fn run() -> anyhow::Result<()> { config::write_default_if_missing(&settings_path)?; let settings = config::Settings::load(&settings_path)?; settings.validate()?; - paths = paths.with_tor_data_dir(&settings.tor.data_dir); + paths = paths + .with_tor_data_dir(&settings.tor.data_dir) + .with_backup_dir(&settings.backup.backup_dir); paths.ensure()?; info!(data_dir = %paths.data_dir.display(), settings = %settings_path.display(), "runtime paths ready"); @@ -259,7 +261,7 @@ fn restore_command( archive: &std::path::Path, include_tor_keys: bool, ) -> anyhow::Result<()> { - backup::restore_backup(paths, archive, include_tor_keys)?; + let report = backup::restore_backup(paths, archive, include_tor_keys)?; stdout_raw(format_args!( "{}", terminal::render_command_success( @@ -267,6 +269,21 @@ fn restore_command( &[ terminal::row("Archive", archive.display().to_string()), terminal::row("Data directory", paths.data_dir.display().to_string()), + terminal::row( + "Pre-restore safety backup", + report.pre_restore_backup.map_or_else( + || "not created".to_owned(), + |path| path.display().to_string() + ), + ), + terminal::row( + "Tor keys", + if report.tor_keys_restored { + "restored" + } else { + "not restored" + }, + ), ], ) ))?; @@ -305,6 +322,7 @@ async fn serve( let state = server::AppState::new(pool, 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()); if settings.tor.tor_only { return serve_tor_only(onion_listener, app, shutdown_rx).await; diff --git a/src/config.rs b/src/config.rs index 3bdebaa..6a64428 100644 --- a/src/config.rs +++ b/src/config.rs @@ -17,6 +17,7 @@ pub struct Settings { pub tor: TorSettings, pub moderation: ModerationSettings, pub admin: AdminSettings, + #[serde(default)] pub backup: BackupSettings, } @@ -120,8 +121,34 @@ pub struct AdminSettings { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BackupSettings { + #[serde(default = "default_true")] pub enabled: bool, + #[serde(default)] pub backup_dir: String, + #[serde(default)] + pub automatic_enabled: bool, + #[serde(default = "default_automatic_interval_minutes")] + pub automatic_interval_minutes: u64, + #[serde(default = "default_retention_keep_last")] + pub retention_keep_last: usize, + #[serde(default = "default_retention_max_age_days")] + pub retention_max_age_days: u64, + #[serde(default)] + pub automatic_include_tor_keys: bool, +} + +impl Default for BackupSettings { + fn default() -> Self { + Self { + enabled: true, + backup_dir: "backups".to_owned(), + automatic_enabled: false, + automatic_interval_minutes: default_automatic_interval_minutes(), + retention_keep_last: default_retention_keep_last(), + retention_max_age_days: default_retention_max_age_days(), + automatic_include_tor_keys: false, + } + } } impl Default for Settings { @@ -204,10 +231,7 @@ impl Default for Settings { admin: AdminSettings { create_admin_on_first_boot: true, }, - backup: BackupSettings { - enabled: true, - backup_dir: "backups".to_owned(), - }, + backup: BackupSettings::default(), } } } @@ -228,6 +252,18 @@ impl Settings { } validate_relative_path(&self.tor.data_dir, "tor.data_dir")?; validate_relative_path(&self.backup.backup_dir, "backup.backup_dir")?; + if self.backup.automatic_interval_minutes == 0 { + anyhow::bail!("backup.automatic_interval_minutes must be at least 1"); + } + if self.backup.retention_keep_last == 0 { + anyhow::bail!("backup.retention_keep_last must be at least 1"); + } + if self.backup.retention_keep_last > 10_000 { + anyhow::bail!("backup.retention_keep_last is too large"); + } + if self.backup.retention_max_age_days > 3_650 { + anyhow::bail!("backup.retention_max_age_days must be 3650 days or less"); + } if self.tor.tor_only && !self.tor.enabled { anyhow::bail!("tor.tor_only requires tor.enabled"); } @@ -484,6 +520,23 @@ enabled = {backup_enabled} # SECURITY: Relative directory for backup archives under the RustPost data directory. backup_dir = {backup_dir} + +# Create scheduled automatic backups. Manual backups remain available when backup.enabled is true. +automatic_enabled = {backup_automatic_enabled} + +# Interval between automatic backup attempts. The scheduler checks periodically and runs when due. +automatic_interval_minutes = {backup_automatic_interval_minutes} + +# Safe retention for automatic backups. Manual and pre-restore backups are never pruned. +retention_keep_last = {backup_retention_keep_last} + +# Delete automatic backups older than this many days after retaining the newest backups. +# Set 0 to disable age-based cleanup. +retention_max_age_days = {backup_retention_max_age_days} + +# SECURITY: Include onion service keys in automatic backups. +# Keep false unless automatic backup storage is encrypted and access-controlled. +automatic_include_tor_keys = {backup_automatic_include_tor_keys} "#, site_name = toml_string(&settings.site.name), server_host = toml_string(&settings.server.host), @@ -542,6 +595,11 @@ backup_dir = {backup_dir} create_admin_on_first_boot = settings.admin.create_admin_on_first_boot, backup_enabled = settings.backup.enabled, backup_dir = toml_string(&settings.backup.backup_dir), + backup_automatic_enabled = settings.backup.automatic_enabled, + backup_automatic_interval_minutes = settings.backup.automatic_interval_minutes, + backup_retention_keep_last = settings.backup.retention_keep_last, + backup_retention_max_age_days = settings.backup.retention_max_age_days, + backup_automatic_include_tor_keys = settings.backup.automatic_include_tor_keys, ) } @@ -620,6 +678,18 @@ const fn default_true() -> bool { true } +const fn default_automatic_interval_minutes() -> u64 { + 1_440 +} + +const fn default_retention_keep_last() -> usize { + 10 +} + +const fn default_retention_max_age_days() -> u64 { + 30 +} + #[cfg(test)] mod tests { use super::*; @@ -684,6 +754,47 @@ mod tests { parsed.validate().expect("defaulted setting validates"); } + #[test] + fn missing_backup_schedule_settings_default_safely_disabled() { + let raw = toml::to_string(&Settings::default()) + .expect("settings toml") + .lines() + .filter(|line| { + ![ + "automatic_enabled", + "automatic_interval_minutes", + "retention_keep_last", + "retention_max_age_days", + "automatic_include_tor_keys", + ] + .iter() + .any(|key| line.trim_start().starts_with(key)) + }) + .collect::>() + .join("\n"); + let parsed: Settings = toml::from_str(&raw).expect("legacy backup settings parse"); + + assert!(!parsed.backup.automatic_enabled); + assert_eq!(parsed.backup.automatic_interval_minutes, 1_440); + assert_eq!(parsed.backup.retention_keep_last, 10); + assert_eq!(parsed.backup.retention_max_age_days, 30); + assert!(!parsed.backup.automatic_include_tor_keys); + parsed + .validate() + .expect("defaulted backup settings validate"); + } + + #[test] + fn rejects_unsafe_backup_retention_settings() { + let mut settings = Settings::default(); + settings.backup.automatic_interval_minutes = 0; + assert!(settings.validate().is_err()); + + let mut settings = Settings::default(); + settings.backup.retention_keep_last = 0; + assert!(settings.validate().is_err()); + } + #[test] fn accepts_zero_post_edit_window_as_disabled() { let mut settings = Settings::default(); diff --git a/src/db.rs b/src/db.rs index c14e25d..fd413f4 100644 --- a/src/db.rs +++ b/src/db.rs @@ -6,6 +6,8 @@ use std::time::Duration; use anyhow::Context as _; use rusqlite::{Connection, OptionalExtension as _}; +pub const CURRENT_SCHEMA_VERSION: i64 = 13; + #[derive(Clone)] pub struct Db { sender: Arc>, @@ -132,9 +134,12 @@ pub async fn migrate(pool: &Db) -> anyhow::Result<()> { tx.execute_batch(MIGRATION_12)?; tx.execute("INSERT INTO schema_migrations (version) VALUES (12)", [])?; } - if applied.unwrap_or(0) < 13 { + if applied.unwrap_or(0) < CURRENT_SCHEMA_VERSION { tx.execute_batch(MIGRATION_13)?; - tx.execute("INSERT INTO schema_migrations (version) VALUES (13)", [])?; + tx.execute( + "INSERT INTO schema_migrations (version) VALUES (?)", + [CURRENT_SCHEMA_VERSION], + )?; } tx.commit()?; Ok(()) diff --git a/src/errors.rs b/src/errors.rs index 6be14d7..def9ce4 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -14,6 +14,8 @@ pub enum AppError { #[error("not found")] NotFound, #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] Sqlite(#[from] rusqlite::Error), #[error(transparent)] Anyhow(#[from] anyhow::Error), @@ -32,7 +34,7 @@ impl IntoResponse for AppError { Self::Forbidden => (StatusCode::FORBIDDEN, "forbidden".to_owned()), Self::RateLimited(message) => (StatusCode::TOO_MANY_REQUESTS, message), Self::NotFound => (StatusCode::NOT_FOUND, "not found".to_owned()), - Self::Sqlite(_) | Self::Anyhow(_) => { + Self::Io(_) | Self::Sqlite(_) | Self::Anyhow(_) => { tracing::error!(error = ?self, "request failed"); ( StatusCode::INTERNAL_SERVER_ERROR, diff --git a/src/runtime.rs b/src/runtime.rs index 227d19c..cfab75b 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -65,6 +65,12 @@ impl RuntimePaths { self } + #[must_use] + pub fn with_backup_dir(mut self, backup_dir: &str) -> Self { + self.backups_dir = self.data_dir.join(backup_dir); + self + } + pub fn ensure(&self) -> anyhow::Result<()> { for path in [ &self.data_dir, diff --git a/src/server.rs b/src/server.rs index 99fa492..c4ff9b9 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,9 +1,12 @@ use std::fmt::Write as _; +use std::io; use std::net::SocketAddr; +use std::path::PathBuf; use std::sync::Arc; use axum::Json; use axum::Router; +use axum::body::{Body, Bytes}; use axum::extract::connect_info::ConnectInfo; use axum::extract::{DefaultBodyLimit, Form, Multipart, Path, Query, State}; use axum::http::{HeaderMap, HeaderValue, StatusCode, Uri, header}; @@ -12,8 +15,10 @@ use axum::response::{Html, IntoResponse as _, Redirect, Response}; use axum::routing::{get, post}; use rusqlite::{OptionalExtension as _, params}; use serde::{Deserialize, Serialize}; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; use tower_http::services::ServeDir; use tower_http::trace::TraceLayer; +use uuid::Uuid; use crate::auth::{self, CurrentUser, Theme}; use crate::config::Settings; @@ -119,9 +124,16 @@ pub fn router(state: Arc) -> Router { ) .route("/admin/favicon", post(admin_favicon_upload)) .route("/admin/favicon/remove", post(admin_favicon_remove)) + .route("/admin/backups", get(admin_backups)) + .route("/admin/backups/create", post(admin_create_backup)) .route( - "/admin/backups", - get(admin_backups).post(admin_create_backup), + "/admin/backups/download/{filename}", + get(admin_download_backup), + ) + .route("/admin/backups/restore", post(admin_restore_backup)) + .route( + "/admin/backups/settings", + post(admin_backup_settings_update), ) .nest_service( "/uploads/originals", @@ -4019,13 +4031,7 @@ async fn admin_backups( ) -> AppResult> { let user = require_admin(&state, &headers).await?; let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); - let body = format!( - r#"

Backups

"#, - html_escape::encode_double_quoted_attribute(&csrf) - ); - Ok(Html( - page_layout(&state, Some(&user), Some(&csrf), "Backups", &body).await?, - )) + backups_page(&state, &user, &csrf, None).await } #[derive(Deserialize)] @@ -4041,25 +4047,458 @@ async fn admin_create_backup( ) -> AppResult> { let user = require_admin(&state, &headers).await?; validate_csrf(&state.pool, &headers, &form.csrf).await?; + let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); + let settings = load_deep_settings(&state)?; + if !settings.backup.enabled { + return backups_page( + &state, + &user, + &csrf, + Some(("error", "Backups are disabled in settings.toml.")), + ) + .await; + } let include_tor = form.include_tor_keys.is_some(); - let archive = backup::create_backup(&state.paths, include_tor)?; + let paths = state.paths.clone(); + let created = tokio::task::spawn_blocking(move || backup::create_backup(&paths, include_tor)) + .await + .map_err(|err| AppError::BadRequest(format!("backup task failed: {err}")))?; + let archive = match created { + Ok(archive) => archive, + Err(err) => { + tracing::warn!(error = %err, "manual backup failed"); + let message = public_backup_error("Backup failed", &err); + return backups_page(&state, &user, &csrf, Some(("error", &message))).await; + } + }; admin::audit( &state.pool, user.id, "create_backup", - archive.to_string_lossy().as_ref(), + archive + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("backup"), ) .await?; - let body = format!( - r#"

Backup created: {}

"#, - html_escape::encode_text(&archive.display().to_string()) + let message = format!( + "Backup created: {}", + archive + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("archive") ); + backups_page(&state, &user, &csrf, Some(("success", &message))).await +} + +async fn admin_download_backup( + State(state): State>, + headers: HeaderMap, + Path(filename): Path, +) -> AppResult { + require_admin(&state, &headers).await?; + let path = backup::backup_path_for_download(&state.paths, &filename) + .map_err(|_err| AppError::NotFound)?; + let metadata = tokio::fs::metadata(&path).await?; + let file = tokio::fs::File::open(&path).await?; + let stream = futures_util::stream::unfold(file, |mut file| async move { + let mut buffer = vec![0u8; 64 * 1024]; + match file.read(&mut buffer).await { + Ok(0) => None, + Ok(read) => { + buffer.truncate(read); + Some((Ok::(Bytes::from(buffer)), file)) + } + Err(error) => Some((Err(error), file)), + } + }); + let disposition = format!("attachment; filename=\"{}\"", filename.replace('"', "")); + let mut response = Body::from_stream(stream).into_response(); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/x-tar"), + ); + 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 admin_backup_settings_update( + 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 csrf = form_csrf(&state, &headers).await.unwrap_or_default(); + let current = load_deep_settings(&state)?; + let values = match admin::parse_backup_settings_form(&form, ¤t) { + Ok(values) => values, + Err(err) => { + let message = err.to_string(); + return backups_page(&state, &user, &csrf, Some(("error", &message))).await; + } + }; + let updated = values.apply_to(¤t); + if let Err(err) = admin::write_backup_settings(&state.paths.settings_path, &updated) { + tracing::error!(error = %err, "failed to save backup settings"); + return backups_page( + &state, + &user, + &csrf, + Some(( + "error", + "Backup settings could not be saved. Check the server logs.", + )), + ) + .await; + } + admin::audit( + &state.pool, + user.id, + "update_backup_settings", + "settings.toml", + ) + .await?; + backups_page( + &state, + &user, + &csrf, + Some(( + "success", + "Backup settings saved. The scheduler reads settings.toml on its next check.", + )), + ) + .await +} + +async fn admin_restore_backup( + State(state): State>, + headers: HeaderMap, + multipart: Multipart, +) -> AppResult> { + let user = require_admin(&state, &headers).await?; let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); + let upload = match parse_restore_upload(&state, &headers, multipart).await { + Ok(upload) => upload, + Err(err) => { + return backups_page(&state, &user, &csrf, Some(("error", &err.to_string()))).await; + } + }; + if upload.confirmation != "RESTORE" { + let _remove_result = tokio::fs::remove_file(&upload.archive_path).await; + return backups_page( + &state, + &user, + &csrf, + Some(( + "error", + "Type RESTORE to confirm restoring from an uploaded backup.", + )), + ) + .await; + } + admin::audit( + &state.pool, + user.id, + "restore_backup_upload", + &upload.filename, + ) + .await?; + let paths = state.paths.clone(); + let archive_path = upload.archive_path.clone(); + let include_tor_keys = upload.include_tor_keys; + let restored = tokio::task::spawn_blocking(move || { + backup::restore_backup(&paths, &archive_path, include_tor_keys) + }) + .await + .map_err(|err| AppError::BadRequest(format!("restore task failed: {err}")))?; + let _remove_result = tokio::fs::remove_file(&upload.archive_path).await; + match restored { + Ok(report) => { + let safety = report + .pre_restore_backup + .as_ref() + .and_then(|path| path.file_name()) + .and_then(|name| name.to_str()) + .unwrap_or("pre-restore backup"); + let message = format!( + "Restore completed. Restart RustPost so the running process reopens the restored database. Safety backup: {safety}." + ); + backups_page(&state, &user, &csrf, Some(("success", &message))).await + } + Err(err) => { + tracing::warn!(error = %err, "restore upload failed"); + let message = public_backup_error("Restore failed", &err); + backups_page(&state, &user, &csrf, Some(("error", &message))).await + } + } +} + +struct ParsedRestoreUpload { + archive_path: PathBuf, + filename: String, + include_tor_keys: bool, + confirmation: String, +} + +async fn parse_restore_upload( + state: &AppState, + headers: &HeaderMap, + mut multipart: Multipart, +) -> AppResult { + let mut csrf_validated = false; + let mut include_tor_keys = false; + let mut confirmation = String::new(); + let mut archive_path = None; + let mut filename = "uploaded-backup.tar".to_owned(); + 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" => { + let token = field + .text() + .await + .map_err(|err| AppError::BadRequest(err.to_string()))?; + validate_csrf(&state.pool, headers, &token).await?; + csrf_validated = true; + } + "include_tor_keys" => { + include_tor_keys = field.text().await.is_ok_and(|value| value == "true"); + } + "restore_confirm" => { + confirmation = field + .text() + .await + .map_err(|err| AppError::BadRequest(err.to_string()))?; + } + "backup" if field.file_name().is_some() => { + if !csrf_validated { + return Err(AppError::Forbidden); + } + filename = field + .file_name() + .filter(|name| !name.trim().is_empty()) + .unwrap_or("uploaded-backup.tar") + .to_owned(); + if !std::path::Path::new(&filename) + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("tar")) + { + return Err(AppError::BadRequest( + "Choose a .tar backup archive.".to_owned(), + )); + } + let path = state + .paths + .tmp_dir + .join(format!("restore-upload-{}.tar", Uuid::new_v4().simple())); + write_multipart_field_to_file(field, &path).await?; + archive_path = Some(path); + } + _ => {} + } + } + if !csrf_validated { + return Err(AppError::Forbidden); + } + Ok(ParsedRestoreUpload { + archive_path: archive_path.ok_or_else(|| { + AppError::BadRequest("Choose a backup archive to restore.".to_owned()) + })?, + filename, + include_tor_keys, + confirmation, + }) +} + +async fn write_multipart_field_to_file( + mut field: axum::extract::multipart::Field<'_>, + path: &std::path::Path, +) -> AppResult<()> { + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + let mut output = tokio::fs::File::create(path).await?; + while let Some(chunk) = field + .chunk() + .await + .map_err(|err| AppError::BadRequest(err.to_string()))? + { + output.write_all(&chunk).await?; + } + output.flush().await?; + Ok(()) +} + +async fn backups_page( + state: &AppState, + user: &CurrentUser, + csrf: &str, + notice: Option<(&str, &str)>, +) -> AppResult> { + let settings = load_deep_settings(state)?; + let archives = backup::list_backups(&state.paths)?; + let notice_html = + notice.map_or_else(String::new, |(kind, message)| render::notice(kind, message)); + let body = format!( + "{}{}{}{}{}{}", + notice_html, + render::page_header( + "Backups", + "Create, download, schedule, and restore full-site runtime backups." + ), + render_manual_backup_panel(csrf, &settings), + render_automatic_backup_panel(csrf, &settings), + render_backup_history(&archives, backup::operation_in_progress(&state.paths)), + render_restore_panel(csrf), + ); Ok(Html( - page_layout(&state, Some(&user), Some(&csrf), "Backup created", &body).await?, + page_layout(state, Some(user), Some(csrf), "Backups", &body).await?, )) } +fn render_manual_backup_panel(csrf: &str, settings: &Settings) -> String { + let disabled = if settings.backup.enabled { + "" + } else { + " disabled" + }; + format!( + r#"

Manual backup

Archives are written under the private backup directory and are available only to admins.

Leave Tor keys unchecked unless the archive storage is encrypted and access-controlled.

"#, + html_escape::encode_double_quoted_attribute(csrf), + ) +} + +fn render_automatic_backup_panel(csrf: &str, settings: &Settings) -> String { + let values = admin::BackupSettingsValues::from_settings(settings); + format!( + r#"

Automatic backups

{}
{}

Set 0 to disable age cleanup. Manual and pre-restore backups are not pruned.

{}
"#, + html_escape::encode_double_quoted_attribute(csrf), + bool_select("backup-enabled", "enabled", values.enabled), + bool_select( + "backup-auto-enabled", + "automatic_enabled", + values.automatic_enabled + ), + values.automatic_interval_minutes, + values.retention_keep_last, + values.retention_max_age_days, + bool_select( + "backup-auto-tor", + "automatic_include_tor_keys", + values.automatic_include_tor_keys + ), + ) +} + +fn bool_select(id: &str, name: &str, value: bool) -> String { + let true_selected = if value { " selected" } else { "" }; + let false_selected = if value { "" } else { " selected" }; + format!( + r#""#, + html_escape::encode_double_quoted_attribute(id), + html_escape::encode_double_quoted_attribute(name), + ) +} + +fn render_backup_history( + archives: &[backup::BackupArchiveInfo], + operation_running: bool, +) -> String { + let status = if archives.is_empty() { + r#"

No backups have been created yet.

"#.to_owned() + } else { + let rows = archives + .iter() + .take(20) + .map(|archive| { + let created = archive.created_at.as_deref().unwrap_or("unknown"); + let tor_keys = archive + .tor_keys_included + .map_or("unknown", |included| if included { "included" } else { "excluded" }); + let kind = if archive.automatic { "automatic" } else { "manual" }; + let manifest = if archive.manifest_valid { "valid" } else { "unreadable" }; + format!( + r#"{}{}{}{}{}{}Download"#, + html_escape::encode_text(&archive.filename), + html_escape::encode_text(kind), + html_escape::encode_text(created), + format_bytes(archive.size), + html_escape::encode_text(tor_keys), + html_escape::encode_text(manifest), + html_escape::encode_double_quoted_attribute(&archive.filename), + ) + }) + .collect::>() + .join(""); + format!( + r#"{rows}
ArchiveKindCreatedSizeTor keysManifestAction
"# + ) + }; + let running = if operation_running { + render::notice( + "info", + "A backup or restore operation is currently running.", + ) + } else { + String::new() + }; + format!( + r#"

Recent backups

{running}{status}
"# + ) +} + +fn render_restore_panel(csrf: &str) -> String { + format!( + r#"

Danger area

Restore from upload

Restore validates the manifest, hashes, paths, settings, and SQLite integrity in a staging directory before replacing live runtime files. A pre-restore safety backup is created first.

"#, + html_escape::encode_double_quoted_attribute(csrf), + ) +} + +fn public_backup_error(prefix: &str, error: &anyhow::Error) -> String { + let detail = error.to_string(); + if detail.contains('/') || detail.contains('\\') { + format!("{prefix}. Check the server logs for details.") + } else { + format!("{prefix}: {detail}") + } +} + +fn format_bytes(bytes: u64) -> String { + const KIB: u64 = 1024; + const MIB: u64 = KIB * 1024; + const GIB: u64 = MIB * 1024; + if bytes >= GIB { + return format_scaled_bytes(bytes, GIB, "GiB"); + } + if bytes >= MIB { + return format_scaled_bytes(bytes, MIB, "MiB"); + } + if bytes >= KIB { + return format_scaled_bytes(bytes, KIB, "KiB"); + } + format!("{bytes} B") +} + +fn format_scaled_bytes(bytes: u64, unit: u64, suffix: &str) -> String { + let tenths = u128::from(bytes) * 10 / u128::from(unit); + format!("{}.{:01} {suffix}", tenths / 10, tenths % 10) +} + fn small_form(action: &str, csrf: &str, label: &str, title: &str) -> String { format!( r#"
"#, @@ -4177,7 +4616,6 @@ mod tests { use flate2::read::GzDecoder; use std::io::Read as _; use std::path::PathBuf; - use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; struct TestServer { base_url: String, @@ -8622,7 +9060,9 @@ mod tests { 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()); + 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( diff --git a/tests/playwright/backup_restore.spec.mjs b/tests/playwright/backup_restore.spec.mjs new file mode 100644 index 0000000..21c5217 --- /dev/null +++ b/tests/playwright/backup_restore.spec.mjs @@ -0,0 +1,427 @@ +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 ba86c30dc7ba8d3e6b70fe959749da9491b41ca8 Mon Sep 17 00:00:00 2001 From: csd113 Date: Tue, 26 May 2026 21:58:10 -0700 Subject: [PATCH 02/12] Bump version to 0.2.0 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1ebfeea..56749cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3223,7 +3223,7 @@ dependencies = [ [[package]] name = "rustpost" -version = "0.1.6" +version = "0.2.0" dependencies = [ "anyhow", "argon2", diff --git a/Cargo.toml b/Cargo.toml index e05a5bd..61027dc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpost" -version = "0.1.6" +version = "0.2.0" edition = "2024" license = "MIT" description = "A single-binary self-hosted microblogging app" From 26efd59414050a69c4f5a002b5b5aade3cf835fa Mon Sep 17 00:00:00 2001 From: csd113 Date: Tue, 26 May 2026 21:58:29 -0700 Subject: [PATCH 03/12] Bump locked dependencies --- Cargo.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 56749cd..d8e46d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1742,9 +1742,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" dependencies = [ "bytes", "itoa", @@ -2247,9 +2247,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" [[package]] name = "matchers" @@ -2268,9 +2268,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "memmap2" @@ -3675,9 +3675,9 @@ checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" [[package]] name = "sqlite-wasm-rs" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd578e94101503d97e2b286bbf8db2135035ca24b2ce4cbf3f9e2fb2bbf1eee" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" dependencies = [ "cc", "js-sys", From 58c04b82ce64f13a032d028a11519908fe1ec4c8 Mon Sep 17 00:00:00 2001 From: csd113 Date: Sat, 30 May 2026 17:28:14 -0700 Subject: [PATCH 04/12] Remove alpha branding from RustPost UI --- Cargo.lock | 26 +++++++++++++------------- README.md | 8 ++++---- src/render.rs | 8 ++++---- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d8e46d8..7edcd19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1115,9 +1115,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -1818,9 +1818,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "eb92f162bf56536459fc83c79b974bb12837acfed43d6bc370a7916d0ae15ecc" dependencies = [ "atomic-waker", "bytes", @@ -2194,9 +2194,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ "libc", ] @@ -2845,7 +2845,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.11+spec-1.1.0", + "toml_edit 0.25.12+spec-1.1.0", ] [[package]] @@ -4094,9 +4094,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.11+spec-1.1.0" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", @@ -6110,18 +6110,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "bce33a6288fa3f072a8c2c7d0f2fdbb90e28298f0135c1f99b96c3db2efcc60b" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "8fd425244944f4ab65ccff928e7323354c5a018c75838362fdce749dfad2ee1e" dependencies = [ "proc-macro2", "quote", diff --git a/README.md b/README.md index b15058f..fa3afd5 100644 --- a/README.md +++ b/README.md @@ -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 MVP** — 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 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. --- @@ -447,7 +447,7 @@ Run the app locally with a disposable data directory: ```sh cargo build --workspace --all-features -./target/debug/rustpost --data-dir /tmp/rustpost-alpha serve +./target/debug/rustpost --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. @@ -539,9 +539,9 @@ Each archive contains `rustpost`, `rustpost-cli`, `README.md`, `LICENSE`, and op - **Tor verification requires network access** — may time out in restricted build or CI environments. - **Onion virtual port** — HTTP virtual port 80 is mapped to the RustPost listener; custom onion virtual ports are not yet configurable. - **Synchronous media conversion** — conversion happens inline during upload; no background queue. -- **Reports and admin toggles** — present in schema and admin structure but functionality is minimal in the current MVP. +- **Reports and admin toggles** — present in schema and admin structure but functionality is minimal in this release. - **Search** — uses SQLite FTS5 with simple user matching and fixed result limits; no ranking tuning yet. -- **UI polish** — server-rendered HTML/CSS has been brought to alpha quality, but final visual design and broader accessibility review are still future work. +- **UI polish** — server-rendered HTML/CSS covers the core flows, but final visual design and broader accessibility review are still future work. --- diff --git a/src/render.rs b/src/render.rs index 6a526d3..f2bdf55 100644 --- a/src/render.rs +++ b/src/render.rs @@ -198,7 +198,7 @@ pub fn layout_with_context(
{}
{}
{}
-
{} alpha
+
{}
"#, html_escape::encode_double_quoted_attribute(theme), @@ -2970,16 +2970,16 @@ mod tests { let body = layout(None, "Home Feed", "

body

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

body

", "My Microblog"); assert!(body.contains("

Dashboard

")); assert!(body.contains("Login required")); - assert!(!body.contains("Alpha status")); + assert!(!body.contains("Release status")); assert!(!body.contains("Self-hosted microblog")); } From 80fdacb6d4c58b7fe2ec35abd57b2fece84857d1 Mon Sep 17 00:00:00 2001 From: csd113 Date: Sun, 31 May 2026 20:02:04 -0700 Subject: [PATCH 05/12] Reset RustPost DB schema to release baseline --- CHANGELOG.md | 10 + README.md | 4 +- src/backup.rs | 36 +- src/cli.rs | 5 + src/db.rs | 2177 ++++++++++++++++++++++++++++++++++++++++++----- src/server.rs | 13 +- src/terminal.rs | 21 + 7 files changed, 2031 insertions(+), 235 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a17b706..2a96519 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased - First-Release Database Baseline + +### 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. +- Existing current alpha databases that structurally match the baseline are marked as baseline version `1` without destructive changes or data loss. +- Incomplete, unknown, or structurally unsafe old alpha 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. +- 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. + ## v0.1.6 - Pinned Profiles ### Profiles and Timelines diff --git a/README.md b/README.md index fa3afd5..babe182 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ This repository is a **production-oriented project** — not a toy, but not over ### Operations | Feature | Details | |---|---| -| Database | SQLite with WAL, foreign keys, migrations, FTS5, and timeline indexes | +| Database | SQLite with WAL, foreign keys, a release schema baseline, 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 | @@ -484,7 +484,7 @@ Each archive contains `rustpost`, `rustpost-cli`, `README.md`, `LICENSE`, and op |---|---| | `axum` | HTTP routing and multipart handling | | `tokio` | Async runtime, filesystem, process, and signals | -| `rusqlite` | SQLite access via a dedicated DB worker; WAL, FK, migrations | +| `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 | | `infer` | Content-based media type detection | diff --git a/src/backup.rs b/src/backup.rs index 1fd2e45..ceeff21 100644 --- a/src/backup.rs +++ b/src/backup.rs @@ -15,7 +15,7 @@ use tracing::{info, warn}; use walkdir::WalkDir; use crate::config::{BackupSettings, Settings}; -use crate::db::CURRENT_SCHEMA_VERSION; +use crate::db; use crate::runtime::RuntimePaths; const FORMAT_VERSION: u16 = 1; @@ -569,7 +569,7 @@ fn sqlite_path_literal(path: &Path) -> String { fn database_schema_version(path: &Path) -> anyhow::Result { let conn = Connection::open(path)?; - schema_version_from_connection(&conn) + db::schema_version_from_connection(&conn) } fn validate_sqlite_database( @@ -588,31 +588,16 @@ fn validate_sqlite_database( if foreign_key_problem.is_some() { anyhow::bail!("restored database failed foreign_key_check"); } - let schema_version = schema_version_from_connection(&conn)?; - if schema_version > CURRENT_SCHEMA_VERSION { - anyhow::bail!( - "backup schema version {schema_version} is newer than this RustPost ({CURRENT_SCHEMA_VERSION})" - ); - } + let schema_version = db::schema_version_from_connection(&conn)?; if let Some(expected) = expected_schema_version && expected != schema_version { anyhow::bail!("manifest schema version does not match restored database"); } + db::validate_restorable_schema(&conn)?; Ok(()) } -fn schema_version_from_connection(conn: &Connection) -> anyhow::Result { - let version = conn - .query_row("SELECT MAX(version) FROM schema_migrations", [], |row| { - row.get::<_, Option>(0) - }) - .optional()? - .flatten() - .ok_or_else(|| anyhow::anyhow!("database has no schema migration version"))?; - Ok(version) -} - fn extract_archive_to_stage( archive_path: &Path, staging_dir: &Path, @@ -1244,18 +1229,15 @@ impl Drop for OperationLock { #[cfg(test)] mod tests { use super::*; + use crate::db::CURRENT_SCHEMA_VERSION; use std::thread; fn test_db(path: &Path, version: i64) { let conn = Connection::open(path).expect("open db"); - conn.execute_batch( - r#" - CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY); - INSERT INTO schema_migrations (version) VALUES (1); - "#, - ) - .expect("schema"); - if version != 1 { + db::install_release_baseline_for_test(&conn).expect("schema"); + if version != CURRENT_SCHEMA_VERSION { + conn.execute("DELETE FROM schema_migrations", []) + .expect("clear version"); conn.execute( "INSERT INTO schema_migrations (version) VALUES (?)", [version], diff --git a/src/cli.rs b/src/cli.rs index d4fb03e..5958682 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -529,12 +529,17 @@ async fn print_startup_dashboard( context: StartupPrintContext<'_>, ) -> anyhow::Result<()> { let (user_count, post_count) = crate::social::instance_counts(pool).await?; + let schema = db::schema_report(pool).await?; + let schema_summary = schema.summary(); stderr_raw(format_args!( "{}", terminal::render_startup_dashboard(&terminal::StartupDashboard { paths: context.paths, settings_path: context.settings_path, settings: context.settings, + schema_version: schema.version(), + schema_compatible: schema.is_compatible(), + schema_summary: &schema_summary, admin_count: context.admin_count, user_count, post_count, diff --git a/src/db.rs b/src/db.rs index fd413f4..7fbb8ae 100644 --- a/src/db.rs +++ b/src/db.rs @@ -6,7 +6,10 @@ use std::time::Duration; use anyhow::Context as _; use rusqlite::{Connection, OptionalExtension as _}; -pub const CURRENT_SCHEMA_VERSION: i64 = 13; +pub const CURRENT_SCHEMA_VERSION: i64 = 1; + +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"; #[derive(Clone)] pub struct Db { @@ -72,82 +75,492 @@ fn open_connection(path: &Path) -> anyhow::Result { pub async fn migrate(pool: &Db) -> anyhow::Result<()> { pool.call(|conn| { let tx = conn.transaction()?; - tx.execute_batch( - r#" - CREATE TABLE IF NOT EXISTS schema_migrations ( - version INTEGER PRIMARY KEY, - applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - "#, - )?; - let applied: Option = tx - .query_row("SELECT MAX(version) FROM schema_migrations", [], |row| { - row.get(0) - }) - .optional()? - .flatten(); - if applied.unwrap_or(0) < 1 { - tx.execute_batch(MIGRATION_1)?; - tx.execute("INSERT INTO schema_migrations (version) VALUES (1)", [])?; + ensure_release_baseline(&tx)?; + tx.commit()?; + Ok(()) + }) + .await +} + +pub async fn schema_report(pool: &Db) -> anyhow::Result { + pool.call(|conn| inspect_schema(conn)).await +} + +pub fn validate_schema(conn: &Connection) -> anyhow::Result<()> { + let report = inspect_schema(conn)?; + if report.is_compatible() { + return Ok(()); + } + anyhow::bail!( + "database schema is incompatible with RustPost release baseline: {}", + report.summary() + ); +} + +pub fn validate_restorable_schema(conn: &Connection) -> anyhow::Result<()> { + let version = schema_version_from_connection(conn)?; + if version == CURRENT_SCHEMA_VERSION { + return validate_schema(conn); + } + if version == OLD_ALPHA_SCHEMA_VERSION { + let issues = inspect_schema_objects(conn)?; + if issues.is_empty() { + return Ok(()); } - if applied.unwrap_or(0) < 2 { - tx.execute_batch(MIGRATION_2)?; - tx.execute("INSERT INTO schema_migrations (version) VALUES (2)", [])?; + anyhow::bail!( + "restored database has incomplete old alpha schema: {}", + summarize_issues(&issues) + ); + } + anyhow::bail!( + "database schema version {version} is not supported by this RustPost release baseline" + ); +} + +pub fn schema_version_from_connection(conn: &Connection) -> anyhow::Result { + if !table_exists(conn, "schema_migrations")? { + anyhow::bail!("database has no schema_migrations table"); + } + migration_versions(conn)? + .into_iter() + .max() + .ok_or_else(|| anyhow::anyhow!("database has no schema migration version")) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SchemaReport { + version: Option, + issues: Vec, +} + +impl SchemaReport { + #[must_use] + pub const fn version(&self) -> Option { + self.version + } + + #[must_use] + pub fn issues(&self) -> &[String] { + &self.issues + } + + #[must_use] + pub fn is_compatible(&self) -> bool { + self.issues.is_empty() + } + + #[must_use] + pub fn summary(&self) -> String { + if self.is_compatible() { + return format!("release baseline schema version {CURRENT_SCHEMA_VERSION}"); } - if applied.unwrap_or(0) < 3 { - tx.execute_batch(MIGRATION_3)?; - tx.execute("INSERT INTO schema_migrations (version) VALUES (3)", [])?; + summarize_issues(&self.issues) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum MigrationState { + Empty, + Baseline, + AlphaLatest, + Incompatible(String), +} + +#[derive(Debug, Clone, Copy)] +struct RequiredColumn { + table: &'static str, + name: &'static str, + type_name: &'static str, + not_null: bool, + default_value: Option<&'static str>, + primary_key_position: i64, +} + +#[derive(Debug, Clone, Copy)] +struct RequiredIndex { + table: &'static str, + name: &'static str, + unique: bool, +} + +#[derive(Debug, Clone, Copy)] +struct RequiredTrigger { + table: &'static str, + name: &'static str, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ColumnInfo { + name: String, + type_name: String, + not_null: bool, + default_value: Option, + primary_key_position: i64, +} + +fn ensure_release_baseline(conn: &Connection) -> anyhow::Result<()> { + match migration_state(conn)? { + MigrationState::Empty => initialize_release_baseline(conn)?, + MigrationState::Baseline => {} + MigrationState::AlphaLatest => adopt_latest_alpha_schema(conn)?, + MigrationState::Incompatible(reason) => { + anyhow::bail!("{reason}. {INCOMPATIBLE_DATABASE_HINT}"); } - if applied.unwrap_or(0) < 4 { - tx.execute_batch(MIGRATION_4)?; - tx.execute("INSERT INTO schema_migrations (version) VALUES (4)", [])?; + } + + validate_schema(conn).with_context(|| INCOMPATIBLE_DATABASE_HINT) +} + +fn migration_state(conn: &Connection) -> anyhow::Result { + let schema_objects = user_schema_objects(conn)?; + if !table_exists(conn, "schema_migrations")? { + if schema_objects.is_empty() { + return Ok(MigrationState::Empty); } - if applied.unwrap_or(0) < 5 { - tx.execute_batch(MIGRATION_5)?; - tx.execute("INSERT INTO schema_migrations (version) VALUES (5)", [])?; + return Ok(MigrationState::Incompatible(format!( + "database has schema objects but no migration metadata: {}", + summarize_issues(&schema_objects) + ))); + } + + let versions = migration_versions(conn)?; + if versions.is_empty() { + if schema_objects + .iter() + .all(|name| name == "schema_migrations") + { + return Ok(MigrationState::Empty); } - if applied.unwrap_or(0) < 6 { - tx.execute_batch(MIGRATION_6)?; - tx.execute("INSERT INTO schema_migrations (version) VALUES (6)", [])?; + return Ok(MigrationState::Incompatible( + "database has schema objects but no migration version".to_owned(), + )); + } + + let Some(latest) = versions.last().copied() else { + return Ok(MigrationState::Incompatible( + "database has no migration version".to_owned(), + )); + }; + if versions == [CURRENT_SCHEMA_VERSION] { + return Ok(MigrationState::Baseline); + } + if latest == OLD_ALPHA_SCHEMA_VERSION { + return Ok(MigrationState::AlphaLatest); + } + Ok(MigrationState::Incompatible(format!( + "database schema version {latest} is not a supported release baseline" + ))) +} + +fn initialize_release_baseline(conn: &Connection) -> anyhow::Result<()> { + conn.execute_batch( + r#" +DROP TABLE IF EXISTS schema_migrations; +"#, + )?; + conn.execute_batch(BASELINE_SCHEMA)?; + Ok(()) +} + +#[cfg(test)] +pub(crate) fn install_release_baseline_for_test(conn: &Connection) -> anyhow::Result<()> { + initialize_release_baseline(conn) +} + +fn adopt_latest_alpha_schema(conn: &Connection) -> anyhow::Result<()> { + let issues = inspect_schema_objects(conn)?; + if !issues.is_empty() { + anyhow::bail!( + "old alpha database is not safe to adopt as release baseline: {}. {}", + summarize_issues(&issues), + INCOMPATIBLE_DATABASE_HINT + ); + } + reset_schema_migrations(conn) +} + +fn reset_schema_migrations(conn: &Connection) -> anyhow::Result<()> { + conn.execute_batch( + r#" +DROP TABLE IF EXISTS schema_migrations; +CREATE TABLE schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +INSERT INTO schema_migrations (version) VALUES (1); +"#, + )?; + Ok(()) +} + +fn inspect_schema(conn: &Connection) -> anyhow::Result { + let mut issues = Vec::new(); + let mut version = None; + + if table_exists(conn, "schema_migrations")? { + let versions = migration_versions(conn)?; + version = versions.iter().max().copied(); + if versions != [CURRENT_SCHEMA_VERSION] { + issues.push(format!( + "expected schema_migrations to contain only version {CURRENT_SCHEMA_VERSION}, found {}", + if versions.is_empty() { + "none".to_owned() + } else { + versions + .iter() + .map(i64::to_string) + .collect::>() + .join(", ") + } + )); } - if applied.unwrap_or(0) < 7 { - tx.execute_batch(MIGRATION_7)?; - tx.execute("INSERT INTO schema_migrations (version) VALUES (7)", [])?; + } else { + issues.push("missing table schema_migrations".to_owned()); + } + + issues.extend(inspect_schema_objects(conn)?); + Ok(SchemaReport { version, issues }) +} + +fn inspect_schema_objects(conn: &Connection) -> anyhow::Result> { + let mut issues = Vec::new(); + + for table in REQUIRED_TABLES { + if !table_exists(conn, table)? { + issues.push(format!("missing table {table}")); } - if applied.unwrap_or(0) < 8 { - tx.execute_batch(MIGRATION_8)?; - tx.execute("INSERT INTO schema_migrations (version) VALUES (8)", [])?; + } + + for table in REQUIRED_TABLES { + if table_exists(conn, table)? { + check_table_columns(conn, table, &mut issues)?; } - if applied.unwrap_or(0) < 9 { - tx.execute_batch(MIGRATION_9)?; - tx.execute("INSERT INTO schema_migrations (version) VALUES (9)", [])?; + } + + for index in REQUIRED_INDEXES { + check_index(conn, index, &mut issues)?; + } + + for trigger in REQUIRED_TRIGGERS { + check_trigger(conn, trigger, &mut issues)?; + } + + for object in user_schema_objects(conn)? { + if !is_expected_schema_object(&object) { + issues.push(format!("unexpected schema object {object}")); } - if applied.unwrap_or(0) < 10 { - tx.execute_batch(MIGRATION_10)?; - tx.execute("INSERT INTO schema_migrations (version) VALUES (10)", [])?; + } + + Ok(issues) +} + +fn check_table_columns( + conn: &Connection, + table: &str, + issues: &mut Vec, +) -> anyhow::Result<()> { + let columns = table_columns(conn, table)?; + let expected = required_columns_for(table); + let actual_names = columns + .iter() + .map(|column| column.name.as_str()) + .collect::>(); + let expected_names = expected + .iter() + .map(|column| column.name) + .collect::>(); + if actual_names != expected_names { + issues.push(format!( + "table {table} columns differ; expected {}, found {}", + expected_names.join(", "), + actual_names.join(", ") + )); + } + + for expected_column in expected { + let Some(actual) = columns + .iter() + .find(|column| column.name == expected_column.name) + else { + issues.push(format!( + "missing column {}.{}", + expected_column.table, expected_column.name + )); + continue; + }; + if !actual + .type_name + .eq_ignore_ascii_case(expected_column.type_name) + { + issues.push(format!( + "column {}.{} has type {}, expected {}", + expected_column.table, + expected_column.name, + actual.type_name, + expected_column.type_name + )); } - if applied.unwrap_or(0) < 11 { - tx.execute_batch(MIGRATION_11)?; - tx.execute("INSERT INTO schema_migrations (version) VALUES (11)", [])?; + if actual.not_null != expected_column.not_null { + issues.push(format!( + "column {}.{} nullability differs", + expected_column.table, expected_column.name + )); } - if applied.unwrap_or(0) < 12 { - tx.execute_batch(MIGRATION_12)?; - tx.execute("INSERT INTO schema_migrations (version) VALUES (12)", [])?; + if actual.default_value.as_deref() != expected_column.default_value { + issues.push(format!( + "column {}.{} default differs", + expected_column.table, expected_column.name + )); } - if applied.unwrap_or(0) < CURRENT_SCHEMA_VERSION { - tx.execute_batch(MIGRATION_13)?; - tx.execute( - "INSERT INTO schema_migrations (version) VALUES (?)", - [CURRENT_SCHEMA_VERSION], - )?; + if actual.primary_key_position != expected_column.primary_key_position { + issues.push(format!( + "column {}.{} primary key position differs", + expected_column.table, expected_column.name + )); } - tx.commit()?; - Ok(()) - }) - .await + } + Ok(()) +} + +fn check_index( + conn: &Connection, + expected: &RequiredIndex, + issues: &mut Vec, +) -> anyhow::Result<()> { + if !table_exists(conn, expected.table)? { + return Ok(()); + } + let indexes = table_indexes(conn, expected.table)?; + let Some((_name, unique)) = indexes.iter().find(|(name, _unique)| name == expected.name) else { + issues.push(format!("missing index {}", expected.name)); + return Ok(()); + }; + if *unique != expected.unique { + issues.push(format!("index {} uniqueness differs", expected.name)); + } + Ok(()) +} + +fn check_trigger( + conn: &Connection, + expected: &RequiredTrigger, + issues: &mut Vec, +) -> anyhow::Result<()> { + let trigger_table: Option = conn + .query_row( + "SELECT tbl_name FROM sqlite_master WHERE type = 'trigger' AND name = ?", + [expected.name], + |row| row.get(0), + ) + .optional()?; + match trigger_table { + Some(table) if table == expected.table => {} + Some(_) => issues.push(format!("trigger {} table differs", expected.name)), + None => issues.push(format!("missing trigger {}", expected.name)), + } + Ok(()) } -const MIGRATION_1: &str = r#" +fn table_exists(conn: &Connection, table: &str) -> anyhow::Result { + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?", + [table], + |row| row.get(0), + )?; + Ok(count > 0) +} + +fn migration_versions(conn: &Connection) -> anyhow::Result> { + let mut stmt = conn.prepare("SELECT version FROM schema_migrations ORDER BY version")?; + let versions = stmt + .query_map([], |row| row.get(0))? + .collect::>>()?; + Ok(versions) +} + +fn user_schema_objects(conn: &Connection) -> anyhow::Result> { + let mut stmt = conn.prepare( + r#" + SELECT name + FROM sqlite_master + WHERE name NOT LIKE 'sqlite_%' + ORDER BY name + "#, + )?; + let objects = stmt + .query_map([], |row| row.get(0))? + .collect::>>()?; + Ok(objects) +} + +fn table_columns(conn: &Connection, table: &str) -> anyhow::Result> { + let sql = format!("PRAGMA table_info({})", quoted_identifier(table)); + let mut stmt = conn.prepare(&sql)?; + let columns = stmt + .query_map([], |row| { + Ok(ColumnInfo { + name: row.get(1)?, + type_name: row.get(2)?, + not_null: row.get::<_, i64>(3)? != 0, + default_value: row.get(4)?, + primary_key_position: row.get(5)?, + }) + })? + .collect::>>()?; + Ok(columns) +} + +fn table_indexes(conn: &Connection, table: &str) -> anyhow::Result> { + let sql = format!("PRAGMA index_list({})", quoted_identifier(table)); + let mut stmt = conn.prepare(&sql)?; + let indexes = stmt + .query_map([], |row| Ok((row.get(1)?, row.get::<_, i64>(2)? != 0)))? + .collect::>>()?; + Ok(indexes) +} + +fn quoted_identifier(identifier: &str) -> String { + format!("\"{}\"", identifier.replace('"', "\"\"")) +} + +fn required_columns_for(table: &str) -> Vec { + REQUIRED_COLUMNS + .iter() + .copied() + .filter(|column| column.table == table) + .collect() +} + +fn is_expected_schema_object(name: &str) -> bool { + REQUIRED_TABLES.contains(&name) + || REQUIRED_INDEXES.iter().any(|index| index.name == name) + || REQUIRED_TRIGGERS.iter().any(|trigger| trigger.name == name) + || FTS_SHADOW_TABLES.contains(&name) +} + +fn summarize_issues(issues: &[String]) -> String { + const MAX_ISSUES: usize = 6; + let mut summary = issues + .iter() + .take(MAX_ISSUES) + .map(String::as_str) + .collect::>() + .join("; "); + let remaining = issues.len().saturating_sub(MAX_ISSUES); + if remaining > 0 { + summary.push_str(&format!("; and {remaining} more issue(s)")); + } + summary +} + +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 (1); + CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL, @@ -163,7 +576,12 @@ CREATE TABLE users ( is_suspended INTEGER NOT NULL DEFAULT 0, is_deleted INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + theme TEXT NOT NULL DEFAULT 'light' CHECK (theme IN ('light', 'dark')), + 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)) ); CREATE TABLE sessions ( @@ -173,7 +591,10 @@ CREATE TABLE sessions ( csrf_token_hash TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, expires_at TEXT NOT NULL, - revoked_at TEXT + revoked_at TEXT, + previous_csrf_token_hash TEXT, + delete_account_token_hash TEXT, + delete_account_token_expires_at TEXT ); CREATE TABLE posts ( @@ -186,7 +607,8 @@ CREATE TABLE posts ( is_deleted INTEGER NOT NULL DEFAULT 0, deleted_at TEXT, edited_at TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + quote_post_id INTEGER REFERENCES posts(id) ON DELETE SET NULL ); CREATE TABLE media ( @@ -201,7 +623,15 @@ CREATE TABLE media ( alt_text TEXT NOT NULL DEFAULT '', conversion_state TEXT NOT NULL DEFAULT 'original', ffmpeg_stderr TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + thumbnail_path TEXT, + thumbnail_public_path TEXT, + is_nsfw INTEGER NOT NULL DEFAULT 0, + original_path TEXT, + original_public_path TEXT, + original_sha256 TEXT NOT NULL DEFAULT '', + normalized_sha256 TEXT, + canonical_media_id INTEGER REFERENCES media(id) ON DELETE SET NULL ); CREATE TABLE post_media ( @@ -322,23 +752,7 @@ CREATE INDEX idx_reposts_post ON reposts(post_id, user_id); CREATE INDEX idx_likes_post ON likes(post_id, user_id); CREATE INDEX idx_bookmarks_post ON bookmarks(post_id, user_id); CREATE INDEX idx_rate_limit_scope_actor_created ON rate_limit_events(scope, actor, created_at); -"#; - -const MIGRATION_2: &str = r#" -ALTER TABLE sessions ADD COLUMN previous_csrf_token_hash TEXT; -"#; - -const MIGRATION_3: &str = r#" -ALTER TABLE media ADD COLUMN thumbnail_path TEXT; -ALTER TABLE media ADD COLUMN thumbnail_public_path TEXT; -"#; - -const MIGRATION_4: &str = r#" -ALTER TABLE users ADD COLUMN theme TEXT NOT NULL DEFAULT 'light' CHECK (theme IN ('light', 'dark')); -"#; - -const MIGRATION_5: &str = r#" -CREATE TABLE IF NOT EXISTS muted_words ( +CREATE TABLE muted_words ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, term TEXT NOT NULL, @@ -347,62 +761,34 @@ CREATE TABLE IF NOT EXISTS muted_words ( UNIQUE(user_id, normalized_term) ); -CREATE INDEX IF NOT EXISTS idx_muted_words_user ON muted_words(user_id, normalized_term); -"#; - -const MIGRATION_6: &str = r#" -ALTER TABLE users ADD COLUMN location TEXT NOT NULL DEFAULT ''; -"#; - -const MIGRATION_7: &str = r#" -ALTER TABLE sessions ADD COLUMN delete_account_token_hash TEXT; -ALTER TABLE sessions ADD COLUMN delete_account_token_expires_at TEXT; -"#; - -const MIGRATION_8: &str = r#" -ALTER TABLE posts ADD COLUMN quote_post_id INTEGER REFERENCES posts(id) ON DELETE SET NULL; +CREATE INDEX idx_muted_words_user ON muted_words(user_id, normalized_term); CREATE INDEX idx_posts_quote ON posts(quote_post_id, created_at DESC, id DESC); CREATE UNIQUE INDEX idx_posts_quote_dedupe ON posts(user_id, quote_post_id, text) WHERE quote_post_id IS NOT NULL AND is_deleted = 0; -"#; -const MIGRATION_9: &str = r#" -CREATE INDEX IF NOT EXISTS idx_notifications_user_id_desc ON notifications(user_id, id DESC); -CREATE INDEX IF NOT EXISTS idx_notifications_dedupe ON notifications(user_id, actor_user_id, post_id, kind); -"#; - -const MIGRATION_10: &str = r#" -ALTER TABLE media ADD COLUMN is_nsfw INTEGER NOT NULL DEFAULT 0; -ALTER TABLE users ADD COLUMN nsfw_blur_enabled INTEGER NOT NULL DEFAULT 1; +CREATE INDEX 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 IF NOT EXISTS idx_media_is_nsfw ON media(is_nsfw); -"#; +CREATE INDEX idx_media_is_nsfw ON media(is_nsfw); -const MIGRATION_11: &str = r#" -ALTER TABLE media ADD COLUMN original_path TEXT; -ALTER TABLE media ADD COLUMN original_public_path TEXT; -ALTER TABLE media ADD COLUMN original_sha256 TEXT NOT NULL DEFAULT ''; -ALTER TABLE media ADD COLUMN normalized_sha256 TEXT; -ALTER TABLE media ADD COLUMN canonical_media_id INTEGER REFERENCES media(id) ON DELETE SET NULL; - -CREATE INDEX IF NOT EXISTS idx_media_original_sha256 +CREATE INDEX idx_media_original_sha256 ON media(media_kind, original_sha256) WHERE original_sha256 != ''; -CREATE INDEX IF NOT EXISTS idx_media_normalized_sha256 +CREATE INDEX idx_media_normalized_sha256 ON media(media_kind, normalized_sha256) WHERE normalized_sha256 IS NOT NULL AND normalized_sha256 != ''; -CREATE INDEX IF NOT EXISTS idx_media_canonical_media_id ON media(canonical_media_id); +CREATE INDEX idx_media_canonical_media_id ON media(canonical_media_id); -CREATE UNIQUE INDEX IF NOT EXISTS idx_media_unique_canonical_original_sha256 +CREATE UNIQUE INDEX idx_media_unique_canonical_original_sha256 ON media(media_kind, original_sha256) WHERE canonical_media_id IS NULL AND original_sha256 != ''; -CREATE UNIQUE INDEX IF NOT EXISTS idx_media_unique_canonical_normalized_sha256 +CREATE UNIQUE INDEX idx_media_unique_canonical_normalized_sha256 ON media(media_kind, normalized_sha256) WHERE canonical_media_id IS NULL AND normalized_sha256 IS NOT NULL AND normalized_sha256 != ''; -CREATE TRIGGER IF NOT EXISTS media_canonical_ref_insert +CREATE TRIGGER media_canonical_ref_insert BEFORE INSERT ON media WHEN NEW.canonical_media_id IS NOT NULL BEGIN @@ -422,7 +808,7 @@ BEGIN END; END; -CREATE TRIGGER IF NOT EXISTS media_canonical_ref_update +CREATE TRIGGER media_canonical_ref_update BEFORE UPDATE OF canonical_media_id, media_kind ON media WHEN NEW.canonical_media_id IS NOT NULL BEGIN @@ -443,16 +829,1134 @@ BEGIN END; "#; -const MIGRATION_12: &str = r#" -ALTER TABLE users ADD COLUMN onboarding_completed_at TEXT; -UPDATE users -SET onboarding_completed_at = CURRENT_TIMESTAMP -WHERE onboarding_completed_at IS NULL AND is_deleted = 0; -"#; - -const MIGRATION_13: &str = r#" -ALTER TABLE users ADD COLUMN liked_posts_public INTEGER NOT NULL DEFAULT 1 CHECK (liked_posts_public IN (0, 1)); -"#; +const REQUIRED_TABLES: &[&str] = &[ + "schema_migrations", + "users", + "sessions", + "posts", + "media", + "post_media", + "reposts", + "follows", + "blocks", + "mutes", + "likes", + "bookmarks", + "notifications", + "rate_limit_events", + "admin_audit_log", + "media_jobs", + "reports", + "posts_fts", + "muted_words", +]; + +const FTS_SHADOW_TABLES: &[&str] = &[ + "posts_fts_data", + "posts_fts_idx", + "posts_fts_content", + "posts_fts_docsize", + "posts_fts_config", +]; + +const REQUIRED_INDEXES: &[RequiredIndex] = &[ + RequiredIndex { + table: "posts", + name: "idx_posts_created", + unique: false, + }, + RequiredIndex { + table: "posts", + name: "idx_posts_user_created", + unique: false, + }, + RequiredIndex { + table: "posts", + name: "idx_posts_parent", + unique: false, + }, + RequiredIndex { + table: "follows", + name: "idx_follows_followed", + unique: false, + }, + RequiredIndex { + table: "blocks", + name: "idx_blocks_blocked", + unique: false, + }, + RequiredIndex { + table: "mutes", + name: "idx_mutes_muted", + unique: false, + }, + RequiredIndex { + table: "notifications", + name: "idx_notifications_user_read_created", + unique: false, + }, + RequiredIndex { + table: "reposts", + name: "idx_reposts_post", + unique: false, + }, + RequiredIndex { + table: "likes", + name: "idx_likes_post", + unique: false, + }, + RequiredIndex { + table: "bookmarks", + name: "idx_bookmarks_post", + unique: false, + }, + RequiredIndex { + table: "rate_limit_events", + name: "idx_rate_limit_scope_actor_created", + unique: false, + }, + RequiredIndex { + table: "muted_words", + name: "idx_muted_words_user", + unique: false, + }, + RequiredIndex { + table: "posts", + name: "idx_posts_quote", + unique: false, + }, + RequiredIndex { + table: "posts", + name: "idx_posts_quote_dedupe", + unique: true, + }, + RequiredIndex { + table: "notifications", + name: "idx_notifications_user_id_desc", + unique: false, + }, + RequiredIndex { + table: "notifications", + name: "idx_notifications_dedupe", + unique: false, + }, + RequiredIndex { + table: "media", + name: "idx_media_is_nsfw", + unique: false, + }, + RequiredIndex { + table: "media", + name: "idx_media_original_sha256", + unique: false, + }, + RequiredIndex { + table: "media", + name: "idx_media_normalized_sha256", + unique: false, + }, + RequiredIndex { + table: "media", + name: "idx_media_canonical_media_id", + unique: false, + }, + RequiredIndex { + table: "media", + name: "idx_media_unique_canonical_original_sha256", + unique: true, + }, + RequiredIndex { + table: "media", + name: "idx_media_unique_canonical_normalized_sha256", + unique: true, + }, +]; + +const REQUIRED_TRIGGERS: &[RequiredTrigger] = &[ + RequiredTrigger { + table: "posts", + name: "posts_ai", + }, + RequiredTrigger { + table: "posts", + name: "posts_ad", + }, + RequiredTrigger { + table: "posts", + name: "posts_au", + }, + RequiredTrigger { + table: "media", + name: "media_canonical_ref_insert", + }, + RequiredTrigger { + table: "media", + name: "media_canonical_ref_update", + }, +]; + +const REQUIRED_COLUMNS: &[RequiredColumn] = &[ + RequiredColumn { + table: "schema_migrations", + name: "version", + type_name: "INTEGER", + not_null: false, + default_value: None, + primary_key_position: 1, + }, + RequiredColumn { + table: "schema_migrations", + name: "applied_at", + type_name: "TEXT", + not_null: true, + default_value: Some("CURRENT_TIMESTAMP"), + primary_key_position: 0, + }, + RequiredColumn { + table: "users", + name: "id", + type_name: "INTEGER", + not_null: false, + default_value: None, + primary_key_position: 1, + }, + RequiredColumn { + table: "users", + name: "username", + type_name: "TEXT", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "users", + name: "normalized_username", + type_name: "TEXT", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "users", + name: "password_hash", + type_name: "TEXT", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "users", + name: "display_name", + type_name: "TEXT", + not_null: true, + default_value: Some("''"), + primary_key_position: 0, + }, + RequiredColumn { + table: "users", + name: "bio", + type_name: "TEXT", + not_null: true, + default_value: Some("''"), + primary_key_position: 0, + }, + RequiredColumn { + table: "users", + name: "website", + type_name: "TEXT", + not_null: true, + default_value: Some("''"), + primary_key_position: 0, + }, + RequiredColumn { + table: "users", + name: "profile_picture_media_id", + type_name: "INTEGER", + not_null: false, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "users", + name: "banner_media_id", + type_name: "INTEGER", + not_null: false, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "users", + name: "pinned_post_id", + type_name: "INTEGER", + not_null: false, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "users", + name: "is_admin", + type_name: "INTEGER", + not_null: true, + default_value: Some("0"), + primary_key_position: 0, + }, + RequiredColumn { + table: "users", + name: "is_suspended", + type_name: "INTEGER", + not_null: true, + default_value: Some("0"), + primary_key_position: 0, + }, + RequiredColumn { + table: "users", + name: "is_deleted", + type_name: "INTEGER", + not_null: true, + default_value: Some("0"), + primary_key_position: 0, + }, + RequiredColumn { + table: "users", + name: "created_at", + type_name: "TEXT", + not_null: true, + default_value: Some("CURRENT_TIMESTAMP"), + primary_key_position: 0, + }, + RequiredColumn { + table: "users", + name: "updated_at", + type_name: "TEXT", + not_null: true, + default_value: Some("CURRENT_TIMESTAMP"), + primary_key_position: 0, + }, + RequiredColumn { + table: "users", + name: "theme", + type_name: "TEXT", + not_null: true, + default_value: Some("'light'"), + primary_key_position: 0, + }, + RequiredColumn { + table: "users", + name: "location", + type_name: "TEXT", + not_null: true, + default_value: Some("''"), + primary_key_position: 0, + }, + RequiredColumn { + table: "users", + name: "nsfw_blur_enabled", + type_name: "INTEGER", + not_null: true, + default_value: Some("1"), + primary_key_position: 0, + }, + RequiredColumn { + table: "users", + name: "onboarding_completed_at", + type_name: "TEXT", + not_null: false, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "users", + name: "liked_posts_public", + type_name: "INTEGER", + not_null: true, + default_value: Some("1"), + primary_key_position: 0, + }, + RequiredColumn { + table: "sessions", + name: "id", + type_name: "INTEGER", + not_null: false, + default_value: None, + primary_key_position: 1, + }, + RequiredColumn { + table: "sessions", + name: "user_id", + type_name: "INTEGER", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "sessions", + name: "token_hash", + type_name: "TEXT", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "sessions", + name: "csrf_token_hash", + type_name: "TEXT", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "sessions", + name: "created_at", + type_name: "TEXT", + not_null: true, + default_value: Some("CURRENT_TIMESTAMP"), + primary_key_position: 0, + }, + RequiredColumn { + table: "sessions", + name: "expires_at", + type_name: "TEXT", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "sessions", + name: "revoked_at", + type_name: "TEXT", + not_null: false, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "sessions", + name: "previous_csrf_token_hash", + type_name: "TEXT", + not_null: false, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "sessions", + name: "delete_account_token_hash", + type_name: "TEXT", + not_null: false, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "sessions", + name: "delete_account_token_expires_at", + type_name: "TEXT", + not_null: false, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "posts", + name: "id", + type_name: "INTEGER", + not_null: false, + default_value: None, + primary_key_position: 1, + }, + RequiredColumn { + table: "posts", + name: "user_id", + type_name: "INTEGER", + not_null: false, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "posts", + name: "anonymous_label", + type_name: "TEXT", + not_null: false, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "posts", + name: "text", + type_name: "TEXT", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "posts", + name: "parent_post_id", + type_name: "INTEGER", + not_null: false, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "posts", + name: "root_post_id", + type_name: "INTEGER", + not_null: false, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "posts", + name: "is_deleted", + type_name: "INTEGER", + not_null: true, + default_value: Some("0"), + primary_key_position: 0, + }, + RequiredColumn { + table: "posts", + name: "deleted_at", + type_name: "TEXT", + not_null: false, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "posts", + name: "edited_at", + type_name: "TEXT", + not_null: false, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "posts", + name: "created_at", + type_name: "TEXT", + not_null: true, + default_value: Some("CURRENT_TIMESTAMP"), + primary_key_position: 0, + }, + RequiredColumn { + table: "posts", + name: "quote_post_id", + type_name: "INTEGER", + not_null: false, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "media", + name: "id", + type_name: "INTEGER", + not_null: false, + default_value: None, + primary_key_position: 1, + }, + RequiredColumn { + table: "media", + name: "owner_user_id", + type_name: "INTEGER", + not_null: false, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "media", + name: "original_filename", + type_name: "TEXT", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "media", + name: "stored_path", + type_name: "TEXT", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "media", + name: "public_path", + type_name: "TEXT", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "media", + name: "mime_type", + type_name: "TEXT", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "media", + name: "media_kind", + type_name: "TEXT", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "media", + name: "byte_len", + type_name: "INTEGER", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "media", + name: "alt_text", + type_name: "TEXT", + not_null: true, + default_value: Some("''"), + primary_key_position: 0, + }, + RequiredColumn { + table: "media", + name: "conversion_state", + type_name: "TEXT", + not_null: true, + default_value: Some("'original'"), + primary_key_position: 0, + }, + RequiredColumn { + table: "media", + name: "ffmpeg_stderr", + type_name: "TEXT", + not_null: true, + default_value: Some("''"), + primary_key_position: 0, + }, + RequiredColumn { + table: "media", + name: "created_at", + type_name: "TEXT", + not_null: true, + default_value: Some("CURRENT_TIMESTAMP"), + primary_key_position: 0, + }, + RequiredColumn { + table: "media", + name: "thumbnail_path", + type_name: "TEXT", + not_null: false, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "media", + name: "thumbnail_public_path", + type_name: "TEXT", + not_null: false, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "media", + name: "is_nsfw", + type_name: "INTEGER", + not_null: true, + default_value: Some("0"), + primary_key_position: 0, + }, + RequiredColumn { + table: "media", + name: "original_path", + type_name: "TEXT", + not_null: false, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "media", + name: "original_public_path", + type_name: "TEXT", + not_null: false, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "media", + name: "original_sha256", + type_name: "TEXT", + not_null: true, + default_value: Some("''"), + primary_key_position: 0, + }, + RequiredColumn { + table: "media", + name: "normalized_sha256", + type_name: "TEXT", + not_null: false, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "media", + name: "canonical_media_id", + type_name: "INTEGER", + not_null: false, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "post_media", + name: "post_id", + type_name: "INTEGER", + not_null: true, + default_value: None, + primary_key_position: 1, + }, + RequiredColumn { + table: "post_media", + name: "media_id", + type_name: "INTEGER", + not_null: true, + default_value: None, + primary_key_position: 2, + }, + RequiredColumn { + table: "post_media", + name: "position", + type_name: "INTEGER", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "reposts", + name: "id", + type_name: "INTEGER", + not_null: false, + default_value: None, + primary_key_position: 1, + }, + RequiredColumn { + table: "reposts", + name: "user_id", + type_name: "INTEGER", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "reposts", + name: "post_id", + type_name: "INTEGER", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "reposts", + name: "created_at", + type_name: "TEXT", + not_null: true, + default_value: Some("CURRENT_TIMESTAMP"), + primary_key_position: 0, + }, + RequiredColumn { + table: "follows", + name: "follower_id", + type_name: "INTEGER", + not_null: true, + default_value: None, + primary_key_position: 1, + }, + RequiredColumn { + table: "follows", + name: "followed_id", + type_name: "INTEGER", + not_null: true, + default_value: None, + primary_key_position: 2, + }, + RequiredColumn { + table: "follows", + name: "created_at", + type_name: "TEXT", + not_null: true, + default_value: Some("CURRENT_TIMESTAMP"), + primary_key_position: 0, + }, + RequiredColumn { + table: "blocks", + name: "blocker_id", + type_name: "INTEGER", + not_null: true, + default_value: None, + primary_key_position: 1, + }, + RequiredColumn { + table: "blocks", + name: "blocked_id", + type_name: "INTEGER", + not_null: true, + default_value: None, + primary_key_position: 2, + }, + RequiredColumn { + table: "blocks", + name: "created_at", + type_name: "TEXT", + not_null: true, + default_value: Some("CURRENT_TIMESTAMP"), + primary_key_position: 0, + }, + RequiredColumn { + table: "mutes", + name: "muter_id", + type_name: "INTEGER", + not_null: true, + default_value: None, + primary_key_position: 1, + }, + RequiredColumn { + table: "mutes", + name: "muted_id", + type_name: "INTEGER", + not_null: true, + default_value: None, + primary_key_position: 2, + }, + RequiredColumn { + table: "mutes", + name: "created_at", + type_name: "TEXT", + not_null: true, + default_value: Some("CURRENT_TIMESTAMP"), + primary_key_position: 0, + }, + RequiredColumn { + table: "likes", + name: "user_id", + type_name: "INTEGER", + not_null: true, + default_value: None, + primary_key_position: 1, + }, + RequiredColumn { + table: "likes", + name: "post_id", + type_name: "INTEGER", + not_null: true, + default_value: None, + primary_key_position: 2, + }, + RequiredColumn { + table: "likes", + name: "created_at", + type_name: "TEXT", + not_null: true, + default_value: Some("CURRENT_TIMESTAMP"), + primary_key_position: 0, + }, + RequiredColumn { + table: "bookmarks", + name: "user_id", + type_name: "INTEGER", + not_null: true, + default_value: None, + primary_key_position: 1, + }, + RequiredColumn { + table: "bookmarks", + name: "post_id", + type_name: "INTEGER", + not_null: true, + default_value: None, + primary_key_position: 2, + }, + RequiredColumn { + table: "bookmarks", + name: "created_at", + type_name: "TEXT", + not_null: true, + default_value: Some("CURRENT_TIMESTAMP"), + primary_key_position: 0, + }, + RequiredColumn { + table: "notifications", + name: "id", + type_name: "INTEGER", + not_null: false, + default_value: None, + primary_key_position: 1, + }, + RequiredColumn { + table: "notifications", + name: "user_id", + type_name: "INTEGER", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "notifications", + name: "actor_user_id", + type_name: "INTEGER", + not_null: false, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "notifications", + name: "post_id", + type_name: "INTEGER", + not_null: false, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "notifications", + name: "kind", + type_name: "TEXT", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "notifications", + name: "message", + type_name: "TEXT", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "notifications", + name: "read_at", + type_name: "TEXT", + not_null: false, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "notifications", + name: "created_at", + type_name: "TEXT", + not_null: true, + default_value: Some("CURRENT_TIMESTAMP"), + primary_key_position: 0, + }, + RequiredColumn { + table: "rate_limit_events", + name: "id", + type_name: "INTEGER", + not_null: false, + default_value: None, + primary_key_position: 1, + }, + RequiredColumn { + table: "rate_limit_events", + name: "scope", + type_name: "TEXT", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "rate_limit_events", + name: "actor", + type_name: "TEXT", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "rate_limit_events", + name: "created_at", + type_name: "TEXT", + not_null: true, + default_value: Some("CURRENT_TIMESTAMP"), + primary_key_position: 0, + }, + RequiredColumn { + table: "admin_audit_log", + name: "id", + type_name: "INTEGER", + not_null: false, + default_value: None, + primary_key_position: 1, + }, + RequiredColumn { + table: "admin_audit_log", + name: "admin_user_id", + type_name: "INTEGER", + not_null: false, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "admin_audit_log", + name: "action", + type_name: "TEXT", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "admin_audit_log", + name: "target", + type_name: "TEXT", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "admin_audit_log", + name: "created_at", + type_name: "TEXT", + not_null: true, + default_value: Some("CURRENT_TIMESTAMP"), + primary_key_position: 0, + }, + RequiredColumn { + table: "media_jobs", + name: "id", + type_name: "INTEGER", + not_null: false, + default_value: None, + primary_key_position: 1, + }, + RequiredColumn { + table: "media_jobs", + name: "media_id", + type_name: "INTEGER", + not_null: false, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "media_jobs", + name: "status", + type_name: "TEXT", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "media_jobs", + name: "stderr_summary", + type_name: "TEXT", + not_null: true, + default_value: Some("''"), + primary_key_position: 0, + }, + RequiredColumn { + table: "media_jobs", + name: "created_at", + type_name: "TEXT", + not_null: true, + default_value: Some("CURRENT_TIMESTAMP"), + primary_key_position: 0, + }, + RequiredColumn { + table: "media_jobs", + name: "finished_at", + type_name: "TEXT", + not_null: false, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "reports", + name: "id", + type_name: "INTEGER", + not_null: false, + default_value: None, + primary_key_position: 1, + }, + RequiredColumn { + table: "reports", + name: "reporter_user_id", + type_name: "INTEGER", + not_null: false, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "reports", + name: "post_id", + type_name: "INTEGER", + not_null: false, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "reports", + name: "reason", + type_name: "TEXT", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "reports", + name: "dismissed_at", + type_name: "TEXT", + not_null: false, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "reports", + name: "created_at", + type_name: "TEXT", + not_null: true, + default_value: Some("CURRENT_TIMESTAMP"), + primary_key_position: 0, + }, + RequiredColumn { + table: "posts_fts", + name: "text", + type_name: "", + not_null: false, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "muted_words", + name: "id", + type_name: "INTEGER", + not_null: false, + default_value: None, + primary_key_position: 1, + }, + RequiredColumn { + table: "muted_words", + name: "user_id", + type_name: "INTEGER", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "muted_words", + name: "term", + type_name: "TEXT", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "muted_words", + name: "normalized_term", + type_name: "TEXT", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "muted_words", + name: "created_at", + type_name: "TEXT", + not_null: true, + default_value: Some("CURRENT_TIMESTAMP"), + primary_key_position: 0, + }, +]; #[cfg(test)] mod tests { @@ -465,11 +1969,19 @@ mod tests { .await .expect("connect"); migrate(&pool).await.expect("migrate"); - let count: i64 = pool - .call(|conn| Ok(conn.query_row("SELECT COUNT(*) FROM users", [], |row| row.get(0))?)) + let (user_count, version): (i64, i64) = pool + .call(|conn| { + let user_count = + conn.query_row("SELECT COUNT(*) FROM users", [], |row| row.get(0))?; + let version = schema_version_from_connection(conn)?; + Ok((user_count, version)) + }) .await - .expect("count"); - assert_eq!(count, 0); + .expect("baseline"); + let report = schema_report(&pool).await.expect("schema report"); + assert_eq!(user_count, 0); + assert_eq!(version, CURRENT_SCHEMA_VERSION); + assert!(report.is_compatible(), "{}", report.summary()); } #[tokio::test] @@ -495,7 +2007,7 @@ mod tests { .await .expect("settings"); - assert_eq!(versions, 13); + assert_eq!(versions, 1); assert_eq!(foreign_keys, 1); assert_eq!(journal_mode, "wal"); assert!(busy_timeout >= 5_000); @@ -688,72 +2200,77 @@ mod tests { } #[tokio::test] - async fn liked_posts_public_migration_defaults_existing_users_public() { + async fn latest_alpha_schema_is_marked_as_baseline_without_data_loss() { let temp = tempfile::tempdir().expect("temp dir"); let pool = connect(&temp.path().join("test.sqlite3")) .await .expect("connect"); pool.call(|conn| { - conn.execute_batch( - r#" - CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY); - INSERT INTO schema_migrations (version) VALUES (12); - CREATE TABLE users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT NOT NULL, - normalized_username TEXT NOT NULL UNIQUE, - password_hash TEXT NOT NULL, - display_name TEXT NOT NULL DEFAULT '', - is_deleted INTEGER NOT NULL DEFAULT 0 - ); - INSERT INTO users (username, normalized_username, password_hash, display_name) - VALUES ('Alice', 'alice', 'hash', 'Alice'); - INSERT INTO users (username, normalized_username, password_hash, display_name, is_deleted) - VALUES ('Deleted', 'deleted', 'hash', 'Deleted', 1); - "#, - )?; + install_latest_alpha_fixture(conn)?; + insert_preservation_fixture(conn)?; Ok(()) }) .await - .expect("legacy schema"); + .expect("alpha fixture"); - migrate(&pool).await.expect("migrate to liked_posts_public"); + migrate(&pool).await.expect("adopt latest alpha"); migrate(&pool).await.expect("second migrate"); - let (alice_public, deleted_public, column_count, version): (i64, i64, i64, i64) = pool + let state: PreservedAlphaState = pool .call(|conn| { - let alice = conn.query_row( - "SELECT liked_posts_public FROM users WHERE normalized_username = 'alice'", + let versions = migration_versions(conn)?; + let counts = [ + "users", + "sessions", + "posts", + "media", + "post_media", + "reposts", + "follows", + "blocks", + "mutes", + "likes", + "bookmarks", + "notifications", + "muted_words", + "admin_audit_log", + "media_jobs", + "reports", + ] + .iter() + .map(|table| table_count(conn, table)) + .collect::>>()?; + let privacy = conn.query_row( + "SELECT liked_posts_public, nsfw_blur_enabled FROM users WHERE normalized_username = 'alice'", [], - |row| row.get(0), + |row| Ok((row.get(0)?, row.get(1)?)), )?; - let deleted = conn.query_row( - "SELECT liked_posts_public FROM users WHERE normalized_username = 'deleted'", + let media = conn.query_row( + "SELECT is_nsfw, original_path FROM media WHERE id = 1", [], - |row| row.get(0), + |row| Ok((row.get(0)?, row.get(1)?)), )?; - let column_count = conn.query_row( - "SELECT COUNT(*) FROM pragma_table_info('users') WHERE name = 'liked_posts_public'", - [], - |row| row.get(0), - )?; - let version = - conn.query_row("SELECT MAX(version) FROM schema_migrations", [], |row| { - row.get(0) - })?; - Ok((alice, deleted, column_count, version)) + Ok(PreservedAlphaState { + versions, + counts, + privacy, + media, + }) }) .await - .expect("liked posts migration state"); + .expect("preserved data"); - assert_eq!(alice_public, 1); - assert_eq!(deleted_public, 1); - assert_eq!(column_count, 1); - assert_eq!(version, 13); + assert_eq!(state.versions, [CURRENT_SCHEMA_VERSION]); + assert_eq!( + state.counts, + [2, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + ); + assert_eq!(state.privacy, (0, 0)); + assert_eq!(state.media, (1, "/uploads/originals/alpha.png".to_owned())); } #[tokio::test] - async fn onboarding_migration_marks_existing_active_users_complete() { + async fn partial_old_alpha_schema_fails_without_data_loss() { let temp = tempfile::tempdir().expect("temp dir"); let pool = connect(&temp.path().join("test.sqlite3")) .await @@ -762,47 +2279,86 @@ mod tests { conn.execute_batch( r#" CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY); - INSERT INTO schema_migrations (version) VALUES (11); + INSERT INTO schema_migrations (version) VALUES (12); CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL, normalized_username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, - display_name TEXT NOT NULL DEFAULT '', - is_deleted INTEGER NOT NULL DEFAULT 0 + display_name TEXT NOT NULL DEFAULT '' ); INSERT INTO users (username, normalized_username, password_hash, display_name) VALUES ('Alice', 'alice', 'hash', 'Alice'); - INSERT INTO users (username, normalized_username, password_hash, display_name, is_deleted) - VALUES ('Deleted', 'deleted', 'hash', 'Deleted', 1); "#, )?; Ok(()) }) .await - .expect("legacy schema"); + .expect("partial alpha schema"); - migrate(&pool).await.expect("migrate"); + let error = migrate(&pool) + .await + .expect_err("partial alpha schema must fail"); + let remaining_users: i64 = pool + .call(|conn| table_count(conn, "users")) + .await + .expect("users remain"); - let (active_completed_at, deleted_completed_at): (Option, Option) = pool - .call(|conn| { - let active = conn.query_row( - "SELECT onboarding_completed_at FROM users WHERE normalized_username = 'alice'", - [], - |row| row.get(0), - )?; - let deleted = conn.query_row( - "SELECT onboarding_completed_at FROM users WHERE normalized_username = 'deleted'", - [], - |row| row.get(0), - )?; - Ok((active, deleted)) - }) + assert!(error.to_string().contains("version 12")); + assert!(error.to_string().contains("blind migration")); + assert_eq!(remaining_users, 1); + } + + #[tokio::test] + async fn structurally_unsafe_latest_alpha_schema_fails() { + let temp = tempfile::tempdir().expect("temp dir"); + let pool = connect(&temp.path().join("test.sqlite3")) .await - .expect("onboarding state"); + .expect("connect"); + pool.call(|conn| { + install_latest_alpha_fixture(conn)?; + conn.execute("DROP INDEX idx_posts_created", [])?; + Ok(()) + }) + .await + .expect("unsafe alpha fixture"); + + let error = migrate(&pool) + .await + .expect_err("unsafe latest alpha schema must fail"); + let version: i64 = pool + .call(|conn| schema_version_from_connection(conn)) + .await + .expect("alpha version remains"); + + assert!(error.to_string().contains("not safe to adopt")); + assert!( + error + .to_string() + .contains("missing index idx_posts_created") + ); + assert_eq!(version, OLD_ALPHA_SCHEMA_VERSION); + } + + #[tokio::test] + async fn schema_report_lists_missing_required_objects() { + 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("DROP INDEX idx_posts_created", [])?; + Ok(()) + }) + .await + .expect("drop index"); - assert!(active_completed_at.is_some()); - assert!(deleted_completed_at.is_none()); + let report = schema_report(&pool).await.expect("schema report"); + + assert_eq!(report.version(), Some(CURRENT_SCHEMA_VERSION)); + assert!(!report.is_compatible()); + assert!(report.summary().contains("missing index idx_posts_created")); } #[tokio::test] @@ -830,4 +2386,215 @@ mod tests { assert_eq!(quote_count, 1); } + + #[tokio::test] + async fn baseline_supports_core_app_records_and_search() { + let temp = tempfile::tempdir().expect("temp dir"); + let pool = connect(&temp.path().join("test.sqlite3")) + .await + .expect("connect"); + migrate(&pool).await.expect("migrate"); + + let (search_count, social_count): (i64, i64) = pool + .call(|conn| { + 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 posts (id, user_id, text) + VALUES (1, 1, 'baseline search token'); + INSERT INTO media (id, owner_user_id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len, is_nsfw) + VALUES (1, 1, 'image.webp', '/media/image.webp', '/uploads/images/image.webp', 'image/webp', 'image', 10, 1); + INSERT INTO post_media (post_id, media_id, position) VALUES (1, 1, 0); + INSERT INTO follows (follower_id, followed_id) VALUES (1, 2); + INSERT INTO likes (user_id, post_id) VALUES (2, 1); + INSERT INTO bookmarks (user_id, post_id) VALUES (1, 1); + INSERT INTO reposts (user_id, post_id) VALUES (2, 1); + INSERT INTO notifications (user_id, actor_user_id, post_id, kind, message) + VALUES (1, 2, 1, 'like', 'Bob liked your post'); + "#, + )?; + let search_count = conn.query_row( + "SELECT COUNT(*) FROM posts_fts WHERE posts_fts MATCH 'baseline'", + [], + |row| row.get(0), + )?; + let social_count = table_count(conn, "follows")? + + table_count(conn, "likes")? + + table_count(conn, "bookmarks")? + + table_count(conn, "reposts")? + + table_count(conn, "notifications")?; + Ok((search_count, social_count)) + }) + .await + .expect("core records"); + + assert_eq!(search_count, 1); + assert_eq!(social_count, 5); + } + + fn install_latest_alpha_fixture(conn: &Connection) -> anyhow::Result<()> { + conn.execute_batch(BASELINE_SCHEMA)?; + conn.execute("DELETE FROM schema_migrations", [])?; + for version in 1..=OLD_ALPHA_SCHEMA_VERSION { + conn.execute( + "INSERT INTO schema_migrations (version) VALUES (?)", + [version], + )?; + } + Ok(()) + } + + fn insert_preservation_fixture(conn: &Connection) -> anyhow::Result<()> { + insert_preservation_users(conn)?; + insert_preservation_session(conn)?; + insert_preservation_posts_and_media(conn)?; + insert_preservation_social_rows(conn) + } + + fn insert_preservation_users(conn: &Connection) -> anyhow::Result<()> { + conn.execute_batch( + r#" + INSERT INTO users ( + id, + username, + normalized_username, + password_hash, + display_name, + bio, + website, + is_admin, + theme, + location, + nsfw_blur_enabled, + onboarding_completed_at, + liked_posts_public + ) + VALUES ( + 1, + 'Alice', + 'alice', + 'hash', + 'Alice', + 'bio', + 'https://example.test', + 1, + 'dark', + 'Vancouver', + 0, + '2026-01-01T00:00:00Z', + 0 + ); + INSERT INTO users (id, username, normalized_username, password_hash, display_name) + VALUES (2, 'Bob', 'bob', 'hash', 'Bob'); + "#, + )?; + Ok(()) + } + + fn insert_preservation_session(conn: &Connection) -> anyhow::Result<()> { + conn.execute_batch( + r#" + INSERT INTO sessions ( + user_id, + token_hash, + csrf_token_hash, + expires_at, + previous_csrf_token_hash, + delete_account_token_hash, + delete_account_token_expires_at + ) + VALUES (1, 'session-token', 'csrf-token', '2099-01-01T00:00:00Z', 'old-csrf', 'delete-token', '2099-01-01T00:05:00Z'); + "#, + )?; + Ok(()) + } + + fn insert_preservation_posts_and_media(conn: &Connection) -> anyhow::Result<()> { + conn.execute_batch( + r#" + INSERT INTO posts (id, user_id, text) + VALUES (1, 1, 'alpha post'); + INSERT INTO posts (id, user_id, text, quote_post_id) + VALUES (2, 2, 'quoted alpha post', 1); + INSERT INTO media ( + id, + owner_user_id, + original_filename, + original_path, + original_public_path, + stored_path, + public_path, + mime_type, + media_kind, + byte_len, + alt_text, + conversion_state, + ffmpeg_stderr, + thumbnail_path, + thumbnail_public_path, + is_nsfw, + original_sha256, + normalized_sha256 + ) + VALUES ( + 1, + 1, + 'alpha.png', + '/uploads/originals/alpha.png', + '/uploads/originals/alpha.png', + '/uploads/images/alpha.webp', + '/uploads/images/alpha.webp', + 'image/webp', + 'image', + 10, + 'alt text', + 'converted', + 'stderr', + '/uploads/images/alpha-thumb.webp', + '/uploads/images/alpha-thumb.webp', + 1, + 'raw-alpha', + 'normalized-alpha' + ); + INSERT INTO post_media (post_id, media_id, position) VALUES (1, 1, 0); + "#, + )?; + Ok(()) + } + + fn insert_preservation_social_rows(conn: &Connection) -> anyhow::Result<()> { + conn.execute_batch( + r#" + INSERT INTO reposts (user_id, post_id) VALUES (1, 2); + INSERT INTO follows (follower_id, followed_id) VALUES (1, 2); + INSERT INTO blocks (blocker_id, blocked_id) VALUES (1, 2); + INSERT INTO mutes (muter_id, muted_id) VALUES (2, 1); + INSERT INTO likes (user_id, post_id) VALUES (1, 2); + INSERT INTO bookmarks (user_id, post_id) VALUES (1, 2); + INSERT INTO notifications (user_id, actor_user_id, post_id, kind, message, read_at) + VALUES (1, 2, 2, 'reply', 'Bob replied', '2026-01-01T00:00:00Z'); + INSERT INTO muted_words (user_id, term, normalized_term) VALUES (1, 'Spoiler', 'spoiler'); + INSERT INTO admin_audit_log (admin_user_id, action, target) VALUES (1, 'suspend', 'bob'); + INSERT INTO media_jobs (media_id, status, stderr_summary, finished_at) + VALUES (1, 'complete', 'ok', '2026-01-01T00:00:00Z'); + INSERT INTO reports (reporter_user_id, post_id, reason) VALUES (2, 1, 'spam'); + "#, + )?; + Ok(()) + } + + struct PreservedAlphaState { + versions: Vec, + counts: Vec, + privacy: (i64, i64), + media: (i64, String), + } + + fn table_count(conn: &Connection, table: &str) -> anyhow::Result { + let sql = format!("SELECT COUNT(*) FROM {}", quoted_identifier(table)); + Ok(conn.query_row(&sql, [], |row| row.get(0))?) + } } diff --git a/src/server.rs b/src/server.rs index c4ff9b9..ced3813 100644 --- a/src/server.rs +++ b/src/server.rs @@ -3430,6 +3430,15 @@ async fn admin_health( let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); let media_jobs = admin::media_jobs_report(&state.pool).await?; let jobs = render_media_jobs_report(&media_jobs); + let schema = crate::db::schema_report(&state.pool).await?; + let schema_version = schema + .version() + .map_or_else(|| "unknown".to_owned(), |version| version.to_string()); + let schema_summary = if schema.is_compatible() { + schema.summary() + } else { + format!("INCOMPATIBLE: {}", schema.summary()) + }; let onion = state .tor .onion_address() @@ -3440,8 +3449,10 @@ async fn admin_health( .bootstrap_status() .unwrap_or_else(|| "unavailable".to_owned()); let body = format!( - r#"

Site health

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

Recent media jobs

{}
"#, + 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

{}
"#, html_escape::encode_text(&state.paths.database_path.display().to_string()), + html_escape::encode_text(&schema_version), + html_escape::encode_text(&schema_summary), html_escape::encode_text(&state.paths.uploads_originals.display().to_string()), html_escape::encode_text(&state.paths.uploads_images.display().to_string()), html_escape::encode_text(&state.paths.logs_dir.display().to_string()), diff --git a/src/terminal.rs b/src/terminal.rs index 5f1ebc5..4a2621a 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -32,6 +32,9 @@ pub struct StartupDashboard<'a> { pub paths: &'a runtime::RuntimePaths, pub settings_path: &'a Path, pub settings: &'a config::Settings, + pub schema_version: Option, + pub schema_compatible: bool, + pub schema_summary: &'a str, pub admin_count: i64, pub user_count: i64, pub post_count: i64, @@ -49,6 +52,7 @@ pub fn render_startup_dashboard(input: &StartupDashboard<'_>) -> String { "Status", &[ row("Version", env!("CARGO_PKG_VERSION")), + row("DB schema", schema_status_value(input)), row( "Server", status_value(Status::Pending, &bind_address(&input.settings.server)), @@ -394,6 +398,18 @@ fn ffmpeg_status_value(ffmpeg: &ffmpeg::FfmpegStatus) -> String { } } +fn schema_status_value(input: &StartupDashboard<'_>) -> String { + let version = input + .schema_version + .map_or_else(|| "unknown".to_owned(), |version| version.to_string()); + let summary = format!("version {version}; {}", input.schema_summary); + if input.schema_compatible { + status_value(Status::Ok, &summary) + } else { + status_value(Status::Warn, &summary) + } +} + fn status_value(status: Status, value: &str) -> String { format!("{} {value}", status.label()) } @@ -463,6 +479,9 @@ mod tests { paths: &paths, settings_path: &paths.settings_path, settings: &settings, + schema_version: Some(1), + schema_compatible: true, + schema_summary: "release baseline schema version 1", admin_count: 0, user_count: 2, post_count: 3, @@ -472,6 +491,8 @@ mod tests { }); assert!(output.contains("Status")); + assert!(output.contains("DB schema")); + assert!(output.contains("release baseline schema version 1")); assert!(output.contains("Endpoints")); assert!(output.contains("Storage")); assert!(output.contains("Next commands")); From ecf944e0c76d5c763cd5cf850c571e7d59e4870d Mon Sep 17 00:00:00 2001 From: csd113 Date: Sun, 31 May 2026 20:16:51 -0700 Subject: [PATCH 06/12] RustPost starts from release database baseline v1. Pre-release alpha migration history was squashed before public release. Future public schema changes use normal forward migrations. --- CHANGELOG.md | 3 +- README.md | 2 +- src/backup.rs | 64 +++++++++++++++++++++-- src/cli.rs | 30 ++++++++++- src/db.rs | 134 ++++++++++++++++++++++++++++++++++++------------ src/server.rs | 10 ++-- src/terminal.rs | 110 +++++++++++++++++++++++++++++++++++++-- 7 files changed, 306 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a96519..6febc85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,9 @@ - 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. - Existing current alpha databases that structurally match the baseline are marked as baseline version `1` without destructive changes or data loss. -- Incomplete, unknown, or structurally unsafe old alpha databases now fail closed with administrator guidance to back up/export/recreate/restore instead of attempting blind migration. +- 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. ## v0.1.6 - Pinned Profiles diff --git a/README.md b/README.md index babe182..7c86fb7 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ Replies remain attached to their parent thread. They render inside the thread vi ```sh rustpost init # Initialize data directory -rustpost check # Validate config and 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 diff --git a/src/backup.rs b/src/backup.rs index ceeff21..851ca4e 100644 --- a/src/backup.rs +++ b/src/backup.rs @@ -558,7 +558,7 @@ fn snapshot_database(source: &Path, destination: &Path) -> anyhow::Result<()> { [], ) .with_context(|| "failed to snapshot SQLite database")?; - validate_sqlite_database(destination, None)?; + validate_and_normalize_sqlite_database(destination, None)?; Ok(()) } @@ -572,7 +572,7 @@ fn database_schema_version(path: &Path) -> anyhow::Result { db::schema_version_from_connection(&conn) } -fn validate_sqlite_database( +fn validate_and_normalize_sqlite_database( path: &Path, expected_schema_version: Option, ) -> anyhow::Result<()> { @@ -594,7 +594,7 @@ fn validate_sqlite_database( { anyhow::bail!("manifest schema version does not match restored database"); } - db::validate_restorable_schema(&conn)?; + db::normalize_restorable_schema(&conn)?; Ok(()) } @@ -740,7 +740,7 @@ fn validate_staged_backup( } } } - validate_sqlite_database( + validate_and_normalize_sqlite_database( &staging_dir.join("db/rustpost.sqlite3"), manifest.db_schema_version, )?; @@ -1304,6 +1304,62 @@ mod tests { assert!(names.iter().any(|name| name == "tor/onion-service/secret")); } + #[test] + fn backup_normalizes_valid_pre_release_schema_without_touching_live_database() { + let (_temp, paths) = test_paths(); + let conn = Connection::open(&paths.database_path).expect("live db"); + conn.execute("DELETE FROM schema_migrations", []) + .expect("clear version"); + conn.execute("INSERT INTO schema_migrations (version) VALUES (13)", []) + .expect("alpha version"); + drop(conn); + + let archive = create_backup(&paths, false).expect("backup"); + let manifest = read_manifest_from_archive(&archive).expect("manifest"); + let live = Connection::open(&paths.database_path).expect("live db"); + let live_version = db::schema_version_from_connection(&live).expect("live version"); + + assert_eq!(manifest.db_schema_version, Some(CURRENT_SCHEMA_VERSION)); + assert_eq!(live_version, 13); + } + + #[test] + fn restore_validation_normalizes_valid_pre_release_schema_before_acceptance() { + let temp = tempfile::tempdir().expect("temp dir"); + let database = temp.path().join("alpha.sqlite3"); + test_db(&database, 13); + + validate_and_normalize_sqlite_database(&database, Some(13)).expect("valid alpha schema"); + let conn = Connection::open(&database).expect("open"); + let version = db::schema_version_from_connection(&conn).expect("version"); + + assert_eq!(version, CURRENT_SCHEMA_VERSION); + } + + #[test] + fn restore_validation_rejects_unsafe_pre_release_schema_without_normalizing() { + let temp = tempfile::tempdir().expect("temp dir"); + let database = temp.path().join("unsafe-alpha.sqlite3"); + test_db(&database, 13); + let conn = Connection::open(&database).expect("open"); + conn.execute("DROP INDEX idx_posts_created", []) + .expect("drop index"); + drop(conn); + + let error = + validate_and_normalize_sqlite_database(&database, Some(13)).expect_err("unsafe schema"); + let conn = Connection::open(&database).expect("open"); + let version = db::schema_version_from_connection(&conn).expect("version"); + + assert!(error.to_string().contains("not structurally compatible")); + assert!( + error + .to_string() + .contains("missing index idx_posts_created") + ); + assert_eq!(version, 13); + } + #[test] fn restore_validates_hashes_before_touching_live_runtime() { let source_temp = tempfile::tempdir().expect("source"); diff --git a/src/cli.rs b/src/cli.rs index 5958682..7f2f790 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -138,9 +138,10 @@ async fn run_command( Command::Check => { let ffmpeg = crate::ffmpeg::probe(&settings.media).await; let tor_status = tor::validate_startup(&settings.tor); + let database_schema = database_schema_status(&paths.database_path); stdout_raw(format_args!( "{}", - terminal::render_check(&settings, &ffmpeg, &tor_status) + terminal::render_check(&settings, &ffmpeg, &tor_status, &database_schema) ))?; Ok(()) } @@ -181,6 +182,33 @@ async fn run_command( } } +fn database_schema_status(path: &std::path::Path) -> terminal::DatabaseSchemaStatus { + if !path.is_file() { + return terminal::DatabaseSchemaStatus::Missing; + } + match db::schema_report_from_path(path) { + Ok(report) if report.is_compatible() => terminal::DatabaseSchemaStatus::Present { + version: report.version(), + compatible: true, + summary: report.summary(), + }, + Ok(report) => match db::adoptable_schema_summary_from_path(path) { + Ok(Some(summary)) => terminal::DatabaseSchemaStatus::Adoptable { summary }, + Ok(None) => terminal::DatabaseSchemaStatus::Present { + version: report.version(), + compatible: false, + summary: report.summary(), + }, + Err(error) => terminal::DatabaseSchemaStatus::Unreadable { + error: error.to_string(), + }, + }, + Err(error) => terminal::DatabaseSchemaStatus::Unreadable { + error: error.to_string(), + }, + } +} + struct AdminDatabase<'a> { paths: &'a runtime::RuntimePaths, settings: &'a config::Settings, diff --git a/src/db.rs b/src/db.rs index 7fbb8ae..608f799 100644 --- a/src/db.rs +++ b/src/db.rs @@ -4,7 +4,7 @@ use std::thread; use std::time::Duration; use anyhow::Context as _; -use rusqlite::{Connection, OptionalExtension as _}; +use rusqlite::{Connection, OpenFlags, OptionalExtension as _}; pub const CURRENT_SCHEMA_VERSION: i64 = 1; @@ -86,6 +86,26 @@ pub async fn schema_report(pool: &Db) -> anyhow::Result { pool.call(|conn| inspect_schema(conn)).await } +pub fn schema_report_from_path(path: &Path) -> anyhow::Result { + let conn = open_read_only_connection(path)?; + inspect_schema(&conn) +} + +pub fn adoptable_schema_summary_from_path(path: &Path) -> anyhow::Result> { + let conn = open_read_only_connection(path)?; + if !matches!(migration_state(&conn)?, MigrationState::AlphaLatest) { + return Ok(None); + } + + let issues = inspect_schema_objects(&conn)?; + if issues.is_empty() { + return Ok(Some(format!( + "pre-release database structurally matches the release baseline; startup will mark it as release baseline schema version {CURRENT_SCHEMA_VERSION} without data loss" + ))); + } + Ok(None) +} + pub fn validate_schema(conn: &Connection) -> anyhow::Result<()> { let report = inspect_schema(conn)?; if report.is_compatible() { @@ -98,23 +118,11 @@ pub fn validate_schema(conn: &Connection) -> anyhow::Result<()> { } pub fn validate_restorable_schema(conn: &Connection) -> anyhow::Result<()> { - let version = schema_version_from_connection(conn)?; - if version == CURRENT_SCHEMA_VERSION { - return validate_schema(conn); - } - if version == OLD_ALPHA_SCHEMA_VERSION { - let issues = inspect_schema_objects(conn)?; - if issues.is_empty() { - return Ok(()); - } - anyhow::bail!( - "restored database has incomplete old alpha schema: {}", - summarize_issues(&issues) - ); - } - anyhow::bail!( - "database schema version {version} is not supported by this RustPost release baseline" - ); + validate_or_normalize_restorable_schema(conn, false) +} + +pub fn normalize_restorable_schema(conn: &Connection) -> anyhow::Result<()> { + validate_or_normalize_restorable_schema(conn, true) } pub fn schema_version_from_connection(conn: &Connection) -> anyhow::Result { @@ -127,6 +135,11 @@ pub fn schema_version_from_connection(conn: &Connection) -> anyhow::Result .ok_or_else(|| anyhow::anyhow!("database has no schema migration version")) } +fn open_read_only_connection(path: &Path) -> anyhow::Result { + Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY) + .with_context(|| format!("failed to open SQLite database {}", path.display())) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct SchemaReport { version: Option, @@ -211,6 +224,38 @@ fn ensure_release_baseline(conn: &Connection) -> anyhow::Result<()> { validate_schema(conn).with_context(|| INCOMPATIBLE_DATABASE_HINT) } +fn validate_or_normalize_restorable_schema( + conn: &Connection, + normalize: bool, +) -> anyhow::Result<()> { + match migration_state(conn)? { + MigrationState::Baseline => validate_schema(conn), + MigrationState::AlphaLatest => { + let issues = inspect_schema_objects(conn)?; + if !issues.is_empty() { + anyhow::bail!( + "pre-release database is not structurally compatible with the release baseline: {}. {}", + summarize_issues(&issues), + INCOMPATIBLE_DATABASE_HINT + ); + } + if normalize { + reset_schema_migrations(conn)?; + validate_schema(conn)?; + } + Ok(()) + } + MigrationState::Empty => { + anyhow::bail!( + "database does not contain RustPost schema metadata. {INCOMPATIBLE_DATABASE_HINT}" + ); + } + MigrationState::Incompatible(reason) => { + anyhow::bail!("{reason}. {INCOMPATIBLE_DATABASE_HINT}"); + } + } +} + fn migration_state(conn: &Connection) -> anyhow::Result { let schema_objects = user_schema_objects(conn)?; if !table_exists(conn, "schema_migrations")? { @@ -247,9 +292,10 @@ fn migration_state(conn: &Connection) -> anyhow::Result { if latest == OLD_ALPHA_SCHEMA_VERSION { return Ok(MigrationState::AlphaLatest); } - Ok(MigrationState::Incompatible(format!( - "database schema version {latest} is not a supported release baseline" - ))) + Ok(MigrationState::Incompatible( + "database migration metadata is not compatible with this RustPost release baseline" + .to_owned(), + )) } fn initialize_release_baseline(conn: &Connection) -> anyhow::Result<()> { @@ -271,7 +317,7 @@ fn adopt_latest_alpha_schema(conn: &Connection) -> anyhow::Result<()> { let issues = inspect_schema_objects(conn)?; if !issues.is_empty() { anyhow::bail!( - "old alpha database is not safe to adopt as release baseline: {}. {}", + "pre-release database is not safe to adopt as release baseline: {}. {}", summarize_issues(&issues), INCOMPATIBLE_DATABASE_HINT ); @@ -302,16 +348,7 @@ fn inspect_schema(conn: &Connection) -> anyhow::Result { version = versions.iter().max().copied(); if versions != [CURRENT_SCHEMA_VERSION] { issues.push(format!( - "expected schema_migrations to contain only version {CURRENT_SCHEMA_VERSION}, found {}", - if versions.is_empty() { - "none".to_owned() - } else { - versions - .iter() - .map(i64::to_string) - .collect::>() - .join(", ") - } + "schema_migrations metadata does not match the release baseline; expected only version {CURRENT_SCHEMA_VERSION}" )); } } else { @@ -2304,7 +2341,8 @@ mod tests { .await .expect("users remain"); - assert!(error.to_string().contains("version 12")); + assert!(error.to_string().contains("migration metadata")); + assert!(!error.to_string().contains("version 12")); assert!(error.to_string().contains("blind migration")); assert_eq!(remaining_users, 1); } @@ -2361,6 +2399,36 @@ mod tests { assert!(report.summary().contains("missing index idx_posts_created")); } + #[test] + fn path_schema_report_is_read_only_for_missing_databases() { + let temp = tempfile::tempdir().expect("temp dir"); + let path = temp.path().join("missing.sqlite3"); + + let error = schema_report_from_path(&path).expect_err("missing database"); + + assert!(error.to_string().contains("failed to open SQLite database")); + assert!(!path.exists()); + } + + #[test] + fn adoptable_schema_summary_hides_internal_alpha_version_numbers() { + let temp = tempfile::tempdir().expect("temp dir"); + let path = temp.path().join("alpha.sqlite3"); + let conn = Connection::open(&path).expect("open"); + install_latest_alpha_fixture(&conn).expect("alpha fixture"); + drop(conn); + + let summary = adoptable_schema_summary_from_path(&path) + .expect("adoption summary") + .expect("adoptable summary"); + let conn = Connection::open(&path).expect("open"); + let version = schema_version_from_connection(&conn).expect("version"); + + assert!(summary.contains("release baseline schema version 1")); + assert!(!summary.contains("version 13")); + assert_eq!(version, OLD_ALPHA_SCHEMA_VERSION); + } + #[tokio::test] async fn posts_support_quote_repost_references() { let temp = tempfile::tempdir().expect("temp dir"); diff --git a/src/server.rs b/src/server.rs index ced3813..cced1d2 100644 --- a/src/server.rs +++ b/src/server.rs @@ -3431,9 +3431,13 @@ async fn admin_health( let media_jobs = admin::media_jobs_report(&state.pool).await?; let jobs = render_media_jobs_report(&media_jobs); let schema = crate::db::schema_report(&state.pool).await?; - let schema_version = schema - .version() - .map_or_else(|| "unknown".to_owned(), |version| version.to_string()); + let schema_version = if schema.is_compatible() { + schema + .version() + .map_or_else(|| "unknown".to_owned(), |version| version.to_string()) + } else { + "incompatible".to_owned() + }; let schema_summary = if schema.is_compatible() { schema.summary() } else { diff --git a/src/terminal.rs b/src/terminal.rs index 4a2621a..b426fb5 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -43,6 +43,22 @@ pub struct StartupDashboard<'a> { pub onion_target: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DatabaseSchemaStatus { + Missing, + Adoptable { + summary: String, + }, + Present { + version: Option, + compatible: bool, + summary: String, + }, + Unreadable { + error: String, + }, +} + #[must_use] pub fn render_startup_dashboard(input: &StartupDashboard<'_>) -> String { let mut out = String::with_capacity(1_800); @@ -114,6 +130,7 @@ pub fn render_check( settings: &config::Settings, ffmpeg: &ffmpeg::FfmpegStatus, tor_status: &tor::TorStatus, + database_schema: &DatabaseSchemaStatus, ) -> String { let mut out = String::with_capacity(512); push_header(&mut out, "RustPost configuration check"); @@ -122,6 +139,7 @@ pub fn render_check( "Result", &[ row("Configuration", status_value(Status::Ok, "valid")), + row("DB schema", database_schema_status_value(database_schema)), row("Bind address", bind_address(&settings.server)), row("Public URL", public_url(settings)), row( @@ -399,10 +417,11 @@ fn ffmpeg_status_value(ffmpeg: &ffmpeg::FfmpegStatus) -> String { } fn schema_status_value(input: &StartupDashboard<'_>) -> String { - let version = input - .schema_version - .map_or_else(|| "unknown".to_owned(), |version| version.to_string()); - let summary = format!("version {version}; {}", input.schema_summary); + let summary = schema_status_summary( + input.schema_version, + input.schema_compatible, + input.schema_summary, + ); if input.schema_compatible { status_value(Status::Ok, &summary) } else { @@ -410,6 +429,38 @@ fn schema_status_value(input: &StartupDashboard<'_>) -> String { } } +fn database_schema_status_value(status: &DatabaseSchemaStatus) -> String { + match status { + DatabaseSchemaStatus::Missing => status_value(Status::Pending, "not created yet"), + DatabaseSchemaStatus::Adoptable { summary } => status_value(Status::Ok, summary), + DatabaseSchemaStatus::Present { + version, + compatible, + summary, + } => { + let summary = schema_status_summary(*version, *compatible, summary); + if *compatible { + status_value(Status::Ok, &summary) + } else { + status_value(Status::Warn, &summary) + } + } + DatabaseSchemaStatus::Unreadable { error } => { + status_value(Status::Warn, &format!("unreadable; {error}")) + } + } +} + +fn schema_status_summary(version: Option, compatible: bool, summary: &str) -> String { + if compatible { + return version.map_or_else( + || summary.to_owned(), + |version| format!("version {version}; {summary}"), + ); + } + summary.to_owned() +} + fn status_value(status: Status, value: &str) -> String { format!("{} {value}", status.label()) } @@ -511,6 +562,57 @@ mod tests { assert!(output.contains("[WARN] unavailable")); } + #[test] + fn check_output_reports_database_schema_status() { + let settings = Settings::default(); + let ffmpeg = FfmpegStatus { + available: false, + version: String::new(), + supports_webp: false, + supports_vp9: false, + error: Some("ffmpeg command not found".to_owned()), + }; + let tor_status = tor::validate_startup(&settings.tor); + let output = render_check( + &settings, + &ffmpeg, + &tor_status, + &DatabaseSchemaStatus::Present { + version: Some(1), + compatible: true, + summary: "release baseline schema version 1".to_owned(), + }, + ); + + assert!(output.contains("DB schema")); + assert!(output.contains("[OK] version 1; release baseline schema version 1")); + } + + #[test] + fn check_output_avoids_internal_alpha_version_wording() { + let settings = Settings::default(); + let ffmpeg = FfmpegStatus { + available: true, + version: "ffmpeg 8.1.1".to_owned(), + supports_webp: true, + supports_vp9: true, + error: None, + }; + let tor_status = tor::validate_startup(&settings.tor); + let output = render_check( + &settings, + &ffmpeg, + &tor_status, + &DatabaseSchemaStatus::Adoptable { + summary: "pre-release database structurally matches the release baseline; startup will mark it as release baseline schema version 1 without data loss".to_owned(), + }, + ); + + assert!(output.contains("release baseline schema version 1")); + assert!(!output.contains("version 13")); + assert!(!output.contains("version 12")); + } + #[test] fn local_url_uses_loopback_for_unspecified_bind_addresses() { let mut settings = Settings::default(); From d275bcad0cc5a8d5c578b0b51e825e1fc865ecf5 Mon Sep 17 00:00:00 2001 From: csd113 Date: Tue, 2 Jun 2026 21:19:08 -0700 Subject: [PATCH 07/12] Replace curl YouTube metadata fetch with native Rust client --- Cargo.lock | 45 +++++ Cargo.toml | 2 + src/db.rs | 245 ++++++++++++++++++++++- src/lib.rs | 1 + src/render.rs | 303 ++++++++++------------------- src/social.rs | 519 +++++++++++++++++++++++++++++++++++++++++++++---- src/youtube.rs | 430 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 1297 insertions(+), 248 deletions(-) create mode 100644 src/youtube.rs diff --git a/Cargo.lock b/Cargo.lock index 7edcd19..3910d03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3243,6 +3243,7 @@ dependencies = [ "rustls", "safelog", "serde", + "serde_json", "sha2", "tar", "tempfile", @@ -3256,6 +3257,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "ureq", "uuid", "walkdir", ] @@ -5409,6 +5411,34 @@ version = "0.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64 0.22.1", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64 0.22.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -5427,6 +5457,12 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -5633,6 +5669,15 @@ dependencies = [ "web-time", ] +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index 61027dc..45635d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ 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"] } @@ -45,6 +46,7 @@ tor-rtcompat = { version = "=0.42.0", default-features = false, features = ["tok tower-http = { version = "0.6", features = ["fs", "trace"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +ureq = { version = "3.3", default-features = false, features = ["rustls"] } uuid = { version = "1.18", features = ["v4", "serde"] } walkdir = "2.5" diff --git a/src/db.rs b/src/db.rs index 608f799..d256062 100644 --- a/src/db.rs +++ b/src/db.rs @@ -6,8 +6,9 @@ use std::time::Duration; use anyhow::Context as _; use rusqlite::{Connection, OpenFlags, OptionalExtension as _}; -pub const CURRENT_SCHEMA_VERSION: i64 = 1; +pub const CURRENT_SCHEMA_VERSION: i64 = 2; +const PREVIOUS_RELEASE_SCHEMA_VERSION: i64 = 1; 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"; @@ -97,7 +98,7 @@ pub fn adoptable_schema_summary_from_path(path: &Path) -> anyhow::Result anyhow::Result<()> { match migration_state(conn)? { MigrationState::Empty => initialize_release_baseline(conn)?, MigrationState::Baseline => {} + MigrationState::ReleaseVersion1 => migrate_release_v1_to_current(conn)?, MigrationState::AlphaLatest => adopt_latest_alpha_schema(conn)?, MigrationState::Incompatible(reason) => { anyhow::bail!("{reason}. {INCOMPATIBLE_DATABASE_HINT}"); @@ -230,8 +233,17 @@ fn validate_or_normalize_restorable_schema( ) -> anyhow::Result<()> { match migration_state(conn)? { MigrationState::Baseline => validate_schema(conn), + MigrationState::ReleaseVersion1 => { + if !normalize { + anyhow::bail!( + "database schema version {PREVIOUS_RELEASE_SCHEMA_VERSION} requires migration before use" + ); + } + migrate_release_v1_to_current(conn)?; + validate_schema(conn) + } MigrationState::AlphaLatest => { - let issues = inspect_schema_objects(conn)?; + let issues = inspect_alpha_schema_objects(conn)?; if !issues.is_empty() { anyhow::bail!( "pre-release database is not structurally compatible with the release baseline: {}. {}", @@ -240,7 +252,8 @@ fn validate_or_normalize_restorable_schema( ); } if normalize { - reset_schema_migrations(conn)?; + reset_schema_migrations_to(conn, PREVIOUS_RELEASE_SCHEMA_VERSION)?; + migrate_release_v1_to_current(conn)?; validate_schema(conn)?; } Ok(()) @@ -289,6 +302,9 @@ fn migration_state(conn: &Connection) -> anyhow::Result { if versions == [CURRENT_SCHEMA_VERSION] { return Ok(MigrationState::Baseline); } + if versions == [PREVIOUS_RELEASE_SCHEMA_VERSION] { + return Ok(MigrationState::ReleaseVersion1); + } if latest == OLD_ALPHA_SCHEMA_VERSION { return Ok(MigrationState::AlphaLatest); } @@ -314,7 +330,7 @@ pub(crate) fn install_release_baseline_for_test(conn: &Connection) -> anyhow::Re } fn adopt_latest_alpha_schema(conn: &Connection) -> anyhow::Result<()> { - let issues = inspect_schema_objects(conn)?; + let issues = inspect_alpha_schema_objects(conn)?; if !issues.is_empty() { anyhow::bail!( "pre-release database is not safe to adopt as release baseline: {}. {}", @@ -322,10 +338,15 @@ fn adopt_latest_alpha_schema(conn: &Connection) -> anyhow::Result<()> { INCOMPATIBLE_DATABASE_HINT ); } - reset_schema_migrations(conn) + reset_schema_migrations_to(conn, PREVIOUS_RELEASE_SCHEMA_VERSION)?; + migrate_release_v1_to_current(conn) } fn reset_schema_migrations(conn: &Connection) -> anyhow::Result<()> { + reset_schema_migrations_to(conn, CURRENT_SCHEMA_VERSION) +} + +fn reset_schema_migrations_to(conn: &Connection, version: i64) -> anyhow::Result<()> { conn.execute_batch( r#" DROP TABLE IF EXISTS schema_migrations; @@ -333,12 +354,20 @@ CREATE TABLE schema_migrations ( version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); -INSERT INTO schema_migrations (version) VALUES (1); "#, )?; + conn.execute( + "INSERT INTO schema_migrations (version) VALUES (?)", + [version], + )?; Ok(()) } +fn migrate_release_v1_to_current(conn: &Connection) -> anyhow::Result<()> { + conn.execute_batch(YOUTUBE_EMBEDS_SCHEMA)?; + reset_schema_migrations(conn) +} + fn inspect_schema(conn: &Connection) -> anyhow::Result { let mut issues = Vec::new(); let mut version = None; @@ -391,6 +420,20 @@ fn inspect_schema_objects(conn: &Connection) -> anyhow::Result> { Ok(issues) } +fn inspect_alpha_schema_objects(conn: &Connection) -> anyhow::Result> { + Ok(inspect_schema_objects(conn)? + .into_iter() + .filter(|issue| { + !matches!( + issue.as_str(), + "missing table post_embeds" + | "missing index idx_post_embeds_post" + | "missing index idx_post_embeds_video" + ) + }) + .collect()) +} + fn check_table_columns( conn: &Connection, table: &str, @@ -590,13 +633,35 @@ fn summarize_issues(issues: &[String]) -> String { summary } +const YOUTUBE_EMBEDS_SCHEMA: &str = r#" +CREATE TABLE IF NOT EXISTS post_embeds ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE, + provider TEXT NOT NULL CHECK (provider = 'youtube'), + video_id TEXT NOT NULL, + original_url TEXT NOT NULL, + canonical_url TEXT NOT NULL, + title TEXT, + thumbnail_url TEXT NOT NULL, + embed_url TEXT NOT NULL, + position INTEGER NOT NULL, + fetched_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(post_id, provider, video_id), + UNIQUE(post_id, position) +); + +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); +"#; + 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 (1); +INSERT INTO schema_migrations (version) VALUES (2); CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -678,6 +743,23 @@ CREATE TABLE post_media ( PRIMARY KEY (post_id, media_id) ); +CREATE TABLE post_embeds ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE, + provider TEXT NOT NULL CHECK (provider = 'youtube'), + video_id TEXT NOT NULL, + original_url TEXT NOT NULL, + canonical_url TEXT NOT NULL, + title TEXT, + thumbnail_url TEXT NOT NULL, + embed_url TEXT NOT NULL, + position INTEGER NOT NULL, + fetched_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(post_id, provider, video_id), + UNIQUE(post_id, position) +); + CREATE TABLE reposts ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -789,6 +871,8 @@ CREATE INDEX idx_reposts_post ON reposts(post_id, user_id); CREATE INDEX idx_likes_post ON likes(post_id, user_id); CREATE INDEX idx_bookmarks_post ON bookmarks(post_id, user_id); CREATE INDEX idx_rate_limit_scope_actor_created ON rate_limit_events(scope, actor, created_at); +CREATE INDEX idx_post_embeds_post ON post_embeds(post_id, position); +CREATE INDEX idx_post_embeds_video ON post_embeds(provider, video_id); CREATE TABLE muted_words ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -873,6 +957,7 @@ const REQUIRED_TABLES: &[&str] = &[ "posts", "media", "post_media", + "post_embeds", "reposts", "follows", "blocks", @@ -952,6 +1037,16 @@ const REQUIRED_INDEXES: &[RequiredIndex] = &[ name: "idx_rate_limit_scope_actor_created", unique: false, }, + RequiredIndex { + table: "post_embeds", + name: "idx_post_embeds_post", + unique: false, + }, + RequiredIndex { + table: "post_embeds", + name: "idx_post_embeds_video", + unique: false, + }, RequiredIndex { table: "muted_words", name: "idx_muted_words_user", @@ -1561,6 +1656,102 @@ const REQUIRED_COLUMNS: &[RequiredColumn] = &[ default_value: None, primary_key_position: 0, }, + RequiredColumn { + table: "post_embeds", + name: "id", + type_name: "INTEGER", + not_null: false, + default_value: None, + primary_key_position: 1, + }, + RequiredColumn { + table: "post_embeds", + name: "post_id", + type_name: "INTEGER", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "post_embeds", + name: "provider", + type_name: "TEXT", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "post_embeds", + name: "video_id", + type_name: "TEXT", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "post_embeds", + name: "original_url", + type_name: "TEXT", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "post_embeds", + name: "canonical_url", + type_name: "TEXT", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "post_embeds", + name: "title", + type_name: "TEXT", + not_null: false, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "post_embeds", + name: "thumbnail_url", + type_name: "TEXT", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "post_embeds", + name: "embed_url", + type_name: "TEXT", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "post_embeds", + name: "position", + type_name: "INTEGER", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "post_embeds", + name: "fetched_at", + type_name: "TEXT", + not_null: false, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "post_embeds", + name: "created_at", + type_name: "TEXT", + not_null: true, + default_value: Some("CURRENT_TIMESTAMP"), + primary_key_position: 0, + }, RequiredColumn { table: "reposts", name: "id", @@ -2126,6 +2317,39 @@ mod tests { assert_eq!(count, 0); } + #[tokio::test] + async fn release_version_1_databases_migrate_to_post_embeds() { + 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("DROP TABLE post_embeds", [])?; + reset_schema_migrations_to(conn, PREVIOUS_RELEASE_SCHEMA_VERSION)?; + Ok(()) + }) + .await + .expect("version 1 fixture"); + + migrate(&pool).await.expect("migrate version 1"); + + let (embed_count, version): (i64, i64) = pool + .call(|conn| { + Ok(( + conn.query_row("SELECT COUNT(*) FROM post_embeds", [], |row| row.get(0))?, + 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] async fn users_get_empty_location_by_default() { let temp = tempfile::tempdir().expect("temp dir"); @@ -2262,6 +2486,7 @@ mod tests { "posts", "media", "post_media", + "post_embeds", "reposts", "follows", "blocks", @@ -2300,7 +2525,7 @@ mod tests { assert_eq!(state.versions, [CURRENT_SCHEMA_VERSION]); assert_eq!( state.counts, - [2, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + [2, 1, 2, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] ); assert_eq!(state.privacy, (0, 0)); assert_eq!(state.media, (1, "/uploads/originals/alpha.png".to_owned())); @@ -2424,7 +2649,7 @@ 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 1")); + assert!(summary.contains("release baseline schema version 2")); assert!(!summary.contains("version 13")); assert_eq!(version, OLD_ALPHA_SCHEMA_VERSION); } diff --git a/src/lib.rs b/src/lib.rs index 73ce77f..30c1567 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -39,3 +39,4 @@ pub mod social; pub mod terminal; pub mod tor; pub mod validation; +pub mod youtube; diff --git a/src/render.rs b/src/render.rs index f2bdf55..11486de 100644 --- a/src/render.rs +++ b/src/render.rs @@ -3,14 +3,10 @@ use crate::social::{ AccountView, MediaView, NotificationGroupView, PostView, ProfileTimelineTab, QuotePreview, TimelineEventKind, }; -use axum::http::{StatusCode, Uri}; +use crate::youtube::{self, YoutubeEmbed}; +use axum::http::StatusCode; use chrono::{DateTime, NaiveDateTime, Utc}; -const YOUTUBE_VIDEO_ID_LEN: usize = 11; -// Keep link-heavy posts compact in timelines while still showing useful context. -const MAX_YOUTUBE_PREVIEWS_PER_POST: usize = 3; -const YOUTUBE_PREVIEW_TITLE: &str = "YouTube video"; - #[derive(Debug, Clone)] pub struct LayoutContext { pub anonymous_mode_enabled: bool, @@ -185,7 +181,7 @@ pub fn layout_with_context( - + @@ -386,7 +382,7 @@ function cardInteractiveTarget(target) { if (!(target instanceof Element)) { return null; } - return target.closest('a,button,input,textarea,select,label,form,[role="button"]'); + return target.closest('a,button,input,textarea,select,label,form,[role="button"],[data-youtube-preview]'); } document.addEventListener("click", (event) => { @@ -413,6 +409,56 @@ document.addEventListener("click", (event) => { window.location.assign(card.getAttribute("data-card-href")); }); +function safeYoutubeEmbedSrc(value) { + try { + const url = new URL(value); + if (url.protocol !== "https:" || url.hostname !== "www.youtube-nocookie.com" || !url.pathname.startsWith("/embed/")) { + return null; + } + url.searchParams.set("autoplay", "1"); + url.searchParams.set("rel", "0"); + return url.toString(); + } catch (_error) { + return null; + } +} + +function activateYoutubePreview(link) { + const card = link.closest("[data-youtube-preview]"); + const player = card ? card.querySelector("[data-youtube-player]") : null; + if (!card || !player || player.querySelector("iframe")) { + return; + } + const src = safeYoutubeEmbedSrc(link.getAttribute("data-youtube-embed-src") || ""); + if (!src) { + return; + } + const iframe = document.createElement("iframe"); + iframe.className = "youtube-iframe"; + iframe.src = src; + iframe.title = link.getAttribute("data-youtube-title") || "YouTube video"; + iframe.loading = "lazy"; + iframe.referrerPolicy = "no-referrer"; + iframe.allow = "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"; + iframe.setAttribute("allowfullscreen", ""); + iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-presentation"); + player.hidden = false; + player.append(iframe); + card.classList.add("youtube-preview-playing"); +} + +document.addEventListener("click", (event) => { + if (!(event.target instanceof Element)) { + return; + } + const link = event.target.closest("[data-youtube-play]"); + if (!link) { + return; + } + event.preventDefault(); + activateYoutubePreview(link); +}); + document.addEventListener("keydown", (event) => { if (!(event.target instanceof Element)) { return; @@ -1932,7 +1978,7 @@ fn post_card_with_options( }, ); let text = linkify(&post.text); - let youtube_previews = render_youtube_previews(&post.text); + let youtube_previews = render_youtube_previews(post); let media = post .media .iter() @@ -2299,15 +2345,14 @@ fn render_media(media: &MediaView, post_id: i64, index: usize, blur_nsfw_media: ) } -#[derive(Debug, Clone, PartialEq, Eq)] -struct YoutubePreview { - video_id: String, - href: String, - display_url: String, -} - -fn render_youtube_previews(text: &str) -> String { - let previews = youtube_previews_for_text(text); +fn render_youtube_previews(post: &PostView) -> String { + let fallback_embeds; + let previews = if post.youtube_embeds.is_empty() { + fallback_embeds = youtube::embeds_for_text(&post.text); + fallback_embeds.as_slice() + } else { + post.youtube_embeds.as_slice() + }; if previews.is_empty() { return String::new(); } @@ -2319,143 +2364,19 @@ fn render_youtube_previews(text: &str) -> String { format!(r#""#) } -fn render_youtube_preview_card(preview: &YoutubePreview) -> String { - let href = html_escape::encode_double_quoted_attribute(&preview.href); - let display_url = html_escape::encode_text(&preview.display_url); - let thumbnail = format!("https://i.ytimg.com/vi/{}/hqdefault.jpg", preview.video_id); +fn render_youtube_preview_card(preview: &YoutubeEmbed) -> String { + let href = html_escape::encode_double_quoted_attribute(&preview.canonical_url); + let display_url = html_escape::encode_text(&preview.source_url); + let thumbnail = html_escape::encode_double_quoted_attribute(&preview.thumbnail_url); + let embed_src = html_escape::encode_double_quoted_attribute(&preview.embed_url); + let title = preview.display_title(); + let title_text = html_escape::encode_text(title); + let title_attr = html_escape::encode_double_quoted_attribute(title); format!( - r#"YouTube{}{display_url}"#, - html_escape::encode_double_quoted_attribute(&thumbnail), - html_escape::encode_text(YOUTUBE_PREVIEW_TITLE), + r#""#, ) } -fn youtube_previews_for_text(text: &str) -> Vec { - let mut previews = Vec::new(); - for word in text.split_whitespace() { - if previews.len() >= MAX_YOUTUBE_PREVIEWS_PER_POST { - break; - } - if let Some(candidate) = youtube_candidate_from_word(word) - && let Some(preview) = youtube_preview_from_url(candidate) - { - previews.push(preview); - } - } - previews -} - -fn youtube_candidate_from_word(word: &str) -> Option<&str> { - let candidate = word - .trim_start_matches(is_url_leading_trim_char) - .trim_end_matches(is_url_trailing_trim_char); - (!candidate.is_empty()).then_some(candidate) -} - -fn is_url_leading_trim_char(ch: char) -> bool { - matches!(ch, '<' | '(' | '[' | '{' | '"' | '\'') -} - -fn is_url_trailing_trim_char(ch: char) -> bool { - matches!( - ch, - '.' | ',' | '!' | '?' | ':' | ';' | ')' | ']' | '}' | '>' | '"' | '\'' - ) -} - -fn youtube_preview_from_url(candidate: &str) -> Option { - let href = youtube_absolute_http_url(candidate)?; - let uri = href.parse::().ok()?; - if !matches!(uri.scheme_str(), Some("http" | "https")) { - return None; - } - let host = uri.host()?.to_ascii_lowercase(); - let video_id = youtube_video_id_from_parts(&host, uri.path(), uri.query())?; - Some(YoutubePreview { - video_id, - href, - display_url: candidate.to_owned(), - }) -} - -fn youtube_absolute_http_url(candidate: &str) -> Option { - if has_http_scheme(candidate) { - return Some(candidate.to_owned()); - } - let (host, _path) = candidate.split_once('/')?; - is_supported_youtube_host(host).then(|| format!("https://{candidate}")) -} - -fn has_http_scheme(candidate: &str) -> bool { - candidate - .get(..7) - .is_some_and(|prefix| prefix.eq_ignore_ascii_case("http://")) - || candidate - .get(..8) - .is_some_and(|prefix| prefix.eq_ignore_ascii_case("https://")) -} - -fn youtube_video_id_from_parts(host: &str, path: &str, query: Option<&str>) -> Option { - if is_youtu_be_host(host) { - return single_path_segment(path).and_then(valid_youtube_video_id); - } - if !is_youtube_host(host) { - return None; - } - if path == "/watch" { - return query.and_then(youtube_video_id_from_query); - } - path_segment_after_prefix(path, "/shorts/") - .or_else(|| path_segment_after_prefix(path, "/embed/")) - .and_then(valid_youtube_video_id) -} - -fn youtube_video_id_from_query(query: &str) -> Option { - let mut video_id = None; - for pair in query.split('&') { - let (key, value) = pair.split_once('=').unwrap_or((pair, "")); - if key != "v" { - continue; - } - let valid = valid_youtube_video_id(value)?; - if video_id.replace(valid).is_some() { - return None; - } - } - video_id -} - -fn single_path_segment(path: &str) -> Option<&str> { - let value = path.strip_prefix('/')?; - (!value.is_empty() && !value.contains('/')).then_some(value) -} - -fn path_segment_after_prefix<'a>(path: &'a str, prefix: &str) -> Option<&'a str> { - let value = path.strip_prefix(prefix)?; - (!value.is_empty() && !value.contains('/')).then_some(value) -} - -fn valid_youtube_video_id(value: &str) -> Option { - (value.len() == YOUTUBE_VIDEO_ID_LEN - && value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))) - .then(|| value.to_owned()) -} - -fn is_supported_youtube_host(host: &str) -> bool { - let host = host.to_ascii_lowercase(); - is_youtube_host(&host) || is_youtu_be_host(&host) -} - -fn is_youtube_host(host: &str) -> bool { - matches!(host, "youtube.com" | "www.youtube.com" | "m.youtube.com") -} - -fn is_youtu_be_host(host: &str) -> bool { - host == "youtu.be" -} - pub fn linkify(text: &str) -> String { html_escape::encode_text(text) .split_whitespace() @@ -2952,7 +2873,7 @@ input:focus,textarea:focus,select:focus,button:focus-visible,a:focus-visible{out 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} .thread-nav{display:flex;margin:0 0 .45rem .85rem}.thread-back{width:2rem;height:2rem;display:inline-flex;align-items:center;justify-content:center;border-radius:999px;color:var(--link-strong)}.thread-back svg{width:1.2rem;height:1.2rem;fill:currentColor}.thread-back:hover{background:var(--hover);text-decoration:none} -.timeline{display:grid;gap:.65rem}.post{overflow:hidden;position:relative}.js-enabled .post[data-card-href]{cursor:pointer}.js-enabled .post[data-card-href]:hover{border-color:var(--border-strong)}.reply-post{margin-left:1.1rem;border-left:4px solid var(--reply-border);background:var(--surface-subtle)}.reply-post::before{content:"";position:absolute;left:-1.1rem;top:1.25rem;width:1.1rem;border-top:2px solid var(--reply-border)}.anchor-target{position:absolute;top:-5rem}.post-header{display:flex;justify-content:space-between;gap:.65rem;align-items:flex-start}.author-block{display:flex;gap:.55rem;align-items:center;min-width:0}.post-avatar{width:2rem;height:2rem;object-fit:cover;border-radius:999px;border:1px solid var(--border);background:var(--avatar-bg);flex:0 0 auto;margin:0}.post-avatar.placeholder{display:inline-grid;place-items:center;color:var(--muted-strong);font-weight:800}.author-name{font-weight:800;color:var(--text-strong)}.username,.post-time,.counts{color:var(--muted);font-size:.92rem}.text{white-space:pre-wrap;margin:.55rem 0;line-height:1.5;overflow-wrap:anywhere}.post img,.post video{display:block;max-width:100%;border-radius:8px;border:1px solid var(--border);margin-top:.5rem;background:var(--media-bg)}.post img.post-avatar{display:block;margin:0;border-radius:999px}.youtube-previews{display:grid;gap:.45rem;margin:.35rem 0 .55rem}.youtube-preview-card{display:grid;grid-template-columns:minmax(5.5rem,7.5rem) minmax(0,1fr);gap:.65rem;align-items:center;min-height:4.5rem;border:1px solid var(--border);border-radius:7px;background:var(--surface-subtle);color:var(--text);overflow:hidden}.youtube-preview-card:hover{border-color:var(--border-strong);background:var(--hover);text-decoration:none}.youtube-thumbnail-frame{position:relative;display:block;width:100%;aspect-ratio:16/9;overflow:hidden;background:var(--media-bg)}.post img.youtube-thumbnail{width:100%;height:100%;object-fit:cover;margin:0;border:0;border-radius:0}.youtube-play{position:absolute;left:50%;top:50%;width:2rem;height:2rem;border-radius:999px;background:rgba(0,0,0,.68);transform:translate(-50%,-50%)}.youtube-play::before{content:"";position:absolute;left:.78rem;top:.55rem;border-top:.45rem solid transparent;border-bottom:.45rem solid transparent;border-left:.65rem solid #fff}.youtube-preview-body{display:grid;gap:.08rem;min-width:0;padding:.45rem .55rem .45rem 0}.youtube-preview-source{color:var(--muted);font-size:.78rem;font-weight:900;text-transform:uppercase}.youtube-preview-title{color:var(--text-strong);font-weight:850;overflow-wrap:anywhere}.youtube-preview-url{color:var(--muted-strong);font-size:.86rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.nsfw-media{position:relative;margin-top:.5rem}.nsfw-media .post img,.nsfw-media .post video{margin-top:0}.nsfw-media-frame{position:relative;display:block;overflow:hidden;border-radius:8px}.nsfw-media-frame img,.nsfw-media-frame video{margin-top:0;filter:blur(24px);transform:scale(1.02)}.nsfw-toggle:checked+.nsfw-media-frame img,.nsfw-toggle:checked+.nsfw-media-frame video{filter:none;transform:none}.nsfw-badge{position:absolute;left:.55rem;bottom:.55rem;border-radius:999px;padding:.18rem .5rem;background:rgba(0,0,0,.72);color:#fff;font-size:.78rem;font-weight:900;letter-spacing:.03em}.nsfw-show{position:absolute;right:.55rem;bottom:.55rem;margin:0;border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);color:var(--text-strong);padding:.32rem .65rem;font-weight:900;box-shadow:0 1px 2px var(--shadow);cursor:pointer}.nsfw-show:hover{background:var(--hover)}.nsfw-toggle:focus-visible~.nsfw-show{outline:3px solid var(--focus);outline-offset:2px}.nsfw-toggle:checked~.nsfw-show,.nsfw-toggle:checked+.nsfw-media-frame .nsfw-badge{display:none} +.timeline{display:grid;gap:.65rem}.post{overflow:hidden;position:relative}.js-enabled .post[data-card-href]{cursor:pointer}.js-enabled .post[data-card-href]:hover{border-color:var(--border-strong)}.reply-post{margin-left:1.1rem;border-left:4px solid var(--reply-border);background:var(--surface-subtle)}.reply-post::before{content:"";position:absolute;left:-1.1rem;top:1.25rem;width:1.1rem;border-top:2px solid var(--reply-border)}.anchor-target{position:absolute;top:-5rem}.post-header{display:flex;justify-content:space-between;gap:.65rem;align-items:flex-start}.author-block{display:flex;gap:.55rem;align-items:center;min-width:0}.post-avatar{width:2rem;height:2rem;object-fit:cover;border-radius:999px;border:1px solid var(--border);background:var(--avatar-bg);flex:0 0 auto;margin:0}.post-avatar.placeholder{display:inline-grid;place-items:center;color:var(--muted-strong);font-weight:800}.author-name{font-weight:800;color:var(--text-strong)}.username,.post-time,.counts{color:var(--muted);font-size:.92rem}.text{white-space:pre-wrap;margin:.55rem 0;line-height:1.5;overflow-wrap:anywhere}.post img,.post video{display:block;max-width:100%;border-radius:8px;border:1px solid var(--border);margin-top:.5rem;background:var(--media-bg)}.post img.post-avatar{display:block;margin:0;border-radius:999px}.youtube-previews{display:grid;gap:.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:.65rem 0;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)} .profile-banner{width:100%;max-height:220px;object-fit:cover;border-radius:8px;border:1px solid var(--border);background:var(--surface-muted)}.profile-heading{display:flex;gap:1rem;align-items:flex-start;margin-top:.85rem}.profile-main{min-width:0;flex:1}.profile-picture{width:88px;height:88px;object-fit:cover;border-radius:999px;border:3px solid var(--surface);background:var(--avatar-bg);flex:0 0 auto}.profile-meta{color:var(--muted-strong);margin:.45rem 0 0}.settings-profile-editor{padding:0;overflow:hidden}.settings-editor-bar{display:flex;justify-content:space-between;align-items:center;gap:1rem;padding:.85rem;border-bottom:1px solid var(--border)}.settings-editor-bar h1{margin:0}.settings-editor-bar .primary{flex:0 0 auto}.settings-profile-form{padding:.85rem;display:grid;gap:1rem}.settings-section{display:grid;gap:.75rem;min-width:0}.settings-section+.settings-section{border-top:1px solid var(--border);padding-top:1rem}.settings-section-heading{display:grid;gap:.18rem}.settings-section-heading h2,.settings-list-panel h2,.settings-security-panel h2,.danger-panel h2{margin:0;font-size:1.05rem;line-height:1.25;color:var(--text-strong)}.settings-section-help,.settings-switch-help{margin:0;color:var(--muted-strong);font-size:.9rem;line-height:1.35;overflow-wrap:anywhere}.settings-profile-fields{gap:.28rem}.settings-profile-fields label,.settings-password-form label{margin:.55rem 0 .28rem}.settings-profile-media{display:grid;gap:.65rem;min-width:0}.settings-banner-wrap{background:var(--media-bg);border-radius:8px;overflow:hidden}.settings-banner-preview{display:block;width:100%;height:170px;object-fit:cover;object-position:left center;background:linear-gradient(135deg,var(--surface-muted),var(--hover));border:1px solid var(--border);border-radius:8px}.settings-banner-preview.placeholder::before{content:"";display:block;width:100%;height:100%}.settings-picture-row{display:grid;grid-template-columns:auto minmax(0,1fr);gap:1rem;align-items:end;margin-top:-42px;padding:0 .75rem .2rem}.settings-picture-preview{width:104px;height:104px;object-fit:cover;border-radius:999px;border:5px solid var(--surface);background:var(--avatar-bg);box-shadow:0 1px 4px var(--shadow)}.settings-picture-preview.placeholder{display:block}.settings-media-controls{display:grid;gap:.45rem;align-content:end;padding-top:2.85rem;min-width:0}.media-control-row{display:flex;align-items:center;gap:.65rem;flex-wrap:wrap;min-width:0}.media-control-row .file-control{display:flex;align-items:center;gap:.35rem;flex-wrap:wrap;margin:0;max-width:100%;overflow-wrap:anywhere}.media-control-row .file-control input[type=file]{max-width:100%}.media-control-row .check-row{margin:0}.settings-switch-list{display:grid;gap:0;border:1px solid var(--border);border-radius:8px;background:var(--surface-subtle);overflow:hidden}.settings-switch-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.85rem;align-items:center;margin:0;padding:.75rem .8rem;color:var(--text);cursor:pointer}.settings-switch-row+.settings-switch-row{border-top:1px solid var(--border)}.settings-switch-copy{display:grid;gap:.12rem;min-width:0}.settings-switch-label{font-weight:800;color:var(--text-strong);overflow-wrap:anywhere}.settings-switch-toggle{position:relative;display:inline-grid;width:2.75rem;height:1.55rem;justify-self:end;flex:0 0 auto}.settings-switch-input{position:absolute;inset:0;z-index:1;width:100%;height:100%;padding:0;margin:0;opacity:0;cursor:pointer}.settings-switch-control{position:relative;display:block;width:2.75rem;height:1.55rem;border:1px solid var(--border-strong);border-radius:999px;background:var(--surface-muted);box-shadow:inset 0 0 0 1px var(--shadow);transition:background-color .15s ease,border-color .15s ease}.settings-switch-control::before{content:"";position:absolute;left:.16rem;top:50%;width:1.15rem;height:1.15rem;border-radius:999px;background:var(--surface);box-shadow:0 1px 3px var(--shadow);transform:translateY(-50%);transition:transform .15s ease}.settings-switch-input:checked+.settings-switch-control{border-color:var(--brand);background:var(--brand)}.settings-switch-input:checked+.settings-switch-control::before{transform:translate(1.2rem,-50%)}.settings-switch-input:focus-visible+.settings-switch-control{outline:3px solid var(--focus);outline-offset:2px}.settings-form-actions{display:flex;justify-content:flex-end;gap:.5rem;border-top:1px solid var(--border);padding-top:.85rem}.onboarding-panel{overflow:hidden}.onboarding-form{display:grid;gap:.9rem}.onboarding-media-row{display:grid;grid-template-columns:auto minmax(0,1fr);gap:1rem;align-items:center}.onboarding-suggestions{border:1px solid var(--border);border-radius:8px;padding:.75rem;display:grid;gap:.55rem}.onboarding-suggestions legend{font-weight:800;padding:0 .35rem}.onboarding-suggestion{display:grid;grid-template-columns:auto auto minmax(0,1fr);gap:.6rem;align-items:center;margin:0;border:1px solid var(--border);border-radius:7px;padding:.55rem;background:var(--surface-subtle);cursor:pointer}.onboarding-suggestion input{margin:0}.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.7rem}.deep-settings-panel{padding:0;overflow:hidden}.deep-settings-form{padding:.85rem;display:grid;gap:.85rem}.deep-settings-group{border:1px solid var(--border);border-radius:8px;padding:.8rem;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.7rem}.deep-settings-group legend{font-weight:800;padding:0 .35rem}.deep-settings-field{display:grid;gap:.25rem;align-content:start}.deep-settings-field label{font-weight:800}.deep-settings-field input,.deep-settings-field select{min-width:0}.field-help{font-size:.88rem;margin:.05rem 0 .35rem;color:var(--muted-strong)}.deep-settings-confirm .settings-item-list li{display:block}.compact-panel h2,.danger-panel h2{margin:0;font-size:1.05rem}.inline-settings-form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.55rem;align-items:center;margin:.65rem 0 .75rem}.inline-settings-form input{min-width:0}.settings-password-form{display:grid;gap:0;margin-top:.65rem}.settings-password-form button[type=submit]{margin-top:0}.settings-list-panel,.settings-security-panel{display:grid;gap:.55rem}.settings-item-list{list-style:none;margin:.25rem 0 0;padding:0;display:grid;gap:.45rem}.settings-item-list li{display:flex;justify-content:space-between;align-items:center;gap:.75rem;border:1px solid var(--border);border-radius:7px;padding:.55rem .65rem;background:var(--surface-subtle)}.settings-item-list li>span{min-width:0;overflow-wrap:anywhere}.settings-item-list form{flex:0 0 auto}.settings-item-list button{padding:.32rem .55rem;background:var(--surface);color:var(--link-strong);border-color:var(--border)}.compact-empty{border:1px dashed var(--border);border-radius:7px;padding:.75rem;background:var(--surface-subtle);color:var(--muted-strong)}.compact-empty p{margin:.25rem 0 0}.danger-panel{border-color:var(--danger-border);background:var(--danger-bg)}.danger,.danger-link{border-color:var(--danger-border);background:var(--danger);color:var(--brand-text)}.danger:hover,.danger-link:hover{background:var(--danger-strong);color:var(--brand-text);text-decoration:none}.delete-account-panel p,.danger-panel p{max-width:62ch}.settings-danger-action{margin:.7rem 0 0}.favicon-preview{width:32px;height:32px;object-fit:contain;border:1px solid var(--border);border-radius:6px;background:var(--surface)}.admin-user-search{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto;gap:.65rem;align-items:end;margin-top:.75rem}.admin-user-search label{margin-top:0}.admin-user-search-actions{display:flex;gap:.4rem;align-items:center;margin-bottom:.05rem}.admin-user-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.75rem;border:1px solid var(--border);border-radius:8px;padding:.75rem;margin-top:.65rem;background:var(--surface-subtle)}.admin-user-heading{overflow-wrap:anywhere}.admin-user-statuses,.admin-user-matches{display:flex;flex-wrap:wrap;gap:.35rem;margin-top:.45rem}.admin-user-pill,.admin-user-match{display:inline-flex;align-items:center;min-height:1.55rem;border:1px solid var(--border);border-radius:999px;padding:.15rem .5rem;background:var(--surface);font-size:.82rem;font-weight:800;color:var(--muted-strong)}.admin-user-match{border-color:var(--success-border);background:var(--success-bg);color:var(--text)}.admin-user-meta{margin:.6rem 0 0}.admin-post-preview{margin:.55rem 0 0;color:var(--muted-strong);overflow-wrap:anywhere}.admin-user-actions{display:flex;align-items:flex-start}.admin-users-empty{margin-top:.75rem}.account-list{display:grid;gap:.65rem}.account-row{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:.75rem;align-items:center;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:.85rem}.account-row p{margin:.3rem 0 0;color:var(--muted-strong);overflow-wrap:anywhere}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:.85rem}.item-list{margin:.75rem 0 0;padding-left:1.2rem}.item-list li{margin:.45rem 0}.panel dl:not(.dashboard-list){display:grid;grid-template-columns:max-content minmax(0,1fr);gap:.45rem .85rem}.panel dl:not(.dashboard-list) dt{font-weight:800}.panel dl:not(.dashboard-list) dd{margin:0;overflow-wrap:anywhere}table{width:100%;border-collapse:collapse}td,th{border-bottom:1px solid var(--border);text-align:left;padding:.55rem;vertical-align:top}pre{white-space:pre-wrap;overflow:auto;max-width:100%} .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} @@ -3543,50 +3464,6 @@ mod tests { assert!(body.contains(r#"alert(1)")); - assert!(body.contains(r#"href="https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1""#)); + assert!(body.contains(r#"href="https://www.youtube.com/watch?v=dQw4w9WgXcQ""#)); + assert!(body.contains( + r#"data-youtube-embed-src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ""# + )); assert!(body.contains( r#"https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1"# )); } + #[test] + fn youtube_preview_uses_stored_title_metadata_when_available() { + let mut post = test_post(); + post.text = "stored metadata wins https://youtu.be/aaaaaaaaaaa".to_owned(); + post.youtube_embeds = vec![ + youtube::embed_from_stored("dQw4w9WgXcQ", Some("Fetched ".to_owned())) + .expect("stored embed"), + ]; + + let body = post_card(&post, None, None); + + assert!( + body.contains(r#"<span class="youtube-preview-title">Fetched <Title></span>"#) + ); + assert!(body.contains(r#"data-youtube-title="Fetched <Title>""#)); + assert!(body.contains("https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg")); + assert!(!body.contains("https://i.ytimg.com/vi/aaaaaaaaaaa/hqdefault.jpg")); + assert!(!body.contains("<iframe")); + } + #[test] fn posts_without_youtube_links_do_not_render_preview_cards() { let mut post = test_post(); @@ -3643,21 +3543,25 @@ mod tests { let body = post_card(&post, None, None); assert!(body.contains( - r#"<a class="youtube-preview-card" data-testid="youtube-preview-card" href="https://youtu.be/dQw4w9WgXcQ""# + r#"<div class="youtube-preview-card" data-testid="youtube-preview-card" data-youtube-preview><a class="youtube-preview-main" data-youtube-play href="https://www.youtube.com/watch?v=dQw4w9WgXcQ""# )); assert!(body.contains( r#"<img class="youtube-thumbnail" src="https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg" alt="" loading="lazy" decoding="async" referrerpolicy="no-referrer">"# )); assert!(body.contains(r#"<span class="youtube-preview-title">YouTube video</span>"#)); + assert!(body.contains( + r#"<a class="youtube-open-link" href="https://www.youtube.com/watch?v=dQw4w9WgXcQ""# + )); assert!(!body.contains("<iframe")); assert!(!body.contains("<script")); } #[test] - fn layout_allows_only_static_youtube_thumbnail_origin() { + fn layout_allows_youtube_thumbnail_and_nocookie_frame_origins() { let body = layout(None, "Home Feed", "<p>body</p>", "RustPost"); assert!(body.contains("img-src 'self' data: https://i.ytimg.com")); + assert!(body.contains("frame-src https://www.youtube-nocookie.com")); } fn test_post() -> PostView { @@ -3690,6 +3594,7 @@ mod tests { reposted_at: None, quote: None, media: Vec::new(), + youtube_embeds: Vec::new(), } } } diff --git a/src/social.rs b/src/social.rs index 567717a..be711e6 100644 --- a/src/social.rs +++ b/src/social.rs @@ -1,10 +1,11 @@ -use std::collections::BTreeSet; +use std::collections::{BTreeSet, HashMap}; use rusqlite::{Connection, OptionalExtension as _, Row, params, params_from_iter}; use crate::config::Settings; use crate::db::SqlitePool; use crate::validation::clean_post_text; +use crate::youtube::YoutubeEmbed; #[derive(Debug, Clone)] pub struct AccountView { @@ -46,6 +47,7 @@ pub struct PostView { pub reposted_at: Option<String>, pub quote: Option<QuotePreview>, pub media: Vec<MediaView>, + pub youtube_embeds: Vec<YoutubeEmbed>, } #[derive(Debug, Clone)] @@ -240,6 +242,7 @@ pub async fn create_post( anyhow::bail!("too many media attachments"); } 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; let media_ids = media_ids.to_vec(); pool.call(move |conn| { @@ -265,6 +268,7 @@ pub async fn create_post( params![user_id, anonymous_label, text, parent_post_id, root_post_id], )?; let post_id = tx.last_insert_rowid(); + replace_youtube_embeds_tx(&tx, post_id, &youtube_embeds)?; for (position, media_id) in media_ids.iter().enumerate() { tx.execute( "INSERT INTO post_media (post_id, media_id, position) VALUES (?, ?, ?)", @@ -302,24 +306,24 @@ pub async fn edit_post( let max_text_chars = settings.posts.max_text_chars; let edit_window_modifier = edit_window_modifier(settings.posts.post_edit_window_seconds); let raw_text = text.to_owned(); - pool.call(move |conn| { - let result: Result<bool, EditPostError> = (|| { - let tx = conn.transaction()?; - let row = tx + let edit_window_for_check = edit_window_modifier.clone(); + let row = pool + .call(move |conn| { + let row = conn .query_row( r#" - SELECT p.user_id, p.text, - CASE - WHEN ? IS NULL THEN 0 - ELSE p.created_at >= datetime('now', ?) - END AS within_window, - (SELECT COUNT(*) FROM post_media WHERE post_id = p.id) AS media_count - FROM posts p - WHERE p.id = ? AND p.is_deleted = 0 - "#, + SELECT p.user_id, p.text, + CASE + WHEN ? IS NULL THEN 0 + ELSE p.created_at >= datetime('now', ?) + END AS within_window, + (SELECT COUNT(*) FROM post_media WHERE post_id = p.id) AS media_count + FROM posts p + WHERE p.id = ? AND p.is_deleted = 0 + "#, params![ - edit_window_modifier.clone(), - edit_window_modifier, + edit_window_for_check.clone(), + edit_window_for_check, post_id ], |row| { @@ -332,30 +336,63 @@ pub async fn edit_post( }, ) .optional()?; - let Some((owner, current_text, within_window, media_count)) = row else { - return Err(EditPostError::NotFound); + let existing_youtube_embeds = if row.is_some() { + youtube_embeds_for_post_conn(conn, post_id)? + } else { + Vec::new() }; - if owner != Some(user_id) { - return Err(EditPostError::Forbidden); - } - if !within_window { - return Err(EditPostError::WindowExpired); - } - let media_count = usize::try_from(media_count).unwrap_or(usize::MAX); - let text = clean_post_text(&raw_text, max_text_chars, media_count) - .map_err(|err| EditPostError::Validation(err.to_string()))?; - if text == current_text { - tx.commit()?; - return Ok(false); - } - tx.execute( - "UPDATE posts SET text = ?, edited_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ? AND is_deleted = 0", - params![text, post_id, user_id], - )?; + Ok(row.map(|(owner, text, within_window, media_count)| { + ( + owner, + text, + within_window, + media_count, + existing_youtube_embeds, + ) + })) + }) + .await + .map_err(|err| EditPostError::Database(err.to_string()))?; + let Some((owner, current_text, within_window, media_count, existing_youtube_embeds)) = row + else { + return Err(EditPostError::NotFound); + }; + if owner != Some(user_id) { + return Err(EditPostError::Forbidden); + } + if !within_window { + return Err(EditPostError::WindowExpired); + } + let media_count = usize::try_from(media_count).unwrap_or(usize::MAX); + let text = clean_post_text(&raw_text, max_text_chars, media_count) + .map_err(|err| EditPostError::Validation(err.to_string()))?; + if text == current_text { + return Ok(false); + } + let mut youtube_embeds = crate::youtube::embeds_for_text(&text); + reuse_stored_youtube_metadata(&mut youtube_embeds, &existing_youtube_embeds); + let youtube_embeds = crate::youtube::metadata_for_embeds(youtube_embeds).await; + pool.call(move |conn| { + let tx = conn.transaction()?; + let Some(edit_window_modifier) = edit_window_modifier else { tx.commit()?; - Ok(true) - })(); - Ok(result) + return Ok(Err(EditPostError::WindowExpired)); + }; + let changed = tx.execute( + r#" + UPDATE posts + SET text = ?, edited_at = CURRENT_TIMESTAMP + WHERE id = ? AND user_id = ? AND is_deleted = 0 AND created_at >= datetime('now', ?) + "#, + params![text, post_id, user_id, edit_window_modifier], + )?; + if changed == 0 { + tx.commit()?; + return Ok(Err(EditPostError::WindowExpired)); + } + replace_youtube_embeds_tx(&tx, post_id, &youtube_embeds)?; + tx.commit()?; + Ok(Ok(true)) }) .await .map_err(|err| EditPostError::Database(err.to_string()))? @@ -547,6 +584,34 @@ pub async fn create_quote_post( text: &str, ) -> anyhow::Result<QuotePostOutcome> { let text = clean_post_text(text, settings.posts.max_text_chars, 0)?; + let duplicate_text = text.clone(); + let existing_post_id = pool + .call(move |conn| { + let tx = conn.transaction()?; + ensure_quote_target_accessible_tx(&tx, user_id, quote_post_id)?; + let existing_post_id = tx + .query_row( + r#" + SELECT id FROM posts + WHERE user_id = ? AND quote_post_id = ? AND text = ? AND is_deleted = 0 + ORDER BY id DESC LIMIT 1 + "#, + params![user_id, quote_post_id, duplicate_text], + |row| row.get(0), + ) + .optional()?; + tx.commit()?; + Ok(existing_post_id) + }) + .await?; + if let Some(post_id) = existing_post_id { + return Ok(QuotePostOutcome { + post_id, + created: false, + }); + } + + let youtube_embeds = crate::youtube::metadata_for_text(&text).await; let allow_mentions = settings.posts.allow_mentions; pool.call(move |conn| { let tx = conn.transaction()?; @@ -569,6 +634,7 @@ pub async fn create_quote_post( )? }; if changed > 0 { + replace_youtube_embeds_tx(&tx, post_id, &youtube_embeds)?; let quote_owner = notify_post_owner_tx( &tx, quote_post_id, @@ -1960,7 +2026,7 @@ async fn repost_events( fn media_surface_condition(post_id_column: &str, text_column: &str) -> String { format!( - "(EXISTS (SELECT 1 FROM post_media pm WHERE pm.post_id = {post_id_column}) OR instr(lower({text_column}), 'youtube.com/') > 0 OR instr(lower({text_column}), 'youtu.be/') > 0)" + "(EXISTS (SELECT 1 FROM post_media pm WHERE pm.post_id = {post_id_column}) OR EXISTS (SELECT 1 FROM post_embeds pe WHERE pe.post_id = {post_id_column} AND pe.provider = 'youtube') OR instr(lower({text_column}), 'youtube.com/') > 0 OR instr(lower({text_column}), 'youtu.be/') > 0 OR instr(lower({text_column}), 'youtube-nocookie.com/') > 0)" ) } @@ -2038,6 +2104,11 @@ async fn rows_to_posts( } 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 { @@ -2094,6 +2165,7 @@ async fn rows_to_posts( reposted_at: row.repost_created_at, quote, media, + youtube_embeds, }); } Ok(posts) @@ -2272,6 +2344,95 @@ async fn media_for_post(pool: &SqlitePool, post_id: i64) -> anyhow::Result<Vec<M .await } +async fn youtube_embeds_for_post( + pool: &SqlitePool, + post_id: i64, +) -> anyhow::Result<Vec<YoutubeEmbed>> { + 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, +) -> rusqlite::Result<Vec<YoutubeEmbed>> { + let mut stmt = conn.prepare( + r#" + SELECT video_id, title + FROM post_embeds + WHERE post_id = ? AND provider = 'youtube' + ORDER BY position ASC, id ASC + "#, + )?; + let rows = stmt + .query_map([post_id], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?)) + })? + .collect::<Result<Vec<_>, _>>()?; + Ok(rows + .into_iter() + .filter_map(|(video_id, title)| crate::youtube::embed_from_stored(&video_id, title)) + .collect()) +} + +fn reuse_stored_youtube_metadata(embeds: &mut [YoutubeEmbed], stored: &[YoutubeEmbed]) { + let stored_titles = stored + .iter() + .filter_map(|embed| { + embed + .title + .as_deref() + .map(|title| (embed.video_id.as_str(), title)) + }) + .collect::<HashMap<_, _>>(); + for embed in embeds { + if let Some(title) = stored_titles.get(embed.video_id.as_str()) { + embed.title = Some((*title).to_owned()); + } + } +} + +fn replace_youtube_embeds_tx( + tx: &rusqlite::Transaction<'_>, + post_id: i64, + embeds: &[YoutubeEmbed], +) -> anyhow::Result<()> { + tx.execute("DELETE FROM post_embeds WHERE post_id = ?", [post_id])?; + for (position, embed) in embeds.iter().enumerate() { + let position = i64::try_from(position)?; + let title = embed.title.as_deref(); + 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 (?, 'youtube', ?, ?, ?, ?, ?, ?, ?, CASE WHEN ? IS NULL THEN NULL ELSE CURRENT_TIMESTAMP END) + "#, + params![ + post_id, + &embed.video_id, + &embed.source_url, + &embed.canonical_url, + title, + &embed.thumbnail_url, + &embed.embed_url, + position, + title + ], + )?; + } + Ok(()) +} + fn notify_post_owner_tx( tx: &rusqlite::Transaction<'_>, owner_post_id: i64, @@ -2631,6 +2792,237 @@ mod tests { mute(&pool, alice, bob).await.expect("mute"); } + #[tokio::test] + async fn youtube_embeds_are_normalized_and_stored_on_create() { + let (pool, settings, alice, _bob) = fixture().await; + let post = crate::youtube::with_test_oembed_fetcher( + |video_id| { + assert_eq!(video_id, "dQw4w9WgXcQ"); + Some("Fetched <Title>".to_owned()) + }, + create_post( + &pool, + &settings, + Some(alice), + "clip https://youtu.be/dQw4w9WgXcQ?t=12", + None, + &[], + ), + ) + .await + .expect("post"); + + let stored: (String, String, String, String, Option<String>) = pool + .call(move |conn| { + conn.query_row( + r#" + SELECT video_id, canonical_url, thumbnail_url, embed_url, title + FROM post_embeds + WHERE post_id = ? AND provider = 'youtube' + "#, + [post], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + )) + }, + ) + .map_err(Into::into) + }) + .await + .expect("stored embed"); + let timeline_posts = timeline(&pool, Some(alice), "local", None) + .await + .expect("timeline"); + let rendered_post = timeline_posts + .iter() + .find(|timeline_post| timeline_post.id == post) + .expect("post in timeline"); + + assert_eq!(stored.0, "dQw4w9WgXcQ"); + assert_eq!(stored.1, "https://www.youtube.com/watch?v=dQw4w9WgXcQ"); + assert_eq!(stored.2, "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg"); + assert_eq!( + stored.3, + "https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ" + ); + assert_eq!(stored.4.as_deref(), Some("Fetched <Title>")); + assert_eq!(rendered_post.youtube_embeds.len(), 1); + assert_eq!(rendered_post.youtube_embeds[0].video_id, "dQw4w9WgXcQ"); + assert_eq!( + rendered_post.youtube_embeds[0].display_title(), + "Fetched <Title>" + ); + } + + #[tokio::test] + async fn posting_youtube_embed_succeeds_when_metadata_fetch_fails() { + let (pool, settings, alice, _bob) = fixture().await; + let post = crate::youtube::with_test_oembed_fetcher( + |_video_id| None, + create_post( + &pool, + &settings, + Some(alice), + "clip https://youtu.be/dQw4w9WgXcQ", + None, + &[], + ), + ) + .await + .expect("post"); + + let stored_title: Option<String> = pool + .call(move |conn| { + conn.query_row( + "SELECT title FROM post_embeds WHERE post_id = ? AND provider = 'youtube'", + [post], + |row| row.get(0), + ) + .map_err(Into::into) + }) + .await + .expect("stored title"); + let timeline_posts = timeline(&pool, Some(alice), "local", None) + .await + .expect("timeline"); + let rendered_post = timeline_posts + .iter() + .find(|timeline_post| timeline_post.id == post) + .expect("post in timeline"); + + assert_eq!(stored_title, None); + assert_eq!(rendered_post.youtube_embeds.len(), 1); + assert_eq!( + rendered_post.youtube_embeds[0].display_title(), + crate::youtube::FALLBACK_TITLE + ); + } + + #[tokio::test] + async fn invalid_youtube_like_urls_do_not_store_embeds() { + let (pool, settings, alice, _bob) = fixture().await; + let post = create_post( + &pool, + &settings, + Some(alice), + "spoof https://youtube.com.evil/watch?v=dQw4w9WgXcQ", + None, + &[], + ) + .await + .expect("post"); + + let count: i64 = pool + .call(move |conn| { + conn.query_row( + "SELECT COUNT(*) FROM post_embeds WHERE post_id = ?", + [post], + |row| row.get(0), + ) + .map_err(Into::into) + }) + .await + .expect("embed count"); + + assert_eq!(count, 0); + } + + #[tokio::test] + async fn editing_post_replaces_youtube_embed_rows() { + let (pool, settings, alice, _bob) = fixture().await; + let post = crate::youtube::with_test_oembed_fetcher( + |_video_id| None, + create_post( + &pool, + &settings, + Some(alice), + "first https://youtu.be/dQw4w9WgXcQ", + None, + &[], + ), + ) + .await + .expect("post"); + + let changed = crate::youtube::with_test_oembed_fetcher( + |_video_id| None, + edit_post( + &pool, + &settings, + alice, + post, + "second https://www.youtube.com/shorts/aaaaaaaaaaa", + ), + ) + .await + .expect("edit"); + let rows: Vec<String> = pool + .call(move |conn| { + let mut stmt = conn.prepare( + "SELECT video_id FROM post_embeds WHERE post_id = ? ORDER BY position", + )?; + let rows = stmt + .query_map([post], |row| row.get(0))? + .collect::<Result<Vec<_>, _>>()?; + Ok(rows) + }) + .await + .expect("embed rows"); + + assert!(changed); + assert_eq!(rows, vec!["aaaaaaaaaaa".to_owned()]); + } + + #[tokio::test] + async fn editing_post_reuses_stored_youtube_title_for_same_video() { + let (pool, settings, alice, _bob) = fixture().await; + let post = crate::youtube::with_test_oembed_fetcher( + |_video_id| Some("Stored Title".to_owned()), + create_post( + &pool, + &settings, + Some(alice), + "first https://youtu.be/dQw4w9WgXcQ", + None, + &[], + ), + ) + .await + .expect("post"); + + let changed = crate::youtube::with_test_oembed_fetcher( + |_video_id| panic!("metadata should not be refetched for an unchanged video id"), + edit_post( + &pool, + &settings, + alice, + post, + "second https://www.youtube.com/watch?v=dQw4w9WgXcQ", + ), + ) + .await + .expect("edit"); + let title: Option<String> = pool + .call(move |conn| { + conn.query_row( + "SELECT title FROM post_embeds WHERE post_id = ? AND provider = 'youtube'", + [post], + |row| row.get(0), + ) + .map_err(Into::into) + }) + .await + .expect("stored title"); + + assert!(changed); + assert_eq!(title.as_deref(), Some("Stored Title")); + } + #[tokio::test] async fn muted_users_can_be_listed_and_removed() { let (pool, _settings, alice, bob) = fixture().await; @@ -2775,6 +3167,55 @@ mod tests { assert_eq!(original_post.repost_count, 1); } + #[tokio::test] + async fn quote_post_succeeds_when_youtube_metadata_fetch_fails() { + let (pool, settings, alice, bob) = fixture().await; + let original = create_post(&pool, &settings, Some(alice), "original", None, &[]) + .await + .expect("original"); + + let quote = crate::youtube::with_test_oembed_fetcher( + |_video_id| None, + create_quote_post( + &pool, + &settings, + bob, + original, + "context https://youtu.be/dQw4w9WgXcQ", + ), + ) + .await + .expect("quote"); + let duplicate = crate::youtube::with_test_oembed_fetcher( + |_video_id| panic!("duplicate quote should not refetch YouTube metadata"), + create_quote_post( + &pool, + &settings, + bob, + original, + "context https://youtu.be/dQw4w9WgXcQ", + ), + ) + .await + .expect("duplicate quote"); + let stored_title: Option<String> = pool + .call(move |conn| { + conn.query_row( + "SELECT title FROM post_embeds WHERE post_id = ? AND provider = 'youtube'", + [quote.post_id], + |row| row.get(0), + ) + .map_err(Into::into) + }) + .await + .expect("stored title"); + + assert!(quote.created); + assert!(!duplicate.created); + assert_eq!(duplicate.post_id, quote.post_id); + assert_eq!(stored_title, None); + } + #[tokio::test] async fn quote_repost_original_becomes_unavailable_after_delete() { let (pool, settings, alice, bob) = fixture().await; diff --git a/src/youtube.rs b/src/youtube.rs new file mode 100644 index 0000000..33ac265 --- /dev/null +++ b/src/youtube.rs @@ -0,0 +1,430 @@ +use std::{collections::BTreeSet, sync::OnceLock, time::Duration}; + +use axum::http::Uri; +use futures_util::future::join_all; +use serde::Deserialize; + +#[cfg(test)] +use std::{future::Future, sync::Arc}; + +pub const FALLBACK_TITLE: &str = "YouTube video"; +pub const MAX_EMBEDS_PER_POST: usize = 3; + +const VIDEO_ID_LEN: usize = 11; +const MAX_TITLE_CHARS: usize = 180; +const MAX_OEMBED_BYTES: u64 = 16 * 1024; +const OEMBED_USER_AGENT: &str = concat!( + "RustPost/", + env!("CARGO_PKG_VERSION"), + " YouTube metadata fetch" +); +const RESOLVE_TIMEOUT: Duration = Duration::from_millis(500); +const CONNECT_TIMEOUT: Duration = Duration::from_millis(500); +const READ_TIMEOUT: Duration = Duration::from_millis(700); +const FETCH_TIMEOUT: Duration = Duration::from_millis(1_300); + +#[cfg(test)] +type TestOembedFetcher = Arc<dyn Fn(&str) -> Option<String> + Send + Sync>; + +#[cfg(test)] +tokio::task_local! { + static TEST_OEMBED_FETCHER: TestOembedFetcher; +} + +#[cfg(test)] +pub async fn with_test_oembed_fetcher<F, Fut, R>(fetcher: F, future: Fut) -> R +where + F: Fn(&str) -> Option<String> + Send + Sync + 'static, + Fut: Future<Output = R>, +{ + TEST_OEMBED_FETCHER.scope(Arc::new(fetcher), future).await +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct YoutubeEmbed { + pub video_id: String, + pub source_url: String, + pub canonical_url: String, + pub title: Option<String>, + pub thumbnail_url: String, + pub embed_url: String, +} + +impl YoutubeEmbed { + fn new(video_id: String, source_url: String, title: Option<String>) -> Self { + let canonical_url = canonical_watch_url(&video_id); + let thumbnail_url = thumbnail_url(&video_id); + let embed_url = embed_url(&video_id); + Self { + video_id, + source_url, + canonical_url, + title, + thumbnail_url, + embed_url, + } + } + + pub fn display_title(&self) -> &str { + self.title.as_deref().unwrap_or(FALLBACK_TITLE) + } +} + +#[derive(Debug, Deserialize)] +struct OembedResponse { + title: Option<String>, +} + +pub fn embeds_for_text(text: &str) -> Vec<YoutubeEmbed> { + let mut embeds = Vec::new(); + let mut seen_video_ids = BTreeSet::new(); + for word in text.split_whitespace() { + if embeds.len() >= MAX_EMBEDS_PER_POST { + break; + } + let Some(candidate) = youtube_candidate_from_word(word) else { + continue; + }; + let Some(embed) = embed_from_url(candidate) else { + continue; + }; + if seen_video_ids.insert(embed.video_id.clone()) { + embeds.push(embed); + } + } + embeds +} + +pub async fn metadata_for_text(text: &str) -> Vec<YoutubeEmbed> { + metadata_for_embeds(embeds_for_text(text)).await +} + +pub async fn metadata_for_embeds(embeds: Vec<YoutubeEmbed>) -> Vec<YoutubeEmbed> { + join_all(embeds.into_iter().map(fetch_embed_title)).await +} + +pub fn embed_from_stored(video_id: &str, title: Option<String>) -> Option<YoutubeEmbed> { + let video_id = valid_youtube_video_id(video_id)?; + Some(YoutubeEmbed::new( + video_id.clone(), + canonical_watch_url(&video_id), + title.and_then(|value| sanitize_title(&value)), + )) +} + +async fn fetch_embed_title(mut embed: YoutubeEmbed) -> YoutubeEmbed { + if embed.title.is_none() + && let Some(title) = fetch_oembed_title(&embed.video_id).await + { + embed.title = Some(title); + } + embed +} + +async fn fetch_oembed_title(video_id: &str) -> Option<String> { + #[cfg(test)] + if let Ok(title) = TEST_OEMBED_FETCHER.try_with(|fetcher| fetcher(video_id)) { + return title.and_then(|title| sanitize_title(&title)); + } + + let video_id = video_id.to_owned(); + tokio::task::spawn_blocking(move || fetch_oembed_title_blocking(&video_id)) + .await + .ok() + .flatten() +} + +fn fetch_oembed_title_blocking(video_id: &str) -> Option<String> { + let oembed_url = format!( + "https://www.youtube.com/oembed?format=json&url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3D{video_id}" + ); + let mut response = oembed_agent().get(&oembed_url).call().ok()?; + let body = response + .body_mut() + .with_config() + .limit(MAX_OEMBED_BYTES) + .read_to_string() + .ok()?; + parse_oembed_title(&body) +} + +fn oembed_agent() -> ureq::Agent { + static AGENT: OnceLock<ureq::Agent> = OnceLock::new(); + AGENT + .get_or_init(|| { + ureq::Agent::config_builder() + .user_agent(OEMBED_USER_AGENT) + .accept("application/json") + .max_response_header_size(8 * 1024) + .timeout_global(Some(FETCH_TIMEOUT)) + .timeout_resolve(Some(RESOLVE_TIMEOUT)) + .timeout_connect(Some(CONNECT_TIMEOUT)) + .timeout_recv_response(Some(READ_TIMEOUT)) + .timeout_recv_body(Some(READ_TIMEOUT)) + .build() + .into() + }) + .clone() +} + +fn parse_oembed_title(body: &str) -> Option<String> { + let response = serde_json::from_str::<OembedResponse>(body).ok()?; + response.title.and_then(|title| sanitize_title(&title)) +} + +fn embed_from_url(candidate: &str) -> Option<YoutubeEmbed> { + let href = youtube_absolute_http_url(candidate)?; + let uri = href.parse::<Uri>().ok()?; + if !matches!(uri.scheme_str(), Some("http" | "https")) { + return None; + } + let host = uri.host()?.to_ascii_lowercase(); + let video_id = youtube_video_id_from_parts(&host, uri.path(), uri.query())?; + Some(YoutubeEmbed::new(video_id, href, None)) +} + +fn youtube_candidate_from_word(word: &str) -> Option<&str> { + let candidate = word + .trim_start_matches(is_url_leading_trim_char) + .trim_end_matches(is_url_trailing_trim_char); + (!candidate.is_empty()).then_some(candidate) +} + +fn is_url_leading_trim_char(ch: char) -> bool { + matches!(ch, '<' | '(' | '[' | '{' | '"' | '\'') +} + +fn is_url_trailing_trim_char(ch: char) -> bool { + matches!( + ch, + '.' | ',' | '!' | '?' | ':' | ';' | ')' | ']' | '}' | '>' | '"' | '\'' + ) +} + +fn youtube_absolute_http_url(candidate: &str) -> Option<String> { + if has_http_scheme(candidate) { + return Some(candidate.to_owned()); + } + let (host, _path) = candidate.split_once('/')?; + is_supported_youtube_host(host).then(|| format!("https://{candidate}")) +} + +fn has_http_scheme(candidate: &str) -> bool { + candidate + .get(..7) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("http://")) + || candidate + .get(..8) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("https://")) +} + +fn youtube_video_id_from_parts(host: &str, path: &str, query: Option<&str>) -> Option<String> { + if is_youtu_be_host(host) { + return single_path_segment(path).and_then(valid_youtube_video_id); + } + if is_youtube_nocookie_host(host) { + return path_segment_after_prefix(path, "/embed/").and_then(valid_youtube_video_id); + } + if !is_youtube_host(host) { + return None; + } + if path == "/watch" { + return query.and_then(youtube_video_id_from_query); + } + path_segment_after_prefix(path, "/shorts/") + .or_else(|| path_segment_after_prefix(path, "/embed/")) + .and_then(valid_youtube_video_id) +} + +fn youtube_video_id_from_query(query: &str) -> Option<String> { + let mut video_id = None; + for pair in query.split('&') { + let (key, value) = pair.split_once('=').unwrap_or((pair, "")); + if key != "v" { + continue; + } + let valid = valid_youtube_video_id(value)?; + if video_id.replace(valid).is_some() { + return None; + } + } + video_id +} + +fn single_path_segment(path: &str) -> Option<&str> { + let value = path.strip_prefix('/')?; + (!value.is_empty() && !value.contains('/')).then_some(value) +} + +fn path_segment_after_prefix<'a>(path: &'a str, prefix: &str) -> Option<&'a str> { + let value = path.strip_prefix(prefix)?; + (!value.is_empty() && !value.contains('/')).then_some(value) +} + +fn valid_youtube_video_id(value: &str) -> Option<String> { + (value.len() == VIDEO_ID_LEN + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))) + .then(|| value.to_owned()) +} + +fn is_supported_youtube_host(host: &str) -> bool { + let host = host.to_ascii_lowercase(); + is_youtube_host(&host) || is_youtu_be_host(&host) || is_youtube_nocookie_host(&host) +} + +fn is_youtube_host(host: &str) -> bool { + matches!(host, "youtube.com" | "www.youtube.com" | "m.youtube.com") +} + +fn is_youtu_be_host(host: &str) -> bool { + host == "youtu.be" +} + +fn is_youtube_nocookie_host(host: &str) -> bool { + matches!(host, "youtube-nocookie.com" | "www.youtube-nocookie.com") +} + +fn sanitize_title(value: &str) -> Option<String> { + let mut title = String::new(); + for character in value.trim().chars().take(MAX_TITLE_CHARS) { + if character.is_control() { + if matches!(character, '\n' | '\r' | '\t') { + title.push(' '); + } + continue; + } + title.push(character); + } + let title = title.split_whitespace().collect::<Vec<_>>().join(" "); + (!title.is_empty()).then_some(title) +} + +fn canonical_watch_url(video_id: &str) -> String { + format!("https://www.youtube.com/watch?v={video_id}") +} + +fn thumbnail_url(video_id: &str) -> String { + format!("https://i.ytimg.com/vi/{video_id}/hqdefault.jpg") +} + +fn embed_url(video_id: &str) -> String { + format!("https://www.youtube-nocookie.com/embed/{video_id}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn video_id_extraction_supports_common_url_shapes() { + let cases = [ + ("https://www.youtube.com/watch?v=dQw4w9WgXcQ", "dQw4w9WgXcQ"), + ( + "https://youtube.com/watch?feature=share&v=dQw4w9WgXcQ", + "dQw4w9WgXcQ", + ), + ("https://youtu.be/dQw4w9WgXcQ?t=12", "dQw4w9WgXcQ"), + ("https://www.youtube.com/shorts/dQw4w9WgXcQ", "dQw4w9WgXcQ"), + ("https://www.youtube.com/embed/dQw4w9WgXcQ", "dQw4w9WgXcQ"), + ( + "https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ", + "dQw4w9WgXcQ", + ), + ("youtube.com/watch?v=dQw4w9WgXcQ", "dQw4w9WgXcQ"), + ]; + + for (url, expected) in cases { + let preview = embed_from_url(url).expect("valid YouTube URL"); + + assert_eq!(preview.video_id, expected); + assert_eq!(preview.canonical_url, canonical_watch_url(expected)); + } + } + + #[test] + fn video_id_extraction_rejects_invalid_and_spoofed_urls() { + let invalid = [ + "https://youtube.com.evil/watch?v=dQw4w9WgXcQ", + "https://evil.example/youtube.com/watch?v=dQw4w9WgXcQ", + "https://youtube.com@evil.example/watch?v=dQw4w9WgXcQ", + "javascript:https://youtube.com/watch?v=dQw4w9WgXcQ", + "https://www.youtube.com/watch?v=dQw4w9WgXc", + "https://www.youtube.com/watch?v=dQw4w9WgXcQ%2F", + "https://youtu.be/dQw4w9WgXcQ/extra", + "https://www.youtube.com/shorts/../dQw4w9WgXcQ", + "https://www.youtube.com/embed/dQw4w9WgXcQ/../x", + "https://www.youtube.com/watch?v=dQw4w9WgXcQ&v=aaaaaaaaaaa", + ]; + + for url in invalid { + assert!(embed_from_url(url).is_none(), "accepted invalid URL: {url}"); + } + } + + #[test] + fn text_extraction_deduplicates_and_caps_embeds() { + let embeds = embeds_for_text( + "https://youtu.be/dQw4w9WgXcQ https://youtube.com/watch?v=dQw4w9WgXcQ https://youtube.com/shorts/aaaaaaaaaaa https://youtube.com/embed/bbbbbbbbbbb https://youtube.com/watch?v=ccccccccccc", + ); + + assert_eq!(embeds.len(), MAX_EMBEDS_PER_POST); + assert_eq!(embeds[0].video_id, "dQw4w9WgXcQ"); + assert_eq!(embeds[1].video_id, "aaaaaaaaaaa"); + assert_eq!(embeds[2].video_id, "bbbbbbbbbbb"); + } + + #[test] + fn stored_embeds_validate_id_and_sanitize_title() { + let embed = embed_from_stored("dQw4w9WgXcQ", Some(" A title\nwith\tspacing ".to_owned())) + .expect("valid stored embed"); + + assert_eq!(embed.display_title(), "A title with spacing"); + assert_eq!( + embed.embed_url, + "https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ" + ); + assert!(embed_from_stored("bad", Some("title".to_owned())).is_none()); + } + + #[tokio::test] + async fn metadata_fetch_uses_sanitized_oembed_title_when_available() { + let embeds = with_test_oembed_fetcher( + |video_id| { + assert_eq!(video_id, "dQw4w9WgXcQ"); + Some(" A fetched\n<title> ".to_owned()) + }, + metadata_for_text("watch https://youtu.be/dQw4w9WgXcQ"), + ) + .await; + + assert_eq!(embeds.len(), 1); + assert_eq!(embeds[0].title.as_deref(), Some("A fetched <title>")); + assert_eq!(embeds[0].display_title(), "A fetched <title>"); + } + + #[tokio::test] + async fn metadata_fetch_keeps_fallback_title_when_oembed_fails() { + let embeds = with_test_oembed_fetcher( + |_video_id| None, + metadata_for_text("https://youtu.be/dQw4w9WgXcQ"), + ) + .await; + + assert_eq!(embeds.len(), 1); + assert_eq!(embeds[0].title, None); + assert_eq!(embeds[0].display_title(), FALLBACK_TITLE); + } + + #[test] + fn oembed_title_parsing_rejects_invalid_or_empty_title() { + assert_eq!( + parse_oembed_title(r#"{"title":" Valid\tTitle "}"#), + Some("Valid Title".to_owned()) + ); + assert_eq!(parse_oembed_title(r#"{"title":" \n\t "}"#), None); + assert_eq!(parse_oembed_title(r#"{"title":42}"#), None); + assert_eq!(parse_oembed_title("not json"), None); + } +} From fe42d9fbccb6e37acc00249fbafd8afac80ce112 Mon Sep 17 00:00:00 2001 From: csd113 <xxcsd113xx@gmail.com> Date: Thu, 4 Jun 2026 19:46:55 -0700 Subject: [PATCH 08/12] Update Cargo.lock --- Cargo.lock | 103 +++++++++++++++++++++++++++-------------------------- 1 file changed, 52 insertions(+), 51 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3910d03..0682771 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -435,9 +435,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" [[package]] name = "bitvec" @@ -551,9 +551,9 @@ checksum = "beae2cb9f60bc3f21effaaf9c64e51f6627edd54eedc9199ba07f519ef2a2101" [[package]] name = "cc" -version = "1.2.62" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", "shlex", @@ -578,9 +578,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -1413,9 +1413,9 @@ dependencies = [ [[package]] name = "fs-mistrust" -version = "0.14.1" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f5ac9f88fd18733e0f9ce1f4a95c40eb1d4f83131bf1472e81d1f128fefb7c2" +checksum = "9cfebc7c6bb65d327ded064db65cd260b6c418c27ae790318650cfa2a81bf33f" dependencies = [ "derive_builder_fork_arti", "dirs", @@ -1423,6 +1423,7 @@ dependencies = [ "pwd-grp", "serde", "thiserror 2.0.18", + "void", "walkdir", ] @@ -1818,9 +1819,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.10.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb92f162bf56536459fc83c79b974bb12837acfed43d6bc370a7916d0ae15ecc" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -2047,11 +2048,11 @@ dependencies = [ [[package]] name = "inotify" -version = "0.11.1" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" +checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "inotify-sys", "libc", ] @@ -2147,9 +2148,9 @@ dependencies = [ [[package]] name = "kqueue" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" dependencies = [ "kqueue-sys", "libc", @@ -2161,7 +2162,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "libc", ] @@ -2247,9 +2248,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.30" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "matchers" @@ -2327,9 +2328,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "log", @@ -2376,7 +2377,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "inotify", "kqueue", "libc", @@ -2393,7 +2394,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", ] [[package]] @@ -2504,7 +2505,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", ] [[package]] @@ -3002,7 +3003,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", ] [[package]] @@ -3145,7 +3146,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -3179,7 +3180,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "errno", "libc", "linux-raw-sys", @@ -3477,9 +3478,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ "base64 0.22.1", "bs58", @@ -3497,9 +3498,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -3572,9 +3573,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -3645,9 +3646,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -4190,7 +4191,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9efd607ea595674678f73ebf77c245caf04e1d229eae8caf58d2989ff6c63959" dependencies = [ "amplify", - "bitflags 2.11.1", + "bitflags 2.12.1", "bytes", "caret", "derive-deftly", @@ -4918,7 +4919,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1351ffe2fff8bad7d5f2b79ec88ea188e159fe94cf705b1e485d8566526a7b5" dependencies = [ "async-trait", - "bitflags 2.11.1", + "bitflags 2.12.1", "derive_more", "digest 0.10.7", "futures", @@ -5235,7 +5236,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "bytes", "futures-core", "futures-util", @@ -5362,9 +5363,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "uncased" @@ -5389,9 +5390,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-xid" @@ -5477,9 +5478,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -5638,7 +5639,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "hashbrown 0.15.5", "indexmap 2.14.0", "semver", @@ -6057,7 +6058,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.1", + "bitflags 2.12.1", "indexmap 2.14.0", "log", "serde", @@ -6132,9 +6133,9 @@ checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -6155,18 +6156,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.49" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bce33a6288fa3f072a8c2c7d0f2fdbb90e28298f0135c1f99b96c3db2efcc60b" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.49" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd425244944f4ab65ccff928e7323354c5a018c75838362fdce749dfad2ee1e" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", From 3378eee7158b388d2f0427d215fcadbcb3559eff Mon Sep 17 00:00:00 2001 From: csd113 <xxcsd113xx@gmail.com> Date: Fri, 5 Jun 2026 18:54:23 -0700 Subject: [PATCH 09/12] Standardize stacked card spacing Introduce shared card and section gaps across timelines, notifications, account lists, search, profiles, and threads. Remove duplicate post margins, preserve intentional section hierarchy, add a render regression test, and ignore Playwright CLI artifacts. --- .gitignore | 1 + src/render.rs | 28 ++++++++++++++++++++-------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 32e4e5e..b5a806e 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ target # Generated by Playwright and browser QA runs node_modules/ +.playwright-cli/ playwright-report/ test-results/ output/playwright/ diff --git a/src/render.rs b/src/render.rs index 11486de..a10700f 100644 --- a/src/render.rs +++ b/src/render.rs @@ -2849,7 +2849,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;--shell-side:240px;--shell-primary:640px;--shell-gap:1.25rem;--shell-max:1160px;--header-padding-y:.8rem;--header-brand-size:2rem;--hairline:1px;--rail-sticky-top:calc(var(--header-brand-size) + var(--header-padding-y) + var(--header-padding-y) + var(--shell-gap) + var(--hairline))} +:root{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:640px;--shell-gap:1.25rem;--shell-max:1160px;--header-padding-y:.8rem;--header-brand-size:2rem;--hairline:1px;--rail-sticky-top:calc(var(--header-brand-size) + var(--header-padding-y) + var(--header-padding-y) + var(--shell-gap) + var(--hairline))} :root[data-theme="dark"]{color-scheme:dark;--bg:#111827;--surface:#182231;--surface-subtle:#1d2939;--surface-muted:#233044;--header-bg:rgba(17,24,39,.96);--text:#eef4fb;--text-strong:#f8fafc;--muted:#c3cfdd;--muted-strong:#d4deea;--border:#344256;--border-strong:#596b83;--link:#8fc7ff;--link-strong:#badcff;--brand:#4f8fc7;--brand-hover:#6aa8df;--brand-text:#06111f;--hover:#243349;--focus:#fbbf24;--shadow:rgba(0,0,0,.26);--reply-border:#4f6680;--avatar-bg:#243349;--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)} @@ -2861,21 +2861,21 @@ nav a:hover,nav button:hover,.button-link:hover{background:var(--hover);text-dec nav svg{width:1.05rem;height:1.05rem;fill:currentColor;flex:0 0 auto} .nav-badge{display:inline-grid;place-items:center;min-width:1.25rem;height:1.25rem;border-radius:999px;padding:0 .35rem;background:var(--brand);color:var(--brand-text);font-size:.78rem;font-weight:800;line-height:1} main{padding:var(--shell-gap)}.app-shell{width:min(100%,var(--shell-max));margin:0 auto;display:grid;grid-template-columns:var(--shell-side) minmax(0,var(--shell-primary)) var(--shell-side);gap:var(--shell-gap);align-items:start;justify-content:center}.primary-column{min-width:0;width:100%}.left-rail,.right-rail{min-width:0;position:sticky;top:var(--rail-sticky-top);display:grid;gap:.75rem;align-items:start}.side-rail-card,.rail-nav{background:var(--surface);border:1px solid var(--border);border-radius:8px;color:var(--muted-strong);box-shadow:0 1px 2px var(--shadow)}.side-rail-card{padding:.85rem}.side-rail-card h2{margin:.1rem 0 .6rem;font-size:1rem;color:var(--text)}.rail-nav{display:grid;grid-template-columns:minmax(0,1fr);gap:.2rem;width:100%;padding:.35rem;justify-content:stretch}.rail-nav a,.rail-nav button{width:100%;min-height:2.35rem;justify-content:flex-start;padding:.5rem .65rem}.rail-nav form{display:block}.mobile-nav{display:none}.dashboard-list{display:grid;grid-template-columns:auto minmax(0,1fr);gap:.45rem .75rem;margin:.25rem 0 .85rem}.dashboard-list dt{font-weight:800;color:var(--text)}.dashboard-list dd{margin:0;overflow-wrap:anywhere}.dashboard-account{color:var(--text)}.dashboard-account:hover{text-decoration:none}.dashboard-actions{display:flex;flex-wrap:wrap;gap:.4rem}.site-footer{max-width:var(--shell-max);margin:0 auto;padding:1rem;color:var(--muted);font-size:.9rem} -.page-header,.post,.composer,.panel,.empty-state,.notice{background:var(--surface);border:1px solid var(--border);border-radius:8px;margin:0 0 .7rem;padding:.85rem;box-shadow:0 1px 2px var(--shadow)} +.page-header,.post,.composer,.panel,.empty-state,.notice{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:.85rem;box-shadow:0 1px 2px var(--shadow)}.page-header,.composer,.panel,.empty-state,.notice{margin:0 0 var(--section-gap)} .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 .7rem;padding:1rem;box-shadow:0 1px 2px var(--shadow);display:flex;align-items:center;justify-content:space-between;gap:1rem}.notifications-hero h1{margin:0;font-size:1.55rem;line-height:1.15}.notifications-hero p:not(.eyebrow){margin:.35rem 0 0;color:var(--muted-strong)}.caught-up-pill{display:inline-flex;align-items:center;min-height:2rem;border:1px solid var(--success-border);border-radius:999px;background:var(--success-bg);color:var(--text-strong);padding:.32rem .75rem;font-weight:800}.caught-up{padding:.75rem .85rem}.caught-up p{margin:0}.notifications-list{display:grid;gap:.5rem}.notification-group{margin:.8rem .15rem .2rem;color:var(--muted);font-size:.82rem;text-transform:uppercase;letter-spacing:.08em}.notification-row{position:relative;display:grid;grid-template-columns:auto minmax(0,1fr) auto 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} +.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)} 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:.65rem}.section-title{margin:.2rem 0 .65rem;font-size:1.05rem;color:var(--text)}.search-users{margin-bottom:0}.search-users .section-title{margin-top:0}.search-account{grid-template-columns:auto minmax(0,1fr)} +.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} -.thread-nav{display:flex;margin:0 0 .45rem .85rem}.thread-back{width:2rem;height:2rem;display:inline-flex;align-items:center;justify-content:center;border-radius:999px;color:var(--link-strong)}.thread-back svg{width:1.2rem;height:1.2rem;fill:currentColor}.thread-back:hover{background:var(--hover);text-decoration:none} -.timeline{display:grid;gap:.65rem}.post{overflow:hidden;position:relative}.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:.65rem 0;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)} -.profile-banner{width:100%;max-height:220px;object-fit:cover;border-radius:8px;border:1px solid var(--border);background:var(--surface-muted)}.profile-heading{display:flex;gap:1rem;align-items:flex-start;margin-top:.85rem}.profile-main{min-width:0;flex:1}.profile-picture{width:88px;height:88px;object-fit:cover;border-radius:999px;border:3px solid var(--surface);background:var(--avatar-bg);flex:0 0 auto}.profile-meta{color:var(--muted-strong);margin:.45rem 0 0}.settings-profile-editor{padding:0;overflow:hidden}.settings-editor-bar{display:flex;justify-content:space-between;align-items:center;gap:1rem;padding:.85rem;border-bottom:1px solid var(--border)}.settings-editor-bar h1{margin:0}.settings-editor-bar .primary{flex:0 0 auto}.settings-profile-form{padding:.85rem;display:grid;gap:1rem}.settings-section{display:grid;gap:.75rem;min-width:0}.settings-section+.settings-section{border-top:1px solid var(--border);padding-top:1rem}.settings-section-heading{display:grid;gap:.18rem}.settings-section-heading h2,.settings-list-panel h2,.settings-security-panel h2,.danger-panel h2{margin:0;font-size:1.05rem;line-height:1.25;color:var(--text-strong)}.settings-section-help,.settings-switch-help{margin:0;color:var(--muted-strong);font-size:.9rem;line-height:1.35;overflow-wrap:anywhere}.settings-profile-fields{gap:.28rem}.settings-profile-fields label,.settings-password-form label{margin:.55rem 0 .28rem}.settings-profile-media{display:grid;gap:.65rem;min-width:0}.settings-banner-wrap{background:var(--media-bg);border-radius:8px;overflow:hidden}.settings-banner-preview{display:block;width:100%;height:170px;object-fit:cover;object-position:left center;background:linear-gradient(135deg,var(--surface-muted),var(--hover));border:1px solid var(--border);border-radius:8px}.settings-banner-preview.placeholder::before{content:"";display:block;width:100%;height:100%}.settings-picture-row{display:grid;grid-template-columns:auto minmax(0,1fr);gap:1rem;align-items:end;margin-top:-42px;padding:0 .75rem .2rem}.settings-picture-preview{width:104px;height:104px;object-fit:cover;border-radius:999px;border:5px solid var(--surface);background:var(--avatar-bg);box-shadow:0 1px 4px var(--shadow)}.settings-picture-preview.placeholder{display:block}.settings-media-controls{display:grid;gap:.45rem;align-content:end;padding-top:2.85rem;min-width:0}.media-control-row{display:flex;align-items:center;gap:.65rem;flex-wrap:wrap;min-width:0}.media-control-row .file-control{display:flex;align-items:center;gap:.35rem;flex-wrap:wrap;margin:0;max-width:100%;overflow-wrap:anywhere}.media-control-row .file-control input[type=file]{max-width:100%}.media-control-row .check-row{margin:0}.settings-switch-list{display:grid;gap:0;border:1px solid var(--border);border-radius:8px;background:var(--surface-subtle);overflow:hidden}.settings-switch-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.85rem;align-items:center;margin:0;padding:.75rem .8rem;color:var(--text);cursor:pointer}.settings-switch-row+.settings-switch-row{border-top:1px solid var(--border)}.settings-switch-copy{display:grid;gap:.12rem;min-width:0}.settings-switch-label{font-weight:800;color:var(--text-strong);overflow-wrap:anywhere}.settings-switch-toggle{position:relative;display:inline-grid;width:2.75rem;height:1.55rem;justify-self:end;flex:0 0 auto}.settings-switch-input{position:absolute;inset:0;z-index:1;width:100%;height:100%;padding:0;margin:0;opacity:0;cursor:pointer}.settings-switch-control{position:relative;display:block;width:2.75rem;height:1.55rem;border:1px solid var(--border-strong);border-radius:999px;background:var(--surface-muted);box-shadow:inset 0 0 0 1px var(--shadow);transition:background-color .15s ease,border-color .15s ease}.settings-switch-control::before{content:"";position:absolute;left:.16rem;top:50%;width:1.15rem;height:1.15rem;border-radius:999px;background:var(--surface);box-shadow:0 1px 3px var(--shadow);transform:translateY(-50%);transition:transform .15s ease}.settings-switch-input:checked+.settings-switch-control{border-color:var(--brand);background:var(--brand)}.settings-switch-input:checked+.settings-switch-control::before{transform:translate(1.2rem,-50%)}.settings-switch-input:focus-visible+.settings-switch-control{outline:3px solid var(--focus);outline-offset:2px}.settings-form-actions{display:flex;justify-content:flex-end;gap:.5rem;border-top:1px solid var(--border);padding-top:.85rem}.onboarding-panel{overflow:hidden}.onboarding-form{display:grid;gap:.9rem}.onboarding-media-row{display:grid;grid-template-columns:auto minmax(0,1fr);gap:1rem;align-items:center}.onboarding-suggestions{border:1px solid var(--border);border-radius:8px;padding:.75rem;display:grid;gap:.55rem}.onboarding-suggestions legend{font-weight:800;padding:0 .35rem}.onboarding-suggestion{display:grid;grid-template-columns:auto auto minmax(0,1fr);gap:.6rem;align-items:center;margin:0;border:1px solid var(--border);border-radius:7px;padding:.55rem;background:var(--surface-subtle);cursor:pointer}.onboarding-suggestion input{margin:0}.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.7rem}.deep-settings-panel{padding:0;overflow:hidden}.deep-settings-form{padding:.85rem;display:grid;gap:.85rem}.deep-settings-group{border:1px solid var(--border);border-radius:8px;padding:.8rem;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.7rem}.deep-settings-group legend{font-weight:800;padding:0 .35rem}.deep-settings-field{display:grid;gap:.25rem;align-content:start}.deep-settings-field label{font-weight:800}.deep-settings-field input,.deep-settings-field select{min-width:0}.field-help{font-size:.88rem;margin:.05rem 0 .35rem;color:var(--muted-strong)}.deep-settings-confirm .settings-item-list li{display:block}.compact-panel h2,.danger-panel h2{margin:0;font-size:1.05rem}.inline-settings-form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.55rem;align-items:center;margin:.65rem 0 .75rem}.inline-settings-form input{min-width:0}.settings-password-form{display:grid;gap:0;margin-top:.65rem}.settings-password-form button[type=submit]{margin-top:0}.settings-list-panel,.settings-security-panel{display:grid;gap:.55rem}.settings-item-list{list-style:none;margin:.25rem 0 0;padding:0;display:grid;gap:.45rem}.settings-item-list li{display:flex;justify-content:space-between;align-items:center;gap:.75rem;border:1px solid var(--border);border-radius:7px;padding:.55rem .65rem;background:var(--surface-subtle)}.settings-item-list li>span{min-width:0;overflow-wrap:anywhere}.settings-item-list form{flex:0 0 auto}.settings-item-list button{padding:.32rem .55rem;background:var(--surface);color:var(--link-strong);border-color:var(--border)}.compact-empty{border:1px dashed var(--border);border-radius:7px;padding:.75rem;background:var(--surface-subtle);color:var(--muted-strong)}.compact-empty p{margin:.25rem 0 0}.danger-panel{border-color:var(--danger-border);background:var(--danger-bg)}.danger,.danger-link{border-color:var(--danger-border);background:var(--danger);color:var(--brand-text)}.danger:hover,.danger-link:hover{background:var(--danger-strong);color:var(--brand-text);text-decoration:none}.delete-account-panel p,.danger-panel p{max-width:62ch}.settings-danger-action{margin:.7rem 0 0}.favicon-preview{width:32px;height:32px;object-fit:contain;border:1px solid var(--border);border-radius:6px;background:var(--surface)}.admin-user-search{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto;gap:.65rem;align-items:end;margin-top:.75rem}.admin-user-search label{margin-top:0}.admin-user-search-actions{display:flex;gap:.4rem;align-items:center;margin-bottom:.05rem}.admin-user-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.75rem;border:1px solid var(--border);border-radius:8px;padding:.75rem;margin-top:.65rem;background:var(--surface-subtle)}.admin-user-heading{overflow-wrap:anywhere}.admin-user-statuses,.admin-user-matches{display:flex;flex-wrap:wrap;gap:.35rem;margin-top:.45rem}.admin-user-pill,.admin-user-match{display:inline-flex;align-items:center;min-height:1.55rem;border:1px solid var(--border);border-radius:999px;padding:.15rem .5rem;background:var(--surface);font-size:.82rem;font-weight:800;color:var(--muted-strong)}.admin-user-match{border-color:var(--success-border);background:var(--success-bg);color:var(--text)}.admin-user-meta{margin:.6rem 0 0}.admin-post-preview{margin:.55rem 0 0;color:var(--muted-strong);overflow-wrap:anywhere}.admin-user-actions{display:flex;align-items:flex-start}.admin-users-empty{margin-top:.75rem}.account-list{display:grid;gap:.65rem}.account-row{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:.75rem;align-items:center;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:.85rem}.account-row p{margin:.3rem 0 0;color:var(--muted-strong);overflow-wrap:anywhere}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:.85rem}.item-list{margin:.75rem 0 0;padding-left:1.2rem}.item-list li{margin:.45rem 0}.panel dl:not(.dashboard-list){display:grid;grid-template-columns:max-content minmax(0,1fr);gap:.45rem .85rem}.panel dl:not(.dashboard-list) dt{font-weight:800}.panel dl:not(.dashboard-list) dd{margin:0;overflow-wrap:anywhere}table{width:100%;border-collapse:collapse}td,th{border-bottom:1px solid var(--border);text-align:left;padding:.55rem;vertical-align:top}pre{white-space:pre-wrap;overflow:auto;max-width:100%} +.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)} +.profile-banner{width:100%;max-height:220px;object-fit:cover;border-radius:8px;border:1px solid var(--border);background:var(--surface-muted)}.profile-heading{display:flex;gap:1rem;align-items:flex-start;margin-top:.85rem}.profile-main{min-width:0;flex:1}.profile-picture{width:88px;height:88px;object-fit:cover;border-radius:999px;border:3px solid var(--surface);background:var(--avatar-bg);flex:0 0 auto}.profile-meta{color:var(--muted-strong);margin:.45rem 0 0}.settings-profile-editor{padding:0;overflow:hidden}.settings-editor-bar{display:flex;justify-content:space-between;align-items:center;gap:1rem;padding:.85rem;border-bottom:1px solid var(--border)}.settings-editor-bar h1{margin:0}.settings-editor-bar .primary{flex:0 0 auto}.settings-profile-form{padding:.85rem;display:grid;gap:1rem}.settings-section{display:grid;gap:.75rem;min-width:0}.settings-section+.settings-section{border-top:1px solid var(--border);padding-top:1rem}.settings-section-heading{display:grid;gap:.18rem}.settings-section-heading h2,.settings-list-panel h2,.settings-security-panel h2,.danger-panel h2{margin:0;font-size:1.05rem;line-height:1.25;color:var(--text-strong)}.settings-section-help,.settings-switch-help{margin:0;color:var(--muted-strong);font-size:.9rem;line-height:1.35;overflow-wrap:anywhere}.settings-profile-fields{gap:.28rem}.settings-profile-fields label,.settings-password-form label{margin:.55rem 0 .28rem}.settings-profile-media{display:grid;gap:.65rem;min-width:0}.settings-banner-wrap{background:var(--media-bg);border-radius:8px;overflow:hidden}.settings-banner-preview{display:block;width:100%;height:170px;object-fit:cover;object-position:left center;background:linear-gradient(135deg,var(--surface-muted),var(--hover));border:1px solid var(--border);border-radius:8px}.settings-banner-preview.placeholder::before{content:"";display:block;width:100%;height:100%}.settings-picture-row{display:grid;grid-template-columns:auto minmax(0,1fr);gap:1rem;align-items:end;margin-top:-42px;padding:0 .75rem .2rem}.settings-picture-preview{width:104px;height:104px;object-fit:cover;border-radius:999px;border:5px solid var(--surface);background:var(--avatar-bg);box-shadow:0 1px 4px var(--shadow)}.settings-picture-preview.placeholder{display:block}.settings-media-controls{display:grid;gap:.45rem;align-content:end;padding-top:2.85rem;min-width:0}.media-control-row{display:flex;align-items:center;gap:.65rem;flex-wrap:wrap;min-width:0}.media-control-row .file-control{display:flex;align-items:center;gap:.35rem;flex-wrap:wrap;margin:0;max-width:100%;overflow-wrap:anywhere}.media-control-row .file-control input[type=file]{max-width:100%}.media-control-row .check-row{margin:0}.settings-switch-list{display:grid;gap:0;border:1px solid var(--border);border-radius:8px;background:var(--surface-subtle);overflow:hidden}.settings-switch-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.85rem;align-items:center;margin:0;padding:.75rem .8rem;color:var(--text);cursor:pointer}.settings-switch-row+.settings-switch-row{border-top:1px solid var(--border)}.settings-switch-copy{display:grid;gap:.12rem;min-width:0}.settings-switch-label{font-weight:800;color:var(--text-strong);overflow-wrap:anywhere}.settings-switch-toggle{position:relative;display:inline-grid;width:2.75rem;height:1.55rem;justify-self:end;flex:0 0 auto}.settings-switch-input{position:absolute;inset:0;z-index:1;width:100%;height:100%;padding:0;margin:0;opacity:0;cursor:pointer}.settings-switch-control{position:relative;display:block;width:2.75rem;height:1.55rem;border:1px solid var(--border-strong);border-radius:999px;background:var(--surface-muted);box-shadow:inset 0 0 0 1px var(--shadow);transition:background-color .15s ease,border-color .15s ease}.settings-switch-control::before{content:"";position:absolute;left:.16rem;top:50%;width:1.15rem;height:1.15rem;border-radius:999px;background:var(--surface);box-shadow:0 1px 3px var(--shadow);transform:translateY(-50%);transition:transform .15s ease}.settings-switch-input:checked+.settings-switch-control{border-color:var(--brand);background:var(--brand)}.settings-switch-input:checked+.settings-switch-control::before{transform:translate(1.2rem,-50%)}.settings-switch-input:focus-visible+.settings-switch-control{outline:3px solid var(--focus);outline-offset:2px}.settings-form-actions{display:flex;justify-content:flex-end;gap:.5rem;border-top:1px solid var(--border);padding-top:.85rem}.onboarding-panel{overflow:hidden}.onboarding-form{display:grid;gap:.9rem}.onboarding-media-row{display:grid;grid-template-columns:auto minmax(0,1fr);gap:1rem;align-items:center}.onboarding-suggestions{border:1px solid var(--border);border-radius:8px;padding:.75rem;display:grid;gap:.55rem}.onboarding-suggestions legend{font-weight:800;padding:0 .35rem}.onboarding-suggestion{display:grid;grid-template-columns:auto auto minmax(0,1fr);gap:.6rem;align-items:center;margin:0;border:1px solid var(--border);border-radius:7px;padding:.55rem;background:var(--surface-subtle);cursor:pointer}.onboarding-suggestion input{margin:0}.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.7rem}.deep-settings-panel{padding:0;overflow:hidden}.deep-settings-form{padding:.85rem;display:grid;gap:.85rem}.deep-settings-group{border:1px solid var(--border);border-radius:8px;padding:.8rem;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.7rem}.deep-settings-group legend{font-weight:800;padding:0 .35rem}.deep-settings-field{display:grid;gap:.25rem;align-content:start}.deep-settings-field label{font-weight:800}.deep-settings-field input,.deep-settings-field select{min-width:0}.field-help{font-size:.88rem;margin:.05rem 0 .35rem;color:var(--muted-strong)}.deep-settings-confirm .settings-item-list li{display:block}.compact-panel h2,.danger-panel h2{margin:0;font-size:1.05rem}.inline-settings-form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.55rem;align-items:center;margin:.65rem 0 .75rem}.inline-settings-form input{min-width:0}.settings-password-form{display:grid;gap:0;margin-top:.65rem}.settings-password-form button[type=submit]{margin-top:0}.settings-list-panel,.settings-security-panel{display:grid;gap:.55rem}.settings-item-list{list-style:none;margin:.25rem 0 0;padding:0;display:grid;gap:.45rem}.settings-item-list li{display:flex;justify-content:space-between;align-items:center;gap:.75rem;border:1px solid var(--border);border-radius:7px;padding:.55rem .65rem;background:var(--surface-subtle)}.settings-item-list li>span{min-width:0;overflow-wrap:anywhere}.settings-item-list form{flex:0 0 auto}.settings-item-list button{padding:.32rem .55rem;background:var(--surface);color:var(--link-strong);border-color:var(--border)}.compact-empty{border:1px dashed var(--border);border-radius:7px;padding:.75rem;background:var(--surface-subtle);color:var(--muted-strong)}.compact-empty p{margin:.25rem 0 0}.danger-panel{border-color:var(--danger-border);background:var(--danger-bg)}.danger,.danger-link{border-color:var(--danger-border);background:var(--danger);color:var(--brand-text)}.danger:hover,.danger-link:hover{background:var(--danger-strong);color:var(--brand-text);text-decoration:none}.delete-account-panel p,.danger-panel p{max-width:62ch}.settings-danger-action{margin:.7rem 0 0}.favicon-preview{width:32px;height:32px;object-fit:contain;border:1px solid var(--border);border-radius:6px;background:var(--surface)}.admin-user-search{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto;gap:.65rem;align-items:end;margin-top:.75rem}.admin-user-search label{margin-top:0}.admin-user-search-actions{display:flex;gap:.4rem;align-items:center;margin-bottom:.05rem}.admin-user-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.75rem;border:1px solid var(--border);border-radius:8px;padding:.75rem;margin-top:.65rem;background:var(--surface-subtle)}.admin-user-heading{overflow-wrap:anywhere}.admin-user-statuses,.admin-user-matches{display:flex;flex-wrap:wrap;gap:.35rem;margin-top:.45rem}.admin-user-pill,.admin-user-match{display:inline-flex;align-items:center;min-height:1.55rem;border:1px solid var(--border);border-radius:999px;padding:.15rem .5rem;background:var(--surface);font-size:.82rem;font-weight:800;color:var(--muted-strong)}.admin-user-match{border-color:var(--success-border);background:var(--success-bg);color:var(--text)}.admin-user-meta{margin:.6rem 0 0}.admin-post-preview{margin:.55rem 0 0;color:var(--muted-strong);overflow-wrap:anywhere}.admin-user-actions{display:flex;align-items:flex-start}.admin-users-empty{margin-top:.75rem}.account-row{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:.75rem;align-items:center;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:.85rem}.account-row p{margin:.3rem 0 0;color:var(--muted-strong);overflow-wrap:anywhere}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:.85rem}.item-list{margin:.75rem 0 0;padding-left:1.2rem}.item-list li{margin:.45rem 0}.panel dl:not(.dashboard-list){display:grid;grid-template-columns:max-content minmax(0,1fr);gap:.45rem .85rem}.panel dl:not(.dashboard-list) dt{font-weight:800}.panel dl:not(.dashboard-list) dd{margin:0;overflow-wrap:anywhere}table{width:100%;border-collapse:collapse}td,th{border-bottom:1px solid var(--border);text-align:left;padding:.55rem;vertical-align:top}pre{white-space:pre-wrap;overflow:auto;max-width:100%} .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} @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}} @@ -2920,6 +2920,18 @@ mod tests { assert!(body.contains(r#"<html lang="en" data-theme="dark">"#)); } + #[test] + fn stacked_cards_use_shared_spacing_without_individual_post_margins() { + assert!(CSS.contains("--card-gap:.5rem;--section-gap:.75rem")); + assert!(CSS.contains( + ".timeline,.notifications-list,.account-list{display:grid;gap:var(--card-gap)}" + )); + assert!(CSS.contains(".post{margin:0;overflow:hidden;position:relative}")); + assert!(CSS.contains( + ".page-header,.composer,.panel,.empty-state,.notice{margin:0 0 var(--section-gap)}" + )); + } + #[test] fn layout_includes_no_javascript_status_as_noscript_only() { let body = layout(None, "Home Feed", "<p>body</p>", "My Microblog"); From e27cabe86439c12d6675c7948167dc302aefdeb2 Mon Sep 17 00:00:00 2001 From: csd113 <xxcsd113xx@gmail.com> Date: Fri, 5 Jun 2026 18:54:47 -0700 Subject: [PATCH 10/12] Improve repost menu focus handling and layout spacing --- src/render.rs | 22 ++++++++++++++-------- src/server.rs | 2 +- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/render.rs b/src/render.rs index a10700f..bdf9c84 100644 --- a/src/render.rs +++ b/src/render.rs @@ -508,15 +508,19 @@ document.addEventListener("click", (event) => { button.setAttribute("aria-label", show ? "Hide password" : "Show password"); }); -function closeRepostMenus(except) { +function closeRepostMenus(except, restoreFocus) { document.querySelectorAll("[data-repost-menu]").forEach((menu) => { if (menu === except) { return; } - menu.hidden = true; const button = document.querySelector(`[aria-controls="${menu.id}"]`); + const shouldRestoreFocus = restoreFocus && menu.contains(document.activeElement); + menu.hidden = true; if (button) { button.setAttribute("aria-expanded", "false"); + if (shouldRestoreFocus) { + button.focus(); + } } }); } @@ -526,7 +530,7 @@ function openRepostMenu(button) { if (!menu) { return; } - closeRepostMenus(menu); + closeRepostMenus(menu, false); menu.hidden = false; button.setAttribute("aria-expanded", "true"); const firstItem = menu.querySelector('[role="menuitem"]'); @@ -565,7 +569,7 @@ document.addEventListener("click", (event) => { return; } if (!event.target.closest("[data-repost-control]")) { - closeRepostMenus(null); + closeRepostMenus(null, false); } }); @@ -577,7 +581,7 @@ document.addEventListener("keydown", (event) => { return; } if (event.key === "Escape") { - closeRepostMenus(null); + closeRepostMenus(null, true); } }); @@ -2849,19 +2853,19 @@ 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:640px;--shell-gap:1.25rem;--shell-max:1160px;--header-padding-y:.8rem;--header-brand-size:2rem;--hairline:1px;--rail-sticky-top:calc(var(--header-brand-size) + var(--header-padding-y) + var(--header-padding-y) + var(--shell-gap) + var(--hairline))} +:root{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[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)} .header-inner{max-width:var(--shell-max);margin:0 auto;padding:var(--header-padding-y) 1rem;display:flex;align-items:center;justify-content:space-between;gap:1rem} .header-brand-row{display:flex;align-items:center;gap:.75rem;min-width:0;max-width:100%}.brand{display:flex;align-items:center;gap:.55rem;font-weight:800;color:var(--text-strong);min-width:0}.brand span:last-child{overflow-wrap:anywhere}.brand-mark{display:inline-grid;place-items:center;width:var(--header-brand-size);height:var(--header-brand-size);border-radius:7px;background:var(--brand);color:var(--brand-text);flex:0 0 auto} .tor-indicator{position:relative;display:inline-flex;align-items:center;min-width:0;max-width:min(18rem,100%);flex:0 1 auto;color:var(--muted-strong);font-size:.84rem;line-height:1;white-space:nowrap}.tor-disclosure{position:relative;min-width:0;max-width:100%;flex:0 1 auto}.tor-pill{display:inline-flex;align-items:center;gap:.38rem;max-width:100%;min-height:2.15rem;border:1px solid var(--border);border-radius:999px;padding:.25rem .68rem;background:var(--surface-subtle);color:var(--link-strong);font-weight:800;cursor:pointer;list-style:none;overflow:hidden}.js-enabled .tor-pill{padding-right:3.85rem}.tor-pill::-webkit-details-marker{display:none}.tor-pill::marker{content:""}.tor-pill:hover{background:var(--hover);text-decoration:none}.tor-pill-label{flex:0 0 auto;color:var(--muted);font-weight:900}.tor-summary-text{display:block;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-variant-numeric:tabular-nums}.tor-details{position:absolute;z-index:12;left:0;top:calc(100% + .35rem);width:max-content;max-width:min(30rem,calc(100vw - 2rem));padding:.55rem .65rem;border:1px solid var(--border-strong);border-radius:8px;background:var(--surface);box-shadow:0 8px 24px var(--shadow);white-space:normal}.tor-full-link{display:block;max-width:min(28rem,calc(100vw - 3.5rem));overflow-wrap:anywhere;color:var(--text-strong);font-weight:800;line-height:1.3}.tor-full-link:hover{color:var(--link-strong)}.tor-copy-button{display:none;position:absolute;z-index:13;right:.22rem;top:50%;transform:translateY(-50%);align-items:center;justify-content:center;width:3.25rem;min-height:1.65rem;border:1px solid var(--border);border-radius:999px;background:var(--surface);color:var(--link-strong);padding:.12rem .35rem;font-size:.78rem;font-weight:850;line-height:1;box-shadow:none}.js-enabled .tor-copy-button{display:inline-flex}.tor-copy-button:hover{background:var(--hover);color:var(--text-strong)} -nav{display:flex;gap:.35rem;align-items:center;flex-wrap:wrap;justify-content:flex-end}nav a,nav button,.button-link{display:inline-flex;align-items:center;gap:.35rem;min-height:2.15rem;border-radius:7px;padding:.42rem .65rem;color:var(--link-strong);border:1px solid transparent;background:transparent} +nav{display:flex;gap:.35rem;align-items:center;flex-wrap:wrap;justify-content:flex-end}nav a,nav button,.button-link{display:inline-flex;align-items:center;gap:.35rem;min-height:2.35rem;border-radius:7px;padding:.46rem .68rem;color:var(--link-strong);border:1px solid transparent;background:transparent} nav a:hover,nav button:hover,.button-link:hover{background:var(--hover);text-decoration:none}nav form,.actions form{display:inline} nav svg{width:1.05rem;height:1.05rem;fill:currentColor;flex:0 0 auto} .nav-badge{display:inline-grid;place-items:center;min-width:1.25rem;height:1.25rem;border-radius:999px;padding:0 .35rem;background:var(--brand);color:var(--brand-text);font-size:.78rem;font-weight:800;line-height:1} main{padding:var(--shell-gap)}.app-shell{width:min(100%,var(--shell-max));margin:0 auto;display:grid;grid-template-columns:var(--shell-side) minmax(0,var(--shell-primary)) var(--shell-side);gap:var(--shell-gap);align-items:start;justify-content:center}.primary-column{min-width:0;width:100%}.left-rail,.right-rail{min-width:0;position:sticky;top:var(--rail-sticky-top);display:grid;gap:.75rem;align-items:start}.side-rail-card,.rail-nav{background:var(--surface);border:1px solid var(--border);border-radius:8px;color:var(--muted-strong);box-shadow:0 1px 2px var(--shadow)}.side-rail-card{padding:.85rem}.side-rail-card h2{margin:.1rem 0 .6rem;font-size:1rem;color:var(--text)}.rail-nav{display:grid;grid-template-columns:minmax(0,1fr);gap:.2rem;width:100%;padding:.35rem;justify-content:stretch}.rail-nav a,.rail-nav button{width:100%;min-height:2.35rem;justify-content:flex-start;padding:.5rem .65rem}.rail-nav form{display:block}.mobile-nav{display:none}.dashboard-list{display:grid;grid-template-columns:auto minmax(0,1fr);gap:.45rem .75rem;margin:.25rem 0 .85rem}.dashboard-list dt{font-weight:800;color:var(--text)}.dashboard-list dd{margin:0;overflow-wrap:anywhere}.dashboard-account{color:var(--text)}.dashboard-account:hover{text-decoration:none}.dashboard-actions{display:flex;flex-wrap:wrap;gap:.4rem}.site-footer{max-width:var(--shell-max);margin:0 auto;padding:1rem;color:var(--muted);font-size:.9rem} -.page-header,.post,.composer,.panel,.empty-state,.notice{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:.85rem;box-shadow:0 1px 2px var(--shadow)}.page-header,.composer,.panel,.empty-state,.notice{margin:0 0 var(--section-gap)} +.page-header,.post,.composer,.panel,.empty-state,.notice{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:.9rem;box-shadow:0 1px 2px var(--shadow)}.page-header,.composer,.panel,.empty-state,.notice{margin:0 0 var(--section-gap)} .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} @@ -2877,6 +2881,8 @@ nav button{border-color:transparent;background:transparent;color:var(--link-stro .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)} .profile-banner{width:100%;max-height:220px;object-fit:cover;border-radius:8px;border:1px solid var(--border);background:var(--surface-muted)}.profile-heading{display:flex;gap:1rem;align-items:flex-start;margin-top:.85rem}.profile-main{min-width:0;flex:1}.profile-picture{width:88px;height:88px;object-fit:cover;border-radius:999px;border:3px solid var(--surface);background:var(--avatar-bg);flex:0 0 auto}.profile-meta{color:var(--muted-strong);margin:.45rem 0 0}.settings-profile-editor{padding:0;overflow:hidden}.settings-editor-bar{display:flex;justify-content:space-between;align-items:center;gap:1rem;padding:.85rem;border-bottom:1px solid var(--border)}.settings-editor-bar h1{margin:0}.settings-editor-bar .primary{flex:0 0 auto}.settings-profile-form{padding:.85rem;display:grid;gap:1rem}.settings-section{display:grid;gap:.75rem;min-width:0}.settings-section+.settings-section{border-top:1px solid var(--border);padding-top:1rem}.settings-section-heading{display:grid;gap:.18rem}.settings-section-heading h2,.settings-list-panel h2,.settings-security-panel h2,.danger-panel h2{margin:0;font-size:1.05rem;line-height:1.25;color:var(--text-strong)}.settings-section-help,.settings-switch-help{margin:0;color:var(--muted-strong);font-size:.9rem;line-height:1.35;overflow-wrap:anywhere}.settings-profile-fields{gap:.28rem}.settings-profile-fields label,.settings-password-form label{margin:.55rem 0 .28rem}.settings-profile-media{display:grid;gap:.65rem;min-width:0}.settings-banner-wrap{background:var(--media-bg);border-radius:8px;overflow:hidden}.settings-banner-preview{display:block;width:100%;height:170px;object-fit:cover;object-position:left center;background:linear-gradient(135deg,var(--surface-muted),var(--hover));border:1px solid var(--border);border-radius:8px}.settings-banner-preview.placeholder::before{content:"";display:block;width:100%;height:100%}.settings-picture-row{display:grid;grid-template-columns:auto minmax(0,1fr);gap:1rem;align-items:end;margin-top:-42px;padding:0 .75rem .2rem}.settings-picture-preview{width:104px;height:104px;object-fit:cover;border-radius:999px;border:5px solid var(--surface);background:var(--avatar-bg);box-shadow:0 1px 4px var(--shadow)}.settings-picture-preview.placeholder{display:block}.settings-media-controls{display:grid;gap:.45rem;align-content:end;padding-top:2.85rem;min-width:0}.media-control-row{display:flex;align-items:center;gap:.65rem;flex-wrap:wrap;min-width:0}.media-control-row .file-control{display:flex;align-items:center;gap:.35rem;flex-wrap:wrap;margin:0;max-width:100%;overflow-wrap:anywhere}.media-control-row .file-control input[type=file]{max-width:100%}.media-control-row .check-row{margin:0}.settings-switch-list{display:grid;gap:0;border:1px solid var(--border);border-radius:8px;background:var(--surface-subtle);overflow:hidden}.settings-switch-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.85rem;align-items:center;margin:0;padding:.75rem .8rem;color:var(--text);cursor:pointer}.settings-switch-row+.settings-switch-row{border-top:1px solid var(--border)}.settings-switch-copy{display:grid;gap:.12rem;min-width:0}.settings-switch-label{font-weight:800;color:var(--text-strong);overflow-wrap:anywhere}.settings-switch-toggle{position:relative;display:inline-grid;width:2.75rem;height:1.55rem;justify-self:end;flex:0 0 auto}.settings-switch-input{position:absolute;inset:0;z-index:1;width:100%;height:100%;padding:0;margin:0;opacity:0;cursor:pointer}.settings-switch-control{position:relative;display:block;width:2.75rem;height:1.55rem;border:1px solid var(--border-strong);border-radius:999px;background:var(--surface-muted);box-shadow:inset 0 0 0 1px var(--shadow);transition:background-color .15s ease,border-color .15s ease}.settings-switch-control::before{content:"";position:absolute;left:.16rem;top:50%;width:1.15rem;height:1.15rem;border-radius:999px;background:var(--surface);box-shadow:0 1px 3px var(--shadow);transform:translateY(-50%);transition:transform .15s ease}.settings-switch-input:checked+.settings-switch-control{border-color:var(--brand);background:var(--brand)}.settings-switch-input:checked+.settings-switch-control::before{transform:translate(1.2rem,-50%)}.settings-switch-input:focus-visible+.settings-switch-control{outline:3px solid var(--focus);outline-offset:2px}.settings-form-actions{display:flex;justify-content:flex-end;gap:.5rem;border-top:1px solid var(--border);padding-top:.85rem}.onboarding-panel{overflow:hidden}.onboarding-form{display:grid;gap:.9rem}.onboarding-media-row{display:grid;grid-template-columns:auto minmax(0,1fr);gap:1rem;align-items:center}.onboarding-suggestions{border:1px solid var(--border);border-radius:8px;padding:.75rem;display:grid;gap:.55rem}.onboarding-suggestions legend{font-weight:800;padding:0 .35rem}.onboarding-suggestion{display:grid;grid-template-columns:auto auto minmax(0,1fr);gap:.6rem;align-items:center;margin:0;border:1px solid var(--border);border-radius:7px;padding:.55rem;background:var(--surface-subtle);cursor:pointer}.onboarding-suggestion input{margin:0}.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.7rem}.deep-settings-panel{padding:0;overflow:hidden}.deep-settings-form{padding:.85rem;display:grid;gap:.85rem}.deep-settings-group{border:1px solid var(--border);border-radius:8px;padding:.8rem;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.7rem}.deep-settings-group legend{font-weight:800;padding:0 .35rem}.deep-settings-field{display:grid;gap:.25rem;align-content:start}.deep-settings-field label{font-weight:800}.deep-settings-field input,.deep-settings-field select{min-width:0}.field-help{font-size:.88rem;margin:.05rem 0 .35rem;color:var(--muted-strong)}.deep-settings-confirm .settings-item-list li{display:block}.compact-panel h2,.danger-panel h2{margin:0;font-size:1.05rem}.inline-settings-form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.55rem;align-items:center;margin:.65rem 0 .75rem}.inline-settings-form input{min-width:0}.settings-password-form{display:grid;gap:0;margin-top:.65rem}.settings-password-form button[type=submit]{margin-top:0}.settings-list-panel,.settings-security-panel{display:grid;gap:.55rem}.settings-item-list{list-style:none;margin:.25rem 0 0;padding:0;display:grid;gap:.45rem}.settings-item-list li{display:flex;justify-content:space-between;align-items:center;gap:.75rem;border:1px solid var(--border);border-radius:7px;padding:.55rem .65rem;background:var(--surface-subtle)}.settings-item-list li>span{min-width:0;overflow-wrap:anywhere}.settings-item-list form{flex:0 0 auto}.settings-item-list button{padding:.32rem .55rem;background:var(--surface);color:var(--link-strong);border-color:var(--border)}.compact-empty{border:1px dashed var(--border);border-radius:7px;padding:.75rem;background:var(--surface-subtle);color:var(--muted-strong)}.compact-empty p{margin:.25rem 0 0}.danger-panel{border-color:var(--danger-border);background:var(--danger-bg)}.danger,.danger-link{border-color:var(--danger-border);background:var(--danger);color:var(--brand-text)}.danger:hover,.danger-link:hover{background:var(--danger-strong);color:var(--brand-text);text-decoration:none}.delete-account-panel p,.danger-panel p{max-width:62ch}.settings-danger-action{margin:.7rem 0 0}.favicon-preview{width:32px;height:32px;object-fit:contain;border:1px solid var(--border);border-radius:6px;background:var(--surface)}.admin-user-search{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto;gap:.65rem;align-items:end;margin-top:.75rem}.admin-user-search label{margin-top:0}.admin-user-search-actions{display:flex;gap:.4rem;align-items:center;margin-bottom:.05rem}.admin-user-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.75rem;border:1px solid var(--border);border-radius:8px;padding:.75rem;margin-top:.65rem;background:var(--surface-subtle)}.admin-user-heading{overflow-wrap:anywhere}.admin-user-statuses,.admin-user-matches{display:flex;flex-wrap:wrap;gap:.35rem;margin-top:.45rem}.admin-user-pill,.admin-user-match{display:inline-flex;align-items:center;min-height:1.55rem;border:1px solid var(--border);border-radius:999px;padding:.15rem .5rem;background:var(--surface);font-size:.82rem;font-weight:800;color:var(--muted-strong)}.admin-user-match{border-color:var(--success-border);background:var(--success-bg);color:var(--text)}.admin-user-meta{margin:.6rem 0 0}.admin-post-preview{margin:.55rem 0 0;color:var(--muted-strong);overflow-wrap:anywhere}.admin-user-actions{display:flex;align-items:flex-start}.admin-users-empty{margin-top:.75rem}.account-row{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:.75rem;align-items:center;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:.85rem}.account-row p{margin:.3rem 0 0;color:var(--muted-strong);overflow-wrap:anywhere}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:.85rem}.item-list{margin:.75rem 0 0;padding-left:1.2rem}.item-list li{margin:.45rem 0}.panel dl:not(.dashboard-list){display:grid;grid-template-columns:max-content minmax(0,1fr);gap:.45rem .85rem}.panel dl:not(.dashboard-list) dt{font-weight:800}.panel dl:not(.dashboard-list) dd{margin:0;overflow-wrap:anywhere}table{width:100%;border-collapse:collapse}td,th{border-bottom:1px solid var(--border);text-align:left;padding:.55rem;vertical-align:top}pre{white-space:pre-wrap;overflow:auto;max-width:100%} .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} @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}} diff --git a/src/server.rs b/src/server.rs index cced1d2..0beac0c 100644 --- a/src/server.rs +++ b/src/server.rs @@ -3340,7 +3340,7 @@ async fn admin_dashboard( "Admin", "Manage site health, users, media jobs, settings, and backups." ), - r#"<section class="grid"><a class="panel admin-card" data-testid="admin-card" href="/admin/health">Site health</a><a class="panel admin-card" data-testid="admin-card" href="/admin/users">Users</a><a class="panel admin-card" data-testid="admin-card" href="/admin/media">Media jobs</a><a class="panel admin-card" data-testid="admin-card" href="/admin/deep-settings">Deep server settings</a><a class="panel admin-card" data-testid="admin-card" href="/admin/backups">Backups</a></section>"#, + r#"<section class="grid admin-nav-grid"><a class="panel admin-card admin-nav-card" data-testid="admin-card" href="/admin/health">Site health</a><a class="panel admin-card admin-nav-card" data-testid="admin-card" href="/admin/users">Users</a><a class="panel admin-card admin-nav-card" data-testid="admin-card" href="/admin/media">Media jobs</a><a class="panel admin-card admin-nav-card" data-testid="admin-card" href="/admin/deep-settings">Deep server settings</a><a class="panel admin-card admin-nav-card" data-testid="admin-card" href="/admin/backups">Backups</a></section>"#, favicon_panel ); Ok(Html( From 716193616e2412c029eeff9fa8f900ca3c6aabf2 Mon Sep 17 00:00:00 2001 From: csd113 <xxcsd113xx@gmail.com> Date: Fri, 5 Jun 2026 19:05:31 -0700 Subject: [PATCH 11/12] Fix Clippy filename assignment in restore upload --- src/server.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server.rs b/src/server.rs index 0beac0c..b6e777c 100644 --- a/src/server.rs +++ b/src/server.rs @@ -4304,11 +4304,11 @@ async fn parse_restore_upload( if !csrf_validated { return Err(AppError::Forbidden); } - filename = field + field .file_name() .filter(|name| !name.trim().is_empty()) .unwrap_or("uploaded-backup.tar") - .to_owned(); + .clone_into(&mut filename); if !std::path::Path::new(&filename) .extension() .is_some_and(|ext| ext.eq_ignore_ascii_case("tar")) From ad0b517e041a830b07b175c519bc2f76c436fdf2 Mon Sep 17 00:00:00 2001 From: csd113 <xxcsd113xx@gmail.com> Date: Fri, 5 Jun 2026 19:11:34 -0700 Subject: [PATCH 12/12] Replace return_and_then with ? in YouTube title parsing --- src/youtube.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/youtube.rs b/src/youtube.rs index 33ac265..40fd072 100644 --- a/src/youtube.rs +++ b/src/youtube.rs @@ -124,7 +124,8 @@ async fn fetch_embed_title(mut embed: YoutubeEmbed) -> YoutubeEmbed { async fn fetch_oembed_title(video_id: &str) -> Option<String> { #[cfg(test)] if let Ok(title) = TEST_OEMBED_FETCHER.try_with(|fetcher| fetcher(video_id)) { - return title.and_then(|title| sanitize_title(&title)); + let title = title?; + return sanitize_title(&title); } let video_id = video_id.to_owned(); @@ -169,7 +170,8 @@ fn oembed_agent() -> ureq::Agent { fn parse_oembed_title(body: &str) -> Option<String> { let response = serde_json::from_str::<OembedResponse>(body).ok()?; - response.title.and_then(|title| sanitize_title(&title)) + let title = response.title?; + sanitize_title(&title) } fn embed_from_url(candidate: &str) -> Option<YoutubeEmbed> {