yggdrasil/api/database/backup/
transfer.rs1use 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
9const DEFAULT_IMPORT_MAX_MB: u64 = 512;
11
12pub(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
22fn 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
36pub 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 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 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 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 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 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 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 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 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 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
208pub 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 crate::api::auth::require_admin_from_headers(&headers)
218 .await
219 .map_err(|(status, msg)| (status, msg.to_string()))?;
220
221 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 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 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 assert_eq!(sanitize_import_filename("x_uploads.tar.gz"), None);
284 assert_eq!(sanitize_import_filename("noext"), None);
285 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}