From f8885eba4bf4c08ab01398e85e7072c040d8767f Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 20 Jan 2026 18:21:58 +0000 Subject: [PATCH] feat(raw-apps): add public URL and custom path support for raw apps (#7630) * feat(raw-apps): add public URL and custom path support for raw apps - Enable public URL UI in raw app editor by removing hideSecretUrl prop - Add bundle_secret field to AppWithLastVersion for raw app rendering - Compute bundle_secret in get_public_app_by_secret endpoint - Update PublicApp.svelte to render RawAppPreview for raw apps - Make get_data endpoint accessible without auth for anonymous raw apps - Use /apps_u/ endpoint for bundle loading to support anonymous access This allows raw apps to use the same public URL and custom path features as regular apps, with proper support for anonymous (no login required) execution mode. Co-Authored-By: Claude Opus 4.5 * refactor: compute bundle_secret only once in get_public_app_by_secret Move bundle_secret computation after all authorization checks to avoid duplication between anonymous and authenticated code paths. Co-Authored-By: Claude Opus 4.5 * fix: add explicit error state for raw apps missing workspace Show a clear error message instead of silently falling through to render AppPreview when a raw app is loaded without workspace info. Co-Authored-By: Claude Opus 4.5 * update sqlx --------- Co-authored-by: Claude Opus 4.5 --- backend/windmill-api/openapi.yaml | 5 ++ backend/windmill-api/src/apps.rs | 62 ++++++++------ frontend/package-lock.json | 46 +--------- .../apps/editor/AppEditorHeaderDeploy.svelte | 2 +- .../components/apps/editor/PublicApp.svelte | 83 ++++++++++++------- .../raw_apps/RawAppEditorHeader.svelte | 1 - frontend/src/lib/components/raw_apps/utils.ts | 4 +- 7 files changed, 99 insertions(+), 104 deletions(-) diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 538ef8ce8c..f18eb8a7c1 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -20973,6 +20973,10 @@ components: type: boolean custom_path: type: string + raw_app: + type: boolean + bundle_secret: + type: string required: - id - workspace_id @@ -20985,6 +20989,7 @@ components: - policy - execution_mode - extra_perms + - raw_app AppWithLastVersionWDraft: allOf: diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 56f604f0e0..f013db7338 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -120,6 +120,7 @@ pub fn unauthed_service() -> Router { .route("/download_s3_file/*path", get(download_s3_file_from_app)) .route("/public_app/:secret", get(get_public_app_by_secret)) .route("/public_resource/*path", get(get_public_resource)) + .route("/get_data/v/*id", get(get_raw_app_data)) } pub fn global_service() -> Router { Router::new() @@ -175,6 +176,9 @@ pub struct AppWithLastVersion { #[serde(skip_serializing_if = "Option::is_none")] pub custom_path: Option, pub raw_app: bool, + #[sqlx(skip)] + #[serde(skip_serializing_if = "Option::is_none")] + pub bundle_secret: Option, } #[derive(Serialize, FromRow)] @@ -770,7 +774,7 @@ async fn get_public_app_by_secret( "SELECT app.id, app.path, app.summary, app.versions, app.policy, app.custom_path, null as extra_perms, coalesce(app_version_lite.value::json, app_version.value::json) as value, app_version.created_at, app_version.created_by, app_version.raw_app - FROM app, app_version + FROM app, app_version LEFT JOIN app_version_lite ON app_version_lite.id = app_version.id WHERE app.id = $1 AND app.workspace_id = $2 AND app_version.id = app.versions[array_upper(app.versions, 1)]") .bind(&id) @@ -778,36 +782,37 @@ async fn get_public_app_by_secret( .fetch_optional(&db) .await?; - let app = not_found_if_none(app_o, "App", id.to_string())?; + let mut app = not_found_if_none(app_o, "App", id.to_string())?; let policy = serde_json::from_str::(app.policy.0.get()).map_err(to_anyhow)?; - if matches!(policy.execution_mode, ExecutionMode::Anonymous) { - return Ok(Json(app)); - } - - if opt_authed.is_none() { - { + if !matches!(policy.execution_mode, ExecutionMode::Anonymous) { + if opt_authed.is_none() { return Err(Error::NotAuthorized( "App visibility does not allow public access and you are not logged in".to_string(), )); + } else { + let authed = opt_authed.unwrap(); + let mut tx = user_db.begin(&authed).await?; + let is_visible = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM app WHERE id = $1 AND workspace_id = $2)", + id, + &w_id + ) + .fetch_one(&mut *tx) + .await?; + tx.commit().await?; + if !is_visible.unwrap_or(false) { + return Err(Error::NotAuthorized( + "App visibility does not allow public access and you are logged in but you have no read-access to that app".to_string(), + )); + } } - } else { - let authed = opt_authed.unwrap(); - let mut tx = user_db.begin(&authed).await?; - let is_visible = sqlx::query_scalar!( - "SELECT EXISTS(SELECT 1 FROM app WHERE id = $1 AND workspace_id = $2)", - id, - &w_id - ) - .fetch_one(&mut *tx) - .await?; - tx.commit().await?; - if !is_visible.unwrap_or(false) { - return Err(Error::NotAuthorized( - "App visibility does not allow public access and you are logged in but you have no read-access to that app".to_string(), - )); - } + } + + // Compute bundle_secret for raw apps + if app.raw_app { + app.bundle_secret = Some(compute_bundle_secret(&db, &w_id, &app.versions).await?); } Ok(Json(app)) @@ -892,6 +897,15 @@ async fn get_secret_id( const BUNDLE_SECRET_PREFIX: &str = "bundle_"; +pub async fn compute_bundle_secret(db: &DB, w_id: &str, versions: &[i64]) -> Result { + let version_id = versions + .last() + .ok_or_else(|| Error::internal_err("App has no versions".to_string()))?; + let mc = build_crypt(db, w_id).await?; + let hx = hex::encode(mc.encrypt_str_to_bytes(format!("{}{}", BUNDLE_SECRET_PREFIX, version_id))); + Ok(hx) +} + async fn get_latest_version_secret_id( authed: ApiAuthed, Extension(user_db): Extension, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cb88b47ffb..44686df50c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -835,7 +835,6 @@ "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", "integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -847,7 +846,6 @@ "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -858,7 +856,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1348,7 +1345,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.0.tgz", "integrity": "sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1550,7 +1546,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1567,7 +1562,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1584,7 +1578,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1601,7 +1594,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1618,7 +1610,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1635,7 +1626,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1652,7 +1642,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1669,7 +1658,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1686,7 +1674,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1703,7 +1690,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1720,7 +1706,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1737,7 +1722,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1754,7 +1738,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2375,7 +2358,6 @@ "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -7214,7 +7196,7 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -7650,7 +7632,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7671,7 +7652,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7692,7 +7672,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7713,7 +7692,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7734,7 +7712,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7755,7 +7732,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7776,7 +7752,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7797,7 +7772,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7818,7 +7792,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7839,7 +7812,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7860,7 +7832,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -12498,21 +12469,6 @@ } } }, - "node_modules/svelte-check/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/svelte-eslint-parser": { "version": "0.43.0", "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz", diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte index 6bcdf298f8..76e74aa2e4 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte @@ -43,7 +43,7 @@ pathError: string newEditedPath: string newPath: string - hideSecretUrl: boolean + hideSecretUrl?: boolean } = $props() let dirtyCustomPath = $state(false) let path: Path | undefined = $state(undefined) diff --git a/frontend/src/lib/components/apps/editor/PublicApp.svelte b/frontend/src/lib/components/apps/editor/PublicApp.svelte index 9495030573..16758983f9 100644 --- a/frontend/src/lib/components/apps/editor/PublicApp.svelte +++ b/frontend/src/lib/components/apps/editor/PublicApp.svelte @@ -13,6 +13,8 @@ import { urlParamsToObject } from '$lib/utils' import { goto } from '$app/navigation' import AppPreview from './AppPreview.svelte' + import RawAppPreview from '$lib/components/raw_apps/RawAppPreview.svelte' + import type { Runnable } from '$lib/components/raw_apps/rawAppPolicy' import { twMerge } from 'tailwind-merge' import { writable } from 'svelte/store' @@ -28,10 +30,13 @@ noPermission: boolean jwtError: boolean onLoginSuccess: () => void - app: (AppWithLastVersion & { value: any }) | undefined + app: (AppWithLastVersion & { value: any; workspace_id?: string }) | undefined workspace: string | undefined } = $props() + // Use workspace from props or from app.workspace_id (for custom path responses) + let effectiveWorkspace = $derived(workspace ?? app?.workspace_id) + setContext(IS_APP_PUBLIC_CONTEXT_KEY, true) const breakpoint = writable('lg') @@ -97,9 +102,9 @@ {:else if noPermission}
This app requires read access
- {#if $userStore}You are logged in but have no read access to this app{:else if globalUser && workspace} + {#if $userStore}You are logged in but have no read access to this app{:else if globalUser && effectiveWorkspace} You are logged in but are not a member of the workspace {workspace}{effectiveWorkspace} this app is part of {:else}You must be logged in and have read access to this app{/if}
@@ -110,35 +115,51 @@ {:else if app} {#key app} -
- goto(path)} - gotoFn={(path, opt) => goto(path, opt)} + {#if app.raw_app && effectiveWorkspace} + } /> -
+ {:else if app.raw_app && !effectiveWorkspace} +
+ + Unable to load raw app: workspace information is missing. + +
+ {:else} +
+ goto(path)} + gotoFn={(path, opt) => goto(path, opt)} + /> +
+ {/if} {/key} {:else} diff --git a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte index 7232318d8c..b732af8979 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte @@ -718,7 +718,6 @@ bind:customPathError bind:pathError bind:newEditedPath - hideSecretUrl={true} /> diff --git a/frontend/src/lib/components/raw_apps/utils.ts b/frontend/src/lib/components/raw_apps/utils.ts index f57a7e3a32..077a9ddad8 100644 --- a/frontend/src/lib/components/raw_apps/utils.ts +++ b/frontend/src/lib/components/raw_apps/utils.ts @@ -27,7 +27,7 @@ export function htmlContent( App Preview - + + ` }