#![allow(clippy::unused_unit, deprecated)] //! 备份与恢复(读写,最高风险)。 //! //! 备份:探测 pg_dump 可用性——可用则子进程生成完整 .sql(`--clean --if-exists`, //! 脚本自带 `DROP ... IF EXISTS`,使恢复幂等),不可用则回退纯 SQL(仅数据)。 //! 备份文件含签名头。 //! 恢复:仅接受本系统生成的备份(签名校验)+ 二次确认 + 路径穿越防护; //! `psql -v ON_ERROR_STOP=1` 确保任何 SQL 错误立即中止并报失败(不再假成功); //! 成功后全量失效文章缓存与 SSR 世代号。 //! 长耗时操作走后台任务 + 进度轮询(见 [`crate::api::database::tasks`])。 //! //! **兼容性提示**:`--clean --if-exists` 是后加的备份参数。本修复之前生成的备份 //! 文件不含 DROP,对其执行恢复会在第一条「relation already exists」处中止并报 //! 失败(行为正确,但无法恢复数据)——需重新创建备份才能恢复。 // Component/PathBuf/chrono::Utc 仅 server 构建的备份逻辑用到。 #[cfg(feature = "server")] use std::path::{Component, PathBuf}; #[cfg(feature = "server")] use chrono::Utc; use dioxus::prelude::*; use serde::{Deserialize, Serialize}; // admin 鉴权 + AppError + tasks 进度表仅在 server 构建里被 server function 体引用。 #[cfg(feature = "server")] use crate::api::auth::get_current_admin_user; #[cfg(feature = "server")] use crate::api::database::tasks::{self, TaskKind, TaskStatus}; #[cfg(feature = "server")] use crate::api::error::AppError; // 以下常量仅被 server 构建的备份/恢复逻辑引用(WASM 构建里相关函数体被 cfg 剥掉, // 故常量也需 gate,否则非 server 构建会报 dead_code)。 /// 备份目录(项目根,与 uploads/ 平级,gitignored)。 #[cfg(feature = "server")] const BACKUP_DIR: &str = "backups"; /// 文件名白名单正则:仅字母数字下划线点连字符(防路径穿越)。 #[cfg(feature = "server")] const FILENAME_RE: &str = r"^[a-zA-Z0-9_.\-]+$"; /// 备份文件签名头(恢复时校验,拒绝非本系统文件)。 #[cfg(feature = "server")] const BACKUP_SIGNATURE: &str = "-- YGGDRASIL BACKUP v1"; /// 备份文件元信息(列表展示用)。 #[derive(Serialize, Deserialize, Debug, Clone)] pub struct BackupInfo { pub filename: String, pub size_bytes: u64, /// 备份模式:pg_dump / sql-fallback(从签名头解析)。 pub mode: String, pub created_at: Option, } /// 发起备份,立即返回 task_id,后台任务执行。 #[server(CreateBackup, "/api")] pub async fn create_backup() -> Result { let _user = get_current_admin_user().await?; #[cfg(feature = "server")] { let task_id = uuid::Uuid::new_v4().to_string(); tasks::insert(task_id.clone(), TaskKind::Backup); let tid = task_id.clone(); tokio::spawn(async move { run_backup(&tid).await; }); Ok(task_id) } #[cfg(not(feature = "server"))] { Ok(String::new()) } } /// 后台执行备份:pg_dump 优先,不可用回退纯 SQL。 #[cfg(feature = "server")] async fn run_backup(task_id: &str) { let _ = std::fs::create_dir_all(BACKUP_DIR); let timestamp = Utc::now().format("%Y%m%d_%H%M%S").to_string(); // 探测 pg_dump let pg_dump_ok = std::process::Command::new("pg_dump") .arg("--version") .output() .is_ok(); if pg_dump_ok { run_pg_dump_backup(task_id, ×tamp).await; } else { run_sql_fallback_backup(task_id, ×tamp).await; } } /// pg_dump 模式:子进程生成完整备份(含 schema),前置签名头。 /// /// `--clean --if-exists`:生成的脚本含 `DROP ... IF EXISTS`,使恢复幂等 /// (恢复前自动删除现有对象,避免「relation already exists」/主键冲突导致 /// 数据零写入)。详见 `run_restore`。 #[cfg(feature = "server")] async fn run_pg_dump_backup(task_id: &str, timestamp: &str) { tasks::update( task_id, "正在用 pg_dump 导出", 10, TaskStatus::Running, None, None, None, ); let filename = format!("backup_{}.sql", timestamp); let path = backup_path(&filename); let db_url = match std::env::var("DATABASE_URL") { Ok(u) if !u.is_empty() => u, _ => { tasks::update( task_id, "DATABASE_URL 未配置", 100, TaskStatus::Failed, None, Some("pg_dump 备份需要 DATABASE_URL".to_string()), None, ); return; } }; let mut header = String::new(); header.push_str(&format!("{}\n", BACKUP_SIGNATURE)); header.push_str(&format!("-- created_at: {}\n", Utc::now())); header.push_str("-- mode: pg_dump\n"); // 先写签名头,再追加 pg_dump 输出。 let write_header = std::fs::write(&path, &header); if write_header.is_err() { tasks::update( task_id, "写入备份文件失败", 100, TaskStatus::Failed, None, Some("无法写入备份目录".to_string()), None, ); return; } let stdout_file = match std::fs::OpenOptions::new().append(true).open(&path) { Ok(f) => f, Err(e) => { tasks::update( task_id, "pg_dump 启动失败", 100, TaskStatus::Failed, None, Some(e.to_string()), None, ); return; } }; let child = match std::process::Command::new("pg_dump") .arg(&db_url) // --clean --if-exists:生成 DROP ... IF EXISTS,让恢复幂等(先删后建), // 否则恢复时表已存在 → CREATE/COPY 全部失败、数据零写入。 .arg("--clean") .arg("--if-exists") .stdout(std::process::Stdio::from(stdout_file)) .stderr(std::process::Stdio::piped()) .spawn() { Ok(c) => c, Err(e) => { tasks::update( task_id, "pg_dump 启动失败", 100, TaskStatus::Failed, None, Some(e.to_string()), None, ); return; } }; let output = child.wait_with_output(); match output { Ok(o) if o.status.success() => { tasks::update( task_id, "完成", 100, TaskStatus::Done, None, None, Some(filename), ); } Ok(o) => { let stderr = String::from_utf8_lossy(&o.stderr).to_string(); tasks::update( task_id, "pg_dump 失败", 100, TaskStatus::Failed, None, Some(stderr), None, ); } Err(e) => { tasks::update( task_id, "pg_dump 执行失败", 100, TaskStatus::Failed, None, Some(e.to_string()), None, ); } } } /// 纯 SQL 回退:仅备份数据(不含 schema),按表计数精确进度。 #[cfg(feature = "server")] async fn run_sql_fallback_backup(task_id: &str, timestamp: &str) { tasks::update( task_id, "pg_dump 不可用,使用纯 SQL 回退(仅数据)", 10, TaskStatus::Running, Some("仅备份数据,不含 schema/索引/触发器".to_string()), None, None, ); let filename = format!("backup_{}_sqlfallback.sql", timestamp); let path = backup_path(&filename); let client = match crate::db::pool::get_conn().await { Ok(c) => c, Err(e) => { tasks::update( task_id, "数据库连接失败", 100, TaskStatus::Failed, None, Some(e.to_string()), None, ); return; } }; // 取 public schema 下所有表名 let tables: Vec = match client .query( "SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename", &[], ) .await { Ok(rows) => rows.into_iter().map(|r| r.get(0)).collect(), Err(e) => { tasks::update( task_id, "读取表清单失败", 100, TaskStatus::Failed, None, Some(e.to_string()), None, ); return; } }; let total = tables.len().max(1); let mut out = String::new(); out.push_str(&format!("{}\n", BACKUP_SIGNATURE)); out.push_str(&format!("-- created_at: {}\n", Utc::now())); out.push_str("-- mode: sql-fallback\n\n"); for (i, table) in tables.iter().enumerate() { out.push_str(&format!("\n-- table: {}\n", table)); let copy_stmt = format!("COPY \"{}\" TO STDOUT WITH CSV", table); match client.copy_out(©_stmt).await { Ok(stream) => { use futures::StreamExt; // CopyOutStream 是 !Unpin,必须 pin 才能调 next。 tokio::pin!(stream); while let Some(chunk) = stream.next().await { if let Ok(bytes) = chunk { out.push_str(&String::from_utf8_lossy(&bytes)); } } } Err(e) => { out.push_str(&format!("-- 导出失败: {}\n", e)); } } // 按表更新进度(用 u32 避免大 schema 下的截断/溢出) tasks::update( task_id, &format!("导出表 {}/{}", i + 1, total), (10 + (i + 1) as u32 * 90 / total as u32).min(99) as u8, TaskStatus::Running, None, None, None, ); } if std::fs::write(&path, out).is_err() { tasks::update( task_id, "写入备份文件失败", 100, TaskStatus::Failed, None, Some("无法写入备份目录".to_string()), None, ); return; } tasks::update( task_id, "完成", 100, TaskStatus::Done, None, None, Some(filename), ); } /// 发起恢复:校验签名 + 路径穿越防护 + 二次确认,立即返回 task_id。 #[server(RestoreBackup, "/api")] pub async fn restore_backup(filename: String, confirm: bool) -> Result { let _user = get_current_admin_user().await?; // 全部校验都在 server cfg 块内:confirm/regex/backup_path/std::fs 都是 server-only。 // WASM 侧的 server-function 客户端桩只返回 Ok(String::new())。 #[cfg(feature = "server")] { if !confirm { return Err(AppError::BadRequest("需确认恢复(会覆盖现有数据)".to_string()).into()); } // 路径穿越防护 if !is_valid_backup_filename(&filename) { return Err(AppError::BadRequest("无效的文件名".to_string()).into()); } let path = backup_path(&filename); if !path.exists() { return Err(AppError::NotFound("备份文件不存在").into()); } // 签名校验:首行需含签名 let content = std::fs::read_to_string(&path).unwrap_or_default(); if !has_valid_signature(&content) { return Err( AppError::BadRequest("非本系统生成的备份文件,拒绝恢复".to_string()).into(), ); } let task_id = uuid::Uuid::new_v4().to_string(); tasks::insert(task_id.clone(), TaskKind::Restore); let tid = task_id.clone(); let f = filename; tokio::spawn(async move { run_restore(&tid, &f).await; }); Ok(task_id) } #[cfg(not(feature = "server"))] { // WASM 客户端桩:忽略参数,返回空 task_id。 let _ = (filename, confirm); Ok(String::new()) } } /// 后台执行恢复:探测 psql,可用则 psql -f,不可用则报告。 /// /// **幂等恢复**:备份由 `pg_dump --clean --if-exists` 生成,脚本自带 /// `DROP ... IF EXISTS`,恢复时会先删后建,数据完全回到备份时刻。 /// /// **错误中止**:`-v ON_ERROR_STOP=1` 让 psql 在第一条 SQL 错误时立即退出 /// (退出码 3)。否则 psql 即使满屏 ERROR 也返回 0,导致 `status.success()` /// 误判为成功——这是「恢复完成却无任何数据变更」假成功的根因。 /// /// **恢复成功后失效全量缓存**:恢复会用备份时刻的数据重建 posts 等表, /// 现有 moka 缓存(列表/标签/单篇/统计/搜索)与 SSR 世代号必须一并冲刷, /// 否则前端仍读旧数据。 #[cfg(feature = "server")] async fn run_restore(task_id: &str, filename: &str) { let path = backup_path(filename); let db_url = match std::env::var("DATABASE_URL") { Ok(u) if !u.is_empty() => u, _ => { tasks::update( task_id, "DATABASE_URL 未配置", 100, TaskStatus::Failed, None, Some("恢复需要 DATABASE_URL".to_string()), None, ); return; } }; let psql_ok = std::process::Command::new("psql") .arg("--version") .output() .is_ok(); if !psql_ok { tasks::update( task_id, "psql 不可用", 100, TaskStatus::Failed, None, Some("恢复需要 psql,但当前环境未安装 psql".to_string()), None, ); return; } tasks::update( task_id, "正在用 psql 恢复", 50, TaskStatus::Running, None, None, None, ); let output = std::process::Command::new("psql") .arg(&db_url) // ON_ERROR_STOP=1:遇 SQL 错误立即中止(退出码 3)。 // 不加这个,psql 即使满屏 ERROR 也返回 0,status.success() 误报成功。 .arg("-v") .arg("ON_ERROR_STOP=1") .arg("-f") .arg(&path) .stderr(std::process::Stdio::piped()) .output(); match output { Ok(o) if o.status.success() => { // 恢复用备份时刻的数据重建了 posts 等表,必须冲刷全部文章相关缓存 // 与 SSR 世代号,否则前端仍读旧数据(被删的文章不会重新出现)。 crate::cache::invalidate_all_post_caches(); crate::cache::invalidate_search_results(); crate::ssr_cache::bump_global_generation(); tasks::update(task_id, "恢复完成", 100, TaskStatus::Done, None, None, None); } Ok(o) => { let stderr = String::from_utf8_lossy(&o.stderr).to_string(); tasks::update( task_id, "恢复失败", 100, TaskStatus::Failed, None, Some(stderr), None, ); } Err(e) => { tasks::update( task_id, "psql 启动失败", 100, TaskStatus::Failed, None, Some(e.to_string()), None, ); } } } /// 列出 backups/ 目录下的备份文件元信息。 #[server(ListBackups, "/api")] pub async fn list_backups() -> Result, ServerFnError> { let _user = get_current_admin_user().await?; #[cfg(feature = "server")] { let mut infos: Vec = Vec::new(); if let Ok(entries) = std::fs::read_dir(BACKUP_DIR) { for entry in entries.flatten() { let name = entry.file_name().to_string_lossy().to_string(); if !name.ends_with(".sql") { continue; } let meta = match entry.metadata() { Ok(m) => m, Err(_) => continue, }; let mode = std::fs::read_to_string(entry.path()) .map(|s| parse_backup_mode(&s)) .unwrap_or_else(|_| "unknown".to_string()); let created_at = meta .modified() .ok() .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| { chrono::DateTime::::from_timestamp(d.as_secs() as i64, 0) .map(|dt| dt.to_rfc3339()) .unwrap_or_default() }); infos.push(BackupInfo { filename: name, size_bytes: meta.len(), mode, created_at, }); } } // 按创建时间降序(新的在前) infos.sort_by(|a, b| b.created_at.cmp(&a.created_at)); Ok(infos) } #[cfg(not(feature = "server"))] { Ok(vec![]) } } /// 删除备份文件。 #[server(DeleteBackup, "/api")] pub async fn delete_backup(filename: String) -> Result<(), ServerFnError> { let _user = get_current_admin_user().await?; #[cfg(feature = "server")] { if !is_valid_backup_filename(&filename) { return Err(AppError::BadRequest("无效的文件名".to_string()).into()); } let path = backup_path(&filename); if !path.exists() { return Err(AppError::NotFound("备份文件不存在").into()); } std::fs::remove_file(&path).map_err(|_| AppError::Internal("删除失败"))?; Ok(()) } #[cfg(not(feature = "server"))] { Ok(()) } } /// 构造 backups/ 下的安全路径(额外防御:校验规范化后仍在 BACKUP_DIR 内)。 /// /// 纵深防御:即便第一道白名单 `is_valid_backup_filename` 被绕过,这里也要 /// 保证结果不逃出 BACKUP_DIR。直接对 filename 做 components 检查—— /// 含 `..`(ParentDir)、绝对路径前缀(RootDir/Prefix,如 `/etc` 或 `C:\`) /// 的 filename 一律降级为 BACKUP_DIR 本身。 /// /// 注意:不能用 `[BACKUP_DIR, filename].collect::()` 后再检—— /// 当 filename 是绝对路径时,PathBuf 语义会丢弃 BACKUP_DIR 前缀(如 /// `["backups", "/etc/passwd"]` → `/etc/passwd`),导致 components 检查 /// 在错位的路径上运行而漏判。必须先检 filename 本身。 #[cfg(feature = "server")] fn backup_path(filename: &str) -> PathBuf { // 直接检查 filename 的 components:只允许 Normal 段。 let filename_is_safe = std::path::Path::new(filename) .components() .all(|c| matches!(c, Component::Normal(_))); if filename_is_safe { let mut p = PathBuf::from(BACKUP_DIR); p.push(filename); p } else { // 命中 ParentDir/RootDir/Prefix/CurDir → 降级为 BACKUP_DIR PathBuf::from(BACKUP_DIR) } } /// 校验备份文件名是否符合白名单(仅字母数字下划线点连字符)。 /// 返回 true 表示安全可用。提取为纯函数便于单测覆盖路径穿越边界。 #[cfg(feature = "server")] fn is_valid_backup_filename(filename: &str) -> bool { // regex::Regex::new 在 FILENAME_RE 是常量正则,编译期可验证不会 panic。 regex::Regex::new(FILENAME_RE) .map(|re| re.is_match(filename)) .unwrap_or(false) } /// 从备份文件全文提取 `-- mode: ` 行的值(如 "pg_dump"/"sql-fallback")。 /// 提取为纯函数:把文件内容作为参数传入,便于单测。 /// 缺失或格式不符返回 "unknown"。 #[cfg(feature = "server")] fn parse_backup_mode(content: &str) -> String { content .lines() .find(|l| l.starts_with("-- mode:")) .map(|l| l.trim_start_matches("-- mode:").trim().to_string()) .filter(|s| !s.is_empty()) .unwrap_or_else(|| "unknown".to_string()) } /// 校验备份文件首行是否含本系统签名头。 /// 提取为纯函数:把首行(或全文)作为参数传入,便于单测。 #[cfg(feature = "server")] fn has_valid_signature(content: &str) -> bool { content .lines() .next() .map(|l| l.trim().contains(BACKUP_SIGNATURE)) .unwrap_or(false) } /// Axum 处理器:下载备份文件(admin 鉴权 + 路径白名单)。 /// 仅 server 构建:纯 Axum 路由(在 main.rs 注册),无 WASM 消费者。 #[cfg(feature = "server")] pub async fn download_backup( axum::extract::Path(filename): axum::extract::Path, headers: axum::http::HeaderMap, ) -> Result { use axum::http::{header, StatusCode}; // 鉴权 let cookie_header = headers .get("cookie") .and_then(|h| h.to_str().ok()) .unwrap_or(""); let token = crate::auth::session::parse_session_token(cookie_header).map(str::to_string); let token = match token { Some(t) => t, None => return Err((StatusCode::UNAUTHORIZED, "未登录".to_string())), }; let user = match crate::api::auth::get_user_by_token(&token).await { Ok(Some(u)) => u, _ => return Err((StatusCode::UNAUTHORIZED, "会话已过期".to_string())), }; if user.role != crate::models::user::UserRole::Admin { return Err((StatusCode::FORBIDDEN, "权限不足".to_string())); } // 路径白名单 if !is_valid_backup_filename(&filename) { return Err((StatusCode::BAD_REQUEST, "无效的文件名".to_string())); } let path = backup_path(&filename); let bytes = tokio::fs::read(&path) .await .map_err(|_| (StatusCode::NOT_FOUND, "文件不存在".to_string()))?; let disposition = format!("attachment; filename=\"{}\"", filename); Ok(( StatusCode::OK, [ ( header::CONTENT_TYPE, axum::http::HeaderValue::from_static("application/sql; charset=utf-8"), ), ( header::CONTENT_DISPOSITION, axum::http::HeaderValue::from_str(&disposition) .unwrap_or_else(|_| axum::http::HeaderValue::from_static("attachment")), ), ], axum::body::Body::from(bytes), )) } #[cfg(all(test, feature = "server"))] mod tests { use super::*; // ── is_valid_backup_filename:文件名白名单(路径穿越第一道防线) ── #[test] fn filename_accepts_normal_names() { for name in [ "backup_20260702_120000.sql", "backup_20260702_120000_sqlfallback.sql", "a.sql", "A-B_C.123", ] { assert!(is_valid_backup_filename(name), "正常文件名应通过: {name}"); } } #[test] fn filename_rejects_path_traversal() { // 路径穿越:白名单只允许字母数字下划线点连字符,/ 和 .. 都应被拒。 for evil in [ "../etc/passwd", "..\\windows\\win.ini", "/etc/passwd", "a/../../b", "backup.sql/../../etc", ] { assert!(!is_valid_backup_filename(evil), "路径穿越应被拒: {evil}"); } } #[test] fn filename_rejects_spaces_and_special_chars() { // 空格、中文、shell 元字符等都不在白名单。 for evil in [ "backup with space.sql", "备份.sql", "a;rm -rf.sql", r"a\$b.sql", "a`b`.sql", "", ] { assert!(!is_valid_backup_filename(evil), "特殊字符应被拒: {evil:?}"); } } // ── backup_path:路径穿越纵深防御(白名单之外的二次防御) ──────── #[test] fn backup_path_stays_in_backup_dir_for_normal_name() { let p = backup_path("backup_20260702.sql"); assert!(p.starts_with(BACKUP_DIR), "应在 {BACKUP_DIR}/ 下"); assert_eq!( p.file_name().and_then(|n| n.to_str()), Some("backup_20260702.sql") ); } #[test] fn backup_path_collapses_traversal_to_backup_dir() { // 即便绕过白名单调用 backup_path(纵深防御),../ 也应被规约回 BACKUP_DIR, // 而非指向 backups/ 之外。Component::ParentDir / RootDir 命中即降级。 for evil in ["../etc/passwd", "../../etc/shadow"] { let p = backup_path(evil); // 不应逃出 BACKUP_DIR(应为 BACKUP_DIR 本身,不含文件名) assert_eq!( p, PathBuf::from(BACKUP_DIR), "穿越应被规约回 {BACKUP_DIR}: {evil}" ); } } #[test] fn backup_path_rejects_absolute_path() { // Component::RootDir 命中也应降级。 let p = backup_path("/etc/passwd"); assert_eq!(p, PathBuf::from(BACKUP_DIR)); } // ── has_valid_signature:备份签名校验(拒绝非本系统文件) ─────── #[test] fn signature_matches_exact_header() { let content = "-- YGGDRASIL BACKUP v1\n-- mode: pg_dump\nSELECT 1;\n"; assert!(has_valid_signature(content)); } #[test] fn signature_matches_with_leading_whitespace() { // 首行允许前导空白(trim 后匹配),容忍编辑器缩进。 let content = " -- YGGDRASIL BACKUP v1\nrest\n"; assert!(has_valid_signature(content)); } #[test] fn signature_rejects_non_system_file() { // 普通 SQL 文件首行不含签名 → 拒绝恢复(防任意文件读取/执行)。 let content = "SELECT * FROM users;\n-- YGGDRASIL BACKUP v1\n"; // 注意:签名必须在首行。第二行有签名不算。 assert!(!has_valid_signature(content)); } #[test] fn signature_rejects_empty_and_garbage() { assert!(!has_valid_signature("")); assert!(!has_valid_signature("garbage\n")); assert!(!has_valid_signature("\n\n-- YGGDRASIL BACKUP v1")); } // ── parse_backup_mode:模式解析(列表展示用) ─────────────────── #[test] fn parse_mode_pg_dump() { let content = "-- YGGDRASIL BACKUP v1\n-- mode: pg_dump\n...\n"; assert_eq!(parse_backup_mode(content), "pg_dump"); } #[test] fn parse_mode_sql_fallback() { let content = "-- YGGDRASIL BACKUP v1\n-- mode: sql-fallback\n\n-- table: posts\n"; assert_eq!(parse_backup_mode(content), "sql-fallback"); } #[test] fn parse_mode_unknown_when_absent() { let content = "-- YGGDRASIL BACKUP v1\nSELECT 1;\n"; assert_eq!(parse_backup_mode(content), "unknown"); } #[test] fn parse_mode_unknown_when_empty_value() { // "-- mode:" 后无值 → unknown(防空字符串显示) let content = "-- mode:\nrest\n"; assert_eq!(parse_backup_mode(content), "unknown"); } #[test] fn parse_mode_only_matches_first_occurrence() { // 多个 -- mode: 行取第一个。 let content = "-- mode: pg_dump\n-- mode: sql-fallback\n"; assert_eq!(parse_backup_mode(content), "pg_dump"); } }