feat: public app rate limiting + fork hub raw apps + raw apps publish to hub button (#7789)

* feat: public app rate limiting + fork hub raw apps + raw apps publish to hub button

* sqlx

* missing sqlx file

* cache rate limiting

---------

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
hugocasa
2026-02-04 18:53:55 +01:00
committed by GitHub
parent db56518e4f
commit 63f9d85bf6
20 changed files with 813 additions and 121 deletions
@@ -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
]
},
@@ -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"
}
@@ -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"
}
+1
View File
@@ -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",
@@ -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;
@@ -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();
+117 -107
View File
@@ -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::<Vec<&str>>().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);
}
+1
View File
@@ -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 }
+60
View File
@@ -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
+28
View File
@@ -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<i32>,
Extension(db): Extension<DB>,
) -> JsonResult<Box<serde_json::value::RawValue>> {
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<DB>,
@@ -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()) {
+1
View File
@@ -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;
@@ -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<DashMap<String, RateLimitEntry>> =
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(())
}
+71 -1
View File
@@ -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_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub success_handler: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub public_app_execution_limit_per_minute: Option<i32>,
}
/// #[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<i32>,
}
async fn edit_public_app_rate_limit(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
authed: ApiAuthed,
Json(req): Json<EditPublicAppRateLimitRequest>,
) -> Result<String> {
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<Option<i32>> {
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<Option<i32>> = 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)]
@@ -119,6 +119,8 @@ pub struct TeamPlanStatus {
lazy_static::lazy_static! {
pub static ref TEAM_PLAN_CACHE: Cache<String, TeamPlanStatus> = Cache::new(5000);
// Value: (rate_limit, cached_at_timestamp)
pub static ref PUBLIC_APP_RATE_LIMIT_CACHE: Cache<String, (Option<i32>, i64)> = Cache::new(1000);
}
#[cfg(feature = "cloud")]
+88 -2
View File
@@ -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": {
+1
View File
@@ -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",
@@ -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 @@
</DrawerContent>
</Drawer>
<Drawer bind:open={publishToHubDrawerOpen} size="600px">
<DrawerContent title="Publish to Hub" on:close={() => (publishToHubDrawerOpen = false)}>
{#snippet actions()}
<Button
loading={publishingToHub}
disabled={!app}
on:click={publishToHub}
variant="accent"
startIcon={{ icon: Download }}
>
Download & open hub
</Button>
{/snippet}
<div class="flex flex-col gap-4">
<p class="text-secondary text-sm">
This will download a zip file containing your raw app bundle and open the Windmill Hub
submission page.
</p>
<div class="text-sm">
<p class="font-semibold mb-2">The zip file will contain:</p>
<ul class="list-disc list-inside text-secondary space-y-1">
<li
><code class="text-xs bg-surface-secondary px-1 rounded">app.yaml</code> - App configuration</li
>
<li
><code class="text-xs bg-surface-secondary px-1 rounded">bundle.js</code> - JavaScript bundle</li
>
<li
><code class="text-xs bg-surface-secondary px-1 rounded">bundle.css</code> - CSS styles</li
>
</ul>
</div>
</div>
</DrawerContent>
</Drawer>
<AppJobsDrawer
bind:open={jobsDrawerOpen}
on:clear={() => {
+8
View File
@@ -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
@@ -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<string, string> = $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, [
{
@@ -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<AIConfig['providers'], undefined> = $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 @@
<Tab
small
value="default_app"
aiId="workspace-settings-default-app"
aiDescription="Default app workspace settings"
label="Default App"
aiId="workspace-settings-apps"
aiDescription="Apps workspace settings"
label="Apps"
/>
<Tab
@@ -1405,6 +1424,33 @@ export async function main(
/>
{/key}
</div>
<hr class="border-t my-8" />
<Section
label="Public App Rate Limiting"
description="Limit the number of public (anonymous) app executions per minute per server. Set to 0 or leave empty to disable. This is a per-server limit, not a global limit."
class="flex flex-col gap-6"
>
<div class="flex flex-row items-center gap-4">
<TextInput
inputProps={{ type: 'number', placeholder: '0 (disabled)' }}
bind:value={publicAppRateLimitPerMinute}
class="w-48"
/>
<span class="text-secondary text-sm">executions per minute per server</span>
</div>
<Button
disabled={publicAppRateLimitPerMinute === initialPublicAppRateLimitPerMinute}
size="sm"
on:click={editPublicAppRateLimit}
variant="default"
startIcon={{ icon: Save }}
btnClasses="w-fit"
>
Save rate limit
</Button>
</Section>
{:else if tab == 'native_triggers'}
{#if $workspaceStore}
{#await import('$lib/components/workspaceSettings/WorkspaceIntegrations.svelte') then { default: WorkspaceIntegrations }}