1#![allow(clippy::unused_unit, deprecated)]
2
3#[cfg(feature = "server")]
19use std::path::{Component, Path, PathBuf};
20
21#[cfg(feature = "server")]
22use chrono::Utc;
23use dioxus::prelude::*;
24use serde::{Deserialize, Serialize};
25
26#[cfg(feature = "server")]
28use crate::api::auth::get_current_admin_user;
29#[cfg(feature = "server")]
30use crate::api::database::tasks::{self, TaskKind, TaskStatus};
31#[cfg(feature = "server")]
32use crate::api::error::AppError;
33
34#[cfg(feature = "server")]
39const BACKUP_DIR: &str = "backups";
40#[cfg(feature = "server")]
42const FILENAME_RE: &str = r"^[a-zA-Z0-9_.\-]+$";
43#[cfg(feature = "server")]
45const BACKUP_SIGNATURE: &str = "-- YGGDRASIL BACKUP v1";
46
47#[derive(Serialize, Deserialize, Debug, Clone)]
49pub struct BackupInfo {
50 pub filename: String,
51 pub size_bytes: u64,
52 pub mode: String,
54 pub created_at: Option<String>,
55}
56
57#[server(CreateBackup, "/api")]
59pub async fn create_backup() -> Result<String, ServerFnError> {
60 let _user = get_current_admin_user().await?;
61
62 #[cfg(feature = "server")]
63 {
64 let task_id = uuid::Uuid::new_v4().to_string();
65 tasks::insert(task_id.clone(), TaskKind::Backup);
66 let tid = task_id.clone();
67 tokio::spawn(async move {
68 run_backup(&tid).await;
69 });
70 Ok(task_id)
71 }
72 #[cfg(not(feature = "server"))]
73 {
74 Ok(String::new())
75 }
76}
77
78#[cfg(feature = "server")]
80async fn run_backup(task_id: &str) {
81 let _ = std::fs::create_dir_all(BACKUP_DIR);
82 let timestamp = Utc::now().format("%Y%m%d_%H%M%S").to_string();
83
84 let pg_dump_ok = tokio::task::spawn_blocking(|| {
86 std::process::Command::new("pg_dump")
87 .arg("--version")
88 .output()
89 .is_ok()
90 })
91 .await
92 .unwrap_or(false);
93
94 if pg_dump_ok {
95 run_pg_dump_backup(task_id, ×tamp).await;
96 } else {
97 run_sql_fallback_backup(task_id, ×tamp).await;
98 }
99}
100
101#[cfg(feature = "server")]
107async fn run_pg_dump_backup(task_id: &str, timestamp: &str) {
108 tasks::update(
109 task_id,
110 "正在用 pg_dump 导出",
111 10,
112 TaskStatus::Running,
113 None,
114 None,
115 None,
116 );
117 let filename = format!("backup_{}.sql", timestamp);
118 let path = backup_path(&filename);
119 let db_url = match std::env::var("DATABASE_URL") {
120 Ok(u) if !u.is_empty() => u,
121 _ => {
122 tasks::update(
123 task_id,
124 "DATABASE_URL 未配置",
125 100,
126 TaskStatus::Failed,
127 None,
128 Some("pg_dump 备份需要 DATABASE_URL".to_string()),
129 None,
130 );
131 return;
132 }
133 };
134
135 let mut header = String::new();
136 header.push_str(&format!("{}\n", BACKUP_SIGNATURE));
137 header.push_str(&format!("-- created_at: {}\n", Utc::now()));
138 header.push_str("-- mode: pg_dump\n");
139
140 if let Err(e) = std::fs::write(&path, &header) {
142 tasks::update(
143 task_id,
144 "写入备份文件失败",
145 100,
146 TaskStatus::Failed,
147 None,
148 Some(format!("无法写入备份目录: {e}")),
149 None,
150 );
151 return;
152 }
153
154 let stdout_file = match std::fs::OpenOptions::new().append(true).open(&path) {
155 Ok(f) => f,
156 Err(e) => {
157 tasks::update(
158 task_id,
159 "pg_dump 启动失败",
160 100,
161 TaskStatus::Failed,
162 None,
163 Some(e.to_string()),
164 None,
165 );
166 return;
167 }
168 };
169 let dump_result = tokio::task::spawn_blocking(
176 move || -> Result<std::process::Output, (bool, std::io::Error)> {
177 std::process::Command::new("pg_dump")
178 .arg(db_url)
179 .arg("--clean")
182 .arg("--if-exists")
183 .stdout(std::process::Stdio::from(stdout_file))
184 .stderr(std::process::Stdio::piped())
185 .spawn()
186 .map_err(|e| (true, e))?
187 .wait_with_output()
188 .map_err(|e| (false, e))
189 },
190 )
191 .await
192 .unwrap_or_else(|join_e| Err((false, std::io::Error::other(join_e.to_string()))));
193 match dump_result {
194 Ok(o) if o.status.success() => {
195 tasks::update(
196 task_id,
197 "完成",
198 100,
199 TaskStatus::Done,
200 None,
201 None,
202 Some(filename),
203 );
204 }
205 Ok(o) => {
206 let stderr = String::from_utf8_lossy(&o.stderr).to_string();
207 tasks::update(
208 task_id,
209 "pg_dump 失败",
210 100,
211 TaskStatus::Failed,
212 None,
213 Some(stderr),
214 None,
215 );
216 }
217 Err((true, e)) => {
218 tasks::update(
219 task_id,
220 "pg_dump 启动失败",
221 100,
222 TaskStatus::Failed,
223 None,
224 Some(e.to_string()),
225 None,
226 );
227 }
228 Err((false, e)) => {
229 tasks::update(
230 task_id,
231 "pg_dump 执行失败",
232 100,
233 TaskStatus::Failed,
234 None,
235 Some(e.to_string()),
236 None,
237 );
238 }
239 }
240}
241
242#[cfg(feature = "server")]
244async fn run_sql_fallback_backup(task_id: &str, timestamp: &str) {
245 tasks::update(
246 task_id,
247 "pg_dump 不可用,使用纯 SQL 回退(仅数据)",
248 10,
249 TaskStatus::Running,
250 Some("仅备份数据,不含 schema/索引/触发器".to_string()),
251 None,
252 None,
253 );
254 let filename = format!("backup_{}_sqlfallback.sql", timestamp);
255 let path = backup_path(&filename);
256
257 let client = match crate::db::pool::get_conn().await {
258 Ok(c) => c,
259 Err(e) => {
260 tasks::update(
261 task_id,
262 "数据库连接失败",
263 100,
264 TaskStatus::Failed,
265 None,
266 Some(e.to_string()),
267 None,
268 );
269 return;
270 }
271 };
272
273 let tables: Vec<String> = match client
275 .query(
276 "SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename",
277 &[],
278 )
279 .await
280 {
281 Ok(rows) => rows.into_iter().map(|r| r.get(0)).collect(),
282 Err(e) => {
283 tasks::update(
284 task_id,
285 "读取表清单失败",
286 100,
287 TaskStatus::Failed,
288 None,
289 Some(e.to_string()),
290 None,
291 );
292 return;
293 }
294 };
295 let total = tables.len().max(1);
296
297 let mut out = String::new();
298 out.push_str(&format!("{}\n", BACKUP_SIGNATURE));
299 out.push_str(&format!("-- created_at: {}\n", Utc::now()));
300 out.push_str("-- mode: sql-fallback\n\n");
301
302 for (i, table) in tables.iter().enumerate() {
303 out.push_str(&format!("\n-- table: {}\n", table));
304 let copy_stmt = format!("COPY \"{}\" TO STDOUT WITH CSV", table);
305 match client.copy_out(©_stmt).await {
306 Ok(stream) => {
307 use futures::StreamExt;
308 tokio::pin!(stream);
310 while let Some(chunk) = stream.next().await {
311 if let Ok(bytes) = chunk {
312 out.push_str(&String::from_utf8_lossy(&bytes));
313 }
314 }
315 }
316 Err(e) => {
317 out.push_str(&format!("-- 导出失败: {}\n", e));
318 }
319 }
320 tasks::update(
322 task_id,
323 &format!("导出表 {}/{}", i + 1, total),
324 (10 + (i + 1) as u32 * 90 / total as u32).min(99) as u8,
325 TaskStatus::Running,
326 None,
327 None,
328 None,
329 );
330 }
331
332 if let Err(e) = std::fs::write(&path, out) {
333 tasks::update(
334 task_id,
335 "写入备份文件失败",
336 100,
337 TaskStatus::Failed,
338 None,
339 Some(format!("无法写入备份目录: {e}")),
340 None,
341 );
342 return;
343 }
344 tasks::update(
345 task_id,
346 "完成",
347 100,
348 TaskStatus::Done,
349 None,
350 None,
351 Some(filename),
352 );
353}
354
355#[server(RestoreBackup, "/api")]
357pub async fn restore_backup(filename: String, confirm: bool) -> Result<String, ServerFnError> {
358 let _user = get_current_admin_user().await?;
359
360 #[cfg(feature = "server")]
363 {
364 if !confirm {
365 return Err(AppError::BadRequest("需确认恢复(会覆盖现有数据)".to_string()).into());
366 }
367 if !is_valid_backup_filename(&filename) {
369 return Err(AppError::BadRequest("无效的文件名".to_string()).into());
370 }
371 let path = backup_path(&filename);
372 if !path.exists() {
373 return Err(AppError::NotFound("备份文件不存在").into());
374 }
375
376 let first_line = read_first_line(&path).unwrap_or_default();
378 if !has_valid_signature(&first_line) {
379 return Err(
380 AppError::BadRequest("非本系统生成的备份文件,拒绝恢复".to_string()).into(),
381 );
382 }
383
384 let task_id = uuid::Uuid::new_v4().to_string();
385 tasks::insert(task_id.clone(), TaskKind::Restore);
386 let tid = task_id.clone();
387 let f = filename;
388 tokio::spawn(async move {
389 run_restore(&tid, &f).await;
390 });
391 Ok(task_id)
392 }
393 #[cfg(not(feature = "server"))]
394 {
395 let _ = (filename, confirm);
397 Ok(String::new())
398 }
399}
400
401#[cfg(feature = "server")]
414async fn run_restore(task_id: &str, filename: &str) {
415 let path = backup_path(filename);
416 let db_url = match std::env::var("DATABASE_URL") {
417 Ok(u) if !u.is_empty() => u,
418 _ => {
419 tasks::update(
420 task_id,
421 "DATABASE_URL 未配置",
422 100,
423 TaskStatus::Failed,
424 None,
425 Some("恢复需要 DATABASE_URL".to_string()),
426 None,
427 );
428 return;
429 }
430 };
431 let psql_ok = tokio::task::spawn_blocking(|| {
432 std::process::Command::new("psql")
433 .arg("--version")
434 .output()
435 .is_ok()
436 })
437 .await
438 .unwrap_or(false);
439 if !psql_ok {
440 tasks::update(
441 task_id,
442 "psql 不可用",
443 100,
444 TaskStatus::Failed,
445 None,
446 Some("恢复需要 psql,但当前环境未安装 psql".to_string()),
447 None,
448 );
449 return;
450 }
451 tasks::update(
452 task_id,
453 "正在用 psql 恢复",
454 50,
455 TaskStatus::Running,
456 None,
457 None,
458 None,
459 );
460 let restore_result = tokio::task::spawn_blocking(move || {
464 std::process::Command::new("psql")
465 .arg(db_url)
466 .arg("-v")
469 .arg("ON_ERROR_STOP=1")
470 .arg("-f")
471 .arg(path)
472 .stderr(std::process::Stdio::piped())
473 .output()
474 })
475 .await
476 .unwrap_or_else(|join_e| Err(std::io::Error::other(join_e.to_string())));
477 match restore_result {
478 Ok(o) if o.status.success() => {
479 crate::cache::invalidate_all_post_caches();
482 crate::cache::invalidate_search_results();
483 crate::ssr_cache::bump_global_generation();
484 tasks::update(task_id, "恢复完成", 100, TaskStatus::Done, None, None, None);
485 }
486 Ok(o) => {
487 let stderr = String::from_utf8_lossy(&o.stderr).to_string();
488 tasks::update(
489 task_id,
490 "恢复失败",
491 100,
492 TaskStatus::Failed,
493 None,
494 Some(stderr),
495 None,
496 );
497 }
498 Err(e) => {
499 tasks::update(
500 task_id,
501 "psql 启动失败",
502 100,
503 TaskStatus::Failed,
504 None,
505 Some(e.to_string()),
506 None,
507 );
508 }
509 }
510}
511
512#[server(ListBackups, "/api")]
514pub async fn list_backups() -> Result<Vec<BackupInfo>, ServerFnError> {
515 let _user = get_current_admin_user().await?;
516 #[cfg(feature = "server")]
517 {
518 let mut infos: Vec<BackupInfo> = Vec::new();
519 if let Ok(entries) = std::fs::read_dir(BACKUP_DIR) {
520 for entry in entries.flatten() {
521 let name = entry.file_name().to_string_lossy().to_string();
522 if !name.ends_with(".sql") {
523 continue;
524 }
525 let meta = match entry.metadata() {
526 Ok(m) => m,
527 Err(_) => continue,
528 };
529 let mode = read_first_lines(entry.path(), 3)
532 .map(|lines| parse_backup_mode(&lines.join("\n")))
533 .unwrap_or_else(|_| "unknown".to_string());
534 let created_at = meta
535 .modified()
536 .ok()
537 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
538 .map(|d| {
539 chrono::DateTime::<Utc>::from_timestamp(d.as_secs() as i64, 0)
540 .map(|dt| dt.to_rfc3339())
541 .unwrap_or_default()
542 });
543 infos.push(BackupInfo {
544 filename: name,
545 size_bytes: meta.len(),
546 mode,
547 created_at,
548 });
549 }
550 }
551 infos.sort_by(|a, b| b.created_at.cmp(&a.created_at));
553 Ok(infos)
554 }
555 #[cfg(not(feature = "server"))]
556 {
557 Ok(vec![])
558 }
559}
560
561#[server(DeleteBackup, "/api")]
563pub async fn delete_backup(filename: String) -> Result<(), ServerFnError> {
564 let _user = get_current_admin_user().await?;
565 #[cfg(feature = "server")]
566 {
567 if !is_valid_backup_filename(&filename) {
568 return Err(AppError::BadRequest("无效的文件名".to_string()).into());
569 }
570 let path = backup_path(&filename);
571 if !path.exists() {
572 return Err(AppError::NotFound("备份文件不存在").into());
573 }
574 std::fs::remove_file(&path).map_err(|_| AppError::Internal("删除失败"))?;
575 Ok(())
576 }
577 #[cfg(not(feature = "server"))]
578 {
579 Ok(())
580 }
581}
582
583#[cfg(feature = "server")]
595fn backup_path(filename: &str) -> PathBuf {
596 let filename_is_safe = std::path::Path::new(filename)
598 .components()
599 .all(|c| matches!(c, Component::Normal(_)));
600 if filename_is_safe {
601 let mut p = PathBuf::from(BACKUP_DIR);
602 p.push(filename);
603 p
604 } else {
605 PathBuf::from(BACKUP_DIR)
607 }
608}
609
610#[cfg(feature = "server")]
613fn is_valid_backup_filename(filename: &str) -> bool {
614 regex::Regex::new(FILENAME_RE)
616 .map(|re| re.is_match(filename))
617 .unwrap_or(false)
618}
619
620#[cfg(feature = "server")]
624fn parse_backup_mode(content: &str) -> String {
625 content
626 .lines()
627 .find(|l| l.starts_with("-- mode:"))
628 .map(|l| l.trim_start_matches("-- mode:").trim().to_string())
629 .filter(|s| !s.is_empty())
630 .unwrap_or_else(|| "unknown".to_string())
631}
632
633#[cfg(feature = "server")]
636fn has_valid_signature(content: &str) -> bool {
637 content
638 .lines()
639 .next()
640 .map(|l| l.trim().contains(BACKUP_SIGNATURE))
641 .unwrap_or(false)
642}
643
644#[cfg(feature = "server")]
646fn read_first_line(path: impl AsRef<Path>) -> std::io::Result<String> {
647 use std::io::BufRead;
648 let mut reader = std::io::BufReader::new(std::fs::File::open(path)?);
649 let mut line = String::new();
650 reader.read_line(&mut line)?;
651 Ok(line)
652}
653
654#[cfg(feature = "server")]
657fn read_first_lines(path: impl AsRef<Path>, n: usize) -> std::io::Result<Vec<String>> {
658 use std::io::BufRead;
659 let reader = std::io::BufReader::new(std::fs::File::open(path)?);
660 reader.lines().take(n).collect()
661}
662
663#[cfg(feature = "server")]
666pub async fn download_backup(
667 axum::extract::Path(filename): axum::extract::Path<String>,
668 headers: axum::http::HeaderMap,
669) -> Result<impl axum::response::IntoResponse, (axum::http::StatusCode, String)> {
670 use axum::http::{header, StatusCode};
671
672 let cookie_header = headers
674 .get("cookie")
675 .and_then(|h| h.to_str().ok())
676 .unwrap_or("");
677 let token = crate::auth::session::parse_session_token(cookie_header).map(str::to_string);
678 let token = match token {
679 Some(t) => t,
680 None => return Err((StatusCode::UNAUTHORIZED, "未登录".to_string())),
681 };
682 let user = match crate::api::auth::get_user_by_token(&token).await {
683 Ok(Some(u)) => u,
684 _ => return Err((StatusCode::UNAUTHORIZED, "会话已过期".to_string())),
685 };
686 if user.role != crate::models::user::UserRole::Admin {
687 return Err((StatusCode::FORBIDDEN, "权限不足".to_string()));
688 }
689
690 if !is_valid_backup_filename(&filename) {
692 return Err((StatusCode::BAD_REQUEST, "无效的文件名".to_string()));
693 }
694 let path = backup_path(&filename);
695 let bytes = tokio::fs::read(&path)
696 .await
697 .map_err(|_| (StatusCode::NOT_FOUND, "文件不存在".to_string()))?;
698 let disposition = format!("attachment; filename=\"{}\"", filename);
699 Ok((
700 StatusCode::OK,
701 [
702 (
703 header::CONTENT_TYPE,
704 axum::http::HeaderValue::from_static("application/sql; charset=utf-8"),
705 ),
706 (
707 header::CONTENT_DISPOSITION,
708 axum::http::HeaderValue::from_str(&disposition)
709 .unwrap_or_else(|_| axum::http::HeaderValue::from_static("attachment")),
710 ),
711 ],
712 axum::body::Body::from(bytes),
713 ))
714}
715
716#[cfg(all(test, feature = "server"))]
717mod tests {
718 use super::*;
719
720 #[test]
723 fn filename_accepts_normal_names() {
724 for name in [
725 "backup_20260702_120000.sql",
726 "backup_20260702_120000_sqlfallback.sql",
727 "a.sql",
728 "A-B_C.123",
729 ] {
730 assert!(is_valid_backup_filename(name), "正常文件名应通过: {name}");
731 }
732 }
733
734 #[test]
735 fn filename_rejects_path_traversal() {
736 for evil in [
738 "../etc/passwd",
739 "..\\windows\\win.ini",
740 "/etc/passwd",
741 "a/../../b",
742 "backup.sql/../../etc",
743 ] {
744 assert!(!is_valid_backup_filename(evil), "路径穿越应被拒: {evil}");
745 }
746 }
747
748 #[test]
749 fn filename_rejects_spaces_and_special_chars() {
750 for evil in [
752 "backup with space.sql",
753 "备份.sql",
754 "a;rm -rf.sql",
755 r"a\$b.sql",
756 "a`b`.sql",
757 "",
758 ] {
759 assert!(!is_valid_backup_filename(evil), "特殊字符应被拒: {evil:?}");
760 }
761 }
762
763 #[test]
766 fn backup_path_stays_in_backup_dir_for_normal_name() {
767 let p = backup_path("backup_20260702.sql");
768 assert!(p.starts_with(BACKUP_DIR), "应在 {BACKUP_DIR}/ 下");
769 assert_eq!(
770 p.file_name().and_then(|n| n.to_str()),
771 Some("backup_20260702.sql")
772 );
773 }
774
775 #[test]
776 fn backup_path_collapses_traversal_to_backup_dir() {
777 for evil in ["../etc/passwd", "../../etc/shadow"] {
780 let p = backup_path(evil);
781 assert_eq!(
783 p,
784 PathBuf::from(BACKUP_DIR),
785 "穿越应被规约回 {BACKUP_DIR}: {evil}"
786 );
787 }
788 }
789
790 #[test]
791 fn backup_path_rejects_absolute_path() {
792 let p = backup_path("/etc/passwd");
794 assert_eq!(p, PathBuf::from(BACKUP_DIR));
795 }
796
797 #[test]
800 fn signature_matches_exact_header() {
801 let content = "-- YGGDRASIL BACKUP v1\n-- mode: pg_dump\nSELECT 1;\n";
802 assert!(has_valid_signature(content));
803 }
804
805 #[test]
806 fn signature_matches_with_leading_whitespace() {
807 let content = " -- YGGDRASIL BACKUP v1\nrest\n";
809 assert!(has_valid_signature(content));
810 }
811
812 #[test]
813 fn signature_rejects_non_system_file() {
814 let content = "SELECT * FROM users;\n-- YGGDRASIL BACKUP v1\n";
816 assert!(!has_valid_signature(content));
818 }
819
820 #[test]
821 fn signature_rejects_empty_and_garbage() {
822 assert!(!has_valid_signature(""));
823 assert!(!has_valid_signature("garbage\n"));
824 assert!(!has_valid_signature("\n\n-- YGGDRASIL BACKUP v1"));
825 }
826
827 #[test]
830 fn parse_mode_pg_dump() {
831 let content = "-- YGGDRASIL BACKUP v1\n-- mode: pg_dump\n...\n";
832 assert_eq!(parse_backup_mode(content), "pg_dump");
833 }
834
835 #[test]
836 fn parse_mode_sql_fallback() {
837 let content = "-- YGGDRASIL BACKUP v1\n-- mode: sql-fallback\n\n-- table: posts\n";
838 assert_eq!(parse_backup_mode(content), "sql-fallback");
839 }
840
841 #[test]
842 fn parse_mode_unknown_when_absent() {
843 let content = "-- YGGDRASIL BACKUP v1\nSELECT 1;\n";
844 assert_eq!(parse_backup_mode(content), "unknown");
845 }
846
847 #[test]
848 fn parse_mode_unknown_when_empty_value() {
849 let content = "-- mode:\nrest\n";
851 assert_eq!(parse_backup_mode(content), "unknown");
852 }
853
854 #[test]
855 fn parse_mode_only_matches_first_occurrence() {
856 let content = "-- mode: pg_dump\n-- mode: sql-fallback\n";
858 assert_eq!(parse_backup_mode(content), "pg_dump");
859 }
860}