From fffdcb9db1bb9b6c6c08ec9c37282e7fab813ee8 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Fri, 19 Jun 2026 09:35:19 +0800 Subject: [PATCH] fix(web): download full database SQL export via browser --- apps/desktop/src/lib/http.ts | 11 ++ crates/dbx-web/src/main.rs | 1 + crates/dbx-web/src/routes/database_export.rs | 110 ++++++++++++++++--- 3 files changed, 107 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/lib/http.ts b/apps/desktop/src/lib/http.ts index 4ec72f00c3..bddd6bc449 100644 --- a/apps/desktop/src/lib/http.ts +++ b/apps/desktop/src/lib/http.ts @@ -1179,6 +1179,11 @@ export async function exportDatabaseSql(request: DatabaseExportRequest, onProgre onProgress(progress); if (progress.status === "Done" || progress.status === "Error" || progress.status === "Cancelled") { es.close(); + if (progress.status === "Done") { + // Trigger browser download; filename is decided by the server's + // Content-Disposition header. + downloadDatabaseExportFile(request.exportId); + } resolve(); } }; @@ -1189,6 +1194,12 @@ export async function exportDatabaseSql(request: DatabaseExportRequest, onProgre }); } +function downloadDatabaseExportFile(exportId: string): void { + const a = document.createElement("a"); + a.href = `/api/export/database/download/${exportId}`; + a.click(); +} + export async function cancelDatabaseExport(exportId: string): Promise { await post("/api/export/database/cancel", { exportId }); } diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 8b172590ae..d557d829e5 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -372,6 +372,7 @@ async fn main() { .route("/export/database", post(routes::database_export::start_database_export)) .route("/export/database/progress/{exportId}", get(routes::database_export::database_export_progress)) .route("/export/database/cancel", post(routes::database_export::cancel_database_export)) + .route("/export/database/download/{exportId}", get(routes::database_export::database_export_download)) // Table export .route("/export/table", post(routes::table_export::start_table_export)) .route("/export/table/progress/{exportId}", get(routes::table_export::table_export_progress)) diff --git a/crates/dbx-web/src/routes/database_export.rs b/crates/dbx-web/src/routes/database_export.rs index b25d0f61d5..58b808d100 100644 --- a/crates/dbx-web/src/routes/database_export.rs +++ b/crates/dbx-web/src/routes/database_export.rs @@ -1,7 +1,13 @@ -use std::sync::Arc; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; +use axum::body::Body; use axum::extract::{Path, State}; +use axum::http::{header, StatusCode}; use axum::response::sse::{Event, Sse}; +use axum::response::Response; use axum::Json; use dbx_core::database_export::{self, DatabaseExportRequest, ExportProgress, ExportStatus}; use futures::stream::Stream; @@ -26,36 +32,75 @@ pub async fn start_database_export( State(state): State>, Json(body): Json, ) -> Result, AppError> { - let req = body.request; + let mut req = body.request; let export_id = req.export_id.clone(); + // Web mode: write to a server-side temp file instead of the client-supplied + // path (which is a relative placeholder and would land in the server process + // CWD, unreachable by the browser). The file is served back via the download + // endpoint after the export completes. + let tmp_dir = state.data_dir.join("tmp"); + std::fs::create_dir_all(&tmp_dir).map_err(|e| AppError(e.to_string()))?; + let tmp_file = tmp_dir.join(format!("database_export_{export_id}.sql")); + let tmp_file_path = tmp_file.to_string_lossy().to_string(); + req.file_path = tmp_file_path.clone(); + + let download_filename = format!("{}.sql", sanitize_export_filename(&req.database)); + + // Store export file mapping for download + state.export_files.write().await.insert(export_id.clone(), (tmp_file_path.clone(), download_filename)); + let (tx, _) = tokio::sync::broadcast::channel::(256); state.sse_channels.write().await.insert(export_id.clone(), tx.clone()); let app = state.app.clone(); let state_clone = state.clone(); + let cancelled = Arc::new(AtomicBool::new(false)); + let cancelled_progress = cancelled.clone(); + tokio::spawn(async move { let result = database_export::export_database_sql_core(&app, &req, |progress| { + if matches!(progress.status, ExportStatus::Cancelled) { + cancelled_progress.store(true, Ordering::SeqCst); + } if let Ok(json) = serde_json::to_string(&progress) { let _ = tx.send(json); } }) .await; - if let Err(e) = result { - let progress = ExportProgress { - export_id: req.export_id.clone(), - current_object: String::new(), - object_index: 0, - total_objects: 0, - rows_exported: 0, - total_rows: None, - status: ExportStatus::Error, - error: Some(e), - }; - if let Ok(json) = serde_json::to_string(&progress) { - let _ = tx.send(json); + // `export_database_sql_core` signals cancellation either by emitting a + // Cancelled progress and returning Ok(()), or by returning + // Err("Export cancelled"). Treat both as cancellation. + let is_cancelled = + cancelled.load(Ordering::SeqCst) || result.as_ref().err().map(|e| e.contains("cancelled")).unwrap_or(false); + + match (&result, is_cancelled) { + (_, true) => { + // Cancelled: drop temp file + mapping, no Error progress. + let _ = tokio::fs::remove_file(&req.file_path).await; + state_clone.export_files.write().await.remove(&req.export_id); + } + (Err(e), false) => { + let _ = tokio::fs::remove_file(&req.file_path).await; + state_clone.export_files.write().await.remove(&req.export_id); + let progress = ExportProgress { + export_id: req.export_id.clone(), + current_object: String::new(), + object_index: 0, + total_objects: 0, + rows_exported: 0, + total_rows: None, + status: ExportStatus::Error, + error: Some(e.clone()), + }; + if let Ok(json) = serde_json::to_string(&progress) { + let _ = tx.send(json); + } + } + (Ok(()), false) => { + // Done: keep temp file + mapping for the download endpoint. } } @@ -66,6 +111,18 @@ pub async fn start_database_export( Ok(Json(serde_json::json!({ "exportId": export_id }))) } +/// Sanitize a database name into a safe download filename component, mirroring +/// the client-side `safeName` logic in DatabaseExportDialog.vue. +fn sanitize_export_filename(name: &str) -> String { + let replaced = name.replace(['\\', '/', ':', '*', '?', '"', '<', '>', '|'], "_"); + let sanitized = replaced.trim(); + if sanitized.is_empty() { + "database".to_string() + } else { + sanitized.to_string() + } +} + pub async fn database_export_progress( State(state): State>, Path(export_id): Path, @@ -84,3 +141,26 @@ pub async fn cancel_database_export( database_export::set_export_cancelled(&req.export_id).await; Json(serde_json::json!({ "cancelled": true })) } + +pub async fn database_export_download( + State(state): State>, + Path(export_id): Path, +) -> Result { + let (file_path, download_filename) = state + .export_files + .write() + .await + .remove(&export_id) + .ok_or_else(|| AppError("Export file not found".to_string()))?; + + let data = tokio::fs::read(&file_path).await.map_err(|e| AppError(e.to_string()))?; + // Clean up temp file + let _ = tokio::fs::remove_file(&file_path).await; + + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/sql; charset=utf-8") + .header(header::CONTENT_DISPOSITION, format!("attachment; filename=\"{download_filename}\"")) + .body(Body::from(data)) + .map_err(|e| AppError(e.to_string())) +}