diff --git a/crates/dbx-core/src/db/redis_driver.rs b/crates/dbx-core/src/db/redis_driver.rs index e64eafa38..083bfddea 100644 --- a/crates/dbx-core/src/db/redis_driver.rs +++ b/crates/dbx-core/src/db/redis_driver.rs @@ -34,6 +34,21 @@ pub struct RedisValue { pub scan_cursor: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RedisCommandSafety { + Allowed, + Confirm, + Blocked, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RedisCommandResult { + pub command: String, + pub safety: RedisCommandSafety, + pub value: serde_json::Value, +} + pub async fn connect(url: &str) -> Result { let client = redis::Client::open(url).map_err(|e| format!("Redis connection failed: {e}"))?; let mut con = tokio::time::timeout(super::connection_timeout(), client.get_multiplexed_async_connection()) @@ -97,6 +112,153 @@ pub async fn select_db(con: &mut redis::aio::MultiplexedConnection, db: u32) -> redis::cmd("SELECT").arg(db).query_async(con).await.map_err(|e| e.to_string()) } +pub fn parse_command_argv(command_text: &str) -> Result, String> { + let mut argv = Vec::new(); + let mut current = String::new(); + let mut chars = command_text.chars().peekable(); + let mut quote: Option = None; + let mut escaping = false; + + while let Some(ch) = chars.next() { + if escaping { + current.push(match ch { + 'n' => '\n', + 'r' => '\r', + 't' => '\t', + other => other, + }); + escaping = false; + continue; + } + + if ch == '\\' { + escaping = true; + continue; + } + + if let Some(q) = quote { + if ch == q { + quote = None; + } else { + current.push(ch); + } + continue; + } + + if ch == '"' || ch == '\'' { + quote = Some(ch); + continue; + } + + if ch.is_whitespace() { + if !current.is_empty() { + argv.push(std::mem::take(&mut current)); + } + while matches!(chars.peek(), Some(next) if next.is_whitespace()) { + chars.next(); + } + continue; + } + + current.push(ch); + } + + if escaping { + current.push('\\'); + } + if quote.is_some() { + return Err("Redis command has an unterminated quote".to_string()); + } + if !current.is_empty() { + argv.push(current); + } + if argv.is_empty() { + return Err("Redis command is empty".to_string()); + } + Ok(argv) +} + +pub fn classify_command(command: &str) -> RedisCommandSafety { + match command.to_ascii_uppercase().as_str() { + "KEYS" | "FLUSHALL" | "SHUTDOWN" | "CONFIG" | "SAVE" | "BGSAVE" | "SLAVEOF" | "REPLICAOF" | "MIGRATE" + | "MODULE" | "SCRIPT" | "EVAL" | "EVALSHA" => RedisCommandSafety::Blocked, + "DEL" | "UNLINK" | "EXPIRE" | "EXPIREAT" | "PEXPIRE" | "PEXPIREAT" | "PERSIST" | "RENAME" | "RENAMENX" + | "SET" | "SETEX" | "PSETEX" | "SETNX" | "MSET" | "MSETNX" | "HSET" | "HDEL" | "LPUSH" | "RPUSH" | "LPOP" + | "RPOP" | "LSET" | "LREM" | "SADD" | "SREM" | "ZADD" | "ZREM" | "XADD" | "XDEL" | "FLUSHDB" => { + RedisCommandSafety::Confirm + } + _ => RedisCommandSafety::Allowed, + } +} + +pub fn redis_command_raw_to_json(value: RedisRawValue) -> serde_json::Value { + match value { + RedisRawValue::Nil => serde_json::Value::Null, + RedisRawValue::Array(values) => { + serde_json::Value::Array(values.into_iter().map(redis_command_raw_to_json).collect()) + } + RedisRawValue::Map(values) => serde_json::Value::Array( + values + .into_iter() + .map(|(key, value)| { + serde_json::json!({ + "key": redis_command_raw_to_json(key), + "value": redis_command_raw_to_json(value), + }) + }) + .collect(), + ), + RedisRawValue::Set(values) => { + serde_json::Value::Array(values.into_iter().map(redis_command_raw_to_json).collect()) + } + RedisRawValue::Attribute { data, attributes } => serde_json::json!({ + "data": redis_command_raw_to_json(*data), + "attributes": redis_command_raw_to_json(RedisRawValue::Map(attributes)), + }), + RedisRawValue::Push { kind, data } => serde_json::json!({ + "kind": format!("{kind:?}"), + "data": redis_command_raw_to_json(RedisRawValue::Array(data)), + }), + RedisRawValue::BulkString(bytes) => serde_json::Value::String(redis_bytes_to_display(&bytes)), + RedisRawValue::SimpleString(value) => serde_json::Value::String(value), + RedisRawValue::Okay => serde_json::Value::String("OK".to_string()), + RedisRawValue::Int(value) => serde_json::Value::Number(value.into()), + RedisRawValue::Double(value) => { + serde_json::Number::from_f64(value).map_or(serde_json::Value::Null, serde_json::Value::Number) + } + RedisRawValue::Boolean(value) => serde_json::Value::Bool(value), + RedisRawValue::VerbatimString { text, .. } => { + serde_json::Value::String(redis_bytes_to_display(text.as_bytes())) + } + RedisRawValue::BigNumber(value) => serde_json::Value::String(value.to_string()), + RedisRawValue::ServerError(error) => serde_json::Value::String(format!("{error:?}")), + } +} + +pub async fn flush_db(con: &mut redis::aio::MultiplexedConnection) -> Result<(), String> { + redis::cmd("FLUSHDB").query_async::<()>(con).await.map_err(|e| e.to_string()) +} + +pub async fn execute_command( + con: &mut redis::aio::MultiplexedConnection, + command_text: &str, +) -> Result { + let argv = parse_command_argv(command_text)?; + let command = argv[0].to_ascii_uppercase(); + let safety = classify_command(&command); + if safety == RedisCommandSafety::Blocked { + return Err(format!("Redis command is blocked for safety: {command}")); + } + + let mut cmd = redis::cmd(&argv[0]); + for arg in argv.iter().skip(1) { + cmd.arg(arg); + } + let raw: RedisRawValue = cmd.query_async(con).await.map_err(|e| e.to_string())?; + + Ok(RedisCommandResult { command, safety, value: redis_command_raw_to_json(raw) }) +} + pub async fn scan_keys_page( con: &mut redis::aio::MultiplexedConnection, cursor: u64, @@ -667,8 +829,9 @@ fn parse_scan_members(raw: RedisRawValue) -> Result<(u64, Vec #[cfg(test)] mod tests { use super::{ - parse_database_count, parse_scan_keys, parse_stream_entries, redis_key_bytes_to_display, - redis_key_bytes_to_raw, redis_key_raw_to_bytes, redis_raw_to_json, redis_value_contains_binary, RedisRawValue, + classify_command, parse_command_argv, parse_database_count, parse_scan_keys, parse_stream_entries, + redis_command_raw_to_json, redis_key_bytes_to_display, redis_key_bytes_to_raw, redis_key_raw_to_bytes, + redis_raw_to_json, redis_value_contains_binary, RedisCommandSafety, RedisRawValue, }; fn bulk(value: &str) -> RedisRawValue { @@ -786,4 +949,37 @@ mod tests { assert!(!redis_value_contains_binary(&raw)); } + + #[test] + fn parses_command_text_with_quotes_and_escapes() { + let argv = parse_command_argv(r#"SET "user:1" "Ada \"Lovelace\"""#).unwrap(); + + assert_eq!(argv, vec!["SET", "user:1", "Ada \"Lovelace\""]); + } + + #[test] + fn rejects_empty_command_text() { + assert_eq!(parse_command_argv(" ").unwrap_err(), "Redis command is empty"); + } + + #[test] + fn classifies_safe_confirmed_and_blocked_commands() { + assert_eq!(classify_command("GET"), RedisCommandSafety::Allowed); + assert_eq!(classify_command("set"), RedisCommandSafety::Confirm); + assert_eq!(classify_command("flushdb"), RedisCommandSafety::Confirm); + assert_eq!(classify_command("KEYS"), RedisCommandSafety::Blocked); + assert_eq!(classify_command("flushall"), RedisCommandSafety::Blocked); + assert_eq!(classify_command("eval"), RedisCommandSafety::Blocked); + } + + #[test] + fn converts_command_results_to_json() { + let raw = RedisRawValue::Array(vec![ + RedisRawValue::SimpleString("OK".to_string()), + RedisRawValue::Int(2), + RedisRawValue::Nil, + ]); + + assert_eq!(redis_command_raw_to_json(raw), serde_json::json!(["OK", 2, null])); + } } diff --git a/crates/dbx-core/src/redis_ops.rs b/crates/dbx-core/src/redis_ops.rs index 9601e86c7..79d93d2e7 100644 --- a/crates/dbx-core/src/redis_ops.rs +++ b/crates/dbx-core/src/redis_ops.rs @@ -1,5 +1,5 @@ use crate::connection::{AppState, PoolKind}; -use crate::db::redis_driver::{self, RedisScanResult, RedisValue}; +use crate::db::redis_driver::{self, RedisCommandResult, RedisScanResult, RedisValue}; pub async fn redis_list_databases_core(state: &AppState, connection_id: &str) -> Result, String> { let connections = state.connections.read().await; @@ -342,6 +342,35 @@ pub async fn redis_delete_keys_in_db_core( } } +pub async fn redis_flush_db_core(state: &AppState, connection_id: &str, db: u32) -> Result<(), String> { + let connections = state.connections.read().await; + match connections.get(connection_id).ok_or("Not found")? { + PoolKind::Redis(con) => { + let mut con = con.lock().await; + redis_driver::select_db(&mut con, db).await?; + redis_driver::flush_db(&mut con).await + } + _ => Err("Not a Redis connection".to_string()), + } +} + +pub async fn redis_execute_command_core( + state: &AppState, + connection_id: &str, + db: u32, + command: &str, +) -> Result { + let connections = state.connections.read().await; + match connections.get(connection_id).ok_or("Not found")? { + PoolKind::Redis(con) => { + let mut con = con.lock().await; + redis_driver::select_db(&mut con, db).await?; + redis_driver::execute_command(&mut con, command).await + } + _ => Err("Not a Redis connection".to_string()), + } +} + pub async fn redis_load_more_in_db_core( state: &AppState, connection_id: &str, diff --git a/src-tauri/src/commands/redis_cmd.rs b/src-tauri/src/commands/redis_cmd.rs index a29c45781..70767c06e 100644 --- a/src-tauri/src/commands/redis_cmd.rs +++ b/src-tauri/src/commands/redis_cmd.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use tauri::State; use crate::commands::connection::AppState; -use dbx_core::db::redis_driver::{RedisScanResult, RedisValue}; +use dbx_core::db::redis_driver::{RedisCommandResult, RedisScanResult, RedisValue}; #[tauri::command] pub async fn redis_list_databases(state: State<'_, Arc>, connection_id: String) -> Result, String> { @@ -164,6 +164,21 @@ pub async fn redis_delete_keys( dbx_core::redis_ops::redis_delete_keys_in_db_core(&state, &connection_id, db, &key_raws).await } +#[tauri::command] +pub async fn redis_flush_db(state: State<'_, Arc>, connection_id: String, db: u32) -> Result<(), String> { + dbx_core::redis_ops::redis_flush_db_core(&state, &connection_id, db).await +} + +#[tauri::command] +pub async fn redis_execute_command( + state: State<'_, Arc>, + connection_id: String, + db: u32, + command: String, +) -> Result { + dbx_core::redis_ops::redis_execute_command_core(&state, &connection_id, db, &command).await +} + #[tauri::command] pub async fn redis_load_more( state: State<'_, Arc>, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 832b25652..cc5c97a11 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -120,6 +120,8 @@ pub fn run() { commands::redis_cmd::redis_zrem, commands::redis_cmd::redis_set_ttl, commands::redis_cmd::redis_delete_keys, + commands::redis_cmd::redis_flush_db, + commands::redis_cmd::redis_execute_command, commands::redis_cmd::redis_load_more, commands::saved_sql::load_saved_sql_library, commands::saved_sql::save_saved_sql_folder, diff --git a/src-web/src/main.rs b/src-web/src/main.rs index a7df117bc..5cc3d235f 100644 --- a/src-web/src/main.rs +++ b/src-web/src/main.rs @@ -117,6 +117,9 @@ async fn main() { .route("/redis/list-remove", post(routes::redis::list_remove)) .route("/redis/set-add", post(routes::redis::set_add)) .route("/redis/set-remove", post(routes::redis::set_remove)) + .route("/redis/delete-keys", post(routes::redis::delete_keys)) + .route("/redis/flush-db", post(routes::redis::flush_db)) + .route("/redis/execute-command", post(routes::redis::execute_command)) // MongoDB .route("/mongo/list-databases", post(routes::mongo::list_databases)) .route("/mongo/list-collections", post(routes::mongo::list_collections)) diff --git a/src-web/src/routes/redis.rs b/src-web/src/routes/redis.rs index 42d520a2c..dd6b067ef 100644 --- a/src-web/src/routes/redis.rs +++ b/src-web/src/routes/redis.rs @@ -70,6 +70,29 @@ pub struct RedisSetRequest { pub member: String, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RedisKeysRequest { + pub connection_id: String, + pub db: u32, + pub key_raws: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RedisDbRequest { + pub connection_id: String, + pub db: u32, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RedisCommandRequest { + pub connection_id: String, + pub db: u32, + pub command: String, +} + pub async fn list_databases( State(state): State>, Json(req): Json, @@ -202,3 +225,32 @@ pub async fn set_remove( .map_err(AppError)?; Ok(Json(())) } + +pub async fn delete_keys( + State(state): State>, + Json(req): Json, +) -> Result, AppError> { + let result = + dbx_core::redis_ops::redis_delete_keys_in_db_core(&state.app, &req.connection_id, req.db, &req.key_raws) + .await + .map_err(AppError)?; + Ok(Json(result)) +} + +pub async fn flush_db( + State(state): State>, + Json(req): Json, +) -> Result, AppError> { + dbx_core::redis_ops::redis_flush_db_core(&state.app, &req.connection_id, req.db).await.map_err(AppError)?; + Ok(Json(())) +} + +pub async fn execute_command( + State(state): State>, + Json(req): Json, +) -> Result, AppError> { + let result = dbx_core::redis_ops::redis_execute_command_core(&state.app, &req.connection_id, req.db, &req.command) + .await + .map_err(AppError)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) +} diff --git a/src/components/redis/RedisKeyBrowser.vue b/src/components/redis/RedisKeyBrowser.vue index e0025f0de..275903d89 100644 --- a/src/components/redis/RedisKeyBrowser.vue +++ b/src/components/redis/RedisKeyBrowser.vue @@ -10,6 +10,9 @@ import { FolderClosed, FolderOpen, Trash2, + DatabaseZap, + Play, + Terminal, } from "lucide-vue-next"; import { RecycleScroller } from "vue-virtual-scroller"; import "vue-virtual-scroller/dist/vue-virtual-scroller.css"; @@ -25,9 +28,11 @@ import type { RedisKeyInfo } from "@/lib/api"; import { buildRedisKeyTree, collectExpandedGroupIds, + collectRedisGroupKeyRaws, flattenVisibleRedisKeyTree, type RedisKeyTreeNode, } from "@/lib/redisKeyTree"; +import { classifyRedisCommandSafety } from "@/lib/redisCommandSafety"; const { t } = useI18n(); @@ -44,7 +49,17 @@ const selectedKeyRaw = ref(null); const hasMore = ref(false); const expandedGroupIds = ref>(new Set()); const checkedKeys = ref>(new Set()); -const showBatchDeleteConfirm = ref(false); +const pendingDanger = ref< + | { kind: "delete-keys"; title: string; keyRaws: string[] } + | { kind: "flush-db" } + | { kind: "command"; command: string } + | null +>(null); +const showDangerConfirm = ref(false); +const commandText = ref(""); +const commandResult = ref(null); +const commandError = ref(""); +const commandRunning = ref(false); const PAGE_SIZE = 200; const keyGridStyle = { @@ -54,6 +69,22 @@ const keyGridStyle = { const effectivePattern = computed(() => searchPattern.value.trim() || "*"); const isSearchMode = computed(() => effectivePattern.value !== "*"); const selectedKey = computed(() => flatKeys.value.find((key) => key.key_raw === selectedKeyRaw.value) ?? null); +const dangerDetails = computed(() => { + if (!pendingDanger.value) return ""; + if (pendingDanger.value.kind === "delete-keys") { + return t("redis.deleteGroupDetails", { + target: pendingDanger.value.title, + count: pendingDanger.value.keyRaws.length, + }); + } + if (pendingDanger.value.kind === "flush-db") return t("redis.flushDbDetails", { db: props.db }); + return pendingDanger.value.command; +}); +const formattedCommandResult = computed(() => { + if (commandResult.value == null) return ""; + if (typeof commandResult.value === "string") return commandResult.value; + return JSON.stringify(commandResult.value, null, 2); +}); const visibleRows = computed(() => flattenVisibleRedisKeyTree(treeKeys.value, expandedGroupIds.value).map((row) => ({ ...row, @@ -139,20 +170,99 @@ function toggleCheck(keyRaw: string, event: Event) { function requestBatchDelete() { if (checkedKeys.value.size === 0) return; - showBatchDeleteConfirm.value = true; + pendingDanger.value = { kind: "delete-keys", title: t("redis.selectedKeys"), keyRaws: [...checkedKeys.value] }; + showDangerConfirm.value = true; } -async function applyBatchDelete() { - const keys = [...checkedKeys.value]; +function requestGroupDelete(node: RedisKeyTreeNode, event: Event) { + event.stopPropagation(); + if (node.kind !== "group") return; + const keyRaws = collectRedisGroupKeyRaws(node); + if (keyRaws.length === 0) return; + pendingDanger.value = { kind: "delete-keys", title: node.pathSegments.join(":"), keyRaws }; + showDangerConfirm.value = true; +} + +function requestFlushDb() { + pendingDanger.value = { kind: "flush-db" }; + showDangerConfirm.value = true; +} + +function resetLoadedKeys() { + flatKeys.value = []; + treeKeys.value = []; + selectedKeyRaw.value = null; + checkedKeys.value = new Set(); + expandedGroupIds.value = new Set(); + hasMore.value = false; +} + +async function deleteKeyRaws(keys: string[]) { await api.redisDeleteKeys(props.connectionId, props.db, keys); - flatKeys.value = flatKeys.value.filter((k) => !checkedKeys.value.has(k.key_raw)); - if (selectedKeyRaw.value && checkedKeys.value.has(selectedKeyRaw.value)) { + const deleted = new Set(keys); + flatKeys.value = flatKeys.value.filter((k) => !deleted.has(k.key_raw)); + if (selectedKeyRaw.value && deleted.has(selectedKeyRaw.value)) { selectedKeyRaw.value = null; } checkedKeys.value = new Set(); rebuildTree(false); } +async function runRedisCommand(command: string) { + commandRunning.value = true; + commandError.value = ""; + commandResult.value = null; + try { + const result = await api.redisExecuteCommand(props.connectionId, props.db, command); + commandResult.value = result.value; + if (result.safety === "confirm") { + await loadKeys(); + } + } catch (error) { + commandError.value = error instanceof Error ? error.message : String(error); + } finally { + commandRunning.value = false; + } +} + +async function executeCommand() { + const command = commandText.value.trim(); + if (!command) { + commandError.value = t("redis.commandEmpty"); + commandResult.value = null; + return; + } + + const safety = classifyRedisCommandSafety(command); + if (safety === "blocked") { + commandError.value = t("redis.commandBlocked"); + commandResult.value = null; + return; + } + if (safety === "confirm") { + pendingDanger.value = { kind: "command", command }; + showDangerConfirm.value = true; + return; + } + await runRedisCommand(command); +} + +async function applyDangerAction() { + const pending = pendingDanger.value; + pendingDanger.value = null; + showDangerConfirm.value = false; + if (!pending) return; + + if (pending.kind === "delete-keys") { + await deleteKeyRaws(pending.keyRaws); + } else if (pending.kind === "flush-db") { + await api.redisFlushDb(props.connectionId, props.db); + resetLoadedKeys(); + } else { + await runRedisCommand(pending.command); + } +} + function typeColor(type: string): string { switch (type) { case "string": @@ -213,6 +323,15 @@ onMounted(loadKeys); + {{ loading && flatKeys.length === 0 ? t("redis.loadingKeys") : t("redis.keys", { count: flatKeys.length }) }} @@ -227,6 +346,35 @@ onMounted(loadKeys); +
+ + + +
+ +
+
{{ commandError || formattedCommandResult }}
+
+
{{ t("redis.columnKey") }}
@@ -274,6 +422,15 @@ onMounted(loadKeys); /> {{ row.node.label }} ({{ countLeaves(row.node) }}) + diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 9cef06334..025b0425f 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -684,6 +684,15 @@ export default { columnTTL: "TTL", binaryStringReadonlyHint: "Binary string values are shown as escaped text in read-only mode; editing raw bytes is not supported.", + selectedKeys: "Selected keys", + deleteGroup: "Delete group", + deleteGroupDetails: "{target}\n{count} keys", + flushDb: "Clear current DB", + flushDbDetails: "Redis db{db}", + commandPlaceholder: "Redis command, e.g. GET user:1", + executeCommand: "Execute command", + commandEmpty: "Enter a Redis command", + commandBlocked: "This Redis command is blocked for safety", }, mongo: { documents: "{count} documents", diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 19733b5b9..b436efada 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -678,8 +678,22 @@ export default { members: "{count} miembros", entries: "{count} entradas", noExpiry: "sin expiración", + columnType: "Tipo", + columnKey: "Clave", + columnValue: "Valor", + columnSize: "Tamaño", + columnTTL: "TTL", binaryStringReadonlyHint: "Los valores de cadena binaria se muestran como texto escapado en modo de solo lectura; la edición de bytes sin procesar no está disponible.", + selectedKeys: "Claves seleccionadas", + deleteGroup: "Eliminar grupo", + deleteGroupDetails: "{target}\n{count} claves", + flushDb: "Limpiar DB actual", + flushDbDetails: "Redis db{db}", + commandPlaceholder: "Comando Redis, p. ej. GET user:1", + executeCommand: "Ejecutar comando", + commandEmpty: "Ingresa un comando Redis", + commandBlocked: "Este comando Redis está bloqueado por seguridad", }, mongo: { documents: "{count} documentos", diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index f834ccc58..bb5b7bd6a 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -670,6 +670,15 @@ export default { columnSize: "大小", columnTTL: "TTL", binaryStringReadonlyHint: "二进制字符串按转义文本只读展示;当前不支持直接编辑原始字节值。", + selectedKeys: "已选择的 key", + deleteGroup: "删除分组", + deleteGroupDetails: "{target}\n{count} 个 key", + flushDb: "清空当前 DB", + flushDbDetails: "Redis db{db}", + commandPlaceholder: "Redis 命令,如 GET user:1", + executeCommand: "执行命令", + commandEmpty: "请输入 Redis 命令", + commandBlocked: "出于安全考虑,此 Redis 命令已被阻止", }, mongo: { documents: "{count} 个文档", diff --git a/src/lib/api.ts b/src/lib/api.ts index ad7dadfa4..86fa229dd 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -114,6 +114,8 @@ export const redisZadd = forward("redisZadd"); export const redisZrem = forward("redisZrem"); export const redisSetTtl = forward("redisSetTtl"); export const redisDeleteKeys = forward("redisDeleteKeys"); +export const redisFlushDb = forward("redisFlushDb"); +export const redisExecuteCommand = forward("redisExecuteCommand"); export const redisLoadMore = forward("redisLoadMore"); // MongoDB @@ -152,6 +154,8 @@ export type { RedisKeyInfo, RedisValue, RedisScanResult, + RedisCommandSafety, + RedisCommandResult, MongoDocumentResult, HistoryEntry, SqlFileStatus, diff --git a/src/lib/http.ts b/src/lib/http.ts index cc0a84759..6fdfb977a 100644 --- a/src/lib/http.ts +++ b/src/lib/http.ts @@ -26,6 +26,7 @@ import type { UpdateInfo, RedisValue, RedisScanResult, + RedisCommandResult, MongoDocumentResult, HistoryEntry, SqlFileRequest, @@ -584,6 +585,18 @@ export async function redisDeleteKeys(connectionId: string, db: number, keyRaws: return post("/api/redis/delete-keys", { connectionId, db, keyRaws }); } +export async function redisFlushDb(connectionId: string, db: number): Promise { + return post("/api/redis/flush-db", { connectionId, db }); +} + +export async function redisExecuteCommand( + connectionId: string, + db: number, + command: string, +): Promise { + return post("/api/redis/execute-command", { connectionId, db, command }); +} + export async function redisLoadMore( connectionId: string, db: number, diff --git a/src/lib/redisCommandSafety.ts b/src/lib/redisCommandSafety.ts new file mode 100644 index 000000000..781e74d25 --- /dev/null +++ b/src/lib/redisCommandSafety.ts @@ -0,0 +1,82 @@ +export type RedisCommandSafety = "allowed" | "confirm" | "blocked"; + +const BLOCKED_COMMANDS = new Set([ + "KEYS", + "FLUSHALL", + "SHUTDOWN", + "CONFIG", + "SAVE", + "BGSAVE", + "SLAVEOF", + "REPLICAOF", + "MIGRATE", + "MODULE", + "SCRIPT", + "EVAL", + "EVALSHA", +]); + +const CONFIRM_COMMANDS = new Set([ + "DEL", + "UNLINK", + "EXPIRE", + "EXPIREAT", + "PEXPIRE", + "PEXPIREAT", + "PERSIST", + "RENAME", + "RENAMENX", + "SET", + "SETEX", + "PSETEX", + "SETNX", + "MSET", + "MSETNX", + "HSET", + "HDEL", + "LPUSH", + "RPUSH", + "LPOP", + "RPOP", + "LSET", + "LREM", + "SADD", + "SREM", + "ZADD", + "ZREM", + "XADD", + "XDEL", + "FLUSHDB", +]); + +export function firstRedisCommandToken(command: string): string { + const trimmed = command.trimStart(); + if (!trimmed) return ""; + + const quote = trimmed[0] === '"' || trimmed[0] === "'" ? trimmed[0] : ""; + let token = ""; + let escaping = false; + for (let i = quote ? 1 : 0; i < trimmed.length; i++) { + const ch = trimmed[i]; + if (escaping) { + token += ch; + escaping = false; + continue; + } + if (ch === "\\") { + escaping = true; + continue; + } + if (quote && ch === quote) break; + if (!quote && /\s/.test(ch)) break; + token += ch; + } + return token.toUpperCase(); +} + +export function classifyRedisCommandSafety(command: string): RedisCommandSafety { + const token = firstRedisCommandToken(command); + if (BLOCKED_COMMANDS.has(token)) return "blocked"; + if (CONFIRM_COMMANDS.has(token)) return "confirm"; + return "allowed"; +} diff --git a/src/lib/redisKeyTree.ts b/src/lib/redisKeyTree.ts index a928afcfe..7124fbef2 100644 --- a/src/lib/redisKeyTree.ts +++ b/src/lib/redisKeyTree.ts @@ -129,6 +129,23 @@ export function collectExpandedGroupIds(nodes: RedisKeyTreeNode[]): Set return ids; } +export function collectRedisGroupKeyRaws(group: RedisKeyTreeGroupNode): string[] { + const keyRaws: string[] = []; + + const visit = (nodes: RedisKeyTreeNode[]) => { + for (const node of nodes) { + if (node.kind === "leaf") { + keyRaws.push(node.keyRaw); + } else { + visit(node.children); + } + } + }; + + visit(group.children); + return keyRaws; +} + export function flattenVisibleRedisKeyTree( nodes: RedisKeyTreeNode[], expandedGroupIds: ReadonlySet, diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 0ef476b1d..1c2906ffc 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -365,6 +365,14 @@ export interface RedisScanResult { keys: RedisKeyInfo[]; } +export type RedisCommandSafety = "allowed" | "confirm" | "blocked"; + +export interface RedisCommandResult { + command: string; + safety: RedisCommandSafety; + value: any; +} + export async function redisListDatabases(connectionId: string): Promise { return invoke("redis_list_databases", { connectionId }); } @@ -449,6 +457,18 @@ export async function redisDeleteKeys(connectionId: string, db: number, keyRaws: return invoke("redis_delete_keys", { connectionId, db, keyRaws }); } +export async function redisFlushDb(connectionId: string, db: number): Promise { + return invoke("redis_flush_db", { connectionId, db }); +} + +export async function redisExecuteCommand( + connectionId: string, + db: number, + command: string, +): Promise { + return invoke("redis_execute_command", { connectionId, db, command }); +} + export async function redisLoadMore( connectionId: string, db: number, diff --git a/tests/redisCommandSafety.test.ts b/tests/redisCommandSafety.test.ts new file mode 100644 index 000000000..8f0822741 --- /dev/null +++ b/tests/redisCommandSafety.test.ts @@ -0,0 +1,17 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { classifyRedisCommandSafety, firstRedisCommandToken } from "../src/lib/redisCommandSafety.ts"; + +test("firstRedisCommandToken reads the first command token case-insensitively", () => { + assert.equal(firstRedisCommandToken(" get user:1"), "GET"); + assert.equal(firstRedisCommandToken('"set" user:1 Ada'), "SET"); +}); + +test("classifyRedisCommandSafety separates allowed confirmed and blocked commands", () => { + assert.equal(classifyRedisCommandSafety("GET user:1"), "allowed"); + assert.equal(classifyRedisCommandSafety("set user:1 Ada"), "confirm"); + assert.equal(classifyRedisCommandSafety("FLUSHDB"), "confirm"); + assert.equal(classifyRedisCommandSafety("KEYS *"), "blocked"); + assert.equal(classifyRedisCommandSafety("flushall"), "blocked"); + assert.equal(classifyRedisCommandSafety("eval return 1 0"), "blocked"); +}); diff --git a/tests/redisKeyTree.test.ts b/tests/redisKeyTree.test.ts index 0977bbbf4..f25abc542 100644 --- a/tests/redisKeyTree.test.ts +++ b/tests/redisKeyTree.test.ts @@ -2,6 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { buildRedisKeyTree, + collectRedisGroupKeyRaws, collectExpandedGroupIds, flattenVisibleRedisKeyTree, type RedisKeyTreeNode, @@ -72,11 +73,24 @@ test("collectExpandedGroupIds and flattenVisibleRedisKeyTree expand all search p assert.deepEqual( rows.map(({ node, depth }) => `${depth}:${node.kind}:${node.label}`), - [ - "0:group:user", - "1:group:profile", - "2:leaf:name", - "1:leaf:settings", - ], + ["0:group:user", "1:group:profile", "2:leaf:name", "1:leaf:settings"], ); }); + +test("collectRedisGroupKeyRaws returns every leaf key under a group", () => { + const tree = buildRedisKeyTree( + [ + makeKey("user:profile:name", "k1"), + makeKey("user:profile:email", "k2"), + makeKey("user:settings", "k3"), + makeKey("session:1", "k4"), + ], + 0, + ); + + const userGroup = tree.find((node) => node.kind === "group" && node.label === "user"); + assert.ok(userGroup); + if (!userGroup || userGroup.kind !== "group") return; + + assert.deepEqual(collectRedisGroupKeyRaws(userGroup), ["k2", "k1", "k3"]); +});