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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* update sqlx

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-01-20 18:21:58 +00:00
committed by GitHub
parent fe1377a07f
commit f8885eba4b
7 changed files with 99 additions and 104 deletions
+5
View File
@@ -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:
+38 -24
View File
@@ -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<String>,
pub raw_app: bool,
#[sqlx(skip)]
#[serde(skip_serializing_if = "Option::is_none")]
pub bundle_secret: Option<String>,
}
#[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::<Policy>(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<String> {
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<UserDB>,
+1 -45
View File
@@ -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",
@@ -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)
@@ -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<EditorBreakpoint>('lg')
@@ -97,9 +102,9 @@
{:else if noPermission}
<div class="px-4 mt-20 w-full text-center font-bold text-xl"> This app requires read access </div>
<div class="text-center mt-8 text-sm text-primary">
{#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 <span class="text-xl font-bold"
>{workspace}</span
>{effectiveWorkspace}</span
> this app is part of
{:else}You must be logged in and have read access to this app{/if}</div
>
@@ -110,35 +115,51 @@
</div>
{:else if app}
{#key app}
<div
class={twMerge(
'min-h-screen h-full w-full flex',
app?.value?.['css']?.['app']?.['viewer']?.class,
'wm-app-viewer'
)}
style={app?.value?.['css']?.['app']?.['viewer']?.style}
>
<AppPreview
noBackend={false}
context={{
email: $userStore?.email,
name: $userStore?.name,
groups: $userStore?.groups,
username: $userStore?.username,
query: urlParamsToObject(page.url.searchParams),
hash: page.url.hash.substring(1)
}}
{workspace}
summary={app.summary}
app={app.value}
appPath={app.path}
{breakpoint}
policy={app.policy}
isEditor={false}
replaceStateFn={(path) => goto(path)}
gotoFn={(path, opt) => goto(path, opt)}
{#if app.raw_app && effectiveWorkspace}
<RawAppPreview
workspace={effectiveWorkspace}
user={$userStore}
secret={app.bundle_secret}
path={app.path}
runnables={(app.value?.runnables ?? {}) as Record<string, Runnable>}
/>
</div>
{:else if app.raw_app && !effectiveWorkspace}
<div class="px-4 mt-20">
<Alert type="error" title="Configuration error">
Unable to load raw app: workspace information is missing.
</Alert>
</div>
{:else}
<div
class={twMerge(
'min-h-screen h-full w-full flex',
app?.value?.['css']?.['app']?.['viewer']?.class,
'wm-app-viewer'
)}
style={app?.value?.['css']?.['app']?.['viewer']?.style}
>
<AppPreview
noBackend={false}
context={{
email: $userStore?.email,
name: $userStore?.name,
groups: $userStore?.groups,
username: $userStore?.username,
query: urlParamsToObject(page.url.searchParams),
hash: page.url.hash.substring(1)
}}
workspace={effectiveWorkspace}
summary={app.summary}
app={app.value}
appPath={app.path}
{breakpoint}
policy={app.policy}
isEditor={false}
replaceStateFn={(path) => goto(path)}
gotoFn={(path, opt) => goto(path, opt)}
/>
</div>
{/if}
{/key}
{:else}
<Skeleton layout={[[4], 0.5, [50]]} />
@@ -718,7 +718,6 @@
bind:customPathError
bind:pathError
bind:newEditedPath
hideSecretUrl={true}
/>
</DrawerContent>
</Drawer>
@@ -27,7 +27,7 @@ export function htmlContent(
<head>
<meta charset="UTF-8" />
<title>App Preview</title>
<link rel="stylesheet" href="${baseUrl}/api/w/${workspace}/apps/get_data/v/${secret}.css" />
<link rel="stylesheet" href="${baseUrl}/api/w/${workspace}/apps_u/get_data/v/${secret}.css" />
<script>
window.ctx = ${ctx ? JSON.stringify(ctx) : 'undefined'};
@@ -80,7 +80,7 @@ export function htmlContent(
</head>
<body>
<div id="root"></div>
<script src="${baseUrl}/api/w/${workspace}/apps/get_data/v/${secret}.js"></script>
<script src="${baseUrl}/api/w/${workspace}/apps_u/get_data/v/${secret}.js"></script>
</body>
</html>`
}