Skip to main content

yggdrasil/api/database/
backup.rs

1#![allow(clippy::unused_unit, deprecated)]
2
3//! 备份与恢复(读写,最高风险)。
4//!
5//! 备份:探测 pg_dump 可用性——可用则子进程生成完整、角色无关的 .sql
6//! (`--clean --if-exists --no-owner --no-privileges`),不可用则回退纯 SQL(仅数据)。
7//! 备份文件含签名头。每次备份按设置附带 uploads 素材打包(`_uploads.tar.gz`,
8//! 排除可重建的 `.cache/`),与 .sql 成对展示/下载/删除。
9//!
10//! 来源标记:手动备份文件名前缀 `backup_`,定时任务自动备份前缀 `auto_`;
11//! 自动备份按 `backup_retention_count` 轮转(只删自动,手动永不自动删)。
12//!
13//! 恢复:仅接受本系统生成的备份(签名校验)+ 二次确认 + 路径穿越防护;
14//! 旧备份的 pg_dump 属主语句会在 COPY 感知的预处理阶段移除,再由
15//! `psql --single-transaction -v ON_ERROR_STOP=1` 原子重放,失败时完整回滚;
16//! 仅恢复数据库——uploads 需从配对 tar.gz 手动还原;成功后全量失效缓存。
17//! 长耗时操作走后台任务 + 进度轮询(见 [`crate::api::database::tasks`])。
18//!
19//! 导入:`POST /api/database/backups/import`(纯 Axum 路由,multipart 流式落盘)
20//! 把本机备份回灌进 backups/。两步式——导入只入库,恢复仍走上述管线。
21//! 导入时即强制签名校验(外来 SQL 拒收不留盘);保留原始文件名、同名冲突拒绝;
22//! `.tmp` 落盘 + 原子 rename,半截文件永不出现在列表。上限 `BACKUP_IMPORT_MAX_MB`
23//! (默认 512MB),路由层 DefaultBodyLimit + 流式计数双保险。
24//!
25//! **兼容性提示**:`--clean --if-exists` 是后加的备份参数。本修复之前生成的备份
26//! 文件不含 DROP,对其执行恢复会在第一条「relation already exists」处中止并报
27//! 失败(行为正确,但无法恢复数据)——需重新创建备份才能恢复。
28
29// Component/PathBuf/chrono::Utc 仅 server 构建的备份逻辑用到。
30#[cfg(feature = "server")]
31use std::path::{Component, Path, PathBuf};
32
33#[cfg(feature = "server")]
34use chrono::Utc;
35use dioxus::prelude::*;
36use serde::{Deserialize, Serialize};
37
38// admin 鉴权 + AppError + tasks 进度表仅在 server 构建里被 server function 体引用。
39#[cfg(feature = "server")]
40use crate::api::auth::get_current_admin_user;
41#[cfg(feature = "server")]
42use crate::api::database::tasks::{self, TaskKind, TaskStatus};
43#[cfg(feature = "server")]
44use crate::api::error::AppError;
45
46// 以下常量仅被 server 构建的备份/恢复逻辑引用(WASM 构建里相关函数体被 cfg 剥掉,
47// 故常量也需 gate,否则非 server 构建会报 dead_code)。
48
49/// 备份目录(项目根,与 uploads/ 平级,gitignored)。
50#[cfg(feature = "server")]
51const BACKUP_DIR: &str = "backups";
52/// 文件名白名单正则:仅字母数字下划线点连字符(防路径穿越)。
53#[cfg(feature = "server")]
54const FILENAME_RE: &str = r"^[a-zA-Z0-9_.\-]+$";
55/// 备份文件签名头(恢复时校验,拒绝非本系统文件)。
56#[cfg(feature = "server")]
57const BACKUP_SIGNATURE: &str = "-- YGGDRASIL BACKUP v1";
58/// 导入单文件上限默认值(MB),env `BACKUP_IMPORT_MAX_MB` 覆盖。
59#[cfg(feature = "server")]
60const DEFAULT_IMPORT_MAX_MB: u64 = 512;
61/// multipart 框架开销宽限(boundary/头部),加在路由 body limit 上,
62/// 避免恰好等于上限的文件被框架字节误杀(流式计数只计载荷字节,才是权威)。
63#[cfg(feature = "server")]
64pub(crate) const MULTIPART_FRAME_SLACK: u64 = 1024 * 1024;
65
66/// 备份文件元信息(列表展示用)。
67#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
68pub struct BackupInfo {
69    pub filename: String,
70    pub size_bytes: u64,
71    /// 备份模式:pg_dump / sql-fallback(从签名头解析)。
72    pub mode: String,
73    pub created_at: Option<String>,
74    /// 来源:manual / auto(从文件名前缀解析)。
75    pub origin: String,
76    /// 配对的 uploads 打包文件名与大小(无则 None——老备份或禁用了素材打包)。
77    pub uploads_filename: Option<String>,
78    pub uploads_size_bytes: Option<u64>,
79}
80
81/// 备份来源:决定文件名前缀与是否参与自动轮转。
82#[cfg(feature = "server")]
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub(crate) enum BackupOrigin {
85    /// admin 面板手动触发(`backup_` 前缀,永不自动删除)。
86    Manual,
87    /// 定时任务触发(`auto_` 前缀,按 retention_count 轮转)。
88    Auto,
89}
90
91#[cfg(feature = "server")]
92impl BackupOrigin {
93    fn file_prefix(self) -> &'static str {
94        match self {
95            Self::Manual => "backup",
96            Self::Auto => "auto",
97        }
98    }
99}
100
101/// 一次备份运行的结果(调度任务据此落库 last_run)。
102#[cfg(feature = "server")]
103#[derive(Debug)]
104pub(crate) struct BackupRunOutcome {
105    /// 数据库 SQL 文件名。
106    pub sql_filename: String,
107    /// uploads 打包文件名(禁用了素材打包时为 None)。
108    pub uploads_filename: Option<String>,
109    /// 非致命告警(如 DB 成功但 uploads 打包失败)。
110    pub warning: Option<String>,
111}
112
113/// 发起备份,立即返回 task_id,后台任务执行。
114#[server(CreateBackup, "/api")]
115pub async fn create_backup() -> Result<String, ServerFnError> {
116    let _user = get_current_admin_user().await?;
117
118    #[cfg(feature = "server")]
119    {
120        let task_id = uuid::Uuid::new_v4().to_string();
121        tasks::insert(task_id.clone(), TaskKind::Backup);
122        let tid = task_id.clone();
123        tokio::spawn(async move {
124            // 手动触发:进度经任务表轮询展示,返回值无人消费。
125            let _ = run_backup(&tid, BackupOrigin::Manual).await;
126        });
127        Ok(task_id)
128    }
129    #[cfg(not(feature = "server"))]
130    {
131        Ok(String::new())
132    }
133}
134
135/// 定时任务入口:注册任务后执行自动备份,返回结果供调度器落库 last_run。
136#[cfg(feature = "server")]
137pub(crate) async fn run_auto_backup() -> Result<BackupRunOutcome, String> {
138    let task_id = uuid::Uuid::new_v4().to_string();
139    tasks::insert(task_id.clone(), TaskKind::Backup);
140    run_backup(&task_id, BackupOrigin::Auto).await
141}
142
143/// 后台执行备份:pg_dump 优先,不可用回退纯 SQL;成功后按设置打包 uploads,
144/// 自动备份再做轮转。Ok/Err 均已同步进任务进度表(供前端轮询),返回值供
145/// 调度器落库 last_run。
146#[cfg(feature = "server")]
147async fn run_backup(task_id: &str, origin: BackupOrigin) -> Result<BackupRunOutcome, String> {
148    let _ = std::fs::create_dir_all(BACKUP_DIR);
149    let timestamp = Utc::now().format("%Y%m%d_%H%M%S").to_string();
150    let prefix = origin.file_prefix();
151
152    // 读取备份设置(include_uploads / retention_count);读取失败用默认值——
153    // DB 不可达时 pg_dump 自身也会失败并按原路径上报。
154    let settings = match crate::db::pool::get_conn().await {
155        Ok(conn) => crate::api::settings::load_backup_settings(&conn)
156            .await
157            .unwrap_or_default(),
158        Err(_) => crate::models::settings::BackupSettings::default(),
159    };
160
161    // 探测 pg_dump(fork+exec+wait 仍是阻塞系统调用,移出 tokio worker 线程)
162    let pg_dump_ok = tokio::task::spawn_blocking(|| {
163        std::process::Command::new("pg_dump")
164            .arg("--version")
165            .output()
166            .is_ok()
167    })
168    .await
169    .unwrap_or(false);
170
171    let sql_result = if pg_dump_ok {
172        run_pg_dump_backup(task_id, prefix, &timestamp).await
173    } else {
174        run_sql_fallback_backup(task_id, prefix, &timestamp).await
175    };
176    let sql_filename = match sql_result {
177        Ok(f) => f,
178        // 子流程已把具体错误写进任务进度表,这里仅透传给调度器。
179        Err(e) => return Err(e),
180    };
181
182    // uploads 素材打包:失败不拖垮已成功的 DB 备份,降级为告警。
183    let mut uploads_filename = None;
184    let mut warning = None;
185    if settings.include_uploads {
186        tasks::update(
187            task_id,
188            "正在打包 uploads 素材",
189            92,
190            TaskStatus::Running,
191            None,
192            None,
193            None,
194        );
195        let tar_name = uploads_archive_name(&sql_filename);
196        let tar_path = backup_path(&tar_name);
197        match tokio::task::spawn_blocking(move || {
198            create_uploads_tarball(Path::new("uploads"), &tar_path)
199        })
200        .await
201        {
202            Ok(Ok(())) => uploads_filename = Some(tar_name),
203            Ok(Err(e)) => warning = Some(format!("uploads 打包失败: {e}")),
204            Err(e) => warning = Some(format!("uploads 打包任务panic: {e}")),
205        }
206        if warning.is_some() {
207            tracing::warn!("backup uploads tarball failed: {:?}", warning);
208        }
209    }
210
211    // 自动备份轮转:只删 auto_ 前缀的最旧者,手动备份永不触碰。
212    if origin == BackupOrigin::Auto {
213        rotate_auto_backups(settings.retention_count);
214    }
215
216    tasks::update(
217        task_id,
218        "完成",
219        100,
220        TaskStatus::Done,
221        warning.clone(),
222        None,
223        Some(sql_filename.clone()),
224    );
225    Ok(BackupRunOutcome {
226        sql_filename,
227        uploads_filename,
228        warning,
229    })
230}
231
232/// SQL 文件名 → 配对 uploads 打包文件名(`X.sql` → `X_uploads.tar.gz`)。
233#[cfg(feature = "server")]
234fn uploads_archive_name(sql_filename: &str) -> String {
235    format!("{}_uploads.tar.gz", sql_filename.trim_end_matches(".sql"))
236}
237
238/// 把 uploads 目录打成 tar.gz(排除可重建的 .cache/ 与 VCS 占位 .gitkeep)。
239/// 阻塞 IO + CPU(gzip),调用方须放 spawn_blocking。
240/// uploads_dir 显式传入(而非写死相对路径)便于测试注入临时目录。
241#[cfg(feature = "server")]
242fn create_uploads_tarball(uploads_dir: &Path, out_path: &Path) -> std::io::Result<()> {
243    let file = std::fs::File::create(out_path)?;
244    let gz = flate2::write::GzEncoder::new(file, flate2::Compression::default());
245    let mut builder = tar::Builder::new(gz);
246    if uploads_dir.is_dir() {
247        for entry in std::fs::read_dir(uploads_dir)? {
248            let entry = entry?;
249            let name = entry.file_name();
250            if name == ".cache" || name == ".gitkeep" {
251                continue;
252            }
253            let path = entry.path();
254            if path.is_dir() {
255                builder.append_dir_all(&name, &path)?;
256            } else if path.is_file() {
257                builder.append_path_with_name(&path, &name)?;
258            }
259        }
260    }
261    let gz = builder.into_inner()?;
262    gz.finish()?;
263    Ok(())
264}
265
266/// 从自动备份 SQL 文件名列表挑出超出保留份数、应删除的最旧者。
267/// 文件名内嵌定宽时间戳,字典序即时间序。纯函数便于单测。
268#[cfg(feature = "server")]
269fn select_expired_auto_backups(names: &[String], keep: usize) -> Vec<String> {
270    let mut autos: Vec<&String> = names
271        .iter()
272        .filter(|n| n.starts_with("auto_") && n.ends_with(".sql"))
273        .collect();
274    autos.sort();
275    let excess = autos.len().saturating_sub(keep);
276    autos.into_iter().take(excess).cloned().collect()
277}
278
279/// 执行自动备份轮转:删除超龄自动备份的 .sql 与配对 tar.gz。
280/// 单个删除失败只记日志,不中断其余。
281#[cfg(feature = "server")]
282fn rotate_auto_backups(keep: i32) {
283    let names: Vec<String> = match std::fs::read_dir(BACKUP_DIR) {
284        Ok(entries) => entries
285            .flatten()
286            .map(|e| e.file_name().to_string_lossy().to_string())
287            .collect(),
288        Err(e) => {
289            tracing::warn!("backup rotation: cannot read {BACKUP_DIR}: {e}");
290            return;
291        }
292    };
293    for sql_name in select_expired_auto_backups(&names, keep.max(0) as usize) {
294        for path in [
295            backup_path(&sql_name),
296            backup_path(&uploads_archive_name(&sql_name)),
297        ] {
298            match std::fs::remove_file(&path) {
299                Ok(()) => tracing::info!("backup rotation: deleted {}", path.display()),
300                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
301                Err(e) => {
302                    tracing::warn!("backup rotation: failed to delete {}: {e}", path.display())
303                }
304            }
305        }
306    }
307}
308
309/// pg_dump 模式:子进程生成完整备份(含 schema),前置签名头。
310///
311/// `--clean --if-exists` 生成幂等的删后重建脚本;`--no-owner --no-privileges`
312/// 去掉集群角色元数据,使备份可恢复到使用不同数据库角色的环境。详见 `run_restore`。
313///
314/// 成功返回 SQL 文件名(最终「完成」进度由调用方统一上报)。
315#[cfg(feature = "server")]
316fn pg_dump_command(db_url: &str) -> std::process::Command {
317    let mut command = std::process::Command::new("pg_dump");
318    command
319        .arg(db_url)
320        .arg("--clean")
321        .arg("--if-exists")
322        .arg("--no-owner")
323        .arg("--no-privileges");
324    command
325}
326
327#[cfg(feature = "server")]
328async fn run_pg_dump_backup(
329    task_id: &str,
330    prefix: &str,
331    timestamp: &str,
332) -> Result<String, String> {
333    tasks::update(
334        task_id,
335        "正在用 pg_dump 导出",
336        10,
337        TaskStatus::Running,
338        None,
339        None,
340        None,
341    );
342    let filename = format!("{prefix}_{timestamp}.sql");
343    let path = backup_path(&filename);
344    let db_url = match std::env::var("DATABASE_URL") {
345        Ok(u) if !u.is_empty() => u,
346        _ => {
347            let msg = "pg_dump 备份需要 DATABASE_URL".to_string();
348            tasks::update(
349                task_id,
350                "DATABASE_URL 未配置",
351                100,
352                TaskStatus::Failed,
353                None,
354                Some(msg.clone()),
355                None,
356            );
357            return Err(msg);
358        }
359    };
360
361    let mut header = String::new();
362    header.push_str(&format!("{}\n", BACKUP_SIGNATURE));
363    header.push_str(&format!("-- created_at: {}\n", Utc::now()));
364    header.push_str("-- mode: pg_dump\n");
365
366    // 先写签名头,再追加 pg_dump 输出。
367    if let Err(e) = std::fs::write(&path, &header) {
368        let msg = format!("无法写入备份目录: {e}");
369        tasks::update(
370            task_id,
371            "写入备份文件失败",
372            100,
373            TaskStatus::Failed,
374            None,
375            Some(msg.clone()),
376            None,
377        );
378        return Err(msg);
379    }
380
381    let stdout_file = match std::fs::OpenOptions::new().append(true).open(&path) {
382        Ok(f) => f,
383        Err(e) => {
384            let msg = e.to_string();
385            tasks::update(
386                task_id,
387                "pg_dump 启动失败",
388                100,
389                TaskStatus::Failed,
390                None,
391                Some(msg.clone()),
392                None,
393            );
394            return Err(msg);
395        }
396    };
397    // pg_dump 导出可持续数十秒到数分钟,整个子进程生命周期(spawn + wait_with_output)
398    // 移入 spawn_blocking,避免阻塞 tokio worker 线程。注意 stdout 重定向到备份文件,
399    // 故不能用 .output()(它会用 piped 覆盖 stdout 配置);保留 spawn() + wait_with_output()
400    // 两段式。闭包返回 Result<Output, (bool, io::Error)>:true=启动(spawn)阶段失败,
401    // false=等待(wait)阶段失败,分别对应原有「启动失败」「执行失败」两条上报路径;
402    // 闭包 panic(JoinError)按「执行失败」处理。
403    let dump_result = tokio::task::spawn_blocking(
404        move || -> Result<std::process::Output, (bool, std::io::Error)> {
405            let mut command = pg_dump_command(&db_url);
406            command
407                .stdout(std::process::Stdio::from(stdout_file))
408                .stderr(std::process::Stdio::piped())
409                .spawn()
410                .map_err(|e| (true, e))?
411                .wait_with_output()
412                .map_err(|e| (false, e))
413        },
414    )
415    .await
416    .unwrap_or_else(|join_e| Err((false, std::io::Error::other(join_e.to_string()))));
417    match dump_result {
418        Ok(o) if o.status.success() => Ok(filename),
419        Ok(o) => {
420            let msg = String::from_utf8_lossy(&o.stderr).to_string();
421            tasks::update(
422                task_id,
423                "pg_dump 失败",
424                100,
425                TaskStatus::Failed,
426                None,
427                Some(msg.clone()),
428                None,
429            );
430            Err(msg)
431        }
432        Err((true, e)) => {
433            let msg = e.to_string();
434            tasks::update(
435                task_id,
436                "pg_dump 启动失败",
437                100,
438                TaskStatus::Failed,
439                None,
440                Some(msg.clone()),
441                None,
442            );
443            Err(msg)
444        }
445        Err((false, e)) => {
446            let msg = e.to_string();
447            tasks::update(
448                task_id,
449                "pg_dump 执行失败",
450                100,
451                TaskStatus::Failed,
452                None,
453                Some(msg.clone()),
454                None,
455            );
456            Err(msg)
457        }
458    }
459}
460
461/// 纯 SQL 回退:仅备份数据(不含 schema),按表计数精确进度。
462///
463/// 注意:该产物不是合法 SQL 脚本(COPY TO STDOUT 的 CSV 原文拼接),
464/// 不能经 psql 恢复——仅供极端环境下抢救数据。生产镜像自带 pg_dump,
465/// 正常不会走到这里。
466///
467/// 成功返回 SQL 文件名(最终「完成」进度由调用方统一上报)。
468#[cfg(feature = "server")]
469async fn run_sql_fallback_backup(
470    task_id: &str,
471    prefix: &str,
472    timestamp: &str,
473) -> Result<String, String> {
474    tasks::update(
475        task_id,
476        "pg_dump 不可用,使用纯 SQL 回退(仅数据)",
477        10,
478        TaskStatus::Running,
479        Some("仅备份数据,不含 schema/索引/触发器,且不可经 psql 恢复".to_string()),
480        None,
481        None,
482    );
483    let filename = format!("{prefix}_{timestamp}_sqlfallback.sql");
484    let path = backup_path(&filename);
485
486    let client = match crate::db::pool::get_conn().await {
487        Ok(c) => c,
488        Err(e) => {
489            let msg = e.to_string();
490            tasks::update(
491                task_id,
492                "数据库连接失败",
493                100,
494                TaskStatus::Failed,
495                None,
496                Some(msg.clone()),
497                None,
498            );
499            return Err(msg);
500        }
501    };
502
503    // 取 public schema 下所有表名
504    let tables: Vec<String> = match client
505        .query(
506            "SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename",
507            &[],
508        )
509        .await
510    {
511        Ok(rows) => rows.into_iter().map(|r| r.get(0)).collect(),
512        Err(e) => {
513            let msg = e.to_string();
514            tasks::update(
515                task_id,
516                "读取表清单失败",
517                100,
518                TaskStatus::Failed,
519                None,
520                Some(msg.clone()),
521                None,
522            );
523            return Err(msg);
524        }
525    };
526    let total = tables.len().max(1);
527
528    let mut out = String::new();
529    out.push_str(&format!("{}\n", BACKUP_SIGNATURE));
530    out.push_str(&format!("-- created_at: {}\n", Utc::now()));
531    out.push_str("-- mode: sql-fallback\n\n");
532
533    for (i, table) in tables.iter().enumerate() {
534        out.push_str(&format!("\n-- table: {}\n", table));
535        let copy_stmt = format!("COPY \"{}\" TO STDOUT WITH CSV", table);
536        match client.copy_out(&copy_stmt).await {
537            Ok(stream) => {
538                use futures::StreamExt;
539                // CopyOutStream 是 !Unpin,必须 pin 才能调 next。
540                tokio::pin!(stream);
541                while let Some(chunk) = stream.next().await {
542                    if let Ok(bytes) = chunk {
543                        out.push_str(&String::from_utf8_lossy(&bytes));
544                    }
545                }
546            }
547            Err(e) => {
548                out.push_str(&format!("-- 导出失败: {}\n", e));
549            }
550        }
551        // 按表更新进度(用 u32 避免大 schema 下的截断/溢出)
552        tasks::update(
553            task_id,
554            &format!("导出表 {}/{}", i + 1, total),
555            (10 + (i + 1) as u32 * 90 / total as u32).min(99) as u8,
556            TaskStatus::Running,
557            None,
558            None,
559            None,
560        );
561    }
562
563    if let Err(e) = std::fs::write(&path, out) {
564        let msg = format!("无法写入备份目录: {e}");
565        tasks::update(
566            task_id,
567            "写入备份文件失败",
568            100,
569            TaskStatus::Failed,
570            None,
571            Some(msg.clone()),
572            None,
573        );
574        return Err(msg);
575    }
576    Ok(filename)
577}
578
579/// 发起恢复:校验签名 + 路径穿越防护 + 二次确认,立即返回 task_id。
580#[server(RestoreBackup, "/api")]
581pub async fn restore_backup(filename: String, confirm: bool) -> Result<String, ServerFnError> {
582    let _user = get_current_admin_user().await?;
583
584    // 全部校验都在 server cfg 块内:confirm/regex/backup_path/std::fs 都是 server-only。
585    // WASM 侧的 server-function 客户端桩只返回 Ok(String::new())。
586    #[cfg(feature = "server")]
587    {
588        if !confirm {
589            return Err(AppError::BadRequest("需确认恢复(会覆盖现有数据)".to_string()).into());
590        }
591        // 路径穿越防护
592        if !is_valid_backup_filename(&filename) {
593            return Err(AppError::BadRequest("无效的文件名".to_string()).into());
594        }
595        let path = backup_path(&filename);
596        if !path.exists() {
597            return Err(AppError::NotFound("备份文件不存在").into());
598        }
599
600        // 签名校验:仅读取首行(备份文件可达数十 MB,无需整文件读入内存)。
601        let first_line = read_first_line(&path).unwrap_or_default();
602        if !has_valid_signature(&first_line) {
603            return Err(
604                AppError::BadRequest("非本系统生成的备份文件,拒绝恢复".to_string()).into(),
605            );
606        }
607
608        let task_id = uuid::Uuid::new_v4().to_string();
609        tasks::insert(task_id.clone(), TaskKind::Restore);
610        let tid = task_id.clone();
611        let f = filename;
612        tokio::spawn(async move {
613            run_restore(&tid, &f).await;
614        });
615        Ok(task_id)
616    }
617    #[cfg(not(feature = "server"))]
618    {
619        // WASM 客户端桩:忽略参数,返回空 task_id。
620        let _ = (filename, confirm);
621        Ok(String::new())
622    }
623}
624
625/// 后台执行恢复:探测 psql,可用则原子恢复,不可用则报告。
626///
627/// **跨环境恢复**:旧备份可能含 `ALTER ... OWNER TO ...`,而目标集群未必存在
628/// 源库角色。恢复前流式生成临时副本,只移除 pg_dump 生成的属主语句;COPY 数据
629/// 原样保留。新备份已由 `--no-owner --no-privileges` 从源头去掉角色元数据。
630///
631/// **原子恢复**:备份由 `pg_dump --clean --if-exists` 生成,脚本自带
632/// `DROP ... IF EXISTS`;psql 使用 `--single-transaction`,任一 SQL 错误都会
633/// 回滚先前的 DROP/CREATE/COPY,不留下半恢复数据库。
634///
635/// **错误中止**:`-v ON_ERROR_STOP=1` 让 psql 在第一条 SQL 错误时立即退出
636/// (退出码 3)。否则 psql 即使满屏 ERROR 也返回 0,导致 `status.success()`
637/// 误判为成功——这是「恢复完成却无任何数据变更」假成功的根因。
638///
639/// **恢复成功后失效全量缓存**:恢复会用备份时刻的数据重建 posts 等表,
640/// 现有 moka 缓存(列表/标签/单篇/统计/搜索)与 SSR 世代号必须一并冲刷,
641/// 否则前端仍读旧数据。
642#[cfg(feature = "server")]
643fn sql_line_without_eol(mut line: &[u8]) -> &[u8] {
644    if let Some(stripped) = line.strip_suffix(b"\n") {
645        line = stripped;
646    }
647    if let Some(stripped) = line.strip_suffix(b"\r") {
648        line = stripped;
649    }
650    line
651}
652
653#[cfg(feature = "server")]
654fn is_legacy_pg_dump_owner_statement(line: &[u8]) -> bool {
655    const PREFIXES: [&[u8]; 3] = [b"ALTER FUNCTION ", b"ALTER SEQUENCE ", b"ALTER TABLE "];
656    let line = sql_line_without_eol(line);
657    PREFIXES.iter().any(|prefix| line.starts_with(prefix))
658        && line
659            .windows(b" OWNER TO ".len())
660            .any(|window| window == b" OWNER TO ")
661        && line.ends_with(b";")
662}
663
664#[cfg(feature = "server")]
665fn write_owner_neutral_restore_sql(
666    mut input: impl std::io::BufRead,
667    mut output: impl std::io::Write,
668) -> std::io::Result<usize> {
669    let mut line = Vec::new();
670    let mut in_copy_data = false;
671    let mut removed = 0;
672    loop {
673        line.clear();
674        if input.read_until(b'\n', &mut line)? == 0 {
675            break;
676        }
677        let sql_line = sql_line_without_eol(&line);
678        if in_copy_data {
679            output.write_all(&line)?;
680            if sql_line == b"\\." {
681                in_copy_data = false;
682            }
683            continue;
684        }
685        if sql_line.starts_with(b"COPY ") && sql_line.ends_with(b" FROM stdin;") {
686            in_copy_data = true;
687            output.write_all(&line)?;
688        } else if is_legacy_pg_dump_owner_statement(&line) {
689            removed += 1;
690        } else {
691            output.write_all(&line)?;
692        }
693    }
694    output.flush()?;
695    Ok(removed)
696}
697
698#[cfg(feature = "server")]
699struct PreparedRestoreSql {
700    path: PathBuf,
701    removed_owner_statements: usize,
702}
703
704#[cfg(feature = "server")]
705impl PreparedRestoreSql {
706    fn prepare(source: &Path) -> std::io::Result<Self> {
707        let input = std::io::BufReader::new(std::fs::File::open(source)?);
708        let path =
709            std::env::temp_dir().join(format!("yggdrasil-restore-{}.sql", uuid::Uuid::new_v4()));
710        let mut options = std::fs::OpenOptions::new();
711        options.write(true).create_new(true);
712        #[cfg(unix)]
713        {
714            use std::os::unix::fs::OpenOptionsExt;
715            options.mode(0o600);
716        }
717        let output = std::io::BufWriter::new(options.open(&path)?);
718        match write_owner_neutral_restore_sql(input, output) {
719            Ok(removed_owner_statements) => Ok(Self {
720                path,
721                removed_owner_statements,
722            }),
723            Err(error) => {
724                let _ = std::fs::remove_file(&path);
725                Err(error)
726            }
727        }
728    }
729}
730
731#[cfg(feature = "server")]
732impl Drop for PreparedRestoreSql {
733    fn drop(&mut self) {
734        if let Err(error) = std::fs::remove_file(&self.path) {
735            tracing::warn!(
736                path = %self.path.display(),
737                "restore: failed to delete prepared SQL: {error}"
738            );
739        }
740    }
741}
742
743#[cfg(feature = "server")]
744fn psql_restore_command(db_url: &str, path: &Path) -> std::process::Command {
745    let mut command = std::process::Command::new("psql");
746    command
747        .arg(db_url)
748        .arg("--single-transaction")
749        .arg("-v")
750        .arg("ON_ERROR_STOP=1")
751        .arg("-f")
752        .arg(path)
753        .stdout(std::process::Stdio::null())
754        .stderr(std::process::Stdio::piped());
755    command
756}
757
758#[cfg(feature = "server")]
759async fn run_restore(task_id: &str, filename: &str) {
760    let path = backup_path(filename);
761    let db_url = match std::env::var("DATABASE_URL") {
762        Ok(u) if !u.is_empty() => u,
763        _ => {
764            tasks::update(
765                task_id,
766                "DATABASE_URL 未配置",
767                100,
768                TaskStatus::Failed,
769                None,
770                Some("恢复需要 DATABASE_URL".to_string()),
771                None,
772            );
773            return;
774        }
775    };
776    let psql_ok = tokio::task::spawn_blocking(|| {
777        std::process::Command::new("psql")
778            .arg("--version")
779            .output()
780            .is_ok()
781    })
782    .await
783    .unwrap_or(false);
784    if !psql_ok {
785        tasks::update(
786            task_id,
787            "psql 不可用",
788            100,
789            TaskStatus::Failed,
790            None,
791            Some("恢复需要 psql,但当前环境未安装 psql".to_string()),
792            None,
793        );
794        return;
795    }
796    tasks::update(
797        task_id,
798        "正在用 psql 恢复",
799        50,
800        TaskStatus::Running,
801        None,
802        None,
803        None,
804    );
805    // 预处理备份与 psql 恢复均为阻塞文件/子进程操作,整体移出 tokio worker。
806    // 临时 SQL 由 RAII 守卫在成功、失败和启动错误路径统一删除。
807    let restore_result = tokio::task::spawn_blocking(move || {
808        let prepared = PreparedRestoreSql::prepare(&path)?;
809        if prepared.removed_owner_statements > 0 {
810            tracing::info!(
811                removed = prepared.removed_owner_statements,
812                "restore: removed legacy pg_dump owner statements"
813            );
814        }
815        psql_restore_command(&db_url, &prepared.path).output()
816    })
817    .await
818    .unwrap_or_else(|join_e| Err(std::io::Error::other(join_e.to_string())));
819    match restore_result {
820        Ok(o) if o.status.success() => {
821            // 恢复用备份时刻的数据重建了 posts 等表,必须冲刷全部文章相关缓存
822            // 与 SSR 世代号,否则前端仍读旧数据(被删的文章不会重新出现)。
823            crate::cache::invalidate_all_post_caches();
824            crate::cache::invalidate_search_results();
825            crate::ssr_cache::bump_global_generation();
826            tasks::update(task_id, "恢复完成", 100, TaskStatus::Done, None, None, None);
827        }
828        Ok(o) => {
829            let stderr = String::from_utf8_lossy(&o.stderr).to_string();
830            tasks::update(
831                task_id,
832                "恢复失败",
833                100,
834                TaskStatus::Failed,
835                None,
836                Some(stderr),
837                None,
838            );
839        }
840        Err(e) => {
841            tasks::update(
842                task_id,
843                "psql 启动失败",
844                100,
845                TaskStatus::Failed,
846                None,
847                Some(e.to_string()),
848                None,
849            );
850        }
851    }
852}
853
854/// 列出 backups/ 目录下的备份文件元信息。
855///
856/// uploads 打包(`*_uploads.tar.gz`)不单独成行,按文件名配对挂到对应 .sql 上。
857#[server(ListBackups, "/api")]
858pub async fn list_backups() -> Result<Vec<BackupInfo>, ServerFnError> {
859    let _user = get_current_admin_user().await?;
860    #[cfg(feature = "server")]
861    {
862        let mut infos: Vec<BackupInfo> = Vec::new();
863        // 先收集 uploads 包:文件名 → 大小,供配对查询。
864        let mut tarballs: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
865        if let Ok(entries) = std::fs::read_dir(BACKUP_DIR) {
866            for entry in entries.flatten() {
867                let name = entry.file_name().to_string_lossy().to_string();
868                if name.ends_with("_uploads.tar.gz") {
869                    if let Ok(meta) = entry.metadata() {
870                        tarballs.insert(name, meta.len());
871                    }
872                }
873            }
874        }
875        if let Ok(entries) = std::fs::read_dir(BACKUP_DIR) {
876            for entry in entries.flatten() {
877                let name = entry.file_name().to_string_lossy().to_string();
878                if !name.ends_with(".sql") {
879                    continue;
880                }
881                let meta = match entry.metadata() {
882                    Ok(m) => m,
883                    Err(_) => continue,
884                };
885                // 仅读取前 3 行(签名/created_at/mode),避免把每个(可能数十 MB
886                // 的)备份文件整文件读入内存只为取 `-- mode:` 行。
887                let mode = read_first_lines(entry.path(), 3)
888                    .map(|lines| parse_backup_mode(&lines.join("\n")))
889                    .unwrap_or_else(|_| "unknown".to_string());
890                let created_at = meta
891                    .modified()
892                    .ok()
893                    .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
894                    .map(|d| {
895                        chrono::DateTime::<Utc>::from_timestamp(d.as_secs() as i64, 0)
896                            .map(|dt| dt.to_rfc3339())
897                            .unwrap_or_default()
898                    });
899                let tar_name = uploads_archive_name(&name);
900                let (uploads_filename, uploads_size_bytes) = tarballs
901                    .get_key_value(&tar_name)
902                    .map(|(k, v)| (Some(k.clone()), Some(*v)))
903                    .unwrap_or((None, None));
904                infos.push(BackupInfo {
905                    origin: if name.starts_with("auto_") {
906                        "auto"
907                    } else {
908                        "manual"
909                    }
910                    .to_string(),
911                    filename: name,
912                    size_bytes: meta.len(),
913                    mode,
914                    created_at,
915                    uploads_filename,
916                    uploads_size_bytes,
917                });
918            }
919        }
920        // 按创建时间降序(新的在前)
921        infos.sort_by(|a, b| b.created_at.cmp(&a.created_at));
922        Ok(infos)
923    }
924    #[cfg(not(feature = "server"))]
925    {
926        Ok(vec![])
927    }
928}
929
930/// 删除备份文件(连同配对的 uploads 打包;配对缺失不视为错误)。
931#[server(DeleteBackup, "/api")]
932pub async fn delete_backup(filename: String) -> Result<(), ServerFnError> {
933    let _user = get_current_admin_user().await?;
934    #[cfg(feature = "server")]
935    {
936        if !is_valid_backup_filename(&filename) {
937            return Err(AppError::BadRequest("无效的文件名".to_string()).into());
938        }
939        let path = backup_path(&filename);
940        if !path.exists() {
941            return Err(AppError::NotFound("备份文件不存在").into());
942        }
943        std::fs::remove_file(&path).map_err(|_| AppError::Internal("删除失败"))?;
944        // 成对清理:传 .sql 删配对 tar.gz,传 tar.gz 删配对 .sql。
945        let pair = if filename.ends_with(".sql") {
946            Some(uploads_archive_name(&filename))
947        } else {
948            filename
949                .strip_suffix("_uploads.tar.gz")
950                .map(|stem| format!("{stem}.sql"))
951        };
952        if let Some(pair_name) = pair {
953            match std::fs::remove_file(backup_path(&pair_name)) {
954                Ok(()) | Err(_) => {} // 配对缺失/删除失败不影响主文件删除结果
955            }
956        }
957        Ok(())
958    }
959    #[cfg(not(feature = "server"))]
960    {
961        Ok(())
962    }
963}
964
965/// 构造 backups/ 下的安全路径(额外防御:校验规范化后仍在 BACKUP_DIR 内)。
966///
967/// 纵深防御:即便第一道白名单 `is_valid_backup_filename` 被绕过,这里也要
968/// 保证结果不逃出 BACKUP_DIR。直接对 filename 做 components 检查——
969/// 含 `..`(ParentDir)、绝对路径前缀(RootDir/Prefix,如 `/etc` 或 `C:\`)
970/// 的 filename 一律降级为 BACKUP_DIR 本身。
971///
972/// 注意:不能用 `[BACKUP_DIR, filename].collect::<PathBuf>()` 后再检——
973/// 当 filename 是绝对路径时,PathBuf 语义会丢弃 BACKUP_DIR 前缀(如
974/// `["backups", "/etc/passwd"]` → `/etc/passwd`),导致 components 检查
975/// 在错位的路径上运行而漏判。必须先检 filename 本身。
976#[cfg(feature = "server")]
977fn backup_path(filename: &str) -> PathBuf {
978    // 直接检查 filename 的 components:只允许 Normal 段。
979    let filename_is_safe = std::path::Path::new(filename)
980        .components()
981        .all(|c| matches!(c, Component::Normal(_)));
982    if filename_is_safe {
983        let mut p = PathBuf::from(BACKUP_DIR);
984        p.push(filename);
985        p
986    } else {
987        // 命中 ParentDir/RootDir/Prefix/CurDir → 降级为 BACKUP_DIR
988        PathBuf::from(BACKUP_DIR)
989    }
990}
991
992/// 校验备份文件名是否符合白名单(仅字母数字下划线点连字符)。
993/// 返回 true 表示安全可用。提取为纯函数便于单测覆盖路径穿越边界。
994#[cfg(feature = "server")]
995fn is_valid_backup_filename(filename: &str) -> bool {
996    // regex::Regex::new 在 FILENAME_RE 是常量正则,编译期可验证不会 panic。
997    regex::Regex::new(FILENAME_RE)
998        .map(|re| re.is_match(filename))
999        .unwrap_or(false)
1000}
1001
1002/// 导入单文件上限(字节):env `BACKUP_IMPORT_MAX_MB`(MB 为单位),默认 512MB。
1003/// main.rs 路由 DefaultBodyLimit 与 handler 流式计数共用此值。
1004#[cfg(feature = "server")]
1005pub(crate) fn import_max_bytes() -> u64 {
1006    std::env::var("BACKUP_IMPORT_MAX_MB")
1007        .ok()
1008        .and_then(|s| s.parse::<u64>().ok())
1009        .filter(|mb| *mb > 0)
1010        .unwrap_or(DEFAULT_IMPORT_MAX_MB)
1011        .saturating_mul(1024 * 1024)
1012}
1013
1014/// 校验并清洗导入文件名:剥离路径分量(旧浏览器可能带 `C:\fakepath\` 前缀),
1015/// 要求 `.sql` 后缀 + 非隐藏文件 + 备份文件名白名单。纯函数便于单测。
1016#[cfg(feature = "server")]
1017fn sanitize_import_filename(raw: &str) -> Option<String> {
1018    let name = raw.rsplit(['/', '\\']).next().unwrap_or(raw);
1019    if name.len() > 255
1020        || !name.ends_with(".sql")
1021        || name.starts_with('.')
1022        || !is_valid_backup_filename(name)
1023    {
1024        return None;
1025    }
1026    Some(name.to_string())
1027}
1028
1029/// 查询 backups/ 所在文件系统的可用空间(取最长挂载点前缀的磁盘)。
1030/// 预检用途:探测失败返回 None,降级为跳过预检(写入失败仍会清理并报错)。
1031#[cfg(feature = "server")]
1032fn backup_partition_free_space() -> Option<u64> {
1033    let dir = std::fs::canonicalize(BACKUP_DIR).ok()?;
1034    let disks = sysinfo::Disks::new_with_refreshed_list();
1035    disks
1036        .iter()
1037        .filter(|d| dir.starts_with(d.mount_point()))
1038        .max_by_key(|d| d.mount_point().as_os_str().len())
1039        .map(|d| d.available_space())
1040}
1041
1042/// 从备份文件全文提取 `-- mode: <value>` 行的值(如 "pg_dump"/"sql-fallback")。
1043/// 提取为纯函数:把文件内容作为参数传入,便于单测。
1044/// 缺失或格式不符返回 "unknown"。
1045#[cfg(feature = "server")]
1046fn parse_backup_mode(content: &str) -> String {
1047    content
1048        .lines()
1049        .find(|l| l.starts_with("-- mode:"))
1050        .map(|l| l.trim_start_matches("-- mode:").trim().to_string())
1051        .filter(|s| !s.is_empty())
1052        .unwrap_or_else(|| "unknown".to_string())
1053}
1054
1055/// 校验备份文件首行是否含本系统签名头。
1056/// 提取为纯函数:把首行(或全文)作为参数传入,便于单测。
1057#[cfg(feature = "server")]
1058fn has_valid_signature(content: &str) -> bool {
1059    content
1060        .lines()
1061        .next()
1062        .map(|l| l.trim().contains(BACKUP_SIGNATURE))
1063        .unwrap_or(false)
1064}
1065
1066/// 仅读取文件首行(用于签名校验,避免把整个备份文件读入内存)。
1067#[cfg(feature = "server")]
1068fn read_first_line(path: impl AsRef<Path>) -> std::io::Result<String> {
1069    use std::io::BufRead;
1070    let mut reader = std::io::BufReader::new(std::fs::File::open(path)?);
1071    let mut line = String::new();
1072    reader.read_line(&mut line)?;
1073    Ok(line)
1074}
1075
1076/// 读取文件前 `n` 行(用于解析 `-- mode:` 等头部元信息,避免整文件读入内存)。
1077/// 不足 `n` 行时返回实际读到的行;任一行读取失败则整体返回该错误。
1078#[cfg(feature = "server")]
1079fn read_first_lines(path: impl AsRef<Path>, n: usize) -> std::io::Result<Vec<String>> {
1080    use std::io::BufRead;
1081    let reader = std::io::BufReader::new(std::fs::File::open(path)?);
1082    reader.lines().take(n).collect()
1083}
1084
1085/// Axum 处理器:导入备份(multipart 流式落盘,admin 鉴权 + 签名校验 + 同名拒绝)。
1086///
1087/// 设计要点(见 `docs/adr/0001-backup-import.md`):
1088/// - 两步式:导入只入库 backups/ 并出现在列表,恢复仍走 [`restore_backup`] 管线;
1089/// - 导入时即校验签名(拒绝外来 SQL 入库),与恢复时的签名校验是两层独立防线;
1090/// - 流式写 `.tmp` + 原子 rename:半截文件永不入库,任一失败路径清理 tmp;
1091/// - 体积双保险:路由 `DefaultBodyLimit`(上限 + [`MULTIPART_FRAME_SLACK`])
1092///   兜整个请求体,此处流式计数只计载荷字节,超限 413 早断;
1093/// - 仅 server 构建:纯 Axum 路由(在 main.rs 注册),无 WASM 消费者。
1094#[cfg(feature = "server")]
1095pub async fn import_backup(
1096    connect_info: Option<
1097        axum::extract::Extension<axum::extract::ConnectInfo<std::net::SocketAddr>>,
1098    >,
1099    headers: axum::http::HeaderMap,
1100    mut multipart: axum::extract::Multipart,
1101) -> Result<axum::Json<serde_json::Value>, (axum::http::StatusCode, axum::Json<serde_json::Value>)>
1102{
1103    use crate::api::upload::upload_error;
1104    use axum::http::StatusCode;
1105    use tokio::io::AsyncWriteExt;
1106
1107    // 0. 限流:复用 UPLOAD 桶(同为 admin 文件上传,不为低频导入单开配置面)。
1108    let peer = connect_info.map(|axum::extract::Extension(axum::extract::ConnectInfo(addr))| addr);
1109    let ip = crate::api::rate_limit::get_client_ip_with_peer(&headers, peer).await;
1110    if let Err(msg) = crate::api::rate_limit::check_upload_limit(&ip) {
1111        return Err(upload_error(StatusCode::TOO_MANY_REQUESTS, msg));
1112    }
1113
1114    // 1. cookie session → admin(与 upload_image 同一校验链)。
1115    let cookie_header = headers
1116        .get("cookie")
1117        .and_then(|h| h.to_str().ok())
1118        .unwrap_or("");
1119    let token = match crate::auth::session::parse_session_token(cookie_header) {
1120        Some(t) => t,
1121        None => return Err(upload_error(StatusCode::UNAUTHORIZED, "未登录")),
1122    };
1123    let user = match crate::api::auth::get_user_by_token(token).await {
1124        Ok(Some(u)) => u,
1125        _ => return Err(upload_error(StatusCode::UNAUTHORIZED, "会话已过期")),
1126    };
1127    if user.role != crate::models::user::UserRole::Admin {
1128        return Err(upload_error(StatusCode::FORBIDDEN, "权限不足"));
1129    }
1130
1131    let max_bytes = import_max_bytes();
1132
1133    // 2. Content-Length 早拒:明显超限不读 body 直接 413。
1134    let content_length = headers
1135        .get(axum::http::header::CONTENT_LENGTH)
1136        .and_then(|v| v.to_str().ok())
1137        .and_then(|s| s.parse::<u64>().ok());
1138    if let Some(cl) = content_length {
1139        if cl > max_bytes + MULTIPART_FRAME_SLACK {
1140            return Err(upload_error(
1141                StatusCode::PAYLOAD_TOO_LARGE,
1142                "文件超过导入上限",
1143            ));
1144        }
1145    }
1146
1147    // 3. 取 multipart 字段并清洗文件名。
1148    let mut field = match multipart.next_field().await {
1149        Ok(Some(f)) => f,
1150        Ok(None) => return Err(upload_error(StatusCode::BAD_REQUEST, "未找到文件")),
1151        Err(e) => {
1152            tracing::error!("backup import multipart error: {e:?}");
1153            return Err(upload_error(StatusCode::BAD_REQUEST, "文件读取失败"));
1154        }
1155    };
1156    let filename = match sanitize_import_filename(field.file_name().unwrap_or_default()) {
1157        Some(n) => n,
1158        None => {
1159            return Err(upload_error(
1160                StatusCode::BAD_REQUEST,
1161                "文件名不合法:仅接受以 .sql 结尾的备份文件名(字母/数字/下划线/点/连字符)",
1162            ))
1163        }
1164    };
1165
1166    // 4. 同名冲突拒绝(不覆盖——覆盖是破坏性操作;用户可先删除旧文件再导入)。
1167    if let Err(e) = std::fs::create_dir_all(BACKUP_DIR) {
1168        tracing::error!("backup import: create dir failed: {e}");
1169        return Err(upload_error(
1170            StatusCode::INTERNAL_SERVER_ERROR,
1171            "无法创建备份目录",
1172        ));
1173    }
1174    let final_path = backup_path(&filename);
1175    if final_path.exists() {
1176        return Err(upload_error(StatusCode::CONFLICT, "已存在同名备份文件"));
1177    }
1178
1179    // 5. 磁盘空间预检(Content-Length 已知且探测成功时;否则降级跳过)。
1180    if let (Some(cl), Some(free)) = (content_length, backup_partition_free_space()) {
1181        if cl > free {
1182            return Err(upload_error(
1183                StatusCode::INSUFFICIENT_STORAGE,
1184                "磁盘空间不足",
1185            ));
1186        }
1187    }
1188
1189    // 6. 流式落盘 tmp:按载荷字节精确计数,超限/写失败/读失败都清理半截文件。
1190    let tmp_name = format!(
1191        ".import-{}-{}.tmp",
1192        std::process::id(),
1193        std::time::SystemTime::now()
1194            .duration_since(std::time::UNIX_EPOCH)
1195            .map(|d| d.as_nanos())
1196            .unwrap_or(0)
1197    );
1198    let tmp_path = backup_path(&tmp_name);
1199    let mut out = match tokio::fs::File::create(&tmp_path).await {
1200        Ok(f) => f,
1201        Err(e) => {
1202            tracing::error!("backup import: create tmp failed: {e}");
1203            return Err(upload_error(
1204                StatusCode::INTERNAL_SERVER_ERROR,
1205                "无法写入备份目录",
1206            ));
1207        }
1208    };
1209    let mut written: u64 = 0;
1210    let stream_result: Result<(), (StatusCode, &'static str)> = loop {
1211        match field.chunk().await {
1212            Ok(Some(chunk)) => {
1213                written += chunk.len() as u64;
1214                if written > max_bytes {
1215                    break Err((StatusCode::PAYLOAD_TOO_LARGE, "文件超过导入上限"));
1216                }
1217                if let Err(e) = out.write_all(&chunk).await {
1218                    tracing::error!("backup import: write failed: {e}");
1219                    break Err((
1220                        StatusCode::INTERNAL_SERVER_ERROR,
1221                        "写入失败(磁盘可能已满)",
1222                    ));
1223                }
1224            }
1225            Ok(None) => break Ok(()),
1226            Err(e) => {
1227                tracing::error!("backup import: chunk error: {e:?}");
1228                break Err((StatusCode::BAD_REQUEST, "文件读取失败"));
1229            }
1230        }
1231    };
1232    drop(out);
1233    if let Err((status, msg)) = stream_result {
1234        let _ = std::fs::remove_file(&tmp_path);
1235        return Err(upload_error(status, msg));
1236    }
1237
1238    // 7. 导入时即签名校验:非本系统文件拒收且不留盘。
1239    let first_line = read_first_line(&tmp_path).unwrap_or_default();
1240    if !has_valid_signature(&first_line) {
1241        let _ = std::fs::remove_file(&tmp_path);
1242        return Err(upload_error(
1243            StatusCode::BAD_REQUEST,
1244            "非本系统生成的备份文件,拒绝导入",
1245        ));
1246    }
1247
1248    // 8. 原子 rename 入库;rename 前二次冲突检查,收窄两个并发同名导入的竞态窗。
1249    if final_path.exists() {
1250        let _ = std::fs::remove_file(&tmp_path);
1251        return Err(upload_error(StatusCode::CONFLICT, "已存在同名备份文件"));
1252    }
1253    if let Err(e) = std::fs::rename(&tmp_path, &final_path) {
1254        let _ = std::fs::remove_file(&tmp_path);
1255        tracing::error!("backup import: rename failed: {e}");
1256        return Err(upload_error(StatusCode::INTERNAL_SERVER_ERROR, "入库失败"));
1257    }
1258
1259    tracing::info!(
1260        operator = %user.username,
1261        filename = %filename,
1262        size_bytes = written,
1263        "备份导入成功"
1264    );
1265    Ok(axum::Json(
1266        serde_json::json!({ "success": true, "filename": filename }),
1267    ))
1268}
1269
1270/// Axum 处理器:下载备份文件(admin 鉴权 + 路径白名单)。
1271/// 仅 server 构建:纯 Axum 路由(在 main.rs 注册),无 WASM 消费者。
1272#[cfg(feature = "server")]
1273pub async fn download_backup(
1274    axum::extract::Path(filename): axum::extract::Path<String>,
1275    headers: axum::http::HeaderMap,
1276) -> Result<impl axum::response::IntoResponse, (axum::http::StatusCode, String)> {
1277    use axum::http::{header, StatusCode};
1278
1279    // 鉴权
1280    let cookie_header = headers
1281        .get("cookie")
1282        .and_then(|h| h.to_str().ok())
1283        .unwrap_or("");
1284    let token = crate::auth::session::parse_session_token(cookie_header).map(str::to_string);
1285    let token = match token {
1286        Some(t) => t,
1287        None => return Err((StatusCode::UNAUTHORIZED, "未登录".to_string())),
1288    };
1289    let user = match crate::api::auth::get_user_by_token(&token).await {
1290        Ok(Some(u)) => u,
1291        _ => return Err((StatusCode::UNAUTHORIZED, "会话已过期".to_string())),
1292    };
1293    if user.role != crate::models::user::UserRole::Admin {
1294        return Err((StatusCode::FORBIDDEN, "权限不足".to_string()));
1295    }
1296
1297    // 路径白名单
1298    if !is_valid_backup_filename(&filename) {
1299        return Err((StatusCode::BAD_REQUEST, "无效的文件名".to_string()));
1300    }
1301    let path = backup_path(&filename);
1302    let bytes = tokio::fs::read(&path)
1303        .await
1304        .map_err(|_| (StatusCode::NOT_FOUND, "文件不存在".to_string()))?;
1305    let disposition = format!("attachment; filename=\"{}\"", filename);
1306    let content_type = if filename.ends_with(".tar.gz") {
1307        "application/gzip"
1308    } else {
1309        "application/sql; charset=utf-8"
1310    };
1311    Ok((
1312        StatusCode::OK,
1313        [
1314            (
1315                header::CONTENT_TYPE,
1316                axum::http::HeaderValue::from_static(content_type),
1317            ),
1318            (
1319                header::CONTENT_DISPOSITION,
1320                axum::http::HeaderValue::from_str(&disposition)
1321                    .unwrap_or_else(|_| axum::http::HeaderValue::from_static("attachment")),
1322            ),
1323        ],
1324        axum::body::Body::from(bytes),
1325    ))
1326}
1327
1328#[cfg(all(test, feature = "server"))]
1329mod tests {
1330    use super::*;
1331
1332    // ── is_valid_backup_filename:文件名白名单(路径穿越第一道防线) ──
1333
1334    #[test]
1335    fn filename_accepts_normal_names() {
1336        for name in [
1337            "backup_20260702_120000.sql",
1338            "backup_20260702_120000_sqlfallback.sql",
1339            "a.sql",
1340            "A-B_C.123",
1341        ] {
1342            assert!(is_valid_backup_filename(name), "正常文件名应通过: {name}");
1343        }
1344    }
1345
1346    #[test]
1347    fn filename_rejects_path_traversal() {
1348        // 路径穿越:白名单只允许字母数字下划线点连字符,/ 和 .. 都应被拒。
1349        for evil in [
1350            "../etc/passwd",
1351            "..\\windows\\win.ini",
1352            "/etc/passwd",
1353            "a/../../b",
1354            "backup.sql/../../etc",
1355        ] {
1356            assert!(!is_valid_backup_filename(evil), "路径穿越应被拒: {evil}");
1357        }
1358    }
1359
1360    #[test]
1361    fn filename_rejects_spaces_and_special_chars() {
1362        // 空格、中文、shell 元字符等都不在白名单。
1363        for evil in [
1364            "backup with space.sql",
1365            "备份.sql",
1366            "a;rm -rf.sql",
1367            r"a\$b.sql",
1368            "a`b`.sql",
1369            "",
1370        ] {
1371            assert!(!is_valid_backup_filename(evil), "特殊字符应被拒: {evil:?}");
1372        }
1373    }
1374
1375    // ── backup_path:路径穿越纵深防御(白名单之外的二次防御) ────────
1376
1377    #[test]
1378    fn backup_path_stays_in_backup_dir_for_normal_name() {
1379        let p = backup_path("backup_20260702.sql");
1380        assert!(p.starts_with(BACKUP_DIR), "应在 {BACKUP_DIR}/ 下");
1381        assert_eq!(
1382            p.file_name().and_then(|n| n.to_str()),
1383            Some("backup_20260702.sql")
1384        );
1385    }
1386
1387    #[test]
1388    fn backup_path_collapses_traversal_to_backup_dir() {
1389        // 即便绕过白名单调用 backup_path(纵深防御),../ 也应被规约回 BACKUP_DIR,
1390        // 而非指向 backups/ 之外。Component::ParentDir / RootDir 命中即降级。
1391        for evil in ["../etc/passwd", "../../etc/shadow"] {
1392            let p = backup_path(evil);
1393            // 不应逃出 BACKUP_DIR(应为 BACKUP_DIR 本身,不含文件名)
1394            assert_eq!(
1395                p,
1396                PathBuf::from(BACKUP_DIR),
1397                "穿越应被规约回 {BACKUP_DIR}: {evil}"
1398            );
1399        }
1400    }
1401
1402    #[test]
1403    fn backup_path_rejects_absolute_path() {
1404        // Component::RootDir 命中也应降级。
1405        let p = backup_path("/etc/passwd");
1406        assert_eq!(p, PathBuf::from(BACKUP_DIR));
1407    }
1408
1409    // ── has_valid_signature:备份签名校验(拒绝非本系统文件) ───────
1410
1411    #[test]
1412    fn signature_matches_exact_header() {
1413        let content = "-- YGGDRASIL BACKUP v1\n-- mode: pg_dump\nSELECT 1;\n";
1414        assert!(has_valid_signature(content));
1415    }
1416
1417    #[test]
1418    fn signature_matches_with_leading_whitespace() {
1419        // 首行允许前导空白(trim 后匹配),容忍编辑器缩进。
1420        let content = "  -- YGGDRASIL BACKUP v1\nrest\n";
1421        assert!(has_valid_signature(content));
1422    }
1423
1424    #[test]
1425    fn signature_rejects_non_system_file() {
1426        // 普通 SQL 文件首行不含签名 → 拒绝恢复(防任意文件读取/执行)。
1427        let content = "SELECT * FROM users;\n-- YGGDRASIL BACKUP v1\n";
1428        // 注意:签名必须在首行。第二行有签名不算。
1429        assert!(!has_valid_signature(content));
1430    }
1431
1432    #[test]
1433    fn signature_rejects_empty_and_garbage() {
1434        assert!(!has_valid_signature(""));
1435        assert!(!has_valid_signature("garbage\n"));
1436        assert!(!has_valid_signature("\n\n-- YGGDRASIL BACKUP v1"));
1437    }
1438
1439    // ── parse_backup_mode:模式解析(列表展示用) ───────────────────
1440
1441    #[test]
1442    fn parse_mode_pg_dump() {
1443        let content = "-- YGGDRASIL BACKUP v1\n-- mode: pg_dump\n...\n";
1444        assert_eq!(parse_backup_mode(content), "pg_dump");
1445    }
1446
1447    #[test]
1448    fn parse_mode_sql_fallback() {
1449        let content = "-- YGGDRASIL BACKUP v1\n-- mode: sql-fallback\n\n-- table: posts\n";
1450        assert_eq!(parse_backup_mode(content), "sql-fallback");
1451    }
1452
1453    #[test]
1454    fn parse_mode_unknown_when_absent() {
1455        let content = "-- YGGDRASIL BACKUP v1\nSELECT 1;\n";
1456        assert_eq!(parse_backup_mode(content), "unknown");
1457    }
1458
1459    #[test]
1460    fn parse_mode_unknown_when_empty_value() {
1461        // "-- mode:" 后无值 → unknown(防空字符串显示)
1462        let content = "-- mode:\nrest\n";
1463        assert_eq!(parse_backup_mode(content), "unknown");
1464    }
1465
1466    #[test]
1467    fn parse_mode_only_matches_first_occurrence() {
1468        // 多个 -- mode: 行取第一个。
1469        let content = "-- mode: pg_dump\n-- mode: sql-fallback\n";
1470        assert_eq!(parse_backup_mode(content), "pg_dump");
1471    }
1472
1473    // ── uploads_archive_name:SQL ↔ uploads 包配对规则 ─────────────
1474
1475    #[test]
1476    fn uploads_archive_name_pairs_with_sql() {
1477        assert_eq!(
1478            uploads_archive_name("backup_20260810_040000.sql"),
1479            "backup_20260810_040000_uploads.tar.gz"
1480        );
1481        assert_eq!(
1482            uploads_archive_name("auto_20260810_040000_sqlfallback.sql"),
1483            "auto_20260810_040000_sqlfallback_uploads.tar.gz"
1484        );
1485    }
1486
1487    // ── select_expired_auto_backups:轮转选择(只动 auto,保最新 N 份) ──
1488
1489    #[test]
1490    fn rotation_keeps_newest_and_deletes_oldest() {
1491        let names: Vec<String> = [
1492            "auto_20260808_040000.sql",
1493            "auto_20260810_040000.sql",
1494            "auto_20260809_040000.sql",
1495        ]
1496        .iter()
1497        .map(|s| s.to_string())
1498        .collect();
1499        // 文件名时间戳定宽,乱序输入也应按时间排序。
1500        assert_eq!(
1501            select_expired_auto_backups(&names, 2),
1502            vec!["auto_20260808_040000.sql".to_string()]
1503        );
1504    }
1505
1506    #[test]
1507    fn rotation_ignores_manual_backups_and_tarballs() {
1508        let names: Vec<String> = [
1509            "backup_20260101_000000.sql",          // 手动:永不轮转
1510            "auto_20260808_040000_uploads.tar.gz", // tar.gz:随配对 sql 删除,不单独计数
1511            "auto_20260808_040000.sql",
1512            "auto_20260809_040000.sql",
1513        ]
1514        .iter()
1515        .map(|s| s.to_string())
1516        .collect();
1517        assert!(select_expired_auto_backups(&names, 2).is_empty());
1518        assert_eq!(
1519            select_expired_auto_backups(&names, 1),
1520            vec!["auto_20260808_040000.sql".to_string()]
1521        );
1522    }
1523
1524    #[test]
1525    fn rotation_empty_and_exact_keep() {
1526        assert!(select_expired_auto_backups(&[], 5).is_empty());
1527        let names: Vec<String> = ["auto_20260808_040000.sql"]
1528            .iter()
1529            .map(|s| s.to_string())
1530            .collect();
1531        assert!(select_expired_auto_backups(&names, 1).is_empty());
1532        // keep=0 全删
1533        assert_eq!(select_expired_auto_backups(&names, 0).len(), 1);
1534    }
1535
1536    // ── create_uploads_tarball:打包排除 .cache/.gitkeep ───────────
1537
1538    #[test]
1539    fn tarball_excludes_cache_and_gitkeep() {
1540        let nanos = std::time::SystemTime::now()
1541            .duration_since(std::time::UNIX_EPOCH)
1542            .expect("系统时间必然晚于 UNIX_EPOCH")
1543            .as_nanos();
1544        let dir = std::env::temp_dir().join(format!(
1545            "yggdrasil_backup_tar_test_{}_{}",
1546            nanos,
1547            std::process::id()
1548        ));
1549        let uploads = dir.join("uploads");
1550        std::fs::create_dir_all(uploads.join("2026")).expect("创建测试目录");
1551        std::fs::create_dir_all(uploads.join(".cache")).expect("创建测试目录");
1552        std::fs::write(uploads.join("2026/pic.webp"), b"img").expect("写测试文件");
1553        std::fs::write(uploads.join(".cache/x.webp"), b"cache").expect("写测试文件");
1554        std::fs::write(uploads.join(".gitkeep"), b"").expect("写测试文件");
1555
1556        let out = dir.join("out.tar.gz");
1557        create_uploads_tarball(&uploads, &out).expect("打包应成功");
1558
1559        let file = std::fs::File::open(&out).expect("打开打包产物");
1560        let gz = flate2::read::GzDecoder::new(file);
1561        let mut archive = tar::Archive::new(gz);
1562        let entries: Vec<String> = archive
1563            .entries()
1564            .expect("读取 tar 条目")
1565            .map(|e| {
1566                e.expect("tar 条目有效")
1567                    .path()
1568                    .expect("路径有效")
1569                    .to_string_lossy()
1570                    .to_string()
1571            })
1572            .collect();
1573        assert!(
1574            entries.iter().any(|p| p.contains("2026/pic.webp")),
1575            "应包含素材文件: {entries:?}"
1576        );
1577        assert!(
1578            !entries
1579                .iter()
1580                .any(|p| p.contains(".cache") || p.contains(".gitkeep")),
1581            "应排除 .cache 与 .gitkeep: {entries:?}"
1582        );
1583        std::fs::remove_dir_all(&dir).expect("清理测试目录");
1584    }
1585
1586    // ── sanitize_import_filename:导入文件名校验 ─────────────────
1587
1588    #[test]
1589    fn import_filename_accepts_plain_backup_name() {
1590        assert_eq!(
1591            sanitize_import_filename("backup_20260816_200000.sql"),
1592            Some("backup_20260816_200000.sql".to_string())
1593        );
1594        // 255 字节边界接受
1595        let max_ok = format!("{}.sql", "a".repeat(251));
1596        assert!(sanitize_import_filename(&max_ok).is_some());
1597    }
1598
1599    #[test]
1600    fn import_filename_strips_path_components() {
1601        // 旧浏览器可能送 `C:\fakepath\` 前缀;POSIX 路径同样剥掉,只留 basename。
1602        assert_eq!(
1603            sanitize_import_filename("C:\\fakepath\\auto_20260816_200000.sql"),
1604            Some("auto_20260816_200000.sql".to_string())
1605        );
1606        assert_eq!(
1607            sanitize_import_filename("/tmp/x/backup_1.sql"),
1608            Some("backup_1.sql".to_string())
1609        );
1610    }
1611
1612    #[test]
1613    fn import_filename_rejects_non_sql_and_hidden() {
1614        // 配对 tar.gz 不可经导入入口塞进 backups/
1615        assert_eq!(sanitize_import_filename("x_uploads.tar.gz"), None);
1616        assert_eq!(sanitize_import_filename("noext"), None);
1617        // 隐藏文件与裸 ".sql" 拒绝(避开 tmp 文件命名空间)
1618        assert_eq!(sanitize_import_filename(".hidden.sql"), None);
1619        assert_eq!(sanitize_import_filename(".sql"), None);
1620        assert_eq!(sanitize_import_filename(""), None);
1621    }
1622
1623    #[test]
1624    fn import_filename_rejects_illegal_chars_and_overlong() {
1625        assert_eq!(sanitize_import_filename("带中文.sql"), None);
1626        assert_eq!(sanitize_import_filename("has space.sql"), None);
1627        let long = format!("{}.sql", "a".repeat(252));
1628        assert_eq!(sanitize_import_filename(&long), None);
1629    }
1630    #[test]
1631    fn pg_dump_is_owner_and_acl_neutral() {
1632        let command = pg_dump_command("<DATABASE_URL>");
1633        let args: Vec<_> = command
1634            .get_args()
1635            .map(|arg| arg.to_string_lossy().into_owned())
1636            .collect();
1637        assert!(args.iter().any(|arg| arg == "--no-owner"), "{args:?}");
1638        assert!(args.iter().any(|arg| arg == "--no-privileges"), "{args:?}");
1639    }
1640
1641    #[test]
1642    fn psql_restore_is_atomic() {
1643        let command = psql_restore_command("<DATABASE_URL>", Path::new("backup.sql"));
1644        let args: Vec<_> = command
1645            .get_args()
1646            .map(|arg| arg.to_string_lossy().into_owned())
1647            .collect();
1648        assert!(
1649            args.iter().any(|arg| arg == "--single-transaction"),
1650            "{args:?}"
1651        );
1652    }
1653
1654    #[test]
1655    fn legacy_owner_statements_are_removed_without_touching_copy_data() {
1656        let sql = concat!(
1657            "-- YGGDRASIL BACKUP v1\n",
1658            "ALTER FUNCTION public.f() OWNER TO yggdrasil;\n",
1659            "COPY public.lines (value) FROM stdin;\n",
1660            "ALTER TABLE public.literal OWNER TO text;\n",
1661            "\\.\n",
1662            "ALTER TABLE public.users OWNER TO yggdrasil;\n",
1663            "ALTER SEQUENCE public.users_id_seq OWNER TO yggdrasil;\n",
1664            "ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;\n",
1665        );
1666        let mut output = Vec::new();
1667        let removed = write_owner_neutral_restore_sql(std::io::Cursor::new(sql), &mut output)
1668            .expect("过滤测试 SQL 应成功");
1669        let output = String::from_utf8(output).expect("测试 SQL 是 UTF-8");
1670
1671        assert_eq!(removed, 3);
1672        assert!(
1673            output.contains("ALTER TABLE public.literal OWNER TO text;"),
1674            "COPY 数据不得被当成 SQL 删除: {output}"
1675        );
1676        assert!(!output.contains("OWNER TO yggdrasil"), "{output}");
1677        assert!(
1678            output.contains("ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;"),
1679            "{output}"
1680        );
1681    }
1682}