From d00ec80635680bfc5ddbebb7660e12a6249a7fc5 Mon Sep 17 00:00:00 2001 From: abeni16 Date: Thu, 4 Jun 2026 11:52:38 +0300 Subject: [PATCH] chore: remove unused Tauri plugin and update connection string handling This commit removes the `@tauri-apps/plugin-fs` from the project dependencies and updates the connection string handling for MySQL to support SSL modes. It also introduces a confirmation prompt for write operations in SQL queries, enhancing safety and user experience. Additionally, the OpenRouter API key is now securely managed in the OS keychain, ensuring sensitive information is not stored in localStorage. --- package.json | 1 - pnpm-lock.yaml | 29 ------- src-tauri/Cargo.lock | 1 - src-tauri/Cargo.toml | 1 - src-tauri/capabilities/default.json | 3 +- src-tauri/src/commands.rs | 85 ++++++++++++++++++- src-tauri/src/credentials.rs | 23 +++++ src-tauri/src/db.rs | 79 ++++++++++++++++- src-tauri/src/lib.rs | 12 +-- src-tauri/src/models.rs | 4 + src-tauri/src/ssh_tunnel.rs | 10 +-- src-tauri/tauri.conf.json | 2 +- src/App.tsx | 7 +- src/data/types.ts | 2 + .../commands/components/SettingsDialog.tsx | 6 +- .../components/ConnectionDialog.tsx | 7 +- .../queries/components/QueryWorkspace.tsx | 9 +- src/features/queries/queries.ts | 7 +- src/i18n/locales/en.json | 1 + src/i18n/locales/zh.json | 1 + src/lib/connection-string.test.ts | 18 ++++ src/lib/connection-string.ts | 26 +++++- src/lib/openrouter-credentials.ts | 43 ++++++++++ src/lib/settings.ts | 6 +- src/lib/sql-intent.test.ts | 31 +++++++ src/lib/sql-intent.ts | 40 +++++++++ 26 files changed, 396 insertions(+), 58 deletions(-) create mode 100644 src/lib/openrouter-credentials.ts create mode 100644 src/lib/sql-intent.test.ts create mode 100644 src/lib/sql-intent.ts diff --git a/package.json b/package.json index 076fe35..56ad53d 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,6 @@ "@tanstack/react-virtual": "^3.13.23", "@tauri-apps/api": "^2.11.0", "@tauri-apps/plugin-dialog": "^2.7.0", - "@tauri-apps/plugin-fs": "^2.5.0", "@tauri-apps/plugin-store": "^2.4.2", "@xyflow/react": "^12.10.2", "class-variance-authority": "^0.7.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9b3be2..3a47f58 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,9 +35,6 @@ importers: '@tauri-apps/plugin-dialog': specifier: ^2.7.0 version: 2.7.1 - '@tauri-apps/plugin-fs': - specifier: ^2.5.0 - version: 2.5.1 '@tauri-apps/plugin-store': specifier: ^2.4.2 version: 2.4.3 @@ -1293,42 +1290,36 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} @@ -1433,28 +1424,24 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.4': resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.4': resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.4': resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.4': resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==} @@ -1543,35 +1530,30 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-arm64-musl@2.11.0': resolution: {integrity: sha512-DtSE8ZBlB9H+L+eHkfZ3myt00EVEyAB3e41juEHoE2qT88fgVlJvyrwa9SZYc/xTwCS9TnmK+R84tpg+ZsAg7Q==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@tauri-apps/cli-linux-riscv64-gnu@2.11.0': resolution: {integrity: sha512-5QdgS4LD+kntClI1aj2JmwjW38LosNXxwCe8viIHEwqYIWuMPdNEIau6/cLogI38Yzx9DnfCPRfEWLyI+5li8Q==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-x64-gnu@2.11.0': resolution: {integrity: sha512-5UynPXo3Zq9khjVdAbD+YogeLltdVUeOah2ioSIM3tu6H7wY9vMy6rgGJhv9r5R8ZXmk9GttMippdqYJWrnLnA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-x64-musl@2.11.0': resolution: {integrity: sha512-CNz7fHbApz1Zyhhq73jtGn9JqgNEV/lIWnTnUo6h6ujw+mHsTmkLszvJSM8W6JBaDjNpTTFr/RSNoVL5FMwcTg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@tauri-apps/cli-win32-arm64-msvc@2.11.0': resolution: {integrity: sha512-K+br+VXZ+Xx0n/9FdWohpW5Ugq+2FQUpJScqcPl1hTxXfh3fgjYgt4qA2NgrjlJo+zZPNrmUMl+NLvm0ufEqBQ==} @@ -1599,9 +1581,6 @@ packages: '@tauri-apps/plugin-dialog@2.7.1': resolution: {integrity: sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==} - '@tauri-apps/plugin-fs@2.5.1': - resolution: {integrity: sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ==} - '@tauri-apps/plugin-store@2.4.3': resolution: {integrity: sha512-9LWPj9yMphRi9czEtUv87XHbl1b6xgd9EXpPrUnq6nG7+nbtoF84d4Kwz9xhAv/Hf30sr58pq7EOlyI936y8qw==} @@ -2810,28 +2789,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -5347,10 +5322,6 @@ snapshots: dependencies: '@tauri-apps/api': 2.11.0 - '@tauri-apps/plugin-fs@2.5.1': - dependencies: - '@tauri-apps/api': 2.11.0 - '@tauri-apps/plugin-store@2.4.3': dependencies: '@tauri-apps/api': 2.11.0 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d75cd55..60ba221 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5899,7 +5899,6 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-dialog", - "tauri-plugin-fs", "tauri-plugin-log", "tauri-plugin-store", "tokio", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6efd235..f8ee154 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,7 +24,6 @@ deadpool-postgres = "0.14" log = "0.4" tauri = { version = "2.10.3", features = [] } tauri-plugin-dialog = "2" -tauri-plugin-fs = "2" tauri-plugin-log = "2" tauri-plugin-store = "2" tokio = { version = "1", features = ["full"] } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 7adeab1..64cb816 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -8,7 +8,6 @@ "permissions": [ "core:default", "store:default", - "dialog:default", - "fs:default" + "dialog:default" ] } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 48a0228..cf26827 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -16,7 +16,8 @@ use crate::db::{ build_mysql_pool, build_mysql_pool_custom, build_pool, build_pool_custom, build_sqlite_pool, disconnect_connection, drop_pool, get_or_create_mysql_pool, get_or_create_sqlite_pool, list_connections, load_connection, persist_connection_with_password, quote_identifier, - refresh_connection_pools, resolve_connection_engine, with_pool_client_retry, AppState, + refresh_connection_pools, require_safe_identifier, resolve_connection_engine, + with_pool_client_retry, AppState, DEFAULT_MYSQL_PORT, MAX_QUERY_ROWS, }; use crate::credentials; @@ -807,6 +808,14 @@ pub async fn run_query( return Err("Enter a SQL statement before running the query.".to_string()); } + if !input.allow_write.unwrap_or(false) && !is_read_only_sql(&sql) { + return Err( + "This statement modifies data or schema. Confirm execution in the editor, \ + or use the model/DDL workflow for schema changes." + .to_string(), + ); + } + let max_query_rows = input.max_rows.unwrap_or(MAX_QUERY_ROWS); match engine { @@ -971,6 +980,7 @@ async fn fetch_query_editor_metadata_for_connection( let mut truncated_columns = false; for row in table_rows.into_iter().take(MAX_EDITOR_TABLES as usize) { let name: String = sqlite_get_idx(&row, 0, "name", "get_query_editor_metadata")?; + require_safe_identifier(&name, "table name")?; let pragma_sql = format!("PRAGMA table_info(\"{}\");", quote_identifier(&name)); let column_rows = sqlx::query(&pragma_sql) .fetch_all(&pool) @@ -1183,6 +1193,7 @@ async fn fetch_foreign_keys_for_connection( let mut edges = Vec::new(); for table in tables { let table_name: String = sqlite_get_idx(&table, 0, "name", "get_foreign_keys")?; + require_safe_identifier(&table_name, "table name")?; let fk_sql = format!("PRAGMA foreign_key_list(\"{}\");", quote_identifier(&table_name)); let fk_rows = sqlx::query(&fk_sql) .fetch_all(&pool) @@ -1426,6 +1437,7 @@ pub async fn get_query_editor_metadata( let mut truncated_columns = false; for row in table_rows.into_iter().take(MAX_EDITOR_TABLES as usize) { let name: String = sqlite_get_idx(&row, 0, "name", "get_query_editor_metadata")?; + require_safe_identifier(&name, "table name")?; let pragma_sql = format!("PRAGMA table_info(\"{}\");", quote_identifier(&name)); let column_rows = sqlx::query(&pragma_sql) .fetch_all(&pool) @@ -1733,6 +1745,29 @@ fn classify_sql_intent(sql: &str) -> String { "unknown".to_string() } +/// Whether every statement in `sql` is read-only. Transaction-control keywords +/// (`begin`/`commit`/`rollback`/`start`/`savepoint`) are ignored so a wrapped +/// `BEGIN; SELECT ...; COMMIT;` still counts as read-only, while any write +/// statement makes the whole batch non-read-only. +fn is_read_only_sql(sql: &str) -> bool { + let mut saw_statement = false; + for statement in sql.split(';').map(str::trim).filter(|s| !s.is_empty()) { + let normalized = statement.to_ascii_lowercase(); + let is_transaction_control = ["begin", "commit", "rollback", "start", "savepoint", "release"] + .iter() + .any(|kw| normalized.starts_with(kw)); + if is_transaction_control { + continue; + } + saw_statement = true; + match classify_sql_intent(statement).as_str() { + "select" | "explain" => {} + _ => return false, + } + } + saw_statement +} + fn has_multiple_statements(sql: &str) -> bool { let statements = sql .split(';') @@ -2688,6 +2723,7 @@ pub async fn get_tables( let mut tables = Vec::new(); for row in rows { let name: String = sqlite_get_idx(&row, 0, "name", "get_tables")?; + require_safe_identifier(&name, "table name")?; tables.push(TableInfo { schema: "main".to_string(), preview_query: format!("select * from \"{}\" limit 100;", quote_identifier(&name)), @@ -2772,6 +2808,7 @@ pub async fn get_schema( } DatabaseEngine::Sqlite => { let pool = get_or_create_sqlite_pool(&app, &state, &connection_id).await?; + require_safe_identifier(&schema_request.table_name, "table name")?; let pragma_sql = format!( "PRAGMA table_info(\"{}\");", quote_identifier(&schema_request.table_name) @@ -2932,6 +2969,7 @@ pub async fn get_table_properties( if engine == DatabaseEngine::Sqlite { let pool = get_or_create_sqlite_pool(&app, &state, &connection_id).await?; + require_safe_identifier(&ctx.table_name, "table name")?; let pragma_sql = format!("PRAGMA table_info(\"{}\");", quote_identifier(&ctx.table_name)); let rows = sqlx::query(&pragma_sql) .fetch_all(&pool) @@ -2954,6 +2992,7 @@ pub async fn get_table_properties( continue; } let index_name = sqlite_get_name::(&index, "name", "get_table_properties")?; + require_safe_identifier(&index_name, "index name")?; let info_sql = format!("PRAGMA index_info(\"{}\");", quote_identifier(&index_name)); let info_rows = sqlx::query(&info_sql) .fetch_all(&pool) @@ -3143,6 +3182,9 @@ pub async fn apply_table_properties( let table_name = input.table_name; let columns = input.columns; + require_safe_identifier(&table_schema, "schema name")?; + require_safe_identifier(&table_name, "table name")?; + let current_columns = client .query( " @@ -3249,6 +3291,7 @@ pub async fn apply_table_properties( quote_identifier(&table_name) ); + require_safe_identifier(column_name, "column name")?; let qualified_column = format!("\"{}\"", quote_identifier(column_name)); if *desired_is_nullable { @@ -3304,6 +3347,7 @@ pub async fn apply_table_properties( quote_identifier(&table_schema), quote_identifier(&table_name) ); + require_safe_identifier(column_name, "column name")?; let qualified_column = format!("\"{}\"", quote_identifier(column_name)); if *desired_is_unique { @@ -3339,6 +3383,7 @@ pub async fn apply_table_properties( .unwrap_or_default(); for constraint_name in constraint_names { + require_safe_identifier(&constraint_name, "constraint name")?; let sql = format!( "ALTER TABLE {} DROP CONSTRAINT \"{}\"", qualified_table, @@ -3414,6 +3459,7 @@ pub async fn get_foreign_keys( let mut edges = Vec::new(); for table in tables { let table_name: String = sqlite_get_idx(&table, 0, "name", "get_foreign_keys")?; + require_safe_identifier(&table_name, "table name")?; let fk_sql = format!("PRAGMA foreign_key_list(\"{}\");", quote_identifier(&table_name)); let fk_rows = sqlx::query(&fk_sql) .fetch_all(&pool) @@ -3552,6 +3598,7 @@ pub async fn get_table_indexes( if engine == DatabaseEngine::Sqlite { let pool = get_or_create_sqlite_pool(&app, &state, &connection_id).await?; + require_safe_identifier(&ctx.table_name, "table name")?; let pragma_sql = format!( "PRAGMA index_list(\"{}\");", quote_identifier(&ctx.table_name) @@ -3564,6 +3611,7 @@ pub async fn get_table_indexes( let mut indexes = Vec::new(); for row in rows.into_iter().take(MAX_TABLE_INDEX_ROWS as usize) { let index_name: String = sqlite_get_name(&row, "name", "get_table_indexes")?; + require_safe_identifier(&index_name, "index name")?; let index_info_sql = format!("PRAGMA index_info(\"{}\");", quote_identifier(&index_name)); let index_info_rows = sqlx::query(&index_info_sql) .fetch_all(&pool) @@ -3820,6 +3868,24 @@ pub async fn save_text_file(content: String, output_path: String) -> Result<(), std::fs::write(&output_path, content).map_err(|e| e.to_string()) } +#[tauri::command] +pub async fn store_openrouter_api_key(api_key: String) -> Result<(), String> { + if api_key.trim().is_empty() { + return credentials::delete_openrouter_api_key(); + } + credentials::store_openrouter_api_key(&api_key) +} + +#[tauri::command] +pub async fn get_openrouter_api_key() -> Result, String> { + credentials::get_openrouter_api_key() +} + +#[tauri::command] +pub async fn delete_openrouter_api_key() -> Result<(), String> { + credentials::delete_openrouter_api_key() +} + #[cfg(test)] mod tests { use super::{ @@ -3946,4 +4012,21 @@ mod tests { fn sql_intent_classifier_recognizes_update() { assert_eq!(classify_sql_intent("UPDATE foo SET bar = 1"), "update"); } + + #[test] + fn read_only_check_allows_selects_and_explain() { + assert!(super::is_read_only_sql("SELECT 1")); + assert!(super::is_read_only_sql("EXPLAIN ANALYZE SELECT * FROM t")); + assert!(super::is_read_only_sql("WITH x AS (SELECT 1) SELECT * FROM x")); + assert!(super::is_read_only_sql("BEGIN; SELECT 1; COMMIT;")); + } + + #[test] + fn read_only_check_blocks_writes() { + assert!(!super::is_read_only_sql("DELETE FROM t")); + assert!(!super::is_read_only_sql("DROP TABLE t")); + assert!(!super::is_read_only_sql("BEGIN; UPDATE t SET a = 1; COMMIT;")); + assert!(!super::is_read_only_sql("SELECT 1; DELETE FROM t")); + assert!(!super::is_read_only_sql("")); + } } diff --git a/src-tauri/src/credentials.rs b/src-tauri/src/credentials.rs index f3c3e39..b13616e 100644 --- a/src-tauri/src/credentials.rs +++ b/src-tauri/src/credentials.rs @@ -1,6 +1,7 @@ use keyring::Entry; const SERVICE_NAME: &str = "com.veloxdb.app"; +const OPENROUTER_API_KEY_ACCOUNT: &str = "openrouter-api-key"; fn entry(connection_id: &str) -> Result { Entry::new(SERVICE_NAME, connection_id).map_err(|e| format!("Keychain error: {}", e)) @@ -27,3 +28,25 @@ pub fn delete_password(connection_id: &str) -> Result<(), String> { Err(e) => Err(format!("Failed to delete password from keychain: {}", e)), } } + +pub fn store_openrouter_api_key(api_key: &str) -> Result<(), String> { + entry(OPENROUTER_API_KEY_ACCOUNT)? + .set_password(api_key) + .map_err(|e| format!("Failed to store API key in keychain: {}", e)) +} + +pub fn get_openrouter_api_key() -> Result, String> { + match entry(OPENROUTER_API_KEY_ACCOUNT)?.get_password() { + Ok(api_key) => Ok(Some(api_key)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(format!("Failed to read API key from keychain: {}", e)), + } +} + +pub fn delete_openrouter_api_key() -> Result<(), String> { + match entry(OPENROUTER_API_KEY_ACCOUNT)?.delete_credential() { + Ok(()) => Ok(()), + Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(format!("Failed to delete API key from keychain: {}", e)), + } +} diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index a20bee2..1b618ab 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -237,7 +237,15 @@ fn mysql_url(host: &str, port: u16, input: &ConnectionInput) -> String { let password = urlencoding::encode(&input.password); let user = urlencoding::encode(&input.user); let database = urlencoding::encode(&input.database); - format!("mysql://{}:{}@{}:{}/{}", user, password, host, port, database) + let ssl_param = match input.ssl_mode { + ConnectionSslMode::Disable => "ssl-mode=DISABLED", + ConnectionSslMode::Prefer => "ssl-mode=PREFERRED", + ConnectionSslMode::Require => "ssl-mode=REQUIRED", + }; + format!( + "mysql://{}:{}@{}:{}/{}?{}", + user, password, host, port, database, ssl_param + ) } fn sqlite_url(input: &ConnectionInput) -> Result { @@ -711,3 +719,72 @@ pub fn load_connection( pub fn quote_identifier(value: &str) -> String { value.replace('"', "\"\"") } + +/// Whether `name` is safe to interpolate into dynamic SQL (e.g. SQLite `PRAGMA` +/// statements, where bind parameters are not allowed). Restricted to ASCII +/// alphanumerics and underscores so it cannot terminate or escape a statement. +pub fn is_safe_identifier(name: &str) -> bool { + !name.is_empty() + && name.len() <= 128 + && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +/// Validates an identifier before it is interpolated into dynamic SQL. Returns +/// the identifier unchanged when safe, or a descriptive error otherwise. +pub fn require_safe_identifier<'a>(name: &'a str, context: &str) -> Result<&'a str, String> { + if is_safe_identifier(name) { + Ok(name) + } else { + Err(format!("Invalid identifier for {}: {:?}", context, name)) + } +} + +#[cfg(test)] +mod tests { + use super::{is_safe_identifier, mysql_url, require_safe_identifier}; + use crate::models::{ConnectionInput, ConnectionSslMode, DatabaseEngine}; + + fn mysql_input(ssl_mode: ConnectionSslMode) -> ConnectionInput { + ConnectionInput { + id: None, + name: "test".to_string(), + engine: DatabaseEngine::Mysql, + host: "localhost".to_string(), + port: 3306, + database: "app".to_string(), + file_path: None, + user: "root".to_string(), + password: "pw".to_string(), + ssl_mode, + ssh_config: None, + extra_params: None, + } + } + + #[test] + fn mysql_url_maps_ssl_mode() { + assert!(mysql_url("localhost", 3306, &mysql_input(ConnectionSslMode::Disable)) + .ends_with("?ssl-mode=DISABLED")); + assert!(mysql_url("localhost", 3306, &mysql_input(ConnectionSslMode::Prefer)) + .ends_with("?ssl-mode=PREFERRED")); + assert!(mysql_url("localhost", 3306, &mysql_input(ConnectionSslMode::Require)) + .ends_with("?ssl-mode=REQUIRED")); + } + + #[test] + fn rejects_sql_injection_in_identifiers() { + assert!(!is_safe_identifier("\"; DROP TABLE users; --")); + assert!(!is_safe_identifier("foo; DELETE FROM bar")); + assert!(!is_safe_identifier("a\0b")); + assert!(!is_safe_identifier("")); + assert!(require_safe_identifier("foo);", "table name").is_err()); + } + + #[test] + fn accepts_plain_identifiers() { + assert!(is_safe_identifier("users")); + assert!(is_safe_identifier("_internal")); + assert!(is_safe_identifier("Table123")); + assert_eq!(require_safe_identifier("users", "table name"), Ok("users")); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ee6b279..e5f991e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,12 +9,12 @@ mod ssh_tunnel; use commands::{ apply_table_properties, cancel_veloxy_request, chat_with_db, clear_veloxy_conversation, connect_db, delete_connection, - disconnect_db, execute_ddl_statement, execute_ddl_transaction, export_diagram_png, + delete_openrouter_api_key, disconnect_db, execute_ddl_statement, execute_ddl_transaction, export_diagram_png, export_results_csv_command, export_results_json_command, generate_sql_from_nl, get_foreign_keys, - get_query_editor_metadata, get_schema, get_table_indexes, get_table_properties, get_tables, + get_openrouter_api_key, get_query_editor_metadata, get_schema, get_table_indexes, get_table_properties, get_tables, lint_sql, list_connections_command, list_databases, load_veloxy_conversation, ping_connection, refresh_connection, rename_connection, run_query, save_base64_png, save_text_file, set_active_connection, - switch_database, + store_openrouter_api_key, switch_database, }; use db::AppState; use tauri::Manager; @@ -25,7 +25,6 @@ pub fn run() { .manage(AppState::default()) .plugin(tauri_plugin_store::Builder::default().build()) .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_fs::init()) .setup(|app| { if cfg!(debug_assertions) { app.handle().plugin( @@ -79,7 +78,10 @@ pub fn run() { chat_with_db, cancel_veloxy_request, load_veloxy_conversation, - clear_veloxy_conversation + clear_veloxy_conversation, + store_openrouter_api_key, + get_openrouter_api_key, + delete_openrouter_api_key ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 38a6234..3f3c876 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -150,6 +150,10 @@ pub struct QueryRequest { pub sql: String, /// Maximum rows to return for this query. When omitted, the default (`MAX_QUERY_ROWS`) is used. pub max_rows: Option, + /// Allows non-read-only statements to run. Defaults to false so the editor + /// must explicitly confirm writes; programmatic DML (grid edits) sets it. + #[serde(default)] + pub allow_write: Option, } #[derive(Debug, Clone, Serialize)] diff --git a/src-tauri/src/ssh_tunnel.rs b/src-tauri/src/ssh_tunnel.rs index 94be6ae..138b73f 100644 --- a/src-tauri/src/ssh_tunnel.rs +++ b/src-tauri/src/ssh_tunnel.rs @@ -40,15 +40,15 @@ async fn spawn_ssh_tunnel_password( .as_deref() .ok_or("SSH password required")?; + // `sshpass -e` reads the password from the SSHPASS env var, which keeps it + // out of the process argument list (visible via `ps aux`). let child = Command::new("sshpass") - .arg("-p") - .arg(password) + .arg("-e") + .env("SSHPASS", password) .arg("ssh") .arg("-o") .arg("StrictHostKeyChecking=accept-new") .arg("-o") - .arg("UserKnownHostsFile=/dev/null") - .arg("-o") .arg("PasswordAuthentication=yes") .arg("-o") .arg("PubkeyAuthentication=no") @@ -85,8 +85,6 @@ async fn spawn_ssh_tunnel_key( cmd.arg("-o") .arg("StrictHostKeyChecking=accept-new") .arg("-o") - .arg("UserKnownHostsFile=/dev/null") - .arg("-o") .arg("ServerAliveInterval=30") .arg("-o") .arg("ServerAliveCountMax=3") diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index bc4a678..079c276 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -22,7 +22,7 @@ } ], "security": { - "csp": null + "csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' https://openrouter.ai https://api.github.com http://localhost:3000 ws://localhost:3000; img-src 'self' data:; font-src 'self'; worker-src 'self' blob:" } }, "bundle": { diff --git a/src/App.tsx b/src/App.tsx index d0f6edc..c06f970 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -48,6 +48,7 @@ import { } from "@/features/queries/components/AskVeloxyDialog"; import { useSaveResultEditsMutation, useDeleteRowsMutation } from "@/features/queries/queries"; import { notifyError, notifySuccess } from "@/lib/error-notifier"; +import { loadOpenRouterApiKey } from "@/lib/openrouter-credentials"; import { useSettings, resolveTheme } from "@/lib/settings"; import { buildDropTableSql, @@ -181,6 +182,10 @@ function VeloxApp() { document.documentElement.style.fontSize = `${sizes[fontSize]}px` }, [fontSize]) + useEffect(() => { + void loadOpenRouterApiKey() + }, []) + useEffect(() => { window.localStorage.setItem( SIDEBAR_COLLAPSED_KEY, @@ -614,7 +619,7 @@ function VeloxApp() { setTablePropertiesTarget(null); } }, - [connection?.id, deleteConnectionMutation], + [connection?.id, deleteConnectionMutation, t], ); const handleCopyConnectionString = useCallback( diff --git a/src/data/types.ts b/src/data/types.ts index 3581a1d..e8610f1 100644 --- a/src/data/types.ts +++ b/src/data/types.ts @@ -51,6 +51,8 @@ export type QueryRequest = { sql: string /** Maximum rows to return. Matches the user's Settings > Results > Max rows preference. */ maxRows?: number + /** Allows non-read-only statements to run. The editor sets this after confirming a write. */ + allowWrite?: boolean } export type LintSqlRequest = { diff --git a/src/features/commands/components/SettingsDialog.tsx b/src/features/commands/components/SettingsDialog.tsx index 1584211..c43f358 100644 --- a/src/features/commands/components/SettingsDialog.tsx +++ b/src/features/commands/components/SettingsDialog.tsx @@ -19,6 +19,7 @@ import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' import { Dialog, DialogContent } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' +import { saveOpenRouterApiKey } from '@/lib/openrouter-credentials' import { fetchOpenRouterModels, OPENROUTER_POPULAR_MODELS, type OpenRouterModelOption } from '@/lib/openrouter-models' import { cn } from '@/lib/utils' import { useSettings, type AppTheme, type FontSize, type NullDisplay } from '@/lib/settings' @@ -53,7 +54,8 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh const [tab, setTab] = useState('appearance') const handleExport = useCallback(() => { - const data = JSON.stringify(useSettings.getState(), null, 2) + const { veloxyOpenRouterApiKey: _omitApiKey, ...exportable } = useSettings.getState() + const data = JSON.stringify(exportable, null, 2) const blob = new Blob([data], { type: 'application/json' }) const url = URL.createObjectURL(blob) const a = document.createElement('a'); a.href = url; a.download = 'veloxdb-settings.json'; a.click() @@ -222,7 +224,7 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh value={settings.veloxyOpenRouterApiKey} onChange={(e) => { const value = e.target.value - useSettings.setState({ veloxyOpenRouterApiKey: value }) + void saveOpenRouterApiKey(value) if (value.trim()) void refreshOpenRouterModels() }} className="h-8 w-[260px] text-[11px]" diff --git a/src/features/connections/components/ConnectionDialog.tsx b/src/features/connections/components/ConnectionDialog.tsx index 4d6b723..f4269b5 100644 --- a/src/features/connections/components/ConnectionDialog.tsx +++ b/src/features/connections/components/ConnectionDialog.tsx @@ -351,7 +351,10 @@ export function ConnectionDialog({ filePath: values.filePath || null, user: values.engine === 'sqlite' ? '' : values.user, password: values.engine === 'sqlite' ? '' : values.password, - sslMode: values.engine === 'postgres' ? values.sslMode : 'disable', + sslMode: + values.engine === 'postgres' || values.engine === 'mysql' + ? values.sslMode + : 'disable', extraParams: values.engine === 'postgres' && Object.keys(extraParams).length > 0 ? extraParams : null, sshConfig: values.engine !== 'sqlite' && values.sshEnabled @@ -605,7 +608,7 @@ export function ConnectionDialog({ )} - {engine === 'postgres' && ( + {(engine === 'postgres' || engine === 'mysql') && ( - veloxDbRepository.runQuery({ connectionId, sql, maxRows: maxQueryRows }), + mutationFn: ({ connectionId, sql, allowWrite }: RunQueryTabVariables) => + veloxDbRepository.runQuery({ connectionId, sql, allowWrite, maxRows: maxQueryRows }), onSuccess: (result, variables) => { options.onSuccess?.(result, variables); }, @@ -212,6 +214,7 @@ export function useInsertRowMutation( await veloxDbRepository.runQuery({ connectionId: request.connectionId, sql, + allowWrite: true, }); }, onError: (error, variables) => { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 4a85e08..0f6e5f2 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -309,6 +309,7 @@ "insertFailed": "Insert failed", "copyFailed": "Copy failed", "runQueryBtn": "Run Query", + "confirmWrite": "This statement modifies data or schema. Run it anyway?", "stopQuery": "Stop Query", "formatSql": "Format SQL", "explainAnalyze": "Explain Analyze", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 809aed4..acf1197 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -309,6 +309,7 @@ "insertFailed": "插入失败", "copyFailed": "复制失败", "runQueryBtn": "运行查询", + "confirmWrite": "此语句将修改数据或结构。仍要运行吗?", "stopQuery": "停止查询", "formatSql": "格式化 SQL", "explainAnalyze": "Explain Analyze", diff --git a/src/lib/connection-string.test.ts b/src/lib/connection-string.test.ts index 6f6a443..af9df76 100644 --- a/src/lib/connection-string.test.ts +++ b/src/lib/connection-string.test.ts @@ -19,6 +19,24 @@ describe('connection string parsing and building', () => { expect(parsed?.port).toBe(3306) }) + it('parses mysql ssl-mode and builds it back', () => { + const parsed = parseConnectionString( + 'mysql://root:pw@127.0.0.1:3306/app_db?ssl-mode=REQUIRED', + ) + expect(parsed?.sslMode).toBe('require') + + const value = buildConnectionString({ + engine: 'mysql', + host: '127.0.0.1', + port: 3306, + database: 'app_db', + user: 'root', + password: 'pw', + sslMode: 'require', + }) + expect(value).toContain('ssl-mode=REQUIRED') + }) + it('parses sqlite uri', () => { const parsed = parseConnectionString('sqlite:///tmp/velox.db') expect(parsed).not.toBeNull() diff --git a/src/lib/connection-string.ts b/src/lib/connection-string.ts index c503a3c..7ca7587 100644 --- a/src/lib/connection-string.ts +++ b/src/lib/connection-string.ts @@ -14,9 +14,22 @@ export type ParsedConnectionString = { const DEFAULT_PG_PORT = 5432 const SSL_MODE_KEY = 'sslmode' +const MYSQL_SSL_MODE_KEY = 'ssl-mode' const VALID_SSL_MODES: Set = new Set(['disable', 'prefer', 'require']) +const MYSQL_SSL_MODE_FROM_PARAM: Record = { + disabled: 'disable', + preferred: 'prefer', + required: 'require', +} + +const MYSQL_SSL_MODE_TO_PARAM: Record = { + disable: 'DISABLED', + prefer: 'PREFERRED', + require: 'REQUIRED', +} + function normalizeUrl(raw: string): string { return raw.trim() } @@ -70,7 +83,7 @@ export function parseConnectionString(raw: string): ParsedConnectionString | nul const password = decodeURIComponent(url.password || '') const params = new URLSearchParams(url.search) - let sslMode: ConnectionSslMode = engine === 'postgres' ? 'prefer' : 'disable' + let sslMode: ConnectionSslMode = 'prefer' if (params.has(SSL_MODE_KEY)) { const rawMode = (params.get(SSL_MODE_KEY) ?? '').toLowerCase() @@ -80,6 +93,14 @@ export function parseConnectionString(raw: string): ParsedConnectionString | nul params.delete(SSL_MODE_KEY) } + if (engine === 'mysql' && params.has(MYSQL_SSL_MODE_KEY)) { + const rawMode = (params.get(MYSQL_SSL_MODE_KEY) ?? '').toLowerCase() + if (rawMode in MYSQL_SSL_MODE_FROM_PARAM) { + sslMode = MYSQL_SSL_MODE_FROM_PARAM[rawMode] + } + params.delete(MYSQL_SSL_MODE_KEY) + } + const extraParams: Record = {} params.forEach((value, key) => { extraParams[key] = value @@ -116,6 +137,9 @@ export function buildConnectionString(fields: { if (fields.engine === 'postgres' && fields.sslMode !== 'prefer') { params.set('sslmode', fields.sslMode) } + if (fields.engine === 'mysql' && fields.sslMode !== 'prefer') { + params.set(MYSQL_SSL_MODE_KEY, MYSQL_SSL_MODE_TO_PARAM[fields.sslMode]) + } if (fields.extraParams) { for (const [key, value] of Object.entries(fields.extraParams)) { params.set(key, value) diff --git a/src/lib/openrouter-credentials.ts b/src/lib/openrouter-credentials.ts new file mode 100644 index 0000000..bc36682 --- /dev/null +++ b/src/lib/openrouter-credentials.ts @@ -0,0 +1,43 @@ +import { invoke } from '@tauri-apps/api/core' + +import { useSettings } from '@/lib/settings' + +/** + * The OpenRouter API key lives in the OS keychain, not in localStorage. These + * helpers keep the in-memory zustand state in sync with the keychain so the + * rest of the app can keep reading `settings.veloxyOpenRouterApiKey`. + */ + +let loaded = false + +export async function loadOpenRouterApiKey(): Promise { + if (loaded) return + loaded = true + + // One-time migration: an older build may have persisted the key in + // localStorage. Move it into the keychain so the next write (below) scrubs + // the plaintext copy via the store's `partialize`. + const persisted = useSettings.getState().veloxyOpenRouterApiKey + if (persisted.trim()) { + try { + await invoke('store_openrouter_api_key', { apiKey: persisted }) + } catch { + // Keep the in-memory key so Veloxy still works this session. + return + } + } + + try { + const stored = await invoke('get_openrouter_api_key') + // Re-setting state triggers a partialized persist, removing any lingering + // plaintext key from localStorage. + useSettings.setState({ veloxyOpenRouterApiKey: stored ?? persisted }) + } catch { + // Leave whatever is already in memory. + } +} + +export async function saveOpenRouterApiKey(apiKey: string): Promise { + useSettings.setState({ veloxyOpenRouterApiKey: apiKey }) + await invoke('store_openrouter_api_key', { apiKey }) +} diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 87a3281..24e1cca 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -45,7 +45,11 @@ const defaults: AppSettings = { } export const useSettings = create()( - persist(() => defaults, { name: 'veloxdb.settings' }), + persist(() => defaults, { + name: 'veloxdb.settings', + // The OpenRouter API key is kept in the OS keychain, never in localStorage. + partialize: ({ veloxyOpenRouterApiKey: _omitApiKey, ...rest }) => rest, + }), ) export function resolveTheme(theme: AppTheme): 'light' | 'dark' { diff --git a/src/lib/sql-intent.test.ts b/src/lib/sql-intent.test.ts new file mode 100644 index 0000000..c5c78f3 --- /dev/null +++ b/src/lib/sql-intent.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { classifySqlIntent, isReadOnlySql } from "@/lib/sql-intent"; + +describe("classifySqlIntent", () => { + it("recognizes statement kinds", () => { + expect(classifySqlIntent("SELECT 1")).toBe("select"); + expect(classifySqlIntent("with x as (select 1) select * from x")).toBe("select"); + expect(classifySqlIntent("INSERT INTO t VALUES (1)")).toBe("insert"); + expect(classifySqlIntent("UPDATE t SET a = 1")).toBe("update"); + expect(classifySqlIntent("DELETE FROM t")).toBe("delete"); + expect(classifySqlIntent("EXPLAIN SELECT 1")).toBe("explain"); + expect(classifySqlIntent("DROP TABLE t")).toBe("unknown"); + }); +}); + +describe("isReadOnlySql", () => { + it("treats selects and explain as read-only", () => { + expect(isReadOnlySql("SELECT 1")).toBe(true); + expect(isReadOnlySql("EXPLAIN ANALYZE SELECT * FROM t")).toBe(true); + expect(isReadOnlySql("BEGIN; SELECT 1; COMMIT;")).toBe(true); + }); + + it("flags writes as not read-only", () => { + expect(isReadOnlySql("DELETE FROM t")).toBe(false); + expect(isReadOnlySql("DROP TABLE t")).toBe(false); + expect(isReadOnlySql("BEGIN; UPDATE t SET a = 1; COMMIT;")).toBe(false); + expect(isReadOnlySql("SELECT 1; DELETE FROM t")).toBe(false); + expect(isReadOnlySql("")).toBe(false); + }); +}); diff --git a/src/lib/sql-intent.ts b/src/lib/sql-intent.ts new file mode 100644 index 0000000..120b967 --- /dev/null +++ b/src/lib/sql-intent.ts @@ -0,0 +1,40 @@ +/** + * Mirrors the Rust `classify_sql_intent` / `is_read_only_sql` in + * `src-tauri/src/commands.rs`. Keep the two in sync: the backend enforces the + * guard, this module powers the confirmation prompt before we send the query. + */ + +export type SqlIntent = + | 'select' + | 'insert' + | 'update' + | 'delete' + | 'explain' + | 'unknown' + +export function classifySqlIntent(sql: string): SqlIntent { + const normalized = sql.trimStart().toLowerCase() + if (normalized.startsWith('select') || normalized.startsWith('with')) return 'select' + if (normalized.startsWith('insert')) return 'insert' + if (normalized.startsWith('update')) return 'update' + if (normalized.startsWith('delete')) return 'delete' + if (normalized.startsWith('explain')) return 'explain' + return 'unknown' +} + +const TRANSACTION_CONTROL = ['begin', 'commit', 'rollback', 'start', 'savepoint', 'release'] + +/** True when every statement in `sql` is read-only (select/explain). */ +export function isReadOnlySql(sql: string): boolean { + let sawStatement = false + for (const raw of sql.split(';')) { + const statement = raw.trim() + if (!statement) continue + const normalized = statement.toLowerCase() + if (TRANSACTION_CONTROL.some((kw) => normalized.startsWith(kw))) continue + sawStatement = true + const intent = classifySqlIntent(statement) + if (intent !== 'select' && intent !== 'explain') return false + } + return sawStatement +}