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` 生成的旧备份不含 DROP,
26//! 恢复到已有表的数据库会在「relation already exists」处中止并回滚,
27//! 需重新创建完整备份才能覆盖已有数据库。
28
29#[cfg(feature = "server")]
30mod commands;
31#[cfg(feature = "server")]
32mod creation;
33#[cfg(feature = "server")]
34mod files;
35#[cfg(feature = "server")]
36mod restore;
37#[cfg(feature = "server")]
38mod transfer;
39
40use crate::api::error::ApiError;
41use leptos::prelude::*;
42use serde::{Deserialize, Serialize};
43
44#[cfg(feature = "server")]
45use crate::api::auth::get_current_admin_user;
46#[cfg(feature = "server")]
47use crate::api::database::tasks::{self, TaskKind};
48#[cfg(feature = "server")]
49use crate::api::error::AppError;
50#[cfg(feature = "server")]
51pub(crate) use creation::run_auto_backup;
52#[cfg(feature = "server")]
53use files::{backup_path, has_valid_signature, is_valid_backup_filename, read_first_line};
54#[cfg(feature = "server")]
55pub(crate) use transfer::import_max_bytes;
56#[cfg(feature = "server")]
57pub use transfer::{download_backup, import_backup};
58
59/// multipart 框架开销宽限,加在路由 body limit 上;流式计数只计算载荷。
60#[cfg(feature = "server")]
61pub(crate) const MULTIPART_FRAME_SLACK: u64 = 1024 * 1024;
62
63/// 备份文件元信息(列表展示用)。
64#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
65pub struct BackupInfo {
66    pub filename: String,
67    pub size_bytes: u64,
68    /// 备份模式:pg_dump / sql-fallback(从签名头解析)。
69    pub mode: String,
70    pub created_at: Option<String>,
71    /// 来源:manual / auto(从文件名前缀解析)。
72    pub origin: String,
73    /// 配对的 uploads 打包文件名与大小(无则 None——老备份或禁用了素材打包)。
74    pub uploads_filename: Option<String>,
75    pub uploads_size_bytes: Option<u64>,
76}
77
78/// 备份来源:决定文件名前缀与是否参与自动轮转。
79#[cfg(feature = "server")]
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub(crate) enum BackupOrigin {
82    /// admin 面板手动触发(`backup_` 前缀,永不自动删除)。
83    Manual,
84    /// 定时任务触发(`auto_` 前缀,按 retention_count 轮转)。
85    Auto,
86}
87
88#[cfg(feature = "server")]
89impl BackupOrigin {
90    fn file_prefix(self) -> &'static str {
91        match self {
92            Self::Manual => "backup",
93            Self::Auto => "auto",
94        }
95    }
96}
97
98/// 一次备份运行的结果(调度任务据此落库 last_run)。
99#[cfg(feature = "server")]
100#[derive(Debug)]
101pub(crate) struct BackupRunOutcome {
102    /// 数据库 SQL 文件名。
103    pub sql_filename: String,
104    /// uploads 打包文件名(禁用了素材打包时为 None)。
105    pub uploads_filename: Option<String>,
106    /// 非致命告警(如 DB 成功但 uploads 打包失败)。
107    pub warning: Option<String>,
108}
109
110/// 发起备份,立即返回 task_id,后台任务执行。已有备份在跑时拒绝。
111#[server(CreateBackup, endpoint = "create_backup", input = leptos::server_fn::codec::Json)]
112pub async fn create_backup() -> Result<String, ApiError> {
113    let _user = get_current_admin_user().await?;
114
115    #[cfg(feature = "server")]
116    {
117        let guard = creation::BackupGuard::try_acquire()
118            .ok_or_else(|| AppError::BadRequest(creation::BACKUP_BUSY.to_string()))?;
119        let task_id = uuid::Uuid::new_v4().to_string();
120        tasks::insert(task_id.clone(), TaskKind::Backup);
121        let tid = task_id.clone();
122        tokio::spawn(async move {
123            // 手动触发:进度经任务表轮询展示,返回值无人消费。
124            let _ = creation::run_backup(&tid, BackupOrigin::Manual, guard).await;
125        });
126        Ok(task_id)
127    }
128    #[cfg(not(feature = "server"))]
129    {
130        Ok(String::new())
131    }
132}
133
134/// 发起恢复:校验签名 + 路径穿越防护 + 二次确认,立即返回 task_id。
135#[server(RestoreBackup, endpoint = "restore_backup", input = leptos::server_fn::codec::Json)]
136pub async fn restore_backup(filename: String, confirm: bool) -> Result<String, ApiError> {
137    let _user = get_current_admin_user().await?;
138
139    // 全部校验都在 server cfg 块内:confirm/regex/backup_path/std::fs 都是 server-only。
140    // WASM 侧的 server-function 客户端桩只返回 Ok(String::new())。
141    #[cfg(feature = "server")]
142    {
143        if !confirm {
144            return Err(AppError::BadRequest("需确认恢复(会覆盖现有数据)".to_string()).into());
145        }
146        // 路径穿越防护
147        if !is_valid_backup_filename(&filename) {
148            return Err(AppError::BadRequest("无效的文件名".to_string()).into());
149        }
150        let path = backup_path(&filename);
151        if !path.exists() {
152            return Err(AppError::NotFound("备份文件不存在").into());
153        }
154
155        // 签名校验:仅读取首行(备份文件可达数十 MB,无需整文件读入内存)。
156        let first_line = read_first_line(&path).unwrap_or_default();
157        if !has_valid_signature(&first_line) {
158            return Err(
159                AppError::BadRequest("非本系统生成的备份文件,拒绝恢复".to_string()).into(),
160            );
161        }
162
163        let task_id = uuid::Uuid::new_v4().to_string();
164        tasks::insert(task_id.clone(), TaskKind::Restore);
165        let tid = task_id.clone();
166        let f = filename;
167        tokio::spawn(async move {
168            restore::run_restore(&tid, &f).await;
169        });
170        Ok(task_id)
171    }
172    #[cfg(not(feature = "server"))]
173    {
174        // WASM 客户端桩:忽略参数,返回空 task_id。
175        let _ = (filename, confirm);
176        Ok(String::new())
177    }
178}
179
180/// 列出 SQL 备份,并附带配对 uploads 归档的元信息。
181#[server(ListBackups, endpoint = "list_backups", input = leptos::server_fn::codec::Json)]
182pub async fn list_backups() -> Result<Vec<BackupInfo>, ApiError> {
183    let _user = get_current_admin_user().await?;
184    #[cfg(feature = "server")]
185    {
186        Ok(files::list_backup_files())
187    }
188    #[cfg(not(feature = "server"))]
189    {
190        Ok(vec![])
191    }
192}
193
194/// 管理员删除备份文件,并清理配对的 uploads 归档。
195#[server(DeleteBackup, endpoint = "delete_backup", input = leptos::server_fn::codec::Json)]
196pub async fn delete_backup(filename: String) -> Result<(), ApiError> {
197    let _user = get_current_admin_user().await?;
198    #[cfg(feature = "server")]
199    {
200        files::delete_backup_files(&filename).map_err(Into::into)
201    }
202    #[cfg(not(feature = "server"))]
203    {
204        let _ = filename;
205        Ok(())
206    }
207}