feat(readonly): add read-only mode for all database connections
- Add read_only field to ConnectionConfig with backward-compatible serde defaults - SQL classifier with first-keyword + embedded keyword defense layers - PRAGMA white-listing for safe read-only operations - Guard all write entry points: SQL, Mongo, Redis, etcd, MCP Bridge - 16 unit tests covering edge cases (CTE, case insensitivity, string masking) - 7-language i18n support - Fast-fail at command/route level before pool creation Closes #889
This commit is contained in:
commit
bfec556e9b
533 files changed
+11710
-20243
No files matched your search
@@ -1,4 +1,4 @@
|
||||
use std::sync::Arc;
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
|
||||
use dbx_core::storage::DesktopSettings;
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
@@ -37,9 +37,15 @@ pub async fn save_pinned_tree_node_ids(state: State<'_, Arc<AppState>>, ids: Vec
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_native_debug_logs(app: AppHandle) -> Result<String, String> {
|
||||
let log_dir = app.path().app_log_dir().map_err(|e| e.to_string())?;
|
||||
tauri::async_runtime::spawn_blocking(move || load_native_debug_logs_from_dir(log_dir))
|
||||
.await
|
||||
.map_err(|err| err.to_string())?
|
||||
}
|
||||
|
||||
fn load_native_debug_logs_from_dir(log_dir: PathBuf) -> Result<String, String> {
|
||||
const MAX_FILES: usize = 6;
|
||||
const MAX_FILE_BYTES: u64 = 512 * 1024;
|
||||
let log_dir = app.path().app_log_dir().map_err(|e| e.to_string())?;
|
||||
if !log_dir.exists() {
|
||||
return Ok(format!("Native log dir does not exist yet: {}", log_dir.display()));
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ use tauri::State;
|
||||
|
||||
pub use dbx_core::agent_connection::{
|
||||
agent_connect_params, mongo_legacy_error_with_auth_hint, oracle_alternate_connect_config,
|
||||
oracle_auth_fallback_profiles, should_retry_oracle_with_10g_driver,
|
||||
oracle_auth_fallback_profiles, oracle_error_with_driver_hint, should_retry_oracle_with_10g_driver,
|
||||
};
|
||||
pub use dbx_core::connection::{
|
||||
connect_bare_metadata_pool, connect_mysql_metadata_pool, connection_url_for_endpoint, metadata_connection_config,
|
||||
@@ -85,7 +85,7 @@ async fn test_agent_connection(
|
||||
));
|
||||
}
|
||||
} else {
|
||||
return Err(err);
|
||||
return Err(oracle_error_with_driver_hint(config, &err));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ async fn connect_agent_pool(
|
||||
format!("{err}\n\nFallback with legacy Oracle drivers failed: {}", fallback_errors.join("\n"))
|
||||
})?;
|
||||
} else {
|
||||
return Err(err);
|
||||
return Err(oracle_error_with_driver_hint(config, &err));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,6 +400,31 @@ pub async fn test_connection(state: State<'_, Arc<AppState>>, config: Connection
|
||||
.await
|
||||
.map(|_| "Connection successful".to_string())
|
||||
}
|
||||
DatabaseType::Turso => {
|
||||
let auth_token = if !config.password.is_empty() {
|
||||
config.password.clone()
|
||||
} else {
|
||||
config
|
||||
.url_params
|
||||
.as_deref()
|
||||
.and_then(|p| {
|
||||
p.trim()
|
||||
.trim_start_matches('?')
|
||||
.split('&')
|
||||
.filter_map(|pair| pair.split_once('='))
|
||||
.find(|(key, _)| {
|
||||
let k = key.trim().to_ascii_lowercase();
|
||||
k == "auth_token" || k == "authtoken" || k == "auth-token"
|
||||
})
|
||||
.map(|(_, value)| value.trim().to_string())
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let client = db::turso_driver::TursoClient::new(&url, &auth_token, config.ssl, connect_timeout)?;
|
||||
db::turso_driver::test_connection(&client, connect_timeout)
|
||||
.await
|
||||
.map(|_| "Connection successful".to_string())
|
||||
}
|
||||
db_type if database_capabilities::is_agent_type(&db_type) => {
|
||||
test_agent_connection(state.inner(), &config, &host, port).await
|
||||
}
|
||||
@@ -567,6 +592,30 @@ pub async fn connect_db(state: State<'_, Arc<AppState>>, config: ConnectionConfi
|
||||
db::rqlite_driver::test_connection(&client, connect_timeout).await?;
|
||||
PoolKind::Rqlite(client)
|
||||
}
|
||||
DatabaseType::Turso => {
|
||||
let auth_token = if !db_config.password.is_empty() {
|
||||
db_config.password.clone()
|
||||
} else {
|
||||
db_config
|
||||
.url_params
|
||||
.as_deref()
|
||||
.and_then(|p| {
|
||||
p.trim()
|
||||
.trim_start_matches('?')
|
||||
.split('&')
|
||||
.filter_map(|pair| pair.split_once('='))
|
||||
.find(|(key, _)| {
|
||||
let k = key.trim().to_ascii_lowercase();
|
||||
k == "auth_token" || k == "authtoken" || k == "auth-token"
|
||||
})
|
||||
.map(|(_, value)| value.trim().to_string())
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let client = db::turso_driver::TursoClient::new(&url, &auth_token, db_config.ssl, connect_timeout)?;
|
||||
db::turso_driver::test_connection(&client, connect_timeout).await?;
|
||||
PoolKind::Turso(client)
|
||||
}
|
||||
db_type if database_capabilities::is_agent_type(&db_type) => {
|
||||
connect_agent_pool(state.inner(), &db_config, &host, port).await?
|
||||
}
|
||||
@@ -610,6 +659,9 @@ pub async fn disconnect_db(state: State<'_, Arc<AppState>>, connection_id: Strin
|
||||
}
|
||||
drop(conns);
|
||||
state.reset_connection_transport(&connection_id).await;
|
||||
if connection_id.starts_with("__visible_draft_") {
|
||||
state.configs.write().await.remove(&connection_id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -15,9 +15,13 @@ pub struct QueryResultCsvExportRequest {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn export_query_result_csv(request: QueryResultCsvExportRequest) -> Result<(), String> {
|
||||
let csv = format_csv(&request.columns, &request.rows);
|
||||
std::fs::write(&request.file_path, format!("\u{FEFF}{csv}")).map_err(|err| err.to_string())
|
||||
pub async fn export_query_result_csv(request: QueryResultCsvExportRequest) -> Result<(), String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let csv = format_csv(&request.columns, &request.rows);
|
||||
std::fs::write(&request.file_path, format!("\u{FEFF}{csv}")).map_err(|err| err.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|err| err.to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -12,8 +12,8 @@ pub fn pending_open_sql_files(state: tauri::State<'_, ExternalSqlOpenState>) ->
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn read_external_sql_file(path: String) -> Result<String, String> {
|
||||
read_external_sql_file_content(Path::new(&path))
|
||||
pub async fn read_external_sql_file(path: String) -> Result<String, String> {
|
||||
read_external_sql_file_content_async(PathBuf::from(path)).await
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -62,7 +62,8 @@ pub fn is_sql_file_path(path: &Path) -> bool {
|
||||
path.extension().and_then(|ext| ext.to_str()).map(|ext| ext.eq_ignore_ascii_case("sql")).unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn read_external_sql_file_content(path: &Path) -> Result<String, String> {
|
||||
#[cfg(test)]
|
||||
fn read_external_sql_file_content(path: &Path) -> Result<String, String> {
|
||||
if !is_sql_file_path(path) {
|
||||
return Err("Only .sql files can be opened this way".to_string());
|
||||
}
|
||||
@@ -70,6 +71,14 @@ pub fn read_external_sql_file_content(path: &Path) -> Result<String, String> {
|
||||
decode_sql_file_bytes(&bytes)
|
||||
}
|
||||
|
||||
async fn read_external_sql_file_content_async(path: PathBuf) -> Result<String, String> {
|
||||
if !is_sql_file_path(&path) {
|
||||
return Err("Only .sql files can be opened this way".to_string());
|
||||
}
|
||||
let bytes = tokio::fs::read(&path).await.map_err(|e| format!("Failed to read SQL file: {e}"))?;
|
||||
decode_sql_file_bytes(&bytes)
|
||||
}
|
||||
|
||||
fn dedupe_paths(paths: Vec<String>) -> Vec<String> {
|
||||
let mut unique = Vec::new();
|
||||
for path in paths {
|
||||
|
||||
@@ -14,8 +14,9 @@ pub async fn load_history(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
limit: usize,
|
||||
offset: usize,
|
||||
activity_kind: Option<String>,
|
||||
) -> Result<Vec<HistoryEntry>, String> {
|
||||
state.storage.load_history_entries(limit, offset).await
|
||||
state.storage.load_history_entries(limit, offset, activity_kind).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/// Read a macOS Keychain generic password by service name.
|
||||
/// Triggers a system authorization dialog (Touch ID / password) for each unique service.
|
||||
#[tauri::command]
|
||||
pub async fn read_keychain_password(service: String, account: Option<String>) -> Result<String, String> {
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let _ = (service, account);
|
||||
return Err("Keychain access is only available on macOS".to_string());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
tauri::async_runtime::spawn_blocking(move || read_keychain_password_blocking(service, account))
|
||||
.await
|
||||
.map_err(|err| err.to_string())?
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn read_keychain_password_blocking(service: String, account: Option<String>) -> Result<String, String> {
|
||||
let mut cmd = std::process::Command::new("security");
|
||||
cmd.args(["find-generic-password", "-s", &service, "-w"]);
|
||||
if let Some(ref acct) = account {
|
||||
cmd.args(["-a", acct]);
|
||||
}
|
||||
|
||||
let output = cmd.output().map_err(|e| format!("Failed to run security command: {e}"))?;
|
||||
|
||||
if output.status.success() {
|
||||
let password = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
Ok(password)
|
||||
} else {
|
||||
// Exit code 44 = user cancelled the authorization dialog
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
if output.status.code() == Some(44) || stderr.contains("User canceled") {
|
||||
Ok(String::new())
|
||||
} else if stderr.contains("could not be found") || stderr.contains("The specified item could not be found") {
|
||||
Ok(String::new())
|
||||
} else {
|
||||
Err(format!("Keychain read failed: {}", stderr.trim()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read multiple Keychain passwords in one call. Returns a map of service -> password.
|
||||
/// Services that fail or are cancelled get an empty string.
|
||||
#[tauri::command]
|
||||
pub async fn read_keychain_passwords(services: Vec<String>) -> Result<Vec<(String, String)>, String> {
|
||||
let mut results = Vec::with_capacity(services.len());
|
||||
for service in services {
|
||||
let password = read_keychain_password(service.clone(), None).await.unwrap_or_default();
|
||||
results.push((service, password));
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
@@ -31,11 +31,17 @@ struct NpmLatestPackage {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_mcp_server_status() -> Result<McpServerStatus, String> {
|
||||
let npm_available = command_success("npm", &["--version"]);
|
||||
let node_version = command_stdout("node", &["--version"]).ok().and_then(first_non_empty_line);
|
||||
let current_version = if npm_available { installed_mcp_version() } else { None };
|
||||
let bin_path = locate_mcp_bin();
|
||||
let latest_version = fetch_latest_mcp_version().await.ok();
|
||||
let local_status = tauri::async_runtime::spawn_blocking(|| {
|
||||
let npm_available = command_success("npm", &["--version"]);
|
||||
let node_version = command_stdout("node", &["--version"]).ok().and_then(first_non_empty_line);
|
||||
let current_version = if npm_available { installed_mcp_version() } else { None };
|
||||
let bin_path = locate_mcp_bin();
|
||||
(npm_available, node_version, current_version, bin_path)
|
||||
});
|
||||
let latest_version = fetch_latest_mcp_version();
|
||||
let (local_status, latest_version) = tokio::join!(local_status, latest_version);
|
||||
let (npm_available, node_version, current_version, bin_path) = local_status.map_err(|err| err.to_string())?;
|
||||
let latest_version = latest_version.ok();
|
||||
let update_available = current_version
|
||||
.as_deref()
|
||||
.zip(latest_version.as_deref())
|
||||
@@ -58,7 +64,9 @@ pub async fn check_mcp_server_status() -> Result<McpServerStatus, String> {
|
||||
|
||||
async fn fetch_latest_mcp_version() -> Result<String, String> {
|
||||
let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(10)).user_agent("dbx-mcp-status-checker");
|
||||
if let Some(proxy_url) = dbx_core::update::system_proxy_url() {
|
||||
let proxy_url =
|
||||
tauri::async_runtime::spawn_blocking(dbx_core::update::system_proxy_url).await.map_err(|e| e.to_string())?;
|
||||
if let Some(proxy_url) = proxy_url {
|
||||
let proxy = reqwest::Proxy::all(&proxy_url).map_err(|e| format!("Invalid system proxy URL: {e}"))?;
|
||||
builder = builder.proxy(proxy);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ pub mod etcd_cmd;
|
||||
pub mod external_db;
|
||||
pub mod external_sql;
|
||||
pub mod history;
|
||||
pub mod keychain;
|
||||
pub mod mcp;
|
||||
pub mod mcp_bridge;
|
||||
pub mod mongo_cmd;
|
||||
|
||||
@@ -8,7 +8,10 @@ use super::connection::AppState;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_plugins(state: State<'_, Arc<AppState>>) -> Result<Vec<InstalledPlugin>, String> {
|
||||
state.plugins.list_installed()
|
||||
let root_dir = state.plugins.root_dir().to_path_buf();
|
||||
tauri::async_runtime::spawn_blocking(move || dbx_core::plugins::PluginRegistry::new(root_dir).list_installed())
|
||||
.await
|
||||
.map_err(|err| err.to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -31,12 +34,18 @@ pub async fn install_jdbc_plugin_local(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn uninstall_jdbc_plugin(state: State<'_, Arc<AppState>>) -> Result<JdbcPluginStatus, String> {
|
||||
jdbc::uninstall_jdbc_plugin(state.plugins.root_dir())
|
||||
let root_dir = state.plugins.root_dir().to_path_buf();
|
||||
tauri::async_runtime::spawn_blocking(move || jdbc::uninstall_jdbc_plugin(&root_dir))
|
||||
.await
|
||||
.map_err(|err| err.to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_jdbc_drivers(state: State<'_, Arc<AppState>>) -> Result<Vec<JdbcDriverInfo>, String> {
|
||||
jdbc::list_jdbc_drivers(state.plugins.root_dir())
|
||||
let root_dir = state.plugins.root_dir().to_path_buf();
|
||||
tauri::async_runtime::spawn_blocking(move || jdbc::list_jdbc_drivers(&root_dir))
|
||||
.await
|
||||
.map_err(|err| err.to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -44,10 +53,16 @@ pub async fn import_jdbc_drivers(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
paths: Vec<String>,
|
||||
) -> Result<Vec<JdbcDriverInfo>, String> {
|
||||
jdbc::import_jdbc_drivers(state.plugins.root_dir(), &paths)
|
||||
let root_dir = state.plugins.root_dir().to_path_buf();
|
||||
tauri::async_runtime::spawn_blocking(move || jdbc::import_jdbc_drivers(&root_dir, &paths))
|
||||
.await
|
||||
.map_err(|err| err.to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_jdbc_driver(state: State<'_, Arc<AppState>>, path: String) -> Result<Vec<JdbcDriverInfo>, String> {
|
||||
jdbc::delete_jdbc_driver(state.plugins.root_dir(), &path)
|
||||
let root_dir = state.plugins.root_dir().to_path_buf();
|
||||
tauri::async_runtime::spawn_blocking(move || jdbc::delete_jdbc_driver(&root_dir, &path))
|
||||
.await
|
||||
.map_err(|err| err.to_string())?
|
||||
}
|
||||
@@ -81,9 +81,10 @@ pub async fn redis_hash_set(
|
||||
key_raw: String,
|
||||
field: String,
|
||||
value: String,
|
||||
ttl: Option<i64>,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "HSET").await?;
|
||||
dbx_core::redis_ops::redis_hash_set_in_db_core(&state, &connection_id, db, &key_raw, &field, &value).await
|
||||
dbx_core::redis_ops::redis_hash_set_in_db_core(&state, &connection_id, db, &key_raw, &field, &value, ttl).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -105,9 +106,10 @@ pub async fn redis_list_push(
|
||||
db: u32,
|
||||
key_raw: String,
|
||||
value: String,
|
||||
ttl: Option<i64>,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "LPUSH").await?;
|
||||
dbx_core::redis_ops::redis_list_push_in_db_core(&state, &connection_id, db, &key_raw, &value).await
|
||||
dbx_core::redis_ops::redis_list_push_in_db_core(&state, &connection_id, db, &key_raw, &value, ttl).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -142,9 +144,10 @@ pub async fn redis_set_add(
|
||||
db: u32,
|
||||
key_raw: String,
|
||||
member: String,
|
||||
ttl: Option<i64>,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "SADD").await?;
|
||||
dbx_core::redis_ops::redis_set_add_in_db_core(&state, &connection_id, db, &key_raw, &member).await
|
||||
dbx_core::redis_ops::redis_set_add_in_db_core(&state, &connection_id, db, &key_raw, &member, ttl).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -167,9 +170,10 @@ pub async fn redis_zadd(
|
||||
key_raw: String,
|
||||
member: String,
|
||||
score: f64,
|
||||
ttl: Option<i64>,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "ZADD").await?;
|
||||
dbx_core::redis_ops::redis_zadd_in_db_core(&state, &connection_id, db, &key_raw, &member, score).await
|
||||
dbx_core::redis_ops::redis_zadd_in_db_core(&state, &connection_id, db, &key_raw, &member, score, ttl).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -184,6 +188,40 @@ pub async fn redis_zrem(
|
||||
dbx_core::redis_ops::redis_zrem_in_db_core(&state, &connection_id, db, &key_raw, &member).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn redis_stream_add(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
db: u32,
|
||||
key_raw: String,
|
||||
entry_id: String,
|
||||
fields: Vec<(String, String)>,
|
||||
ttl: Option<i64>,
|
||||
) -> Result<(), String> {
|
||||
dbx_core::redis_ops::redis_stream_add_in_db_core(&state, &connection_id, db, &key_raw, &entry_id, fields, ttl).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn redis_json_set(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
db: u32,
|
||||
key_raw: String,
|
||||
value: String,
|
||||
ttl: Option<i64>,
|
||||
) -> Result<(), String> {
|
||||
dbx_core::redis_ops::redis_json_set_in_db_core(&state, &connection_id, db, &key_raw, &value, ttl).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn redis_check_json_module(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
db: u32,
|
||||
) -> Result<bool, String> {
|
||||
dbx_core::redis_ops::redis_check_json_module_in_db_core(&state, &connection_id, db).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn redis_set_ttl(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
|
||||
@@ -1,9 +1,38 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
use dbx_core::connection::AppState;
|
||||
use dbx_core::saved_sql::{SavedSqlFile, SavedSqlFolder, SavedSqlLibrary};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SavedSqlStorageState {
|
||||
pub data_dir: PathBuf,
|
||||
}
|
||||
|
||||
const SYNC_MANIFEST_FILE: &str = ".dbx-sql-library-sync.json";
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SavedSqlSyncEntry {
|
||||
pub folder_name: Option<String>,
|
||||
pub file_name: String,
|
||||
pub sql: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SavedSqlSyncRequest {
|
||||
pub target_dir: String,
|
||||
pub entries: Vec<SavedSqlSyncEntry>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SavedSqlSyncManifest {
|
||||
files: Vec<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_saved_sql_library(state: State<'_, Arc<AppState>>) -> Result<SavedSqlLibrary, String> {
|
||||
state.storage.load_saved_sql_library().await
|
||||
@@ -33,3 +62,158 @@ pub async fn save_saved_sql_file(state: State<'_, Arc<AppState>>, file: SavedSql
|
||||
pub async fn delete_saved_sql_file(state: State<'_, Arc<AppState>>, id: String) -> Result<(), String> {
|
||||
state.storage.delete_saved_sql_file(&id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn saved_sql_storage_dir(state: State<'_, SavedSqlStorageState>) -> Result<String, String> {
|
||||
Ok(state.data_dir.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_saved_sql_storage_dir(
|
||||
state: State<'_, SavedSqlStorageState>,
|
||||
dir: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let target_dir = dir
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|path| !path.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| state.data_dir.clone());
|
||||
std::fs::create_dir_all(&target_dir).map_err(|e| e.to_string())?;
|
||||
open_path(&target_dir)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn sync_saved_sql_directory(request: SavedSqlSyncRequest) -> Result<(), String> {
|
||||
let target_dir = PathBuf::from(request.target_dir.trim());
|
||||
if target_dir.as_os_str().is_empty() {
|
||||
return Err("Target directory is empty".to_string());
|
||||
}
|
||||
tokio::task::spawn_blocking(move || sync_saved_sql_directory_blocking(&target_dir, &request.entries))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
fn sync_saved_sql_directory_blocking(target_dir: &Path, entries: &[SavedSqlSyncEntry]) -> Result<(), String> {
|
||||
let sync_root = target_dir.join("dbx-sql-library");
|
||||
std::fs::create_dir_all(&sync_root).map_err(|e| e.to_string())?;
|
||||
remove_previous_sync_files(&sync_root)?;
|
||||
|
||||
let mut written_files = Vec::new();
|
||||
for entry in entries {
|
||||
let mut file_dir = sync_root.to_path_buf();
|
||||
if let Some(folder_name) = entry.folder_name.as_deref().map(str::trim).filter(|name| !name.is_empty()) {
|
||||
file_dir.push(sanitize_file_segment(folder_name));
|
||||
}
|
||||
std::fs::create_dir_all(&file_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
let file_name = ensure_sql_extension(&sanitize_file_segment(&entry.file_name));
|
||||
let file_path = unique_file_path(&file_dir, &file_name);
|
||||
std::fs::write(&file_path, &entry.sql).map_err(|e| e.to_string())?;
|
||||
if let Ok(relative) = file_path.strip_prefix(&sync_root) {
|
||||
written_files.push(relative.to_string_lossy().replace('\\', "/"));
|
||||
}
|
||||
}
|
||||
|
||||
let manifest = SavedSqlSyncManifest { files: written_files };
|
||||
let manifest_json = serde_json::to_string_pretty(&manifest).map_err(|e| e.to_string())?;
|
||||
std::fs::write(sync_root.join(SYNC_MANIFEST_FILE), manifest_json).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_previous_sync_files(target_dir: &Path) -> Result<(), String> {
|
||||
let manifest_path = target_dir.join(SYNC_MANIFEST_FILE);
|
||||
let Ok(raw) = std::fs::read_to_string(&manifest_path) else {
|
||||
return Ok(());
|
||||
};
|
||||
let manifest = serde_json::from_str::<SavedSqlSyncManifest>(&raw).unwrap_or_default();
|
||||
for relative in manifest.files {
|
||||
let file_path = target_dir.join(relative);
|
||||
if file_path.is_file() {
|
||||
std::fs::remove_file(&file_path).map_err(|e| e.to_string())?;
|
||||
remove_empty_parent_dirs(target_dir, file_path.parent());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_empty_parent_dirs(root: &Path, parent: Option<&Path>) {
|
||||
let Some(dir) = parent else {
|
||||
return;
|
||||
};
|
||||
if dir == root {
|
||||
return;
|
||||
}
|
||||
if std::fs::remove_dir(dir).is_ok() {
|
||||
remove_empty_parent_dirs(root, dir.parent());
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_file_segment(value: &str) -> String {
|
||||
let sanitized = value
|
||||
.chars()
|
||||
.map(|ch| match ch {
|
||||
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
|
||||
ch if ch.is_control() => '_',
|
||||
ch => ch,
|
||||
})
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.trim_matches('.')
|
||||
.to_string();
|
||||
if sanitized.is_empty() {
|
||||
"untitled".to_string()
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_sql_extension(name: &str) -> String {
|
||||
if name.to_lowercase().ends_with(".sql") {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("{name}.sql")
|
||||
}
|
||||
}
|
||||
|
||||
fn unique_file_path(dir: &Path, file_name: &str) -> PathBuf {
|
||||
let mut candidate = dir.join(file_name);
|
||||
if !candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
let base = file_name.strip_suffix(".sql").unwrap_or(file_name);
|
||||
let mut counter = 2;
|
||||
loop {
|
||||
candidate = dir.join(format!("{base} ({counter}).sql"));
|
||||
if !candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
counter += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn open_path(path: &Path) -> Result<(), String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
let mut command = {
|
||||
let mut command = std::process::Command::new("open");
|
||||
command.arg(path);
|
||||
command
|
||||
};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let mut command = {
|
||||
let mut command = std::process::Command::new("explorer");
|
||||
command.arg(path);
|
||||
command
|
||||
};
|
||||
|
||||
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
|
||||
let mut command = {
|
||||
let mut command = std::process::Command::new("xdg-open");
|
||||
command.arg(path);
|
||||
command
|
||||
};
|
||||
|
||||
command.spawn().map(|_| ()).map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
#[tauri::command]
|
||||
pub fn list_system_fonts() -> Result<Vec<String>, String> {
|
||||
Ok(dbx_core::jdbc::list_system_fonts())
|
||||
pub async fn list_system_fonts() -> Result<Vec<String>, String> {
|
||||
tauri::async_runtime::spawn_blocking(|| Ok(dbx_core::jdbc::list_system_fonts()))
|
||||
.await
|
||||
.map_err(|err| err.to_string())?
|
||||
}
|
||||
@@ -11,19 +11,29 @@ pub struct QueryResultTextExportRequest {
|
||||
}
|
||||
|
||||
impl QueryResultTextExportRequest {
|
||||
fn data(&self) -> QueryResultTextExportData {
|
||||
QueryResultTextExportData { columns: self.columns.clone(), rows: self.rows.clone() }
|
||||
fn into_data(self) -> QueryResultTextExportData {
|
||||
QueryResultTextExportData { columns: self.columns, rows: self.rows }
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn export_query_result_json(request: QueryResultTextExportRequest) -> Result<(), String> {
|
||||
let content = format_json(&request.data())?;
|
||||
std::fs::write(&request.file_path, format!("\u{FEFF}{content}")).map_err(|err| err.to_string())
|
||||
pub async fn export_query_result_json(request: QueryResultTextExportRequest) -> Result<(), String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let file_path = request.file_path.clone();
|
||||
let content = format_json(&request.into_data())?;
|
||||
std::fs::write(file_path, format!("\u{FEFF}{content}")).map_err(|err| err.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|err| err.to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn export_query_result_markdown(request: QueryResultTextExportRequest) -> Result<(), String> {
|
||||
let content = format_markdown(&request.data());
|
||||
std::fs::write(&request.file_path, format!("\u{FEFF}{content}")).map_err(|err| err.to_string())
|
||||
pub async fn export_query_result_markdown(request: QueryResultTextExportRequest) -> Result<(), String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let file_path = request.file_path.clone();
|
||||
let content = format_markdown(&request.into_data());
|
||||
std::fs::write(file_path, format!("\u{FEFF}{content}")).map_err(|err| err.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|err| err.to_string())?
|
||||
}
|
||||
@@ -10,6 +10,6 @@ pub async fn check_for_updates() -> Result<UpdateInfo, String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_system_proxy_url() -> Option<String> {
|
||||
dbx_core::update::system_proxy_url()
|
||||
pub async fn get_system_proxy_url() -> Option<String> {
|
||||
tauri::async_runtime::spawn_blocking(dbx_core::update::system_proxy_url).await.ok().flatten()
|
||||
}
|
||||
@@ -12,11 +12,15 @@ pub struct QueryResultXlsxExportRequest {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn export_query_result_xlsx(request: QueryResultXlsxExportRequest) -> Result<(), String> {
|
||||
let workbook = build_xlsx_workbook(&XlsxWorksheetData {
|
||||
sheet_name: request.sheet_name,
|
||||
columns: request.columns,
|
||||
rows: request.rows,
|
||||
})?;
|
||||
std::fs::write(&request.file_path, workbook).map_err(|err| err.to_string())
|
||||
pub async fn export_query_result_xlsx(request: QueryResultXlsxExportRequest) -> Result<(), String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let workbook = build_xlsx_workbook(&XlsxWorksheetData {
|
||||
sheet_name: request.sheet_name,
|
||||
columns: request.columns,
|
||||
rows: request.rows,
|
||||
})?;
|
||||
std::fs::write(&request.file_path, workbook).map_err(|err| err.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|err| err.to_string())?
|
||||
}
|
||||
Reference in new issue
Block a user