diff --git a/backend/.sqlx/query-08f288d2781d823e109a9e5b8848234ca7d1efeee9661f3901f298da375e73f7.json b/backend/.sqlx/query-08f288d2781d823e109a9e5b8848234ca7d1efeee9661f3901f298da375e73f7.json index a16a99ac73..a4445ebd16 100644 --- a/backend/.sqlx/query-08f288d2781d823e109a9e5b8848234ca7d1efeee9661f3901f298da375e73f7.json +++ b/backend/.sqlx/query-08f288d2781d823e109a9e5b8848234ca7d1efeee9661f3901f298da375e73f7.json @@ -152,6 +152,11 @@ "ordinal": 29, "name": "success_handler", "type_info": "Jsonb" + }, + { + "ordinal": 30, + "name": "public_app_execution_limit_per_minute", + "type_info": "Int4" } ], "parameters": { @@ -189,6 +194,7 @@ true, true, true, + true, true ] }, diff --git a/backend/.sqlx/query-3fe6f5d77332cce5ad249b8d6e1ea34aa57650c6effc3a9a2f4f720ea934669b.json b/backend/.sqlx/query-3fe6f5d77332cce5ad249b8d6e1ea34aa57650c6effc3a9a2f4f720ea934669b.json new file mode 100644 index 0000000000..9409cbe41e --- /dev/null +++ b/backend/.sqlx/query-3fe6f5d77332cce5ad249b8d6e1ea34aa57650c6effc3a9a2f4f720ea934669b.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_settings SET public_app_execution_limit_per_minute = $1 WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4", + "Text" + ] + }, + "nullable": [] + }, + "hash": "3fe6f5d77332cce5ad249b8d6e1ea34aa57650c6effc3a9a2f4f720ea934669b" +} diff --git a/backend/.sqlx/query-a479cd371fb5d1f52e7c727730cf48ab229e63b8dfe377975d48dcd223251e7c.json b/backend/.sqlx/query-a479cd371fb5d1f52e7c727730cf48ab229e63b8dfe377975d48dcd223251e7c.json new file mode 100644 index 0000000000..6f75beab08 --- /dev/null +++ b/backend/.sqlx/query-a479cd371fb5d1f52e7c727730cf48ab229e63b8dfe377975d48dcd223251e7c.json @@ -0,0 +1,202 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n deploy_to,\n ai_config,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler,\n public_app_execution_limit_per_minute\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "slack_team_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "teams_team_id", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "teams_team_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "teams_team_guid", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "slack_name", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "slack_command_script", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "teams_command_script", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "slack_email", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "slack_oauth_client_id", + "type_info": "Varchar" + }, + { + "ordinal": 10, + "name": "slack_oauth_client_secret", + "type_info": "Varchar" + }, + { + "ordinal": 11, + "name": "customer_id", + "type_info": "Varchar" + }, + { + "ordinal": 12, + "name": "plan", + "type_info": "Varchar" + }, + { + "ordinal": 13, + "name": "webhook", + "type_info": "Text" + }, + { + "ordinal": 14, + "name": "deploy_to", + "type_info": "Varchar" + }, + { + "ordinal": 15, + "name": "ai_config", + "type_info": "Jsonb" + }, + { + "ordinal": 16, + "name": "large_file_storage", + "type_info": "Jsonb" + }, + { + "ordinal": 17, + "name": "datatable", + "type_info": "Jsonb" + }, + { + "ordinal": 18, + "name": "ducklake", + "type_info": "Jsonb" + }, + { + "ordinal": 19, + "name": "git_sync", + "type_info": "Jsonb" + }, + { + "ordinal": 20, + "name": "deploy_ui", + "type_info": "Jsonb" + }, + { + "ordinal": 21, + "name": "default_app", + "type_info": "Varchar" + }, + { + "ordinal": 22, + "name": "default_scripts", + "type_info": "Jsonb" + }, + { + "ordinal": 23, + "name": "mute_critical_alerts", + "type_info": "Bool" + }, + { + "ordinal": 24, + "name": "color", + "type_info": "Varchar" + }, + { + "ordinal": 25, + "name": "operator_settings", + "type_info": "Jsonb" + }, + { + "ordinal": 26, + "name": "git_app_installations", + "type_info": "Jsonb" + }, + { + "ordinal": 27, + "name": "auto_invite", + "type_info": "Jsonb" + }, + { + "ordinal": 28, + "name": "error_handler", + "type_info": "Jsonb" + }, + { + "ordinal": 29, + "name": "success_handler", + "type_info": "Jsonb" + }, + { + "ordinal": 30, + "name": "public_app_execution_limit_per_minute", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + true, + true, + true, + true, + true, + true, + true, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + true, + true, + true, + true + ] + }, + "hash": "a479cd371fb5d1f52e7c727730cf48ab229e63b8dfe377975d48dcd223251e7c" +} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 8a3bc32d5e..aba370eb85 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -15587,6 +15587,7 @@ dependencies = [ "constant_time_eq 0.3.1", "cookie 0.17.0", "cron", + "dashmap 6.1.0", "datafusion", "deno_core", "deno_error", diff --git a/backend/migrations/20260204120403_add_public_app_rate_limit.down.sql b/backend/migrations/20260204120403_add_public_app_rate_limit.down.sql new file mode 100644 index 0000000000..6967162662 --- /dev/null +++ b/backend/migrations/20260204120403_add_public_app_rate_limit.down.sql @@ -0,0 +1,5 @@ +DROP TRIGGER IF EXISTS workspace_rate_limit_change_trigger ON workspace_settings; +DROP FUNCTION IF EXISTS notify_workspace_rate_limit_change(); + +ALTER TABLE workspace_settings +DROP COLUMN IF EXISTS public_app_execution_limit_per_minute; diff --git a/backend/migrations/20260204120403_add_public_app_rate_limit.up.sql b/backend/migrations/20260204120403_add_public_app_rate_limit.up.sql new file mode 100644 index 0000000000..34d096abbd --- /dev/null +++ b/backend/migrations/20260204120403_add_public_app_rate_limit.up.sql @@ -0,0 +1,19 @@ +ALTER TABLE workspace_settings +ADD COLUMN IF NOT EXISTS public_app_execution_limit_per_minute INTEGER DEFAULT NULL; + +-- Add trigger function for rate limit changes +CREATE OR REPLACE FUNCTION notify_workspace_rate_limit_change() +RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO notify_event (channel, payload) + VALUES ('notify_workspace_rate_limit_change', NEW.workspace_id); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Create trigger on workspace_settings (drop first if exists) +DROP TRIGGER IF EXISTS workspace_rate_limit_change_trigger ON workspace_settings; +CREATE TRIGGER workspace_rate_limit_change_trigger +AFTER UPDATE OF public_app_execution_limit_per_minute ON workspace_settings +FOR EACH ROW +EXECUTE FUNCTION notify_workspace_rate_limit_change(); diff --git a/backend/src/main.rs b/backend/src/main.rs index fc9a412ab3..389bfff71a 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1145,16 +1145,22 @@ Windmill Community Edition {GIT_VERSION} let db = db.clone(); let h = tokio::spawn(async move { // Initialize last_event_id to current max to avoid processing old events on startup - let mut last_event_id: i64 = match windmill_common::notify_events::get_latest_event_id(&db).await { - Ok(id) => { - tracing::info!("Initialized notify event polling with last_event_id: {}", id); - id - } - Err(e) => { - tracing::warn!("Could not get latest event id, starting from 0: {e:#}"); - 0 - } - }; + let mut last_event_id: i64 = + match windmill_common::notify_events::get_latest_event_id(&db).await { + Ok(id) => { + tracing::info!( + "Initialized notify event polling with last_event_id: {}", + id + ); + id + } + Err(e) => { + tracing::warn!( + "Could not get latest event id, starting from 0: {e:#}" + ); + 0 + } + }; let mut last_settings_reload = Instant::now(); let mut monitor_iteration: u64 = 0; let rd_shift: u8 = rand::rng().random_range(0..200); @@ -1382,36 +1388,57 @@ async fn process_notify_event( tx: &KillpillSender, server_mode: bool, worker_mode: bool, - #[cfg(feature = "parquet")] - disable_s3_store: bool, + #[cfg(feature = "parquet")] disable_s3_store: bool, ) { match channel { "notify_config_change" => { if payload == "server" && server_mode { - tracing::error!("Server config change detected but server config is obsolete: {}", payload); + tracing::error!( + "Server config change detected but server config is obsolete: {}", + payload + ); } else if worker_mode && payload == format!("worker__{}", *WORKER_GROUP) { tracing::info!("Worker config change detected: {}", payload); reload_worker_config(db, tx.clone(), true).await; } else { tracing::debug!("config changed but did not target this server/worker"); } - }, + } "notify_webhook_change" => { - tracing::info!("Webhook change detected, invalidating webhook cache: {}", payload); + tracing::info!( + "Webhook change detected, invalidating webhook cache: {}", + payload + ); windmill_api::webhook_util::WEBHOOK_CACHE.remove(payload); - }, + } "notify_workspace_envs_change" => { - tracing::info!("Workspace envs change detected, invalidating workspace envs cache: {}", payload); + tracing::info!( + "Workspace envs change detected, invalidating workspace envs cache: {}", + payload + ); windmill_common::variables::CUSTOM_ENVS_CACHE.remove(payload); - }, + } "notify_workspace_key_change" => { - tracing::info!("Workspace key change detected, invalidating workspace key cache: {}", payload); + tracing::info!( + "Workspace key change detected, invalidating workspace key cache: {}", + payload + ); windmill_common::variables::WORKSPACE_CRYPT_CACHE.remove(payload); - }, + } "notify_workspace_premium_change" => { - tracing::info!("Workspace premium change detected, invalidating workspace premium cache: {}", payload); + tracing::info!( + "Workspace premium change detected, invalidating workspace premium cache: {}", + payload + ); windmill_common::workspaces::TEAM_PLAN_CACHE.remove(payload); - }, + } + "notify_workspace_rate_limit_change" => { + tracing::info!( + "Workspace rate limit change detected, invalidating rate limit cache: {}", + payload + ); + windmill_common::workspaces::PUBLIC_APP_RATE_LIMIT_CACHE.remove(payload); + } "notify_runnable_version_change" => { tracing::info!("Runnable version change detected: {}", payload); match payload.split(':').collect::>().as_slice() { @@ -1446,39 +1473,48 @@ async fn process_notify_event( } } "flow" => { - let dynamic_input_key = windmill_common::jobs::generate_dynamic_input_key(workspace_id, path); + let dynamic_input_key = + windmill_common::jobs::generate_dynamic_input_key( + workspace_id, + path, + ); windmill_common::DYNAMIC_INPUT_CACHE.remove(&dynamic_input_key); windmill_common::FLOW_VERSION_CACHE.remove(&key); - }, + } _ => { tracing::warn!("Unknown runnable version change payload: {}", payload); } } - }, + } _ => { tracing::warn!("Unknown runnable version change payload: {}", payload); } } - }, + } #[cfg(feature = "http_trigger")] "notify_http_trigger_change" => { tracing::info!("HTTP trigger change detected: {}", payload); match windmill_api::triggers::http::refresh_routers(db).await { Ok((true, _)) => { tracing::info!("Refreshed HTTP routers (trigger change)"); - }, + } Ok((false, _)) => { - tracing::warn!("Should have refreshed HTTP routers (trigger change) but did not"); - }, + tracing::warn!( + "Should have refreshed HTTP routers (trigger change) but did not" + ); + } Err(err) => { tracing::error!("Error refreshing HTTP routers (trigger change): {err:#}"); } }; - }, + } "notify_token_invalidation" => { - tracing::info!("Token invalidation detected for token: {}...", payload.get(..8).unwrap_or(payload)); + tracing::info!( + "Token invalidation detected for token: {}...", + payload.get(..8).unwrap_or(payload) + ); windmill_api::auth::invalidate_token_from_cache(payload); - }, + } "notify_global_setting_change" => { tracing::info!("Global setting change detected: {}", payload); match payload { @@ -1486,176 +1522,150 @@ async fn process_notify_event( if let Err(e) = reload_base_url_setting(conn).await { tracing::error!(error = %e, "Could not reload base url setting"); } - }, + } OAUTH_SETTING => { if let Err(e) = reload_base_url_setting(conn).await { tracing::error!(error = %e, "Could not reload oauth setting"); } - }, + } CUSTOM_TAGS_SETTING => { if let Err(e) = reload_custom_tags_setting(db).await { tracing::error!(error = %e, "Could not reload custom tags setting"); } - }, + } LICENSE_KEY_SETTING => { if let Err(e) = reload_license_key(&db.into()).await { tracing::error!("Failed to reload license key: {e:#}"); } - }, + } DEFAULT_TAGS_PER_WORKSPACE_SETTING => { if let Err(e) = load_tag_per_workspace_enabled(db).await { tracing::error!("Error loading default tag per workspace: {e:#}"); } - }, + } DEFAULT_TAGS_WORKSPACES_SETTING => { if let Err(e) = load_tag_per_workspace_workspaces(db).await { - tracing::error!("Error loading default tag per workspace workspaces: {e:#}"); + tracing::error!( + "Error loading default tag per workspace workspaces: {e:#}" + ); } - }, + } SMTP_SETTING => { reload_smtp_config(db).await; - }, + } TEAMS_SETTING => { tracing::info!("Teams setting changed."); - }, + } INDEXER_SETTING => { reload_indexer_config(db).await; - }, - TIMEOUT_WAIT_RESULT_SETTING => { - reload_timeout_wait_result_setting(conn).await - }, - RETENTION_PERIOD_SECS_SETTING => { - reload_retention_period_setting(conn).await - }, + } + TIMEOUT_WAIT_RESULT_SETTING => reload_timeout_wait_result_setting(conn).await, + RETENTION_PERIOD_SECS_SETTING => reload_retention_period_setting(conn).await, MONITOR_LOGS_ON_OBJECT_STORE_SETTING => { reload_delete_logs_periodically_setting(conn).await - }, - JOB_DEFAULT_TIMEOUT_SECS_SETTING => { - reload_job_default_timeout_setting(conn).await - }, + } + JOB_DEFAULT_TIMEOUT_SECS_SETTING => reload_job_default_timeout_setting(conn).await, #[cfg(feature = "parquet")] OBJECT_STORE_CONFIG_SETTING => { if !disable_s3_store { reload_object_store_setting(db).await; } - }, - SCIM_TOKEN_SETTING => { - reload_scim_token_setting(conn).await - }, - EXTRA_PIP_INDEX_URL_SETTING => { - reload_extra_pip_index_url_setting(conn).await - }, - PIP_INDEX_URL_SETTING => { - reload_pip_index_url_setting(conn).await - }, + } + SCIM_TOKEN_SETTING => reload_scim_token_setting(conn).await, + EXTRA_PIP_INDEX_URL_SETTING => reload_extra_pip_index_url_setting(conn).await, + PIP_INDEX_URL_SETTING => reload_pip_index_url_setting(conn).await, INSTANCE_PYTHON_VERSION_SETTING => { reload_instance_python_version_setting(conn).await - }, - NPM_CONFIG_REGISTRY_SETTING => { - reload_npm_config_registry_setting(conn).await - }, - BUNFIG_INSTALL_SCOPES_SETTING => { - reload_bunfig_install_scopes_setting(conn).await - }, - NUGET_CONFIG_SETTING => { - reload_nuget_config_setting(conn).await - }, - POWERSHELL_REPO_URL_SETTING => { - reload_powershell_repo_url_setting(conn).await - }, - POWERSHELL_REPO_PAT_SETTING => { - reload_powershell_repo_pat_setting(conn).await - }, - MAVEN_REPOS_SETTING => { - reload_maven_repos_setting(conn).await - }, - NO_DEFAULT_MAVEN_SETTING => { - reload_no_default_maven_setting(conn).await - }, - RUBY_REPOS_SETTING => { - reload_ruby_repos_setting(conn).await - }, - HUB_API_SECRET_SETTING => { - reload_hub_api_secret_setting(conn).await - }, + } + NPM_CONFIG_REGISTRY_SETTING => reload_npm_config_registry_setting(conn).await, + BUNFIG_INSTALL_SCOPES_SETTING => reload_bunfig_install_scopes_setting(conn).await, + NUGET_CONFIG_SETTING => reload_nuget_config_setting(conn).await, + POWERSHELL_REPO_URL_SETTING => reload_powershell_repo_url_setting(conn).await, + POWERSHELL_REPO_PAT_SETTING => reload_powershell_repo_pat_setting(conn).await, + MAVEN_REPOS_SETTING => reload_maven_repos_setting(conn).await, + NO_DEFAULT_MAVEN_SETTING => reload_no_default_maven_setting(conn).await, + RUBY_REPOS_SETTING => reload_ruby_repos_setting(conn).await, + HUB_API_SECRET_SETTING => reload_hub_api_secret_setting(conn).await, KEEP_JOB_DIR_SETTING => { load_keep_job_dir(conn).await; - }, + } OTEL_TRACING_PROXY_SETTING => { reload_otel_tracing_proxy_setting(conn).await; if worker_mode { tracing::info!("OTEL tracing proxy setting changed, restarting worker"); send_delayed_killpill(tx, 4, "OTEL tracing proxy setting change").await; } - }, + } REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING => { load_require_preexisting_user(db).await; - }, + } EXPOSE_METRICS_SETTING => { tracing::info!("Metrics setting changed, restarting"); send_delayed_killpill(tx, 40, "metrics setting change").await; - }, + } EMAIL_DOMAIN_SETTING => { tracing::info!("Email domain setting changed"); if server_mode { send_delayed_killpill(tx, 4, "email domain setting change").await; } - }, + } EXPOSE_DEBUG_METRICS_SETTING => { if let Err(e) = load_metrics_debug_enabled(conn).await { tracing::error!(error = %e, "Could not reload debug metrics setting"); } - }, + } APP_WORKSPACED_ROUTE_SETTING => { if let Err(e) = reload_app_workspaced_route_setting(db).await { tracing::error!(error = %e, "Could not reload app workspaced route setting"); } - }, + } OTEL_SETTING => { tracing::info!("OTEL setting changed, restarting"); send_delayed_killpill(tx, 4, "OTEL setting change").await; - }, + } REQUEST_SIZE_LIMIT_SETTING => { if server_mode { tracing::info!("Request limit size change detected, killing server expecting to be restarted"); send_delayed_killpill(tx, 4, "request size limit change").await; } - }, + } SAML_METADATA_SETTING => { - tracing::info!("SAML metadata change detected, killing server expecting to be restarted"); + tracing::info!( + "SAML metadata change detected, killing server expecting to be restarted" + ); send_delayed_killpill(tx, 0, "SAML metadata change").await; - }, + } HUB_BASE_URL_SETTING => { if let Err(e) = reload_hub_base_url_setting(conn, server_mode).await { tracing::error!(error = %e, "Could not reload hub base url setting"); } - }, + } CRITICAL_ERROR_CHANNELS_SETTING => { if let Err(e) = reload_critical_error_channels_setting(db).await { tracing::error!(error = %e, "Could not reload critical error emails setting"); } - }, + } CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING => { if let Err(e) = reload_critical_alerts_on_db_oversize(db).await { tracing::error!(error = %e, "Could not reload critical alerts on db oversize setting"); } - }, + } JWT_SECRET_SETTING => { if let Err(e) = reload_jwt_secret_setting(db).await { tracing::error!(error = %e, "Could not reload jwt secret setting"); } - }, + } CRITICAL_ALERT_MUTE_UI_SETTING => { tracing::info!("Critical alert UI setting changed"); if let Err(e) = reload_critical_alert_mute_ui_setting(conn).await { tracing::error!(error = %e, "Could not reload critical alert UI setting"); } - }, + } _ => { tracing::info!("Unrecognized Global Setting Change Payload: {:?}", payload); } } - }, + } _ => { tracing::warn!("Unknown notification channel: {}", channel); } diff --git a/backend/windmill-api/Cargo.toml b/backend/windmill-api/Cargo.toml index 16a0bc3a16..f0672666d0 100644 --- a/backend/windmill-api/Cargo.toml +++ b/backend/windmill-api/Cargo.toml @@ -169,6 +169,7 @@ tar.workspace = true flate2.workspace = true backon = {workspace = true, optional = true} strum = { workspace = true, optional = true } +dashmap.workspace = true [build-dependencies] deno_core = { workspace = true, optional = true } diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 41ef3babb4..4ca0fc21f4 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -2383,6 +2383,9 @@ paths: type: string operator_settings: $ref: "#/components/schemas/OperatorSettings" + public_app_execution_limit_per_minute: + type: integer + description: Rate limit for public app executions per minute per server. NULL or 0 means disabled. /w/{workspace}/workspaces/get_deploy_to: get: @@ -4152,6 +4155,35 @@ paths: type: string example: "Updated mute critical alert UI settings for workspace: workspace_id" + /w/{workspace}/workspaces/public_app_rate_limit: + post: + summary: Set public app rate limit for this workspace + operationId: setPublicAppRateLimit + tags: + - setting + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + description: Public app rate limit configuration + required: true + content: + application/json: + schema: + type: object + properties: + public_app_execution_limit_per_minute: + type: integer + description: Rate limit for public app executions per minute per server. NULL or 0 to disable. + example: 100 + responses: + "200": + description: Successfully updated public app rate limit settings. + content: + application/json: + schema: + type: string + example: "Updated public app rate limit for workspace: workspace_id" + /oauth/login_callback/{client_name}: post: security: [] @@ -5403,6 +5435,34 @@ paths: required: - app + /apps/hub/get_raw/{id}: + get: + summary: get hub raw app by id + operationId: getHubRawAppById + tags: + - app + parameters: + - $ref: "#/components/parameters/PathId" + responses: + "200": + description: raw app + content: + application/json: + schema: + type: object + properties: + app: + type: object + properties: + summary: + type: string + value: {} + required: + - summary + - value + required: + - app + /apps_u/public_app_by_custom_path/{custom_path}: get: summary: get public app by custom path diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index cf26dfd8a0..9d2d6d9db3 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -126,6 +126,7 @@ pub fn global_service() -> Router { Router::new() .route("/hub/list", get(list_hub_apps)) .route("/hub/get/:id", get(get_hub_app_by_id)) + .route("/hub/get_raw/:id", get(get_hub_raw_app_by_id)) } #[derive(FromRow, Deserialize, Serialize)] @@ -1312,6 +1313,24 @@ pub async fn get_hub_app_by_id( Ok(Json(value)) } +pub async fn get_hub_raw_app_by_id( + Path(id): Path, + Extension(db): Extension, +) -> JsonResult> { + let value = http_get_from_hub( + &HTTP_CLIENT, + &format!("{}/raw_apps/{}/json", *HUB_BASE_URL.read().await, id), + false, + None, + Some(&db), + ) + .await? + .json() + .await + .map_err(to_anyhow)?; + Ok(Json(value)) +} + async fn delete_app( authed: ApiAuthed, Extension(db): Extension, @@ -1910,6 +1929,15 @@ async fn execute_component( } }; + // Check rate limit for anonymous (public) executions + if matches!(policy.execution_mode, ExecutionMode::Anonymous) && opt_authed.is_none() { + if let Some(limit) = crate::workspaces::get_public_app_rate_limit(&db, &w_id).await? { + if limit > 0 { + crate::public_app_rate_limit::check_and_increment(&w_id, limit)?; + } + } + } + // Execution is publisher and an user is authenticated: check if the user is authorized to // execute the app. if let (ExecutionMode::Publisher, Some(authed)) = (policy.execution_mode, opt_authed.as_ref()) { diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 8a44ee8360..a78b6fa2a3 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -167,6 +167,7 @@ mod teams_approvals_oss; #[cfg(feature = "native_trigger")] pub mod native_triggers; mod public_app_layer; +mod public_app_rate_limit; mod static_assets; #[cfg(all(feature = "stripe", feature = "enterprise", feature = "private"))] pub mod stripe_ee; diff --git a/backend/windmill-api/src/public_app_rate_limit.rs b/backend/windmill-api/src/public_app_rate_limit.rs new file mode 100644 index 0000000000..bff89d694b --- /dev/null +++ b/backend/windmill-api/src/public_app_rate_limit.rs @@ -0,0 +1,48 @@ +/* + * Author: Windmill Labs, Inc + * Copyright: Windmill Labs, Inc 2024 + * This file and its contents are licensed under the AGPLv3 License. + * Please see the included NOTICE for copyright information and + * LICENSE-AGPL for a copy of the license. + */ + +use chrono::Utc; +use dashmap::DashMap; +use hyper::StatusCode; +use std::sync::LazyLock; +use windmill_common::error::{Error, Result}; + +struct RateLimitEntry { + count: i32, + minute_bucket: i64, +} + +static RATE_LIMIT_COUNTER: LazyLock> = + LazyLock::new(DashMap::new); + +pub fn check_and_increment(workspace_id: &str, limit: i32) -> Result<()> { + let current_minute = Utc::now().timestamp() / 60; + + let mut entry = RATE_LIMIT_COUNTER + .entry(workspace_id.to_string()) + .or_insert(RateLimitEntry { count: 0, minute_bucket: current_minute }); + + if entry.minute_bucket != current_minute { + entry.count = 0; + entry.minute_bucket = current_minute; + } + + if entry.count >= limit { + return Err(Error::Generic( + StatusCode::TOO_MANY_REQUESTS, + format!( + "Rate limit exceeded for public app executions in workspace '{}'. \ + Limit: {} per minute per server.", + workspace_id, limit + ), + )); + } + + entry.count += 1; + Ok(()) +} diff --git a/backend/windmill-api/src/workspaces.rs b/backend/windmill-api/src/workspaces.rs index 633a39d996..93c524f5fc 100644 --- a/backend/windmill-api/src/workspaces.rs +++ b/backend/windmill-api/src/workspaces.rs @@ -176,6 +176,7 @@ pub fn workspaced_service() -> Router { post(acknowledge_all_critical_alerts), ) .route("/critical_alerts/mute", post(mute_critical_alerts)) + .route("/public_app_rate_limit", post(edit_public_app_rate_limit)) .route("/operator_settings", post(update_operator_settings)) .route( "/create_workspace_fork_branch", @@ -287,6 +288,8 @@ pub struct WorkspaceSettings { pub error_handler: Option, #[serde(skip_serializing_if = "Option::is_none")] pub success_handler: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub public_app_execution_limit_per_minute: Option, } /// #[derive(sqlx::Type, Serialize, Deserialize, Debug)] @@ -625,7 +628,8 @@ async fn get_settings( git_app_installations, auto_invite, error_handler, - success_handler + success_handler, + public_app_execution_limit_per_minute FROM workspace_settings WHERE @@ -4401,6 +4405,72 @@ pub async fn mute_critical_alerts() -> Error { Error::NotFound("Critical Alerts require EE".to_string()) } +#[derive(Deserialize)] +pub struct EditPublicAppRateLimitRequest { + pub public_app_execution_limit_per_minute: Option, +} + +async fn edit_public_app_rate_limit( + Extension(db): Extension, + Path(w_id): Path, + authed: ApiAuthed, + Json(req): Json, +) -> Result { + require_admin(authed.is_admin, &authed.username)?; + + sqlx::query!( + "UPDATE workspace_settings SET public_app_execution_limit_per_minute = $1 WHERE workspace_id = $2", + req.public_app_execution_limit_per_minute, + &w_id + ) + .execute(&db) + .await?; + + // Cache is invalidated via DB trigger -> notify_event -> polling in main.rs + + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + DeployedObject::Settings { setting_type: "public_app_rate_limit".to_string() }, + None, + false, + None, + ) + .await?; + + Ok(format!( + "Updated public app rate limit for workspace: {}", + &w_id + )) +} + +// 5 minutes fallback TTL (in addition to event-based invalidation) +const PUBLIC_APP_RATE_LIMIT_CACHE_TTL_SECS: i64 = 300; + +pub async fn get_public_app_rate_limit(db: &DB, w_id: &str) -> Result> { + use windmill_common::workspaces::PUBLIC_APP_RATE_LIMIT_CACHE; + + let now = Utc::now().timestamp(); + + if let Some((rate_limit, cached_at)) = PUBLIC_APP_RATE_LIMIT_CACHE.get(w_id) { + if now - cached_at < PUBLIC_APP_RATE_LIMIT_CACHE_TTL_SECS { + return Ok(rate_limit); + } + } + + let result: Option> = sqlx::query_scalar( + "SELECT public_app_execution_limit_per_minute FROM workspace_settings WHERE workspace_id = $1", + ) + .bind(w_id) + .fetch_optional(db) + .await?; + let rate_limit = result.flatten(); + PUBLIC_APP_RATE_LIMIT_CACHE.insert(w_id.to_string(), (rate_limit, now)); + Ok(rate_limit) +} + #[derive(Deserialize, Serialize)] struct ChangeOperatorSettings { #[serde(default)] diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 3668d86978..4cbb569431 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -119,6 +119,8 @@ pub struct TeamPlanStatus { lazy_static::lazy_static! { pub static ref TEAM_PLAN_CACHE: Cache = Cache::new(5000); + // Value: (rate_limit, cached_at_timestamp) + pub static ref PUBLIC_APP_RATE_LIMIT_CACHE: Cache, i64)> = Cache::new(1000); } #[cfg(feature = "cloud")] diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a882369b69..995257f2f3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -47,6 +47,7 @@ "hash-sum": "^2.0.0", "highlight.js": "^11.8.0", "idb": "^8.0.2", + "jszip": "^3.10.1", "lru-cache": "^11.1.0", "lucide-svelte": "^0.540.0", "minimatch": "^10.0.1", @@ -4300,6 +4301,12 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cosmiconfig": { "version": "8.3.6", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", @@ -6902,7 +6909,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "devOptional": true, "license": "ISC" }, "node_modules/ini": { @@ -7102,6 +7108,12 @@ "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", "license": "MIT" }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -7302,6 +7314,54 @@ "node": ">=0.10.0" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -7542,6 +7602,21 @@ "url": "https://github.com/sponsors/dmonad" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lie/node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.30.2", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", @@ -10744,6 +10819,12 @@ "node": ">= 0.6.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -11549,6 +11630,12 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -13282,7 +13369,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "devOptional": true, "license": "MIT" }, "node_modules/validate-npm-package-license": { diff --git a/frontend/package.json b/frontend/package.json index 3c3b559ab1..4c6875d1ee 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -117,6 +117,7 @@ "hash-sum": "^2.0.0", "highlight.js": "^11.8.0", "idb": "^8.0.2", + "jszip": "^3.10.1", "lru-cache": "^11.1.0", "lucide-svelte": "^0.540.0", "minimatch": "^10.0.1", diff --git a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte index 0f2f615429..747a2797b5 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte @@ -4,13 +4,17 @@ import UndoRedo from '$lib/components/common/button/UndoRedo.svelte' import { AppService, DraftService, type Policy } from '$lib/gen' - import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores' + import { rawAppToHubUrl } from '$lib/hub' + import { enterpriseLicense, hubBaseUrlStore, userStore, workspaceStore } from '$lib/stores' + import JSZip from 'jszip' + import YAML from 'yaml' import { Bug, DiffIcon, + Download, EllipsisVertical, FileJson, - FileUp, + Globe, History, Pen, Save, @@ -143,8 +147,40 @@ let draftDrawerOpen = $state(false) let saveDrawerOpen = $state(false) let historyBrowserDrawerOpen = $state(false) + let publishToHubDrawerOpen = $state(false) + let publishingToHub = $state(false) let deploymentMsg: string | undefined = $state(undefined) + async function publishToHub() { + if (!app) return + publishingToHub = true + try { + const { js, css } = await getBundle() + const zip = new JSZip() + zip.file('app.yaml', YAML.stringify(app)) + zip.file('bundle.js', js) + zip.file('bundle.css', css) + const blob = await zip.generateAsync({ type: 'blob' }) + + // Download the zip + const url = window.URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `${(appPath || 'raw-app').replaceAll('/', '__')}.zip` + a.click() + setTimeout(() => URL.revokeObjectURL(url), 100) + + // Open hub page + const hubUrl = rawAppToHubUrl( + $hubBaseUrlStore, + summary || appPath.split('/').pop()?.replace('_', ' ') || 'my raw app' + ) + window.open(hubUrl.toString(), '_blank') + } finally { + publishingToHub = false + } + } + function closeSaveDrawer() { saveDrawerOpen = false } @@ -538,11 +574,10 @@ } }, { - displayName: 'Hub compatible JSON', - icon: FileUp, + displayName: 'Publish to Hub', + icon: Globe, action: () => { - sendUserToast('todo') - // appExport.open(toStatic(app, $staticExporter, summary).app) + publishToHubDrawerOpen = true } }, { @@ -728,6 +763,42 @@ + + (publishToHubDrawerOpen = false)}> + {#snippet actions()} + + {/snippet} +
+

+ This will download a zip file containing your raw app bundle and open the Windmill Hub + submission page. +

+
+

The zip file will contain:

+
    +
  • app.yaml - App configuration
  • +
  • bundle.js - JavaScript bundle
  • +
  • bundle.css - CSS styles
  • +
+
+
+
+
+ { diff --git a/frontend/src/lib/hub.ts b/frontend/src/lib/hub.ts index 80a92cc245..fcaa2ab832 100644 --- a/frontend/src/lib/hub.ts +++ b/frontend/src/lib/hub.ts @@ -83,6 +83,14 @@ export function appToHubUrl(staticApp: any, hubBaseUrl: string): URL { return url } +export function rawAppToHubUrl(hubBaseUrl: string, summary?: string): URL { + const url = new URL(hubBaseUrl + '/raw_apps/add') + if (summary) { + url.searchParams.append('summary', summary) + } + return url +} + type HubPaths = { gitSync: string gitSyncTest: string diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte index a837c47b0b..6181204e3b 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte @@ -39,13 +39,14 @@ let nodraft = $page.url.searchParams.get('nodraft') const templatePath = $page.url.searchParams.get('template') const templateId = $page.url.searchParams.get('template_id') + const hubId = $page.url.searchParams.get('hub') const importRaw = $importStore if ($importStore) { $importStore = undefined } - const appState = nodraft ? undefined : localStorage.getItem('rawapp') + const appState = nodraft || hubId ? undefined : localStorage.getItem('rawapp') let summary = $state('') let files: Record = $state(react19Template) @@ -143,7 +144,18 @@ console.log('App loaded from template id') sendUserToast('App loaded from template') goto('?', { replaceState: true }) - } else if (!templatePath && appState) { + } else if (hubId) { + const hub = await AppService.getHubRawAppById({ id: Number(hubId) }) + if (hub.app?.value) { + extractValue(hub.app.value) + } + if (hub.app?.summary) { + summary = hub.app.summary + } + console.log('App loaded from Hub') + sendUserToast('App loaded from Hub') + goto('?', { replaceState: true }) + } else if (!templatePath && !hubId && appState) { console.log('App loaded from browser stored autosave') sendUserToast('App restored from browser stored autosave', false, [ { diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte index 18ff4a394a..b59dcaaccf 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte @@ -102,6 +102,8 @@ let successHandlerScriptPath: string | undefined = $state(undefined) let criticalAlertUIMuted: boolean | undefined = $state(undefined) let initialCriticalAlertUIMuted: boolean | undefined = $state(undefined) + let publicAppRateLimitPerMinute: number | undefined = $state(undefined) + let initialPublicAppRateLimitPerMinute: number | undefined = $state(undefined) let aiProviders: Exclude = $state({}) let codeCompletionModel: string | undefined = $state(undefined) @@ -347,6 +349,8 @@ errorHandlerMutedOnUserPath = errorHandler?.muted_on_user_path criticalAlertUIMuted = settings.mute_critical_alerts initialCriticalAlertUIMuted = settings.mute_critical_alerts + publicAppRateLimitPerMinute = settings.public_app_execution_limit_per_minute ?? undefined + initialPublicAppRateLimitPerMinute = settings.public_app_execution_limit_per_minute ?? undefined if (emptyString($enterpriseLicense)) { errorHandlerSelected = 'custom' } else { @@ -561,6 +565,21 @@ }, 3000) } + async function editPublicAppRateLimit() { + await SettingService.setPublicAppRateLimit({ + workspace: $workspaceStore!, + requestBody: { + public_app_execution_limit_per_minute: publicAppRateLimitPerMinute + } + }) + initialPublicAppRateLimitPerMinute = publicAppRateLimitPerMinute + sendUserToast( + publicAppRateLimitPerMinute + ? `Public app rate limit set to ${publicAppRateLimitPerMinute} per minute per server` + : `Public app rate limit disabled` + ) + } + // Function to check if there are unsaved changes in AI settings function getAiSettingsInitialAndModifiedValues() { // Only check for unsaved changes when on the AI tab @@ -764,9 +783,9 @@ {/key} + +
+
+
+ + executions per minute per server +
+ + +
{:else if tab == 'native_triggers'} {#if $workspaceStore} {#await import('$lib/components/workspaceSettings/WorkspaceIntegrations.svelte') then { default: WorkspaceIntegrations }}