use crate::auth::Tokened; use crate::db::ApiAuthed; use crate::HTTP_CLIENT; use axum::{ extract::{DefaultBodyLimit, FromRequestParts, Json, Path, Query, RawPathParams}, http::{request::Parts, StatusCode}, response::{IntoResponse, Response}, routing::{get, post}, Extension, Router, }; use serde::{Deserialize, Deserializer, Serialize}; use windmill_common::{ error::{to_anyhow, Error}, global_settings::{load_value_from_global_settings, DISABLE_HUB_SETTING}, utils::require_admin, DB, DEFAULT_HUB_BASE_URL, HUB_BASE_URL, }; pub fn workspaced_service() -> Router { Router::new() .route("/publish_draft", post(publish_draft)) .route("/scripts", post(publish_script)) .route("/flows", post(publish_flow)) .route("/apps", post(publish_app)) .route("/raw_apps", post(publish_raw_app)) .route("/raw_apps/{id}/recording", post(publish_raw_app_recording)) .route( "/scripts/{ask_id}/recording", post(publish_script_recording), ) .route("/flows/{flow_id}/recording", post(publish_flow_recording)) .route( "/projects/{slug}/pipeline_recording", post(publish_pipeline_recording), ) .route( "/projects/{slug}/logo", post(publish_project_logo).layer(DefaultBodyLimit::max(LOGO_BODY_LIMIT)), ) .route("/resource_types", post(publish_resource_type)) .route("/resources", post(publish_resources)) .route("/triggers", post(publish_triggers)) .route("/migrations", post(publish_migrations)) .route("/projects/{slug}/export", get(get_project_export)) .route("/projects/{slug}/submit", post(submit_project)) .route("/projects/{slug}/withdraw", post(withdraw_project)) .route( "/projects/{slug}/discard_update", post(discard_project_update), ) .route("/project", get(get_project_by_source)) .route("/projects", get(list_projects)) } #[derive(Deserialize)] struct HubScope { folder: Option, } fn validate_folder(folder: &str) -> Result<(), Error> { let ok = !folder.is_empty() && folder.len() <= 255 && folder .bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-'); if ok { Ok(()) } else { Err(Error::BadRequest(format!("invalid folder: {folder}"))) } } fn source_key(workspace: &str, folder: &str) -> Result { validate_folder(folder)?; Ok(format!("{workspace}:{folder}")) } fn validate_project_slug(slug: &str) -> Result<(), Error> { let ok = slug.len() >= 3 && slug.len() <= 50 && !slug.starts_with('-') && !slug.ends_with('-') && slug .bytes() .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'); if ok { Ok(()) } else { Err(Error::BadRequest(format!("invalid project slug: {slug}"))) } } /// A Hub project slug that is valid by construction: deserialization (from a /// request body or a path segment) is the only way to obtain one and it runs /// `validate_project_slug`, so no handler can forward or interpolate an /// unvalidated slug into a Hub URL. #[derive(Serialize)] #[serde(transparent)] struct ProjectSlug(String); impl<'de> Deserialize<'de> for ProjectSlug { fn deserialize>(d: D) -> Result { let s = String::deserialize(d)?; validate_project_slug(&s).map_err(serde::de::Error::custom)?; Ok(ProjectSlug(s)) } } impl std::fmt::Display for ProjectSlug { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.0) } } /// The single gate every Hub endpoint goes through: an admin caller, their /// token (the Hub authenticates it back against this instance's whoami), and /// the validated `workspace_id:folder` source key scoping ownership Hub-side. /// Handlers can only reach the Hub via this extractor's methods, so a new /// endpoint cannot forget the admin check or folder validation. /// /// A workspace can publish one Hub project per folder. The stable, never-mutated /// link key is `workspace_id:folder_name` (folder name is the path segment and is /// never renamed — only display_name changes). `:` is safe: neither workspace ids /// nor folder names (alphanumeric, underscore, hyphen) contain it. struct HubPublishCtx { source_id: Option, token: String, } impl FromRequestParts for HubPublishCtx where S: Send + Sync, { type Rejection = Response; async fn from_request_parts( parts: &mut Parts, state: &S, ) -> std::result::Result { let authed = ApiAuthed::from_request_parts(parts, state) .await .map_err(IntoResponse::into_response)?; let tokened = Tokened::from_request_parts(parts, state) .await .map_err(IntoResponse::into_response)?; let params = RawPathParams::from_request_parts(parts, state) .await .map_err(IntoResponse::into_response)?; let workspace = params .iter() .find(|(k, _)| *k == "workspace_id") .map(|(_, v)| v.to_owned()); let Query(scope) = Query::::from_request_parts(parts, state) .await .map_err(IntoResponse::into_response)?; let build = || -> Result { require_admin(authed.is_admin, &authed.username)?; let workspace = workspace.ok_or_else(|| { Error::internal_err( "hub publish route must be nested under /w/{workspace_id}".to_string(), ) })?; let source_id = scope .folder .as_deref() .map(|f| source_key(&workspace, f)) .transpose()?; Ok(HubPublishCtx { source_id, token: tokened.token }) }; build().map_err(IntoResponse::into_response) } } impl HubPublishCtx { fn require_source(&self) -> Result<&str, Error> { self.source_id .as_deref() .ok_or_else(|| Error::BadRequest("missing folder query param".to_string())) } async fn post( &self, path: &str, body: &T, ) -> Result<(StatusCode, String), Error> { forward_to_hub(path, self.require_source()?, &self.token, body).await } async fn get(&self, path: &str) -> Result<(StatusCode, String), Error> { get_from_hub(path, self.require_source()?, &self.token).await } /// GET without requiring a folder scope. Only for reads the Hub allows /// publicly (e.g. exporting an approved project); the empty source id makes /// the Hub skip the ownership match. async fn get_maybe_unscoped(&self, path: &str) -> Result<(StatusCode, String), Error> { get_from_hub(path, self.source_id.as_deref().unwrap_or(""), &self.token).await } } #[derive(Deserialize, Serialize)] struct PublishDraftBody { slug: ProjectSlug, name: String, summary: String, #[serde(default, skip_serializing_if = "Option::is_none")] readme: Option, } async fn publish_draft( ctx: HubPublishCtx, Json(body): Json, ) -> Result { ctx.post("/projects", &body).await } #[derive(Deserialize, Serialize)] struct PublishScriptBody { summary: String, app: String, #[serde(skip_serializing_if = "Option::is_none")] description: Option, #[serde(skip_serializing_if = "Option::is_none")] kind: Option, content: String, language: String, #[serde(skip_serializing_if = "Option::is_none")] schema: Option, #[serde(skip_serializing_if = "Option::is_none")] lockfile: Option, #[serde(skip_serializing_if = "Option::is_none")] path: Option, #[serde(skip_serializing_if = "Option::is_none")] source_path: Option, project_slug: ProjectSlug, } async fn publish_script( ctx: HubPublishCtx, Json(body): Json, ) -> Result { ctx.post("/scripts/add", &body).await } #[derive(Deserialize, Serialize)] struct PublishFlowInner { summary: String, #[serde(skip_serializing_if = "Option::is_none")] description: Option, value: serde_json::Value, #[serde(skip_serializing_if = "Option::is_none")] schema: Option, } #[derive(Deserialize, Serialize)] struct PublishFlowBody { flow: PublishFlowInner, apps: Vec, #[serde(skip_serializing_if = "Option::is_none")] path: Option, #[serde(skip_serializing_if = "Option::is_none")] source_path: Option, project_slug: ProjectSlug, } async fn publish_flow( ctx: HubPublishCtx, Json(body): Json, ) -> Result { ctx.post("/flows", &body).await } #[derive(Deserialize, Serialize)] struct PublishAppBody { app: serde_json::Value, apps: Vec, #[serde(skip_serializing_if = "Option::is_none")] description: Option, summary: String, #[serde(skip_serializing_if = "Option::is_none")] path: Option, #[serde(skip_serializing_if = "Option::is_none")] source_path: Option, project_slug: ProjectSlug, } async fn publish_app( ctx: HubPublishCtx, Json(body): Json, ) -> Result { ctx.post("/apps", &body).await } #[derive(Deserialize, Serialize)] struct PublishRawAppBody { raw: String, apps: Vec, #[serde(skip_serializing_if = "Option::is_none")] description: Option, summary: String, #[serde(skip_serializing_if = "Option::is_none")] path: Option, #[serde(skip_serializing_if = "Option::is_none")] source_path: Option, project_slug: ProjectSlug, } async fn publish_raw_app( ctx: HubPublishCtx, Json(body): Json, ) -> Result { ctx.post("/raw_apps", &body).await } #[derive(Deserialize, Serialize)] struct RecordingBody { #[serde(skip_serializing_if = "Option::is_none")] recording: Option, project_slug: ProjectSlug, } async fn publish_raw_app_recording( ctx: HubPublishCtx, Path((_workspace, id)): Path<(String, i64)>, Json(body): Json, ) -> Result { ctx.post(&format!("/raw_apps/{}/recording", id), &body) .await } async fn publish_script_recording( ctx: HubPublishCtx, Path((_workspace, ask_id)): Path<(String, i64)>, Json(body): Json, ) -> Result { ctx.post(&format!("/scripts/{}/recording", ask_id), &body) .await } async fn publish_flow_recording( ctx: HubPublishCtx, Path((_workspace, flow_id)): Path<(String, i64)>, Json(body): Json, ) -> Result { ctx.post(&format!("/flows/{}/recording", flow_id), &body) .await } // A data-pipeline recording is scoped to the whole project (a folder cascade), // not a single Hub item, so the slug comes from the path (validated by // construction) and only the opaque recording is forwarded. #[derive(Deserialize, Serialize)] struct PipelineRecordingBody { #[serde(skip_serializing_if = "Option::is_none")] recording: Option, } async fn publish_pipeline_recording( ctx: HubPublishCtx, Path((_workspace, slug)): Path<(String, ProjectSlug)>, Json(body): Json, ) -> Result { ctx.post(&format!("/projects/{}/pipeline_recording", slug), &body) .await } #[derive(Deserialize, Serialize)] struct ProjectLogoInner { b64: String, mime: String, } // Custom project logo (png/svg, base64). Double-Option so a missing `logo` // key is distinguishable from an explicit `logo: null` (which clears the // logo on the Hub) — otherwise POSTing `{}` would silently delete it. #[derive(Deserialize, Serialize)] struct ProjectLogoBody { #[serde(default, deserialize_with = "deserialize_explicit")] logo: Option>, } fn deserialize_explicit<'de, D: Deserializer<'de>>( d: D, ) -> Result>, D::Error> { Option::::deserialize(d).map(Some) } // Mirrors the Hub's own limits so an oversized/invalid payload is rejected // here instead of being deserialized, copied and forwarded first. The route // also carries a DefaultBodyLimit sized for a max logo in base64 + JSON // envelope, overriding the much larger global request limit. const MAX_LOGO_BYTES: usize = 512 * 1024; const LOGO_BODY_LIMIT: usize = MAX_LOGO_BYTES / 3 * 4 + 16 * 1024; const ALLOWED_LOGO_MIMES: [&str; 2] = ["image/png", "image/svg+xml"]; fn validate_logo(inner: &ProjectLogoInner) -> Result<(), Error> { if !ALLOWED_LOGO_MIMES.contains(&inner.mime.as_str()) { return Err(Error::BadRequest( "logo mime must be image/png or image/svg+xml".to_string(), )); } let b = inner.b64.as_bytes(); let padding = b.iter().rev().take_while(|&&c| c == b'=').count(); let valid = !b.is_empty() && b.len() % 4 == 0 && padding <= 2 && b[..b.len() - padding] .iter() .all(|&c| c.is_ascii_alphanumeric() || c == b'+' || c == b'/'); if !valid { return Err(Error::BadRequest("invalid base64".to_string())); } if b.len() / 4 * 3 - padding > MAX_LOGO_BYTES { return Err(Error::BadRequest(format!( "logo too large (max {}KB)", MAX_LOGO_BYTES / 1024 ))); } Ok(()) } async fn publish_project_logo( ctx: HubPublishCtx, Path((_workspace, slug)): Path<(String, ProjectSlug)>, Json(body): Json, ) -> Result { let Some(logo) = body.logo else { return Err(Error::BadRequest( "logo field is required: an object to set it, or null to clear it".to_string(), )); }; if let Some(inner) = &logo { validate_logo(inner)?; } ctx.post( &format!("/projects/{}/logo", slug), &serde_json::json!({ "logo": logo }), ) .await } #[derive(Deserialize, Serialize)] struct PublishResourceTypeBody { name: String, #[serde(skip_serializing_if = "Option::is_none")] schema: Option, #[serde(skip_serializing_if = "Option::is_none")] description: Option, project_slug: ProjectSlug, } async fn publish_resource_type( ctx: HubPublishCtx, Json(body): Json, ) -> Result { ctx.post( &format!("/projects/{}/resource_types", body.project_slug), &body, ) .await } #[derive(Deserialize, Serialize)] struct PublishResourceBody { path: String, resource_type: String, } #[derive(Deserialize, Serialize)] struct PublishResourcesBody { resources: Vec, project_slug: ProjectSlug, } async fn publish_resources( ctx: HubPublishCtx, Json(body): Json, ) -> Result { ctx.post(&format!("/projects/{}/resources", body.project_slug), &body) .await } #[derive(Deserialize, Serialize)] struct PublishTriggerBody { path: String, kind: String, #[serde(skip_serializing_if = "Option::is_none")] summary: Option, #[serde(skip_serializing_if = "Option::is_none")] description: Option, config: serde_json::Value, #[serde(skip_serializing_if = "Option::is_none")] script_ask_id: Option, #[serde(skip_serializing_if = "Option::is_none")] flow_id: Option, } #[derive(Deserialize, Serialize)] struct PublishTriggersBody { triggers: Vec, project_slug: ProjectSlug, } async fn publish_triggers( ctx: HubPublishCtx, Json(body): Json, ) -> Result { ctx.post(&format!("/projects/{}/triggers", body.project_slug), &body) .await } // One best-effort data table migration attached to a project (per data table). #[derive(Deserialize, Serialize)] struct PublishMigrationBody { datatable_name: String, sql: String, #[serde(default)] sql_down: String, enabled: bool, } #[derive(Deserialize, Serialize)] struct PublishMigrationsBody { migrations: Vec, project_slug: ProjectSlug, } async fn publish_migrations( ctx: HubPublishCtx, Json(body): Json, ) -> Result { ctx.post( &format!("/projects/{}/migrations", body.project_slug), &body, ) .await } // Export is owner-scoped only when re-exporting your own draft; approved // projects are public, so the folder scope is optional here. async fn get_project_export( ctx: HubPublishCtx, Path((_workspace, slug)): Path<(String, ProjectSlug)>, ) -> Result { ctx.get_maybe_unscoped(&format!("/projects/{}/export", slug)) .await } async fn get_project_by_source(ctx: HubPublishCtx) -> Result { ctx.get("/projects/by_source").await } /// Whether this instance points at the public hub. Compared by parsed host rather than by the /// string: `hub_base_url` is stored as the operator typed it, so `http://`, a port, a trailing /// slash, a mixed-case scheme or host, userinfo and a trailing dot all name the same public /// host — and each spelling that failed to match would send a member's token there. Parsing is /// what `reqwest` does with the same string a line later, so this reads the host the request /// will actually go to. /// /// A value that does not parse answers "not the public hub", so the caller attaches the token — /// harmless, because `reqwest` cannot build a request from that same value: it is rejected /// before a connection is opened, and the token never reaches a socket. fn is_public_hub(hub: &str) -> bool { fn host_of(url: &str) -> Option { let parsed = url::Url::parse(url.trim()).ok()?; if !matches!(parsed.scheme(), "http" | "https") { return None; } Some( parsed .host_str()? .trim_end_matches('.') .to_ascii_lowercase(), ) } match (host_of(hub), host_of(DEFAULT_HUB_BASE_URL)) { (Some(host), Some(default_host)) => host == default_host, _ => false, } } // The hub's project catalogue. Read by any workspace member rather than through // `HubPublishCtx`, which requires an admin: nothing here is workspace-scoped or // publishing-related. It exists at all because the hub's listing endpoint sends no // CORS header, so the browser cannot read it directly the way it reads a single // project. `accept: application/json` is what makes the hub answer with JSON. // // The caller's token is sent only to a hub this instance was pointed at deliberately. // Every other route here is admin-only; this one is not, so forwarding a member's // bearer token to `hub.windmill.dev` would put a credential replayable against this // instance on a host outside it — for a listing that needs no credential at all. async fn list_projects( _authed: ApiAuthed, Extension(db): Extension, Tokened { token }: Tokened, ) -> Result { // `disable_hub` turns the hub off for a closed instance, and this handler makes an // outbound request. The frontend hides its entry points on the same setting, but that // is presentation: an authenticated member can call this route directly, so the refusal // has to live here. let disabled = load_value_from_global_settings(&db, DISABLE_HUB_SETTING) .await? .and_then(|v| v.as_bool()) .unwrap_or(false); if disabled { return Err(Error::BadRequest( "The hub is disabled on this instance".to_string(), )); } let hub = (**HUB_BASE_URL.load()).clone(); let url = format!("{}/projects", hub); let mut req = HTTP_CLIENT.get(&url).header("accept", "application/json"); if !is_public_hub(&hub) { req = req.bearer_auth(&token); } let res = req .send() .await .map_err(|e| Error::InternalErr(format!("hub request failed: {e}")))?; let status = StatusCode::from_u16(res.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); let text = res .text() .await .map_err(|e| Error::InternalErr(format!("hub response read failed: {e}")))?; Ok((status, text)) } async fn submit_project( ctx: HubPublishCtx, Path((_workspace, slug)): Path<(String, ProjectSlug)>, ) -> Result { ctx.post( &format!("/projects/{}/submit", slug), &serde_json::json!({}), ) .await } // Take a submission back out of review, keeping what was pushed for it. async fn withdraw_project( ctx: HubPublishCtx, Path((_workspace, slug)): Path<(String, ProjectSlug)>, ) -> Result { ctx.post( &format!("/projects/{}/withdraw", slug), &serde_json::json!({}), ) .await } // Throw away the pending update to an already-published project. The published // version is untouched — it never saw the update. async fn discard_project_update( ctx: HubPublishCtx, Path((_workspace, slug)): Path<(String, ProjectSlug)>, ) -> Result { ctx.post( &format!("/projects/{}/discard_update", slug), &serde_json::json!({}), ) .await } // The Hub has no auth of its own: it validates bearer tokens by calling // /api/users/whoami — on app.windmill.dev for the public Hub, on the paired // instance for a private one. Forwarding the caller's own token logs them in on // the Hub as themselves (account auto-created on first use). async fn get_from_hub( path: &str, source_id: &str, token: &str, ) -> Result<(StatusCode, String), Error> { let url = format!("{}{}", **HUB_BASE_URL.load(), path); let res = HTTP_CLIENT .get(&url) .query(&[("source_id", source_id)]) .bearer_auth(token) .send() .await .map_err(|e| Error::InternalErr(format!("hub request failed: {e}")))?; let status = StatusCode::from_u16(res.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); let text = res .text() .await .map_err(|e| Error::InternalErr(format!("hub response read failed: {e}")))?; Ok((status, text)) } async fn forward_to_hub( path: &str, source_id: &str, token: &str, body: &T, ) -> Result<(StatusCode, String), Error> { let url = format!("{}{}", **HUB_BASE_URL.load(), path); let mut payload = serde_json::to_value(body).map_err(to_anyhow)?; let obj = payload .as_object_mut() .ok_or_else(|| Error::internal_err("hub publish body must be a JSON object".to_string()))?; obj.insert( "source_id".to_string(), serde_json::Value::String(source_id.to_string()), ); let res = HTTP_CLIENT .post(&url) .bearer_auth(token) .json(&payload) .send() .await .map_err(|e| Error::InternalErr(format!("hub request failed: {e}")))?; let status = StatusCode::from_u16(res.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); let text = res .text() .await .map_err(|e| Error::InternalErr(format!("hub response read failed: {e}")))?; Ok((status, text)) } #[cfg(test)] mod tests { use super::is_public_hub; #[test] fn public_hub_recognized_in_every_spelling() { // The predicate decides whether a workspace member's bearer token leaves the // instance, so both directions matter: a miss on the public hub sends the token // to windmill.dev, and a false match withholds it from a private hub that needs it. // Every spelling here is one `hub_base_url` can hold and `reqwest` will still send. for hub in [ "https://hub.windmill.dev", "http://hub.windmill.dev/", "HTTPS://hub.windmill.dev", "https://HUB.WINDMILL.DEV", "https://hub.windmill.dev:443", "https://hub.windmill.dev.", "https://hub.windmill.dev/some/path", " https://hub.windmill.dev ", ] { assert!(is_public_hub(hub), "{hub} should be the public hub"); } for hub in [ "https://hub.internal.example", "https://hub.windmill.dev.evil.example", "https://windmill.dev", // The host is what the request goes to, whatever precedes the `@`. "https://hub.windmill.dev@hub.internal.example", // Unparseable, or not a scheme a request can be built from. Grouped with the // private hubs because the caller then attaches the token, which is harmless here: // `reqwest` rejects the same value before opening a connection. "hub.windmill.dev", "ftp://hub.windmill.dev", ] { assert!(!is_public_hub(hub), "{hub} should not be the public hub"); } } }