mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
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:
+6
@@ -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
|
||||
]
|
||||
},
|
||||
|
||||
+15
@@ -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"
|
||||
}
|
||||
+202
@@ -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"
|
||||
}
|
||||
Generated
+1
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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")]
|
||||
|
||||
Generated
+88
-2
@@ -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": {
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
Reference in New Issue
Block a user