Skip to main content

yggdrasil/api/database/backup/
transfer.rs

1//! 管理员备份导入和下载 HTTP 处理器。
2
3use super::files::{
4    backup_partition_free_space, backup_path, has_valid_signature, is_valid_backup_filename,
5    read_first_line, BACKUP_DIR,
6};
7use super::MULTIPART_FRAME_SLACK;
8
9/// 单文件默认上限,可由 BACKUP_IMPORT_MAX_MB 覆盖。
10const DEFAULT_IMPORT_MAX_MB: u64 = 512;
11
12/// 路由请求体上限与处理器流式载荷计数共用的单文件字节上限。
13pub(crate) fn import_max_bytes() -> u64 {
14    std::env::var("BACKUP_IMPORT_MAX_MB")
15        .ok()
16        .and_then(|s| s.parse::<u64>().ok())
17        .filter(|mb| *mb > 0)
18        .unwrap_or(DEFAULT_IMPORT_MAX_MB)
19        .saturating_mul(1024 * 1024)
20}
21
22/// 校验并清洗导入文件名:剥离路径分量(旧浏览器可能带 `C:\fakepath\` 前缀),
23/// 要求 `.sql` 后缀 + 非隐藏文件 + 备份文件名白名单。纯函数便于单测。
24fn sanitize_import_filename(raw: &str) -> Option<String> {
25    let name = raw.rsplit(['/', '\\']).next().unwrap_or(raw);
26    if name.len() > 255
27        || !name.ends_with(".sql")
28        || name.starts_with('.')
29        || !is_valid_backup_filename(name)
30    {
31        return None;
32    }
33    Some(name.to_string())
34}
35
36/// Axum 处理器:导入备份(multipart 流式落盘,admin 鉴权 + 签名校验 + 同名拒绝)。
37///
38/// 设计要点(见 `docs/adr/0001-backup-import.md`):
39/// - 两步式:导入只入库 backups/ 并出现在列表,恢复仍走 [`super::restore_backup`] 管线;
40/// - 导入时即校验签名(拒绝外来 SQL 入库),与恢复时的签名校验是两层独立防线;
41/// - 流式写 `.tmp` + 原子 rename:半截文件永不入库,任一失败路径清理 tmp;
42/// - 体积双保险:路由 `DefaultBodyLimit`(上限 + [`MULTIPART_FRAME_SLACK`])
43///   兜整个请求体,此处流式计数只计载荷字节,超限 413 早断;
44/// - 仅 server 构建:纯 Axum 路由(在 main.rs 注册),无 WASM 消费者。
45pub async fn import_backup(
46    connect_info: Option<
47        axum::extract::Extension<axum::extract::ConnectInfo<std::net::SocketAddr>>,
48    >,
49    headers: axum::http::HeaderMap,
50    mut multipart: axum::extract::Multipart,
51) -> Result<axum::Json<serde_json::Value>, (axum::http::StatusCode, axum::Json<serde_json::Value>)>
52{
53    use crate::api::upload::upload_error;
54    use axum::http::StatusCode;
55    use tokio::io::AsyncWriteExt;
56
57    // 0. 限流:复用 UPLOAD 桶(同为 admin 文件上传,不为低频导入单开配置面)。
58    let peer = connect_info.map(|axum::extract::Extension(axum::extract::ConnectInfo(addr))| addr);
59    let ip = crate::api::rate_limit::get_client_ip_with_peer(&headers, peer).await;
60    if let Err(msg) = crate::api::rate_limit::check_upload_limit(&ip) {
61        return Err(upload_error(StatusCode::TOO_MANY_REQUESTS, msg));
62    }
63
64    // 1. cookie session → admin(与 upload_image 同一校验链)。
65    let user = crate::api::auth::require_admin_from_headers(&headers)
66        .await
67        .map_err(|(status, msg)| upload_error(status, msg))?;
68
69    let max_bytes = import_max_bytes();
70
71    // 2. Content-Length 早拒:明显超限不读 body 直接 413。
72    let content_length = headers
73        .get(axum::http::header::CONTENT_LENGTH)
74        .and_then(|v| v.to_str().ok())
75        .and_then(|s| s.parse::<u64>().ok());
76    if let Some(cl) = content_length {
77        if cl > max_bytes + MULTIPART_FRAME_SLACK {
78            return Err(upload_error(
79                StatusCode::PAYLOAD_TOO_LARGE,
80                "文件超过导入上限",
81            ));
82        }
83    }
84
85    // 3. 取 multipart 字段并清洗文件名。
86    let mut field = match multipart.next_field().await {
87        Ok(Some(f)) => f,
88        Ok(None) => return Err(upload_error(StatusCode::BAD_REQUEST, "未找到文件")),
89        Err(e) => {
90            tracing::error!("backup import multipart error: {e:?}");
91            return Err(upload_error(StatusCode::BAD_REQUEST, "文件读取失败"));
92        }
93    };
94    let filename = match sanitize_import_filename(field.file_name().unwrap_or_default()) {
95        Some(n) => n,
96        None => {
97            return Err(upload_error(
98                StatusCode::BAD_REQUEST,
99                "文件名不合法:仅接受以 .sql 结尾的备份文件名(字母/数字/下划线/点/连字符)",
100            ))
101        }
102    };
103
104    // 4. 同名冲突拒绝(不覆盖——覆盖是破坏性操作;用户可先删除旧文件再导入)。
105    if let Err(e) = std::fs::create_dir_all(BACKUP_DIR) {
106        tracing::error!("backup import: create dir failed: {e}");
107        return Err(upload_error(
108            StatusCode::INTERNAL_SERVER_ERROR,
109            "无法创建备份目录",
110        ));
111    }
112    let final_path = backup_path(&filename);
113    if final_path.exists() {
114        return Err(upload_error(StatusCode::CONFLICT, "已存在同名备份文件"));
115    }
116
117    // 5. 磁盘空间预检(Content-Length 已知且探测成功时;否则降级跳过)。
118    if let (Some(cl), Some(free)) = (content_length, backup_partition_free_space()) {
119        if cl > free {
120            return Err(upload_error(
121                StatusCode::INSUFFICIENT_STORAGE,
122                "磁盘空间不足",
123            ));
124        }
125    }
126
127    // 6. 流式落盘 tmp:按载荷字节精确计数,超限/写失败/读失败都清理半截文件。
128    let tmp_name = format!(
129        ".import-{}-{}.tmp",
130        std::process::id(),
131        std::time::SystemTime::now()
132            .duration_since(std::time::UNIX_EPOCH)
133            .map(|d| d.as_nanos())
134            .unwrap_or(0)
135    );
136    let tmp_path = backup_path(&tmp_name);
137    let mut out = match tokio::fs::File::create(&tmp_path).await {
138        Ok(f) => f,
139        Err(e) => {
140            tracing::error!("backup import: create tmp failed: {e}");
141            return Err(upload_error(
142                StatusCode::INTERNAL_SERVER_ERROR,
143                "无法写入备份目录",
144            ));
145        }
146    };
147    let mut written: u64 = 0;
148    let stream_result: Result<(), (StatusCode, &'static str)> = loop {
149        match field.chunk().await {
150            Ok(Some(chunk)) => {
151                written += chunk.len() as u64;
152                if written > max_bytes {
153                    break Err((StatusCode::PAYLOAD_TOO_LARGE, "文件超过导入上限"));
154                }
155                if let Err(e) = out.write_all(&chunk).await {
156                    tracing::error!("backup import: write failed: {e}");
157                    break Err((
158                        StatusCode::INTERNAL_SERVER_ERROR,
159                        "写入失败(磁盘可能已满)",
160                    ));
161                }
162            }
163            Ok(None) => break Ok(()),
164            Err(e) => {
165                tracing::error!("backup import: chunk error: {e:?}");
166                break Err((StatusCode::BAD_REQUEST, "文件读取失败"));
167            }
168        }
169    };
170    drop(out);
171    if let Err((status, msg)) = stream_result {
172        let _ = std::fs::remove_file(&tmp_path);
173        return Err(upload_error(status, msg));
174    }
175
176    // 7. 导入时即签名校验:非本系统文件拒收且不留盘。
177    let first_line = read_first_line(&tmp_path).unwrap_or_default();
178    if !has_valid_signature(&first_line) {
179        let _ = std::fs::remove_file(&tmp_path);
180        return Err(upload_error(
181            StatusCode::BAD_REQUEST,
182            "非本系统生成的备份文件,拒绝导入",
183        ));
184    }
185
186    // 8. 原子 rename 入库;rename 前二次冲突检查,收窄两个并发同名导入的竞态窗。
187    if final_path.exists() {
188        let _ = std::fs::remove_file(&tmp_path);
189        return Err(upload_error(StatusCode::CONFLICT, "已存在同名备份文件"));
190    }
191    if let Err(e) = std::fs::rename(&tmp_path, &final_path) {
192        let _ = std::fs::remove_file(&tmp_path);
193        tracing::error!("backup import: rename failed: {e}");
194        return Err(upload_error(StatusCode::INTERNAL_SERVER_ERROR, "入库失败"));
195    }
196
197    tracing::info!(
198        operator = %user.username,
199        filename = %filename,
200        size_bytes = written,
201        "备份导入成功"
202    );
203    Ok(axum::Json(
204        serde_json::json!({ "success": true, "filename": filename }),
205    ))
206}
207
208/// Axum 处理器:下载备份文件(admin 鉴权 + 路径白名单)。
209/// 仅 server 构建:纯 Axum 路由(在 main.rs 注册),无 WASM 消费者。
210pub async fn download_backup(
211    axum::extract::Path(filename): axum::extract::Path<String>,
212    headers: axum::http::HeaderMap,
213) -> Result<impl axum::response::IntoResponse, (axum::http::StatusCode, String)> {
214    use axum::http::{header, StatusCode};
215
216    // 鉴权
217    crate::api::auth::require_admin_from_headers(&headers)
218        .await
219        .map_err(|(status, msg)| (status, msg.to_string()))?;
220
221    // 路径白名单
222    if !is_valid_backup_filename(&filename) {
223        return Err((StatusCode::BAD_REQUEST, "无效的文件名".to_string()));
224    }
225    let path = backup_path(&filename);
226    let bytes = tokio::fs::read(&path)
227        .await
228        .map_err(|_| (StatusCode::NOT_FOUND, "文件不存在".to_string()))?;
229    let disposition = format!("attachment; filename=\"{}\"", filename);
230    let content_type = if filename.ends_with(".tar.gz") {
231        "application/gzip"
232    } else {
233        "application/sql; charset=utf-8"
234    };
235    Ok((
236        StatusCode::OK,
237        [
238            (
239                header::CONTENT_TYPE,
240                axum::http::HeaderValue::from_static(content_type),
241            ),
242            (
243                header::CONTENT_DISPOSITION,
244                axum::http::HeaderValue::from_str(&disposition)
245                    .unwrap_or_else(|_| axum::http::HeaderValue::from_static("attachment")),
246            ),
247        ],
248        axum::body::Body::from(bytes),
249    ))
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn import_filename_accepts_plain_backup_name() {
258        assert_eq!(
259            sanitize_import_filename("backup_20260816_200000.sql"),
260            Some("backup_20260816_200000.sql".to_string())
261        );
262        // 255 字节边界接受
263        let max_ok = format!("{}.sql", "a".repeat(251));
264        assert!(sanitize_import_filename(&max_ok).is_some());
265    }
266
267    #[test]
268    fn import_filename_strips_path_components() {
269        // 旧浏览器可能送 `C:\fakepath\` 前缀;POSIX 路径同样剥掉,只留 basename。
270        assert_eq!(
271            sanitize_import_filename("C:\\fakepath\\auto_20260816_200000.sql"),
272            Some("auto_20260816_200000.sql".to_string())
273        );
274        assert_eq!(
275            sanitize_import_filename("/tmp/x/backup_1.sql"),
276            Some("backup_1.sql".to_string())
277        );
278    }
279
280    #[test]
281    fn import_filename_rejects_non_sql_and_hidden() {
282        // 配对 tar.gz 不可经导入入口塞进 backups/
283        assert_eq!(sanitize_import_filename("x_uploads.tar.gz"), None);
284        assert_eq!(sanitize_import_filename("noext"), None);
285        // 隐藏文件与裸 ".sql" 拒绝(避开 tmp 文件命名空间)
286        assert_eq!(sanitize_import_filename(".hidden.sql"), None);
287        assert_eq!(sanitize_import_filename(".sql"), None);
288        assert_eq!(sanitize_import_filename(""), None);
289    }
290
291    #[test]
292    fn import_filename_rejects_illegal_chars_and_overlong() {
293        assert_eq!(sanitize_import_filename("带中文.sql"), None);
294        assert_eq!(sanitize_import_filename("has space.sql"), None);
295        let long = format!("{}.sql", "a".repeat(252));
296        assert_eq!(sanitize_import_filename(&long), None);
297    }
298}