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
", "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,
pub quote: Option,
pub media: Vec,
+ pub youtube_embeds: Vec,
}
#[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 = (|| {
- 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 {
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 anyhow::Result> {
+ pool.call(move |conn| youtube_embeds_for_post_conn(conn, post_id).map_err(Into::into))
+ .await
+}
+
+fn youtube_embeds_for_post_conn(
+ conn: &Connection,
+ post_id: i64,
+) -> rusqlite::Result> {
+ 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>(1)?))
+ })?
+ .collect::, _>>()?;
+ 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::>();
+ 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 ".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) = 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 "));
+ 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 "
+ );
+ }
+
+ #[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 = 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 = 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::, _>>()?;
+ 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 = 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 = 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 Option + Send + Sync>;
+
+#[cfg(test)]
+tokio::task_local! {
+ static TEST_OEMBED_FETCHER: TestOembedFetcher;
+}
+
+#[cfg(test)]
+pub async fn with_test_oembed_fetcher(fetcher: F, future: Fut) -> R
+where
+ F: Fn(&str) -> Option + Send + Sync + 'static,
+ Fut: Future