From 69ce946241d98ea90bc7135d44ca0c87f928be88 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 26 Mar 2026 09:51:34 +0000 Subject: [PATCH] feat: add trashbin system for soft-deleting items (#8519) --- ...f0ed3c5a197dd744c87cb760aaf47001febbd.json | 14 + ...6fdeb22714af38162e87c02b052a5b8ec2645.json | 15 + ...01741955502c9345e82a9b7fbbf3d3c7ab517.json | 12 + ...2f88594825dbaa647290a58bd63df61b531a7.json | 65 +++ ...96cc3ba1957042a48ac5f9629ada25b3e78ef.json | 61 +++ ...8251dbb3c6d4c095efa015823f0324ab27d7f.json | 26 + ...8e702fc1577d3fa4ff1ff2f1e089971ff5e32.json | 60 ++ ...4624403b6f97e13f13f056501fe2f4efb0f06.json | 14 + ...ae8d224457171b1b73fe145d28e67f1fe03af.json | 23 + .../20260326000000_trashbin.down.sql | 1 + .../migrations/20260326000000_trashbin.up.sql | 16 + backend/src/monitor.rs | 9 + backend/summarized_schema.txt | 3 + backend/windmill-api-flows/src/flows.rs | 54 ++ backend/windmill-api-groups/src/groups.rs | 1 + backend/windmill-api-schedule/src/lib.rs | 21 + backend/windmill-api-scripts/src/scripts.rs | 89 ++- backend/windmill-api/src/apps.rs | 43 ++ backend/windmill-api/src/lib.rs | 2 + backend/windmill-api/src/trash.rs | 511 ++++++++++++++++++ backend/windmill-common/src/lib.rs | 1 + backend/windmill-common/src/trashbin.rs | 155 ++++++ backend/windmill-store/src/resources.rs | 82 ++- backend/windmill-store/src/variables.rs | 69 +++ backend/windmill-trigger/src/handler.rs | 24 + .../ConfirmationModal.svelte | 8 + frontend/src/lib/components/home/Item.svelte | 1 + .../lib/components/settings/Trashbin.svelte | 249 +++++++++ .../triggers/DeleteTriggerButton.svelte | 1 + frontend/src/lib/services/trashService.ts | 69 +++ .../(root)/(logged)/resources/+page.svelte | 21 +- .../(root)/(logged)/variables/+page.svelte | 8 +- .../(logged)/workspace_settings/+page.svelte | 35 +- 33 files changed, 1718 insertions(+), 45 deletions(-) create mode 100644 backend/.sqlx/query-08522e494e34f4ecae21460262bf0ed3c5a197dd744c87cb760aaf47001febbd.json create mode 100644 backend/.sqlx/query-1d995dd5a094631ae96c16d68026fdeb22714af38162e87c02b052a5b8ec2645.json create mode 100644 backend/.sqlx/query-3453c0b7dd3c4d2c9bc639f379901741955502c9345e82a9b7fbbf3d3c7ab517.json create mode 100644 backend/.sqlx/query-446404eda9b9632c9a1384af6bf2f88594825dbaa647290a58bd63df61b531a7.json create mode 100644 backend/.sqlx/query-51c3274a8092d80503a6b97ef3896cc3ba1957042a48ac5f9629ada25b3e78ef.json create mode 100644 backend/.sqlx/query-8b25c4252da77cd2fe1b3916b518251dbb3c6d4c095efa015823f0324ab27d7f.json create mode 100644 backend/.sqlx/query-92fb6afe3b7041b2954340094c08e702fc1577d3fa4ff1ff2f1e089971ff5e32.json create mode 100644 backend/.sqlx/query-bae31609123da68d16bea8e0f1c4624403b6f97e13f13f056501fe2f4efb0f06.json create mode 100644 backend/.sqlx/query-c1fd495abb4353b46361ec94fd4ae8d224457171b1b73fe145d28e67f1fe03af.json create mode 100644 backend/migrations/20260326000000_trashbin.down.sql create mode 100644 backend/migrations/20260326000000_trashbin.up.sql create mode 100644 backend/windmill-api/src/trash.rs create mode 100644 backend/windmill-common/src/trashbin.rs create mode 100644 frontend/src/lib/components/settings/Trashbin.svelte create mode 100644 frontend/src/lib/services/trashService.ts diff --git a/backend/.sqlx/query-08522e494e34f4ecae21460262bf0ed3c5a197dd744c87cb760aaf47001febbd.json b/backend/.sqlx/query-08522e494e34f4ecae21460262bf0ed3c5a197dd744c87cb760aaf47001febbd.json new file mode 100644 index 0000000000..31eaa4bbd3 --- /dev/null +++ b/backend/.sqlx/query-08522e494e34f4ecae21460262bf0ed3c5a197dd744c87cb760aaf47001febbd.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM trashbin WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "08522e494e34f4ecae21460262bf0ed3c5a197dd744c87cb760aaf47001febbd" +} diff --git a/backend/.sqlx/query-1d995dd5a094631ae96c16d68026fdeb22714af38162e87c02b052a5b8ec2645.json b/backend/.sqlx/query-1d995dd5a094631ae96c16d68026fdeb22714af38162e87c02b052a5b8ec2645.json new file mode 100644 index 0000000000..4ae3cea3ff --- /dev/null +++ b/backend/.sqlx/query-1d995dd5a094631ae96c16d68026fdeb22714af38162e87c02b052a5b8ec2645.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM trashbin WHERE workspace_id = $1 AND id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "1d995dd5a094631ae96c16d68026fdeb22714af38162e87c02b052a5b8ec2645" +} diff --git a/backend/.sqlx/query-3453c0b7dd3c4d2c9bc639f379901741955502c9345e82a9b7fbbf3d3c7ab517.json b/backend/.sqlx/query-3453c0b7dd3c4d2c9bc639f379901741955502c9345e82a9b7fbbf3d3c7ab517.json new file mode 100644 index 0000000000..5893b08c81 --- /dev/null +++ b/backend/.sqlx/query-3453c0b7dd3c4d2c9bc639f379901741955502c9345e82a9b7fbbf3d3c7ab517.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM trashbin WHERE expires_at <= now()", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "3453c0b7dd3c4d2c9bc639f379901741955502c9345e82a9b7fbbf3d3c7ab517" +} diff --git a/backend/.sqlx/query-446404eda9b9632c9a1384af6bf2f88594825dbaa647290a58bd63df61b531a7.json b/backend/.sqlx/query-446404eda9b9632c9a1384af6bf2f88594825dbaa647290a58bd63df61b531a7.json new file mode 100644 index 0000000000..e2c5050f1d --- /dev/null +++ b/backend/.sqlx/query-446404eda9b9632c9a1384af6bf2f88594825dbaa647290a58bd63df61b531a7.json @@ -0,0 +1,65 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, workspace_id, item_kind, item_path, item_data, deleted_by, deleted_at, expires_at\n FROM trashbin\n WHERE workspace_id = $1 AND id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "item_kind", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "item_path", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "item_data", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "deleted_by", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "deleted_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "expires_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "446404eda9b9632c9a1384af6bf2f88594825dbaa647290a58bd63df61b531a7" +} diff --git a/backend/.sqlx/query-51c3274a8092d80503a6b97ef3896cc3ba1957042a48ac5f9629ada25b3e78ef.json b/backend/.sqlx/query-51c3274a8092d80503a6b97ef3896cc3ba1957042a48ac5f9629ada25b3e78ef.json new file mode 100644 index 0000000000..0f2c7ab318 --- /dev/null +++ b/backend/.sqlx/query-51c3274a8092d80503a6b97ef3896cc3ba1957042a48ac5f9629ada25b3e78ef.json @@ -0,0 +1,61 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, workspace_id, item_kind, item_path, deleted_by, deleted_at, expires_at\n FROM trashbin\n WHERE workspace_id = $1 AND item_kind = $2\n ORDER BY deleted_at DESC\n LIMIT $3 OFFSET $4", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "item_kind", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "item_path", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "deleted_by", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "deleted_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "expires_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "51c3274a8092d80503a6b97ef3896cc3ba1957042a48ac5f9629ada25b3e78ef" +} diff --git a/backend/.sqlx/query-8b25c4252da77cd2fe1b3916b518251dbb3c6d4c095efa015823f0324ab27d7f.json b/backend/.sqlx/query-8b25c4252da77cd2fe1b3916b518251dbb3c6d4c095efa015823f0324ab27d7f.json new file mode 100644 index 0000000000..4692b430ec --- /dev/null +++ b/backend/.sqlx/query-8b25c4252da77cd2fe1b3916b518251dbb3c6d4c095efa015823f0324ab27d7f.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO trashbin (workspace_id, item_kind, item_path, item_data, deleted_by)\n VALUES ($1, $2, $3, $4, $5) RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Jsonb", + "Varchar" + ] + }, + "nullable": [ + false + ] + }, + "hash": "8b25c4252da77cd2fe1b3916b518251dbb3c6d4c095efa015823f0324ab27d7f" +} diff --git a/backend/.sqlx/query-92fb6afe3b7041b2954340094c08e702fc1577d3fa4ff1ff2f1e089971ff5e32.json b/backend/.sqlx/query-92fb6afe3b7041b2954340094c08e702fc1577d3fa4ff1ff2f1e089971ff5e32.json new file mode 100644 index 0000000000..191630bd35 --- /dev/null +++ b/backend/.sqlx/query-92fb6afe3b7041b2954340094c08e702fc1577d3fa4ff1ff2f1e089971ff5e32.json @@ -0,0 +1,60 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, workspace_id, item_kind, item_path, deleted_by, deleted_at, expires_at\n FROM trashbin\n WHERE workspace_id = $1\n ORDER BY deleted_at DESC\n LIMIT $2 OFFSET $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "item_kind", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "item_path", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "deleted_by", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "deleted_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "expires_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "92fb6afe3b7041b2954340094c08e702fc1577d3fa4ff1ff2f1e089971ff5e32" +} diff --git a/backend/.sqlx/query-bae31609123da68d16bea8e0f1c4624403b6f97e13f13f056501fe2f4efb0f06.json b/backend/.sqlx/query-bae31609123da68d16bea8e0f1c4624403b6f97e13f13f056501fe2f4efb0f06.json new file mode 100644 index 0000000000..bc0cc1a797 --- /dev/null +++ b/backend/.sqlx/query-bae31609123da68d16bea8e0f1c4624403b6f97e13f13f056501fe2f4efb0f06.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM trashbin WHERE workspace_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "bae31609123da68d16bea8e0f1c4624403b6f97e13f13f056501fe2f4efb0f06" +} diff --git a/backend/.sqlx/query-c1fd495abb4353b46361ec94fd4ae8d224457171b1b73fe145d28e67f1fe03af.json b/backend/.sqlx/query-c1fd495abb4353b46361ec94fd4ae8d224457171b1b73fe145d28e67f1fe03af.json new file mode 100644 index 0000000000..d25ae0613a --- /dev/null +++ b/backend/.sqlx/query-c1fd495abb4353b46361ec94fd4ae8d224457171b1b73fe145d28e67f1fe03af.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM script WHERE path = $1 AND workspace_id = $2)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "c1fd495abb4353b46361ec94fd4ae8d224457171b1b73fe145d28e67f1fe03af" +} diff --git a/backend/migrations/20260326000000_trashbin.down.sql b/backend/migrations/20260326000000_trashbin.down.sql new file mode 100644 index 0000000000..11dca69162 --- /dev/null +++ b/backend/migrations/20260326000000_trashbin.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS trashbin; diff --git a/backend/migrations/20260326000000_trashbin.up.sql b/backend/migrations/20260326000000_trashbin.up.sql new file mode 100644 index 0000000000..a5bfd74294 --- /dev/null +++ b/backend/migrations/20260326000000_trashbin.up.sql @@ -0,0 +1,16 @@ +CREATE TABLE trashbin ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + item_kind VARCHAR(50) NOT NULL, + item_path TEXT NOT NULL, + item_data JSONB NOT NULL, + deleted_by VARCHAR(255) NOT NULL, + deleted_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + INTERVAL '3 days' +); + +CREATE INDEX idx_trashbin_expires_at ON trashbin(expires_at); +CREATE INDEX idx_trashbin_workspace_kind ON trashbin(workspace_id, item_kind); + +GRANT ALL ON trashbin TO windmill_user; +GRANT ALL ON trashbin TO windmill_admin; diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index d7f5784b50..bb7640c5ed 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -1178,6 +1178,15 @@ pub async fn delete_expired_items(db: &DB) -> () { tracing::error!("Error deleting custom concurrency key: {:?}", e); } } + + match windmill_common::trashbin::delete_expired_trash(db).await { + Ok(count) => { + if count > 0 { + tracing::info!("deleted {} expired trash items", count); + } + } + Err(e) => tracing::error!("Error deleting expired trash items: {}", e.to_string()), + } } pub async fn check_expiring_tokens(db: &DB) { diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt index ce0905ac08..7380ccce8c 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -151,6 +151,9 @@ script: workspace_id(char), hash(bigint), path(char), parent_hashes(bigint[]), s skip_workspace_diff_tally: workspace_id(char), added_at(ts) sqs_trigger: path(char), queue_url(char), aws_resource_path(char), message_attributes(text[]), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), error(text), server_id(char), last_server_ping(ts), aws_auth_resource_type(aws_auth_resource_type), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode) FK: (workspace_id) -> workspace(id) +trashbin: id(bigint), workspace_id(char), item_kind(char), item_path(char), item_data(jsonb), deleted_by(char), deleted_at(ts), expires_at(ts) + FK: (workspace_id) -> workspace(id) + INDEX: idx_trashbin_expires_at (expires_at), idx_trashbin_workspace_kind (workspace_id, item_kind) token: token_hash(char), token_prefix(char), token(char), label(char), expiration(ts), workspace_id(char), owner(char), email(char), super_admin(bool), created_at(ts), last_used_at(ts), scopes(text[]), job(uuid) FK: (workspace_id) -> workspace(id) token_expiry_notification: token_hash(char), expiration(ts) diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index 41cacb4291..e5a78da5b3 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -1657,6 +1657,38 @@ async fn delete_flow_by_path( } let mut tx = user_db.begin(&authed).await?; + // Capture all related data for trashbin before deleting (CASCADE will remove flow_version, flow_node) + let trash_flow: Option = + sqlx::query_scalar("SELECT to_jsonb(t) FROM flow t WHERE path = $1 AND workspace_id = $2") + .bind(path) + .bind(&w_id) + .fetch_optional(&mut *tx) + .await?; + + let trash_flow_versions: Vec = sqlx::query_scalar( + "SELECT to_jsonb(t) FROM flow_version t WHERE path = $1 AND workspace_id = $2", + ) + .bind(path) + .bind(&w_id) + .fetch_all(&mut *tx) + .await?; + + let trash_flow_nodes: Vec = sqlx::query_scalar( + "SELECT to_jsonb(t) FROM flow_node t WHERE path = $1 AND workspace_id = $2", + ) + .bind(path) + .bind(&w_id) + .fetch_all(&mut *tx) + .await?; + + let trash_drafts: Vec = sqlx::query_scalar( + "SELECT to_jsonb(t) FROM draft t WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'", + ) + .bind(path) + .bind(&w_id) + .fetch_all(&mut *tx) + .await?; + sqlx::query!( "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'", path, @@ -1673,6 +1705,28 @@ async fn delete_flow_by_path( .execute(&mut *tx) .await?; + if let Some(flow_data) = trash_flow { + let mut trash_data = serde_json::json!({"row": flow_data}); + if !trash_flow_versions.is_empty() { + trash_data["flow_versions"] = serde_json::Value::Array(trash_flow_versions); + } + if !trash_flow_nodes.is_empty() { + trash_data["flow_nodes"] = serde_json::Value::Array(trash_flow_nodes); + } + if !trash_drafts.is_empty() { + trash_data["drafts"] = serde_json::Value::Array(trash_drafts); + } + windmill_common::trashbin::move_to_trash( + &mut *tx, + &w_id, + "flow", + path, + trash_data, + &authed.username, + ) + .await?; + } + if !query.keep_captures.unwrap_or(false) { sqlx::query!( "DELETE FROM capture_config WHERE path = $1 AND workspace_id = $2 AND is_flow IS TRUE", diff --git a/backend/windmill-api-groups/src/groups.rs b/backend/windmill-api-groups/src/groups.rs index 071c3a8649..ad0dde1781 100644 --- a/backend/windmill-api-groups/src/groups.rs +++ b/backend/windmill-api-groups/src/groups.rs @@ -656,6 +656,7 @@ async fn delete_group( ) .execute(&mut *tx) .await?; + audit_log( &mut *tx, &authed, diff --git a/backend/windmill-api-schedule/src/lib.rs b/backend/windmill-api-schedule/src/lib.rs index a6224a7639..fc12ccc043 100644 --- a/backend/windmill-api-schedule/src/lib.rs +++ b/backend/windmill-api-schedule/src/lib.rs @@ -963,6 +963,15 @@ async fn delete_schedule( ))); } + // Capture row for trashbin before deleting + let trash_data: Option = sqlx::query_scalar( + "SELECT jsonb_build_object('row', to_jsonb(t)) FROM schedule t WHERE path = $1 AND workspace_id = $2", + ) + .bind(path) + .bind(&w_id) + .fetch_optional(&mut *tx) + .await?; + let del = sqlx::query_scalar!( "DELETE FROM schedule WHERE path = $1 AND workspace_id = $2 RETURNING 1", path, @@ -979,6 +988,18 @@ async fn delete_schedule( ))); } + if let Some(data) = trash_data { + windmill_common::trashbin::move_to_trash( + &mut *tx, + &w_id, + "schedule", + path, + data, + &authed.username, + ) + .await?; + } + audit_log( &mut *tx, &authed, diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index 54ac78fade..2729357328 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -2240,33 +2240,58 @@ async fn delete_script_by_path( .await? .unwrap_or(false); - let script = if !draft_only { + if !draft_only { require_admin(authed.is_admin, &authed.username)?; - sqlx::query_scalar!( - "DELETE FROM script WHERE path = $1 AND workspace_id = $2 RETURNING path", + } + + // Capture all script versions and drafts for trashbin before deleting + let trash_scripts: Vec = sqlx::query_scalar( + "SELECT to_jsonb(t) FROM script t WHERE path = $1 AND workspace_id = $2", + ) + .bind(path) + .bind(&w_id) + .fetch_all(&mut *tx) + .await?; + + let trash_drafts: Vec = sqlx::query_scalar( + "SELECT to_jsonb(t) FROM draft t WHERE path = $1 AND workspace_id = $2 AND typ = 'script'", + ) + .bind(path) + .bind(&w_id) + .fetch_all(&mut *tx) + .await?; + + let script = sqlx::query_scalar!( + "DELETE FROM script WHERE path = $1 AND workspace_id = $2 RETURNING path", + path, + w_id + ) + .fetch_one(&mut *tx) + .await + .map_err(|e| Error::internal_err(format!("deleting script by path {w_id}: {e:#}")))?; + + if !trash_scripts.is_empty() { + let mut trash_data = serde_json::json!({"scripts": trash_scripts}); + if !trash_drafts.is_empty() { + trash_data["drafts"] = serde_json::Value::Array(trash_drafts); + } + windmill_common::trashbin::move_to_trash( + &mut *tx, + &w_id, + "script", path, - w_id + trash_data, + &authed.username, ) - .fetch_one(&db) - .await - .map_err(|e| Error::internal_err(format!("deleting script by path {w_id}: {e:#}")))? - } else { - sqlx::query_scalar!( - "DELETE FROM script WHERE path = $1 AND workspace_id = $2 RETURNING path", - path, - w_id - ) - .fetch_one(&mut *tx) - .await - .map_err(|e| Error::internal_err(format!("deleting script by path {w_id}: {e:#}")))? - }; + .await?; + } sqlx::query!( "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'", path, w_id ) - .execute(&db) + .execute(&mut *tx) .await?; if !query.keep_captures.unwrap_or(false) { @@ -2275,7 +2300,7 @@ async fn delete_script_by_path( path, w_id ) - .execute(&db) + .execute(&mut *tx) .await?; sqlx::query!( @@ -2283,7 +2308,7 @@ async fn delete_script_by_path( path, w_id ) - .execute(&db) + .execute(&mut *tx) .await?; } @@ -2370,6 +2395,30 @@ async fn delete_scripts_bulk( let mut tx = db.begin().await?; + // Capture scripts for trashbin per path before bulk delete + for path in &request.paths { + let trash_scripts: Vec = sqlx::query_scalar( + "SELECT to_jsonb(t) FROM script t WHERE path = $1 AND workspace_id = $2", + ) + .bind(path) + .bind(&w_id) + .fetch_all(&mut *tx) + .await?; + + if !trash_scripts.is_empty() { + let trash_data = serde_json::json!({"scripts": trash_scripts}); + windmill_common::trashbin::move_to_trash( + &mut *tx, + &w_id, + "script", + path, + trash_data, + &authed.username, + ) + .await?; + } + } + let mut deleted_paths = sqlx::query_scalar!( "DELETE FROM script WHERE workspace_id = $1 AND path = ANY($2) RETURNING path", w_id, diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index eaead14558..9ff74387d1 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -1451,6 +1451,30 @@ async fn delete_app( let mut tx = user_db.begin(&authed).await?; + // Capture all related data for trashbin before deleting (CASCADE will remove app_version, etc.) + let trash_app: Option = + sqlx::query_scalar("SELECT to_jsonb(t) FROM app t WHERE path = $1 AND workspace_id = $2") + .bind(path) + .bind(&w_id) + .fetch_optional(&mut *tx) + .await?; + + let trash_app_versions: Vec = sqlx::query_scalar( + "SELECT to_jsonb(t) FROM app_version t WHERE app_id = (SELECT id FROM app WHERE path = $1 AND workspace_id = $2)", + ) + .bind(path) + .bind(&w_id) + .fetch_all(&mut *tx) + .await?; + + let trash_drafts: Vec = sqlx::query_scalar( + "SELECT to_jsonb(t) FROM draft t WHERE path = $1 AND workspace_id = $2 AND typ = 'app'", + ) + .bind(path) + .bind(&w_id) + .fetch_all(&mut *tx) + .await?; + sqlx::query!( "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'", path, @@ -1467,6 +1491,25 @@ async fn delete_app( .execute(&mut *tx) .await?; + if let Some(app_data) = trash_app { + let mut trash_data = serde_json::json!({"row": app_data}); + if !trash_app_versions.is_empty() { + trash_data["app_versions"] = serde_json::Value::Array(trash_app_versions); + } + if !trash_drafts.is_empty() { + trash_data["drafts"] = serde_json::Value::Array(trash_drafts); + } + windmill_common::trashbin::move_to_trash( + &mut *tx, + &w_id, + "app", + path, + trash_data, + &authed.username, + ) + .await?; + } + audit_log( &mut *tx, &authed, diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 565b01a4b4..65ba4cad15 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -165,6 +165,7 @@ pub mod teams_ee; mod teams_oss; mod token; mod tracing_init; +mod trash; pub mod triggers; mod users; #[cfg(feature = "private")] @@ -597,6 +598,7 @@ pub async fn run_server( .nest("/resources", resources::workspaced_service()) .nest("/schedules", windmill_api_schedule::workspaced_service()) .nest("/scripts", scripts::workspaced_service()) + .nest("/trash", trash::workspaced_service()) .nest( "/users", users::workspaced_service().layer(Extension(argon2.clone())), diff --git a/backend/windmill-api/src/trash.rs b/backend/windmill-api/src/trash.rs new file mode 100644 index 0000000000..8e7b7f0ad7 --- /dev/null +++ b/backend/windmill-api/src/trash.rs @@ -0,0 +1,511 @@ +use axum::{ + extract::{Extension, Json, Path, Query}, + routing::{delete, get, post}, + Router, +}; +use serde::Deserialize; +use windmill_audit::{audit_oss::audit_log, ActionKind}; +use windmill_common::{ + db::UserDB, + error::{Error, Result}, + trashbin::{self, TrashItem, TrashItemWithData}, + utils::require_admin, +}; + +use crate::db::{ApiAuthed, DB}; + +pub fn workspaced_service() -> Router { + Router::new() + .route("/list", get(list_trash)) + .route("/get/:id", get(get_trash_item)) + .route("/restore/:id", post(restore_trash_item)) + .route("/delete/:id", delete(permanently_delete_item)) + .route("/empty", post(empty_trash)) +} + +#[derive(Deserialize)] +struct ListTrashQuery { + item_kind: Option, + page: Option, + per_page: Option, +} + +async fn list_trash( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + Query(query): Query, +) -> Result>> { + require_admin(authed.is_admin, &authed.username)?; + let items = trashbin::list_trash( + &db, + &w_id, + query.item_kind.as_deref(), + query.page, + query.per_page, + ) + .await?; + Ok(Json(items)) +} + +async fn get_trash_item( + authed: ApiAuthed, + Extension(db): Extension, + Path((w_id, id)): Path<(String, i64)>, +) -> Result> { + require_admin(authed.is_admin, &authed.username)?; + let item = trashbin::get_trash_item(&db, &w_id, id).await?; + Ok(Json(item)) +} + +async fn restore_trash_item( + authed: ApiAuthed, + Extension(db): Extension, + Extension(user_db): Extension, + Path((w_id, id)): Path<(String, i64)>, +) -> Result { + require_admin(authed.is_admin, &authed.username)?; + let item = trashbin::get_trash_item(&db, &w_id, id).await?; + let mut tx = user_db.begin(&authed).await?; + + match item.item_kind.as_str() { + "script" => restore_script(&mut tx, &item).await?, + "flow" => restore_flow(&mut tx, &item).await?, + "app" => restore_app(&mut tx, &item).await?, + "schedule" => restore_schedule(&mut tx, &item).await?, + "variable" => restore_variable(&mut tx, &item).await?, + "resource" => restore_resource(&mut tx, &item).await?, + kind if kind.ends_with("_trigger") => restore_trigger(&mut tx, &item).await?, + _ => { + return Err(Error::BadRequest(format!( + "Unknown item kind: {}", + item.item_kind + ))) + } + } + + sqlx::query!("DELETE FROM trashbin WHERE id = $1", item.id) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "trash.restore", + ActionKind::Create, + &w_id, + Some(&item.item_path), + None, + ) + .await?; + + tx.commit().await?; + Ok(format!("{} '{}' restored", item.item_kind, item.item_path)) +} + +async fn permanently_delete_item( + authed: ApiAuthed, + Extension(db): Extension, + Extension(user_db): Extension, + Path((w_id, id)): Path<(String, i64)>, +) -> Result { + require_admin(authed.is_admin, &authed.username)?; + let item = trashbin::get_trash_item(&db, &w_id, id).await?; + let mut tx = user_db.begin(&authed).await?; + trashbin::permanently_delete_item(&mut *tx, &w_id, id).await?; + audit_log( + &mut *tx, + &authed, + "trashbin.permanently_delete", + ActionKind::Delete, + &w_id, + Some(&item.item_path), + None, + ) + .await?; + tx.commit().await?; + Ok("permanently deleted".to_string()) +} + +async fn empty_trash( + authed: ApiAuthed, + Extension(_db): Extension, + Extension(user_db): Extension, + Path(w_id): Path, +) -> Result { + require_admin(authed.is_admin, &authed.username)?; + let mut tx = user_db.begin(&authed).await?; + let count = trashbin::empty_trash(&mut *tx, &w_id).await?; + audit_log( + &mut *tx, + &authed, + "trashbin.empty", + ActionKind::Delete, + &w_id, + None, + None, + ) + .await?; + tx.commit().await?; + Ok(format!("{} items permanently deleted", count)) +} + +// --- Restore functions per item kind --- + +async fn restore_script(tx: &mut sqlx::PgConnection, item: &TrashItemWithData) -> Result<()> { + let data = &item.item_data; + + // Check for path conflict + let exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM script WHERE path = $1 AND workspace_id = $2)", + &item.item_path, + &item.workspace_id, + ) + .fetch_one(&mut *tx) + .await? + .unwrap_or(false); + + if exists { + return Err(Error::BadRequest(format!( + "A script already exists at path '{}'", + item.item_path + ))); + } + + // Scripts are stored as an array (all versions for the path) + let scripts = data + .get("scripts") + .and_then(|v| v.as_array()) + .ok_or_else(|| Error::internal_err("Invalid trash data for script"))?; + + for script in scripts { + sqlx::query("INSERT INTO script SELECT * FROM jsonb_populate_record(null::script, $1)") + .bind(script) + .execute(&mut *tx) + .await + .map_err(|e| Error::internal_err(format!("restoring script: {e:#}")))?; + } + + // Restore drafts if present + if let Some(drafts) = data.get("drafts").and_then(|v| v.as_array()) { + for draft in drafts { + sqlx::query( + "INSERT INTO draft SELECT * FROM jsonb_populate_record(null::draft, $1) + ON CONFLICT DO NOTHING", + ) + .bind(draft) + .execute(&mut *tx) + .await?; + } + } + + Ok(()) +} + +async fn restore_flow(tx: &mut sqlx::PgConnection, item: &TrashItemWithData) -> Result<()> { + let data = &item.item_data; + + let exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM flow WHERE path = $1 AND workspace_id = $2)", + &item.item_path, + &item.workspace_id, + ) + .fetch_one(&mut *tx) + .await? + .unwrap_or(false); + + if exists { + return Err(Error::BadRequest(format!( + "A flow already exists at path '{}'", + item.item_path + ))); + } + + let row = data + .get("row") + .ok_or_else(|| Error::internal_err("Invalid trash data for flow"))?; + + sqlx::query("INSERT INTO flow SELECT * FROM jsonb_populate_record(null::flow, $1)") + .bind(row) + .execute(&mut *tx) + .await?; + + // Restore flow_versions + if let Some(versions) = data.get("flow_versions").and_then(|v| v.as_array()) { + for version in versions { + sqlx::query( + "INSERT INTO flow_version SELECT * FROM jsonb_populate_record(null::flow_version, $1) + ON CONFLICT DO NOTHING", + ) + .bind(version) + .execute(&mut *tx) + .await?; + } + } + + // Restore flow_nodes + if let Some(nodes) = data.get("flow_nodes").and_then(|v| v.as_array()) { + for node in nodes { + sqlx::query( + "INSERT INTO flow_node SELECT * FROM jsonb_populate_record(null::flow_node, $1) + ON CONFLICT DO NOTHING", + ) + .bind(node) + .execute(&mut *tx) + .await?; + } + } + + // Restore drafts + if let Some(drafts) = data.get("drafts").and_then(|v| v.as_array()) { + for draft in drafts { + sqlx::query( + "INSERT INTO draft SELECT * FROM jsonb_populate_record(null::draft, $1) + ON CONFLICT DO NOTHING", + ) + .bind(draft) + .execute(&mut *tx) + .await?; + } + } + + Ok(()) +} + +async fn restore_app(tx: &mut sqlx::PgConnection, item: &TrashItemWithData) -> Result<()> { + let data = &item.item_data; + + let exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM app WHERE path = $1 AND workspace_id = $2)", + &item.item_path, + &item.workspace_id, + ) + .fetch_one(&mut *tx) + .await? + .unwrap_or(false); + + if exists { + return Err(Error::BadRequest(format!( + "An app already exists at path '{}'", + item.item_path + ))); + } + + let row = data + .get("row") + .ok_or_else(|| Error::internal_err("Invalid trash data for app"))?; + + // Insert app first (app_version has FK to app.id) + sqlx::query("INSERT INTO app SELECT * FROM jsonb_populate_record(null::app, $1)") + .bind(row) + .execute(&mut *tx) + .await + .map_err(|e| Error::internal_err(format!("restoring app row: {e:#}")))?; + + // Then restore app_versions + if let Some(versions) = data.get("app_versions").and_then(|v| v.as_array()) { + for version in versions { + sqlx::query( + "INSERT INTO app_version SELECT * FROM jsonb_populate_record(null::app_version, $1) + ON CONFLICT DO NOTHING", + ) + .bind(version) + .execute(&mut *tx) + .await + .map_err(|e| Error::internal_err(format!("restoring app_version: {e:#}")))?; + } + } + + // Restore drafts + if let Some(drafts) = data.get("drafts").and_then(|v| v.as_array()) { + for draft in drafts { + sqlx::query( + "INSERT INTO draft SELECT * FROM jsonb_populate_record(null::draft, $1) + ON CONFLICT DO NOTHING", + ) + .bind(draft) + .execute(&mut *tx) + .await?; + } + } + + Ok(()) +} + +async fn restore_schedule(tx: &mut sqlx::PgConnection, item: &TrashItemWithData) -> Result<()> { + let data = &item.item_data; + + let exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM schedule WHERE path = $1 AND workspace_id = $2)", + &item.item_path, + &item.workspace_id, + ) + .fetch_one(&mut *tx) + .await? + .unwrap_or(false); + + if exists { + return Err(Error::BadRequest(format!( + "A schedule already exists at path '{}'", + item.item_path + ))); + } + + let row = data + .get("row") + .ok_or_else(|| Error::internal_err("Invalid trash data for schedule"))?; + + sqlx::query("INSERT INTO schedule SELECT * FROM jsonb_populate_record(null::schedule, $1)") + .bind(row) + .execute(&mut *tx) + .await?; + + Ok(()) +} + +async fn restore_variable(tx: &mut sqlx::PgConnection, item: &TrashItemWithData) -> Result<()> { + let data = &item.item_data; + + let exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM variable WHERE path = $1 AND workspace_id = $2)", + &item.item_path, + &item.workspace_id, + ) + .fetch_one(&mut *tx) + .await? + .unwrap_or(false); + + if exists { + return Err(Error::BadRequest(format!( + "A variable already exists at path '{}'", + item.item_path + ))); + } + + let row = data + .get("row") + .ok_or_else(|| Error::internal_err("Invalid trash data for variable"))?; + + sqlx::query("INSERT INTO variable SELECT * FROM jsonb_populate_record(null::variable, $1)") + .bind(row) + .execute(&mut *tx) + .await?; + + // Restore linked resource if present + if let Some(linked_resource) = data.get("linked_resource") { + if !linked_resource.is_null() { + sqlx::query( + "INSERT INTO resource SELECT * FROM jsonb_populate_record(null::resource, $1) + ON CONFLICT DO NOTHING", + ) + .bind(linked_resource) + .execute(&mut *tx) + .await?; + } + } + + Ok(()) +} + +async fn restore_resource(tx: &mut sqlx::PgConnection, item: &TrashItemWithData) -> Result<()> { + let data = &item.item_data; + + let exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM resource WHERE path = $1 AND workspace_id = $2)", + &item.item_path, + &item.workspace_id, + ) + .fetch_one(&mut *tx) + .await? + .unwrap_or(false); + + if exists { + return Err(Error::BadRequest(format!( + "A resource already exists at path '{}'", + item.item_path + ))); + } + + let row = data + .get("row") + .ok_or_else(|| Error::internal_err("Invalid trash data for resource"))?; + + sqlx::query("INSERT INTO resource SELECT * FROM jsonb_populate_record(null::resource, $1)") + .bind(row) + .execute(&mut *tx) + .await?; + + // Restore linked variables if present + if let Some(linked_vars) = data.get("linked_variables").and_then(|v| v.as_array()) { + for var in linked_vars { + sqlx::query( + "INSERT INTO variable SELECT * FROM jsonb_populate_record(null::variable, $1) + ON CONFLICT DO NOTHING", + ) + .bind(var) + .execute(&mut *tx) + .await?; + } + } + + Ok(()) +} + +async fn restore_trigger(tx: &mut sqlx::PgConnection, item: &TrashItemWithData) -> Result<()> { + let data = &item.item_data; + + let table_name = data + .get("table_name") + .and_then(|v| v.as_str()) + .ok_or_else(|| Error::internal_err("Invalid trash data for trigger: missing table_name"))?; + + // Validate table name to prevent SQL injection + let valid_tables = [ + "http_trigger", + "websocket_trigger", + "kafka_trigger", + "nats_trigger", + "postgres_trigger", + "mqtt_trigger", + "sqs_trigger", + "gcp_trigger", + "email_trigger", + ]; + + if !valid_tables.contains(&table_name) { + return Err(Error::BadRequest(format!( + "Invalid trigger table: {}", + table_name + ))); + } + + let exists: bool = sqlx::query_scalar(&format!( + "SELECT EXISTS(SELECT 1 FROM {} WHERE path = $1 AND workspace_id = $2)", + table_name + )) + .bind(&item.item_path) + .bind(&item.workspace_id) + .fetch_one(&mut *tx) + .await?; + + if exists { + return Err(Error::BadRequest(format!( + "A trigger already exists at path '{}'", + item.item_path + ))); + } + + let row = data + .get("row") + .ok_or_else(|| Error::internal_err("Invalid trash data for trigger"))?; + + sqlx::query(&format!( + "INSERT INTO {} SELECT * FROM jsonb_populate_record(null::{}, $1)", + table_name, table_name + )) + .bind(row) + .execute(&mut *tx) + .await?; + + Ok(()) +} diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index a31b665868..0093f1088f 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -96,6 +96,7 @@ pub mod stream; pub mod teams_ee; pub mod teams_oss; pub mod tracing_init; +pub mod trashbin; pub mod triggers; pub mod usernames; pub mod users; diff --git a/backend/windmill-common/src/trashbin.rs b/backend/windmill-common/src/trashbin.rs new file mode 100644 index 0000000000..6d88f12871 --- /dev/null +++ b/backend/windmill-common/src/trashbin.rs @@ -0,0 +1,155 @@ +use serde::Serialize; +use sqlx::PgConnection; + +use crate::error::Result; + +#[derive(Serialize, sqlx::FromRow)] +pub struct TrashItem { + pub id: i64, + pub workspace_id: String, + pub item_kind: String, + pub item_path: String, + pub deleted_by: String, + pub deleted_at: chrono::DateTime, + pub expires_at: chrono::DateTime, +} + +#[derive(Serialize, sqlx::FromRow)] +pub struct TrashItemWithData { + pub id: i64, + pub workspace_id: String, + pub item_kind: String, + pub item_path: String, + pub item_data: serde_json::Value, + pub deleted_by: String, + pub deleted_at: chrono::DateTime, + pub expires_at: chrono::DateTime, +} + +pub async fn move_to_trash( + tx: &mut PgConnection, + workspace_id: &str, + item_kind: &str, + item_path: &str, + item_data: serde_json::Value, + deleted_by: &str, +) -> Result { + let id = sqlx::query_scalar!( + "INSERT INTO trashbin (workspace_id, item_kind, item_path, item_data, deleted_by) + VALUES ($1, $2, $3, $4, $5) RETURNING id", + workspace_id, + item_kind, + item_path, + item_data, + deleted_by, + ) + .fetch_one(&mut *tx) + .await?; + + Ok(id) +} + +pub async fn list_trash<'e, E: sqlx::PgExecutor<'e>>( + db: E, + workspace_id: &str, + kind_filter: Option<&str>, + page: Option, + per_page: Option, +) -> Result> { + let per_page = per_page.unwrap_or(100).min(1000); + let offset = page.unwrap_or(0) * per_page; + + let items = if let Some(kind) = kind_filter { + sqlx::query_as!( + TrashItem, + "SELECT id, workspace_id, item_kind, item_path, deleted_by, deleted_at, expires_at + FROM trashbin + WHERE workspace_id = $1 AND item_kind = $2 + ORDER BY deleted_at DESC + LIMIT $3 OFFSET $4", + workspace_id, + kind, + per_page, + offset, + ) + .fetch_all(db) + .await? + } else { + sqlx::query_as!( + TrashItem, + "SELECT id, workspace_id, item_kind, item_path, deleted_by, deleted_at, expires_at + FROM trashbin + WHERE workspace_id = $1 + ORDER BY deleted_at DESC + LIMIT $2 OFFSET $3", + workspace_id, + per_page, + offset, + ) + .fetch_all(db) + .await? + }; + + Ok(items) +} + +pub async fn get_trash_item<'e, E: sqlx::PgExecutor<'e>>( + db: E, + workspace_id: &str, + id: i64, +) -> Result { + let item = sqlx::query_as!( + TrashItemWithData, + "SELECT id, workspace_id, item_kind, item_path, item_data, deleted_by, deleted_at, expires_at + FROM trashbin + WHERE workspace_id = $1 AND id = $2", + workspace_id, + id, + ) + .fetch_optional(db) + .await? + .ok_or_else(|| crate::error::Error::NotFound("Trash item not found".to_string()))?; + + Ok(item) +} + +pub async fn permanently_delete_item<'e, E: sqlx::PgExecutor<'e>>( + db: E, + workspace_id: &str, + id: i64, +) -> Result<()> { + let rows = sqlx::query!( + "DELETE FROM trashbin WHERE workspace_id = $1 AND id = $2", + workspace_id, + id, + ) + .execute(db) + .await? + .rows_affected(); + + if rows == 0 { + return Err(crate::error::Error::NotFound( + "Trash item not found".to_string(), + )); + } + + Ok(()) +} + +pub async fn empty_trash<'e, E: sqlx::PgExecutor<'e>>(db: E, workspace_id: &str) -> Result { + let rows = sqlx::query!("DELETE FROM trashbin WHERE workspace_id = $1", workspace_id,) + .execute(db) + .await? + .rows_affected(); + + Ok(rows as i64) +} + +pub async fn delete_expired_trash<'e, E: sqlx::PgExecutor<'e>>(db: E) -> Result { + let rows = sqlx::query!("DELETE FROM trashbin WHERE expires_at <= now()") + .execute(db) + .await? + .rows_affected(); + + Ok(rows as i64) +} diff --git a/backend/windmill-store/src/resources.rs b/backend/windmill-store/src/resources.rs index ef1602062a..102bab8585 100644 --- a/backend/windmill-store/src/resources.rs +++ b/backend/windmill-store/src/resources.rs @@ -877,6 +877,15 @@ async fn delete_resource( } let mut tx = user_db.begin(&authed).await?; + // Capture resource data for trashbin before deleting + let trash_resource: Option = sqlx::query_scalar( + "SELECT to_jsonb(t) FROM resource t WHERE path = $1 AND workspace_id = $2", + ) + .bind(path) + .bind(&w_id) + .fetch_optional(&mut *tx) + .await?; + // Fetch the resource value before deleting, so we can find linked $var: references let resource_value: Option> = sqlx::query_scalar("SELECT value FROM resource WHERE path = $1 AND workspace_id = $2") @@ -885,6 +894,32 @@ async fn delete_resource( .fetch_optional(&mut *tx) .await?; + // Collect all $var: paths referenced in the resource value + let mut linked_var_paths: Vec = Vec::new(); + if let Some(Some(ref value)) = resource_value { + collect_var_refs(value, &mut linked_var_paths); + } + + // Capture linked variables for trashbin before deleting them + let trash_linked_vars: Vec = if linked_var_paths.is_empty() { + Vec::new() + } else { + let placeholders: Vec = linked_var_paths + .iter() + .enumerate() + .map(|(i, _)| format!("${}", i + 2)) + .collect(); + let query = format!( + "SELECT to_jsonb(t) FROM variable t WHERE workspace_id = $1 AND path IN ({})", + placeholders.join(", ") + ); + let mut q = sqlx::query_scalar::<_, serde_json::Value>(&query).bind(&w_id); + for var_path in &linked_var_paths { + q = q.bind(var_path); + } + q.fetch_all(&mut *tx).await? + }; + let deleted_path = sqlx::query_scalar!( "DELETE FROM resource WHERE path = $1 AND workspace_id = $2 RETURNING path", path, @@ -894,12 +929,6 @@ async fn delete_resource( .await?; not_found_if_none(deleted_path, "Resource", &path)?; - // Collect all $var: paths referenced in the resource value - let mut linked_var_paths: Vec = Vec::new(); - if let Some(Some(value)) = resource_value { - collect_var_refs(&value, &mut linked_var_paths); - } - // Delete linked variables that are actually referenced in the resource value let deleted_linked_variables: Vec = if linked_var_paths.is_empty() { Vec::new() @@ -919,6 +948,23 @@ async fn delete_resource( } q.fetch_all(&mut *tx).await? }; + + if let Some(res_data) = trash_resource { + let mut trash_data = serde_json::json!({"row": res_data}); + if !trash_linked_vars.is_empty() { + trash_data["linked_variables"] = serde_json::Value::Array(trash_linked_vars); + } + windmill_common::trashbin::move_to_trash( + &mut *tx, + &w_id, + "resource", + path, + trash_data, + &authed.username, + ) + .await?; + } + audit_log( &mut *tx, &authed, @@ -1025,6 +1071,30 @@ async fn delete_resources_bulk( let mut tx = user_db.begin(&authed).await?; + // Capture resources for trashbin per path before bulk delete + for path in &request.paths { + let trash_resource: Option = sqlx::query_scalar( + "SELECT to_jsonb(t) FROM resource t WHERE path = $1 AND workspace_id = $2", + ) + .bind(path) + .bind(&w_id) + .fetch_optional(&mut *tx) + .await?; + + if let Some(res_data) = trash_resource { + let trash_data = serde_json::json!({"row": res_data}); + windmill_common::trashbin::move_to_trash( + &mut *tx, + &w_id, + "resource", + path, + trash_data, + &authed.username, + ) + .await?; + } + } + let deleted_paths = sqlx::query_scalar!( "DELETE FROM resource WHERE path = ANY($1) AND workspace_id = $2 RETURNING path", &request.paths, diff --git a/backend/windmill-store/src/variables.rs b/backend/windmill-store/src/variables.rs index 7d2c8cbfe8..9db4f010a6 100644 --- a/backend/windmill-store/src/variables.rs +++ b/backend/windmill-store/src/variables.rs @@ -520,6 +520,23 @@ async fn delete_variable( let mut tx = user_db.begin(&authed).await?; + // Capture data for trashbin before deleting + let trash_var: Option = sqlx::query_scalar( + "SELECT to_jsonb(t) FROM variable t WHERE path = $1 AND workspace_id = $2", + ) + .bind(path) + .bind(&w_id) + .fetch_optional(&mut *tx) + .await?; + + let trash_linked_resource: Option = sqlx::query_scalar( + "SELECT to_jsonb(t) FROM resource t WHERE path = $1 AND workspace_id = $2", + ) + .bind(path) + .bind(&w_id) + .fetch_optional(&mut *tx) + .await?; + sqlx::query!( "DELETE FROM variable WHERE path = $1 AND workspace_id = $2", path, @@ -534,6 +551,23 @@ async fn delete_variable( ) .fetch_optional(&mut *tx) .await?; + + if let Some(var_data) = trash_var { + let mut trash_data = serde_json::json!({"row": var_data}); + if let Some(linked) = trash_linked_resource { + trash_data["linked_resource"] = linked; + } + windmill_common::trashbin::move_to_trash( + &mut *tx, + &w_id, + "variable", + path, + trash_data, + &authed.username, + ) + .await?; + } + audit_log( &mut *tx, &authed, @@ -633,6 +667,41 @@ async fn delete_variables_bulk( let mut tx = user_db.begin(&authed).await?; + // Capture variables for trashbin per path before bulk delete + for path in &request.paths { + let trash_var: Option = sqlx::query_scalar( + "SELECT to_jsonb(t) FROM variable t WHERE path = $1 AND workspace_id = $2", + ) + .bind(path) + .bind(&w_id) + .fetch_optional(&mut *tx) + .await?; + + if let Some(var_data) = trash_var { + let trash_linked: Option = sqlx::query_scalar( + "SELECT to_jsonb(t) FROM resource t WHERE path = $1 AND workspace_id = $2", + ) + .bind(path) + .bind(&w_id) + .fetch_optional(&mut *tx) + .await?; + + let mut trash_data = serde_json::json!({"row": var_data}); + if let Some(linked) = trash_linked { + trash_data["linked_resource"] = linked; + } + windmill_common::trashbin::move_to_trash( + &mut *tx, + &w_id, + "variable", + path, + trash_data, + &authed.username, + ) + .await?; + } + } + let deleted_paths = sqlx::query_scalar!( "DELETE FROM variable WHERE path = ANY($1) AND workspace_id = $2 RETURNING path", &request.paths, diff --git a/backend/windmill-trigger/src/handler.rs b/backend/windmill-trigger/src/handler.rs index 1f89be9419..478f20811d 100644 --- a/backend/windmill-trigger/src/handler.rs +++ b/backend/windmill-trigger/src/handler.rs @@ -610,6 +610,17 @@ async fn delete_trigger( })?; let mut tx = user_db.begin(&authed).await?; + + // Capture trigger data for trashbin before deleting + let trash_data: Option = sqlx::query_scalar(&format!( + "SELECT jsonb_build_object('row', to_jsonb(t), 'table_name', '{table}') FROM {table} t WHERE path = $1 AND workspace_id = $2", + table = T::TABLE_NAME + )) + .bind(path) + .bind(&workspace_id) + .fetch_optional(&mut *tx) + .await?; + let deleted = handler .delete_by_path(&mut *tx, &workspace_id, path) .await?; @@ -621,6 +632,19 @@ async fn delete_trigger( ))); } + if let Some(data) = trash_data { + let item_kind = format!("{}_trigger", T::TRIGGER_TYPE); + windmill_common::trashbin::move_to_trash( + &mut *tx, + &workspace_id, + &item_kind, + path, + data, + &authed.username, + ) + .await?; + } + audit_log( &mut *tx, &authed, diff --git a/frontend/src/lib/components/common/confirmationModal/ConfirmationModal.svelte b/frontend/src/lib/components/common/confirmationModal/ConfirmationModal.svelte index a04923e156..f7205b2531 100644 --- a/frontend/src/lib/components/common/confirmationModal/ConfirmationModal.svelte +++ b/frontend/src/lib/components/common/confirmationModal/ConfirmationModal.svelte @@ -15,6 +15,7 @@ type?: 'danger' | 'reload' showIcon?: boolean id?: string + trashbin?: boolean children?: Snippet onConfirmed?: () => void | Promise onCanceled?: () => void @@ -29,6 +30,7 @@ type: _type, showIcon = true, id, + trashbin = false, children, onConfirmed, onCanceled @@ -120,6 +122,12 @@
{@render children?.()}
+ {#if trashbin} +

This item will be moved to the trashbin and can be restored by a workspace admin + within 3 days.

+ {/if}
diff --git a/frontend/src/lib/components/home/Item.svelte b/frontend/src/lib/components/home/Item.svelte index b4a57175cd..2e06e66417 100644 --- a/frontend/src/lib/components/home/Item.svelte +++ b/frontend/src/lib/components/home/Item.svelte @@ -93,6 +93,7 @@ open={Boolean(deleteConfirmedCallback)} title="Remove" confirmationText="Remove" + trashbin on:canceled={() => { deleteConfirmedCallback = undefined }} diff --git a/frontend/src/lib/components/settings/Trashbin.svelte b/frontend/src/lib/components/settings/Trashbin.svelte new file mode 100644 index 0000000000..3b9b4f5f29 --- /dev/null +++ b/frontend/src/lib/components/settings/Trashbin.svelte @@ -0,0 +1,249 @@ + + +
+ + +
+ +{#if items === undefined} + +{:else if items.length === 0} +
+ +

Trashbin is empty

+

No recently deleted items.

+
+{:else} + + + + Type + Path + Deleted by + Deleted + Expires + Actions + + + {#each items as item (item.id)} + {@const Icon = getKindIcon(item.item_kind)} + + +
+ + {getKindLabel(item.item_kind)} +
+
+ + {item.item_path} + + + {item.deleted_by} + + + {timeAgo(item.deleted_at)} + + + {timeRemaining(item.expires_at)} + + +
+ + +
+
+
+ {/each} +
+{/if} + + { + deleteConfirmedCallback = undefined + }} + onConfirmed={() => { + if (deleteConfirmedCallback) { + deleteConfirmedCallback() + } + deleteConfirmedCallback = undefined + }} +> +

This item will be permanently deleted. This action cannot be undone.

+
+ + { + emptyConfirmOpen = false + }} + onConfirmed={() => { + emptyAll() + emptyConfirmOpen = false + }} +> +

All items in the trashbin will be permanently deleted. This action cannot be undone.

+
diff --git a/frontend/src/lib/components/triggers/DeleteTriggerButton.svelte b/frontend/src/lib/components/triggers/DeleteTriggerButton.svelte index b7fed3d6d0..26b1933de7 100644 --- a/frontend/src/lib/components/triggers/DeleteTriggerButton.svelte +++ b/frontend/src/lib/components/triggers/DeleteTriggerButton.svelte @@ -21,6 +21,7 @@ title={`Are you sure you want to delete this ${trigger?.isDraft ? 'draft' : 'deployed'} trigger ?`} confirmationText="Delete" open={confirmationModalOpen} + trashbin on:canceled={() => { confirmationModalOpen = false }} diff --git a/frontend/src/lib/services/trashService.ts b/frontend/src/lib/services/trashService.ts new file mode 100644 index 0000000000..c1bdac5a23 --- /dev/null +++ b/frontend/src/lib/services/trashService.ts @@ -0,0 +1,69 @@ +import { OpenAPI } from '$lib/gen/core/OpenAPI' +import { request as __request } from '$lib/gen/core/request' + +export type TrashItem = { + id: number + workspace_id: string + item_kind: string + item_path: string + deleted_by: string + deleted_at: string + expires_at: string +} + +export class TrashService { + public static listTrash(data: { + workspace: string + itemKind?: string + page?: number + perPage?: number + }): Promise { + return __request(OpenAPI, { + method: 'GET', + url: '/w/{workspace}/trash/list', + path: { + workspace: data.workspace + }, + query: { + item_kind: data.itemKind, + page: data.page, + per_page: data.perPage + } + }) + } + + public static restoreTrashItem(data: { workspace: string; id: number }): Promise { + return __request(OpenAPI, { + method: 'POST', + url: '/w/{workspace}/trash/restore/{id}', + path: { + workspace: data.workspace, + id: data.id + } + }) + } + + public static permanentlyDeleteTrashItem(data: { + workspace: string + id: number + }): Promise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/w/{workspace}/trash/delete/{id}', + path: { + workspace: data.workspace, + id: data.id + } + }) + } + + public static emptyTrash(data: { workspace: string }): Promise { + return __request(OpenAPI, { + method: 'POST', + url: '/w/{workspace}/trash/empty', + path: { + workspace: data.workspace + } + }) + } +} diff --git a/frontend/src/routes/(root)/(logged)/resources/+page.svelte b/frontend/src/routes/(root)/(logged)/resources/+page.svelte index 600cc45183..b6b1c8c6e5 100644 --- a/frontend/src/routes/(root)/(logged)/resources/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/resources/+page.svelte @@ -30,13 +30,14 @@ import Toggle from '$lib/components/Toggle.svelte' import Tooltip from '$lib/components/Tooltip.svelte' import type { ResourceType, WorkspaceDeployUISettings } from '$lib/gen' - import { FolderService, OauthService, ResourceService, WorkspaceService, type ListableResource } from '$lib/gen' import { - enterpriseLicense, - userStore, - workspaceStore, - userWorkspaces - } from '$lib/stores' + FolderService, + OauthService, + ResourceService, + WorkspaceService, + type ListableResource + } from '$lib/gen' + import { enterpriseLicense, userStore, workspaceStore, userWorkspaces } from '$lib/stores' import { sendUserToast } from '$lib/toast' import { canWrite, @@ -151,7 +152,12 @@ let folderPresets = $derived([ ...folders.map((f) => ({ name: `f/${f}`, value: `path_start:\\ f/${f}/` })), ...(resourcesFilterSchema.user_folders_only - ? [{ name: resourcesFilterSchema.user_folders_only.label ?? '?', value: 'user_folders_only:\\ true' }] + ? [ + { + name: resourcesFilterSchema.user_folders_only.label ?? '?', + value: 'user_folders_only:\\ true' + } + ] : []) ]) @@ -577,6 +583,7 @@ open={Boolean(deleteConfirmedCallback)} title="Remove resource" confirmationText="Remove" + trashbin on:canceled={() => { deleteConfirmedCallback = undefined }} diff --git a/frontend/src/routes/(root)/(logged)/variables/+page.svelte b/frontend/src/routes/(root)/(logged)/variables/+page.svelte index ea48db42cd..e253a21fed 100644 --- a/frontend/src/routes/(root)/(logged)/variables/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/variables/+page.svelte @@ -74,7 +74,12 @@ let folderPresets = $derived([ ...folders.map((f) => ({ name: `f/${f}`, value: `path_start:\\ f/${f}/` })), ...(variablesFilterSchema.user_folders_only - ? [{ name: variablesFilterSchema.user_folders_only.label ?? '?', value: 'user_folders_only:\\ true' }] + ? [ + { + name: variablesFilterSchema.user_folders_only.label ?? '?', + value: 'user_folders_only:\\ true' + } + ] : []) ]) let contextualVariables: ContextualVariable[] = $state([]) @@ -576,6 +581,7 @@ {open} title="Remove variable" confirmationText="Remove" + trashbin on:canceled={() => { deleteConfirmedCallback = undefined }} diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte index db3a140c0b..24900d9076 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte @@ -56,6 +56,7 @@ import StorageSettings from '$lib/components/workspaceSettings/StorageSettings.svelte' import VolumeStorageSettings from '$lib/components/workspaceSettings/VolumeStorageSettings.svelte' import GitSyncSection from '$lib/components/git_sync/GitSyncSection.svelte' + import Trashbin from '$lib/components/settings/Trashbin.svelte' import { untrack } from 'svelte' import { getHandlerType } from '$lib/components/triggers/utils' import DucklakeSettings, { @@ -473,17 +474,15 @@ } async function loadSettings(): Promise { - const [settings, copilotSettingsState]: [ - GetSettingsResponse, - GetCopilotSettingsStateResponse - ] = await Promise.all([ - WorkspaceService.getSettings({ - workspace: $workspaceStore! - }), - WorkspaceService.getCopilotSettingsState({ - workspace: $workspaceStore! - }) - ]) + const [settings, copilotSettingsState]: [GetSettingsResponse, GetCopilotSettingsStateResponse] = + await Promise.all([ + WorkspaceService.getSettings({ + workspace: $workspaceStore! + }), + WorkspaceService.getCopilotSettingsState({ + workspace: $workspaceStore! + }) + ]) slack_team_name = settings.slack_name teams_team_id = settings.teams_team_id teams_team_name = settings.teams_team_name @@ -1193,6 +1192,12 @@ label: 'Encryption', aiId: 'workspace-settings-encryption', aiDescription: 'Encryption workspace settings' + }, + { + id: 'trashbin', + label: 'Trashbin', + aiId: 'workspace-settings-trashbin', + aiDescription: 'Trashbin for recently deleted items' } ] } @@ -1927,6 +1932,14 @@ export async function main( saveLabel="Save & Re-encrypt workspace" disabled={!!encryptionKeyValidationError || workspaceReencryptionInProgress} /> + {:else if tab == 'trashbin'} + +
+ +
{/if}