diff --git a/backend/.sqlx/query-87ee10d8ba5ba281781f23e5390190fb90df980a19c900452f9b1a19c3620e30.json b/backend/.sqlx/query-87ee10d8ba5ba281781f23e5390190fb90df980a19c900452f9b1a19c3620e30.json new file mode 100644 index 0000000000..aaad5153bd --- /dev/null +++ b/backend/.sqlx/query-87ee10d8ba5ba281781f23e5390190fb90df980a19c900452f9b1a19c3620e30.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n route_path,\n workspace_id,\n http_method::TEXT AS \"http_method!\"\n FROM\n http_trigger\n WHERE\n workspaced_route IS FALSE\n AND route_path_key IN (\n SELECT\n route_path_key\n FROM\n http_trigger\n WHERE\n workspaced_route IS FALSE\n GROUP BY\n route_path_key, http_method\n HAVING COUNT(*) > 1\n )\n ORDER BY route_path_key\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "route_path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "http_method!", + "type_info": "Text" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + null + ] + }, + "hash": "87ee10d8ba5ba281781f23e5390190fb90df980a19c900452f9b1a19c3620e30" +} diff --git a/backend/src/main.rs b/backend/src/main.rs index bba51ada6b..ad5b9e14b8 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -44,8 +44,8 @@ use windmill_common::{ CRITICAL_ERROR_CHANNELS_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS, EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING, EXTRA_PIP_INDEX_URL_SETTING, - HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, INDEXER_SETTING, - INSTANCE_EVENTS_WEBHOOK_SETTING, INSTANCE_PYTHON_VERSION_SETTING, + HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, + INDEXER_SETTING, INSTANCE_EVENTS_WEBHOOK_SETTING, INSTANCE_PYTHON_VERSION_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MAVEN_REPOS_SETTING, MAVEN_SETTINGS_XML_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NO_DEFAULT_MAVEN_SETTING, @@ -104,10 +104,10 @@ use crate::monitor::{ reload_base_url_setting, reload_bunfig_install_scopes_setting, reload_critical_alert_mute_ui_setting, reload_critical_alerts_on_token_expiry_setting, reload_critical_error_channels_setting, reload_extra_pip_index_url_setting, - reload_hub_api_secret_setting, reload_hub_base_url_setting, - reload_instance_events_webhook_setting, reload_job_default_timeout_setting, - reload_job_isolation_setting, reload_jwt_secret_setting, reload_license_key, - reload_npm_config_registry_setting, reload_otel_tracing_proxy_setting, + reload_http_route_workspaced_route_setting, reload_hub_api_secret_setting, + reload_hub_base_url_setting, reload_instance_events_webhook_setting, + reload_job_default_timeout_setting, reload_job_isolation_setting, reload_jwt_secret_setting, + reload_license_key, reload_npm_config_registry_setting, reload_otel_tracing_proxy_setting, reload_pip_index_url_setting, reload_retention_period_setting, reload_scim_token_setting, reload_smtp_config, reload_uv_index_strategy_setting, reload_worker_config, MonitorIteration, }; @@ -1814,6 +1814,23 @@ async fn process_notify_event( tracing::error!(error = %e, "Could not reload app workspaced route setting"); } } + HTTP_ROUTE_WORKSPACED_ROUTE_SETTING => { + if let Err(e) = reload_http_route_workspaced_route_setting(db).await { + tracing::error!(error = %e, "Could not reload http route workspaced route setting"); + } + #[cfg(feature = "http_trigger")] + match windmill_api::triggers::http::refresh_routers(db).await { + Ok((true, _)) => { + tracing::info!( + "Refreshed HTTP routers (http workspaced route setting change)" + ); + } + Err(err) => { + tracing::error!("Error refreshing HTTP routers (http workspaced route setting change): {err:#}"); + } + _ => {} + } + } AI_CONFIG_SETTING => { tracing::info!("AI config setting changed, bumping instance AI cache revision"); bump_instance_ai_config_revision(); diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 082789ef4f..d7f5784b50 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -88,7 +88,13 @@ use windmill_common::{ MONITOR_LOGS_ON_OBJECT_STORE, OTEL_LOGS_ENABLED, OTEL_METRICS_ENABLED, OTEL_TRACING_ENABLED, SERVICE_LOG_RETENTION_SECS, }; -use windmill_common::{client::AuthedClient, global_settings::APP_WORKSPACED_ROUTE_SETTING}; +use windmill_common::{ + client::AuthedClient, + global_settings::{ + APP_WORKSPACED_ROUTE_SETTING, HTTP_ROUTE_WORKSPACED_ROUTE, + HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, + }, +}; #[cfg(feature = "parquet")] use windmill_object_store::reload_object_store_setting; use windmill_queue::{cancel_job, get_queued_job_v2, SameWorkerPayload}; @@ -296,6 +302,10 @@ pub async fn initial_load( if let Err(e) = reload_app_workspaced_route_setting(db).await { tracing::error!("Error reloading app workspaced route: {:?}", e) } + + if let Err(e) = reload_http_route_workspaced_route_setting(db).await { + tracing::error!("Error reloading http route workspaced route: {:?}", e) + } } #[cfg(feature = "parquet")] @@ -3390,6 +3400,39 @@ pub async fn reload_app_workspaced_route_setting(conn: &DB) -> error::Result<()> Ok(()) } +pub async fn reload_http_route_workspaced_route_setting(conn: &DB) -> error::Result<()> { + let http_route_workspaced_route = + load_value_from_global_settings(conn, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING).await?; + + let ws_route = match http_route_workspaced_route { + Some(serde_json::Value::Bool(ws_route)) => ws_route, + None => false, + _ => { + tracing::error!( + "Expected {} to be a boolean got: {:?}. Defaulting to false", + HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, + http_route_workspaced_route + ); + false + } + }; + + let mut l = HTTP_ROUTE_WORKSPACED_ROUTE.write().await; + + if *l != ws_route { + *l = ws_route; + drop(l); + // Bump the HTTP trigger version so the route cache is rebuilt with + // the updated workspaced_route behavior on the next request. + sqlx::query!("SELECT nextval('http_trigger_version_seq')") + .fetch_one(conn) + .await?; + } else { + *l = ws_route; + } + Ok(()) +} + pub async fn reload_critical_alerts_on_db_oversize(conn: &DB) -> error::Result<()> { #[derive(Deserialize)] struct DBOversize { diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index d1b52a8cc3..a1ea80f173 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -45,8 +45,8 @@ use windmill_common::{ global_settings::{ AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, DISABLE_HUB_SETTING, - EMAIL_DOMAIN_SETTING, ENV_SETTINGS, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING, - WS_BASE_URL_SETTING, + EMAIL_DOMAIN_SETTING, ENV_SETTINGS, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, + HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING, WS_BASE_URL_SETTING, }, instance_config::{self, ApplyMode, InstanceConfig}, server::Smtp, @@ -424,6 +424,74 @@ async fn run_setting_pre_write_hook( } } } + HTTP_ROUTE_WORKSPACED_ROUTE_SETTING => { + let serde_json::Value::Bool(workspaced_route) = value else { + return Err(error::Error::BadRequest(format!( + "{} setting expected to be boolean", + HTTP_ROUTE_WORKSPACED_ROUTE_SETTING + ))); + }; + + if !*workspaced_route { + #[derive(Debug, Deserialize, Serialize)] + #[allow(unused)] + struct DuplicateRoute { + route_path: String, + workspace_id: String, + http_method: String, + } + let duplicate_routes = sqlx::query_as!( + DuplicateRoute, + r#" + SELECT + route_path, + workspace_id, + http_method::TEXT AS "http_method!" + FROM + http_trigger + WHERE + workspaced_route IS FALSE + AND route_path_key IN ( + SELECT + route_path_key + FROM + http_trigger + WHERE + workspaced_route IS FALSE + GROUP BY + route_path_key, http_method + HAVING COUNT(*) > 1 + ) + ORDER BY route_path_key + "# + ) + .fetch_all(db) + .await?; + + if !duplicate_routes.is_empty() { + tracing::error!( + "Cannot disable {} setting as duplicate http routes were found: {:?}", + HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, + &duplicate_routes + ); + + #[derive(Serialize)] + struct ErrorResponse { + error: String, + details: Vec, + } + + let error_response = ErrorResponse { + error: "Duplicate HTTP route paths detected".to_string(), + details: duplicate_routes, + }; + + return Err(error::Error::JsonErr( + serde_json::to_value(error_response).unwrap(), + )); + } + } + } _ => {} } Ok(()) @@ -541,6 +609,7 @@ pub async fn get_global_setting( && key != DISABLE_HUB_SETTING && key != EMAIL_DOMAIN_SETTING && key != APP_WORKSPACED_ROUTE_SETTING + && key != HTTP_ROUTE_WORKSPACED_ROUTE_SETTING && key != WS_BASE_URL_SETTING { require_super_admin(&db, &authed.email).await?; diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index a473c06ebc..31aadb8210 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -58,12 +58,20 @@ pub const EMAIL_DOMAIN_SETTING: &str = "email_domain"; pub const OTEL_SETTING: &str = "otel"; pub const OTEL_TRACING_PROXY_SETTING: &str = "otel_tracing_proxy"; pub const APP_WORKSPACED_ROUTE_SETTING: &str = "app_workspaced_route"; +pub const HTTP_ROUTE_WORKSPACED_ROUTE_SETTING: &str = "http_route_workspaced_route"; pub const SECRET_BACKEND_SETTING: &str = "secret_backend"; pub const MIN_KEEP_ALIVE_VERSION_SETTING: &str = "min_keep_alive_version"; pub const GITHUB_ENTERPRISE_APP_SETTING: &str = "github_enterprise_app"; pub const INSTANCE_EVENTS_WEBHOOK_SETTING: &str = "instance_events_webhook"; pub const WORKSPACE_REGISTRIES_SETTING: &str = "workspace_registries"; +use std::sync::Arc; +use tokio::sync::RwLock; + +lazy_static::lazy_static! { + pub static ref HTTP_ROUTE_WORKSPACED_ROUTE: Arc> = Arc::new(RwLock::new(false)); +} + pub const ENV_SETTINGS: &[&str] = &[ "DISABLE_NSJAIL", "MODE", diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index 63dde895fa..c843cc6621 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -237,6 +237,8 @@ pub struct GlobalSettings { #[serde(skip_serializing_if = "Option::is_none")] pub app_workspaced_route: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub http_route_workspaced_route: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub no_default_maven: Option, #[serde(skip_serializing_if = "Option::is_none")] pub default_tags_per_workspace: Option, diff --git a/backend/windmill-trigger-http/src/handler.rs b/backend/windmill-trigger-http/src/handler.rs index c7ca7431fd..f6ca4739da 100644 --- a/backend/windmill-trigger-http/src/handler.rs +++ b/backend/windmill-trigger-http/src/handler.rs @@ -8,6 +8,7 @@ use sqlx::PgConnection; use std::collections::HashSet; use windmill_api_auth::ApiAuthed; use windmill_audit::{audit_oss::audit_log, ActionKind}; +use windmill_common::global_settings::HTTP_ROUTE_WORKSPACED_ROUTE; use windmill_common::{ db::UserDB, error::{Error, Result}, @@ -61,11 +62,12 @@ pub async fn route_path_key_exists( .await? .unwrap_or(false) } else { - let route_path_key = match workspaced_route { - Some(true) => { - std::borrow::Cow::Owned(format!("{}/{}", w_id, route_path_key.trim_matches('/'))) - } - _ => std::borrow::Cow::Borrowed(route_path_key), + let http_route_workspaced = *HTTP_ROUTE_WORKSPACED_ROUTE.read().await; + let effective_workspaced = workspaced_route.unwrap_or(false) || http_route_workspaced; + let route_path_key = if effective_workspaced { + std::borrow::Cow::Owned(format!("{}/{}", w_id, route_path_key.trim_matches('/'))) + } else { + std::borrow::Cow::Borrowed(route_path_key) }; sqlx::query_scalar!( @@ -146,6 +148,10 @@ pub async fn insert_new_trigger_into_db( ) -> Result<()> { require_admin(authed.is_admin, &authed.username)?; + let http_route_workspaced = *HTTP_ROUTE_WORKSPACED_ROUTE.read().await; + let effective_workspaced = + trigger.config.workspaced_route.unwrap_or(false) || http_route_workspaced; + let request_type = trigger.config.request_type; let resolved_edited_by = trigger.base.resolve_edited_by(authed); let resolved_permissioned_as = trigger.base.resolve_permissioned_as(authed); @@ -186,7 +192,7 @@ pub async fn insert_new_trigger_into_db( trigger.base.path, trigger.config.route_path, route_path_key, - trigger.config.workspaced_route.unwrap_or(false), + effective_workspaced, trigger.config.authentication_resource_path, trigger.config.wrap_body.unwrap_or(false), trigger.config.raw_string.unwrap_or(false), @@ -445,6 +451,10 @@ impl TriggerCrud for HttpTrigger { let route_path_key = check_if_route_exist(db, &trigger.config, workspace_id, Some(path)).await?; + let http_route_workspaced = *HTTP_ROUTE_WORKSPACED_ROUTE.read().await; + let effective_workspaced = + trigger.config.workspaced_route.unwrap_or(false) || http_route_workspaced; + let request_type = trigger.config.request_type; sqlx::query!( @@ -481,7 +491,7 @@ impl TriggerCrud for HttpTrigger { "#, route_path, &route_path_key, - trigger.config.workspaced_route, + Some(effective_workspaced), trigger.config.wrap_body, trigger.config.raw_string, trigger.config.authentication_resource_path, diff --git a/backend/windmill-trigger-http/src/lib.rs b/backend/windmill-trigger-http/src/lib.rs index f5fd563ab3..f851dfc12e 100644 --- a/backend/windmill-trigger-http/src/lib.rs +++ b/backend/windmill-trigger-http/src/lib.rs @@ -7,6 +7,7 @@ use tokio::sync::{RwLock, RwLockReadGuard}; use windmill_common::{ error::{Error, Result}, flows::Retry, + global_settings::HTTP_ROUTE_WORKSPACED_ROUTE, utils::ExpiringCacheEntry, worker::CLOUD_HOSTED, DB, @@ -273,13 +274,15 @@ pub async fn refresh_routers(db: &DB) -> Result<(bool, RwLockReadGuard<'_, Route .await?; let mut router = matchit::Router::new(); + let http_route_workspaced = *HTTP_ROUTE_WORKSPACED_ROUTE.read().await; for trigger in triggers { - let full_path = if trigger.workspaced_route || *CLOUD_HOSTED { - format!("/{}/{}", trigger.workspace_id, trigger.route_path) - } else { - format!("/{}", trigger.route_path) - }; + let full_path = + if trigger.workspaced_route || *CLOUD_HOSTED || http_route_workspaced { + format!("/{}/{}", trigger.workspace_id, trigger.route_path) + } else { + format!("/{}", trigger.route_path) + }; if trigger.is_static_website { router diff --git a/frontend/src/lib/components/instanceSettings.ts b/frontend/src/lib/components/instanceSettings.ts index 438c09fdb5..c6096fab7b 100644 --- a/frontend/src/lib/components/instanceSettings.ts +++ b/frontend/src/lib/components/instanceSettings.ts @@ -196,6 +196,16 @@ export const settings: Record = { ee_only: '', hideInQuickSetup: true }, + { + label: 'HTTP route workspace prefix', + description: + 'When enabled HTTP routes will be accessible at /api/r/{workspace_id}/{route} instead of /api/r/{route} allowing you to define same route path in different workspaces without conflict', + key: 'http_route_workspaced_route', + fieldType: 'boolean', + storage: 'setting', + ee_only: '', + hideInQuickSetup: true + }, { label: 'Audit log retention (days)', key: 'audit_log_retention_days', diff --git a/frontend/src/lib/components/triggers/http/RouteEditorConfigSection.svelte b/frontend/src/lib/components/triggers/http/RouteEditorConfigSection.svelte index cbbaef9395..5642ea784a 100644 --- a/frontend/src/lib/components/triggers/http/RouteEditorConfigSection.svelte +++ b/frontend/src/lib/components/triggers/http/RouteEditorConfigSection.svelte @@ -5,7 +5,7 @@ import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' import { userStore, workspaceStore } from '$lib/stores' - import { HttpTriggerService } from '$lib/gen' + import { HttpTriggerService, SettingService } from '$lib/gen' // import { page } from '$app/state' import { getHttpRoute } from './utils' import { isCloudHosted } from '$lib/cloud' @@ -106,6 +106,26 @@ let userIsAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin) let userCanEditConfig = $derived(userIsAdmin || isDraftOnly) // User can edit config if they are admin or if the trigger is a draft which will not be saved + + let globalHttpWorkspacedRoute = $state(false) + + async function loadGlobalHttpWorkspacedRouteSetting() { + try { + const setting = await SettingService.getGlobal({ key: 'http_route_workspaced_route' }) + globalHttpWorkspacedRoute = (setting as boolean) ?? false + } catch (error) { + globalHttpWorkspacedRoute = false + } + } + + loadGlobalHttpWorkspacedRouteSetting() + + $effect.pre(() => { + if (globalHttpWorkspacedRoute && !workspaced_route) { + workspaced_route = true + dirtyRoutePath = true + } + })
@@ -172,13 +192,15 @@ { workspaced_route = !workspaced_route dirtyRoutePath = true }} options={{ - right: 'Prefix with workspace', + right: globalHttpWorkspacedRoute + ? 'Prefix with workspace (enforced by instance setting)' + : 'Prefix with workspace', rightTooltip: 'Prefixes the route with the workspace ID (e.g., {base_url}/api/r/{workspace_id}/{route}). Note: deploying the HTTP trigger to another workspace updates the route workspace prefix accordingly.', rightDocumentationLink: diff --git a/frontend/src/lib/components/triggers/http/RoutesGenerator.svelte b/frontend/src/lib/components/triggers/http/RoutesGenerator.svelte index b8f4a39759..1fe13d01d4 100644 --- a/frontend/src/lib/components/triggers/http/RoutesGenerator.svelte +++ b/frontend/src/lib/components/triggers/http/RoutesGenerator.svelte @@ -1,5 +1,11 @@