raw app storage on db + s3 (#6749)

This commit is contained in:
Ruben Fiszel
2025-10-03 16:30:06 +00:00
committed by GitHub
parent 7c39aa6f6f
commit 3c7a12da57
23 changed files with 851 additions and 709 deletions

View File

@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "SELECT data FROM app_bundles WHERE app_version_id = $1 AND file_type = $2 AND w_id = $3",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "data",
"type_info": "Bytea"
}
],
"parameters": {
"Left": [
"Int8",
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "01050e7057f3d1971ad9e47ac83bf6a3c3c9f41689c3607f0b264437ae6b3324"
}

View File

@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO app_bundles (app_version_id, w_id, file_type, data) VALUES ($1, $2, $3, $4)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Varchar",
"Varchar",
"Bytea"
]
},
"nullable": []
},
"hash": "abaae3dde751a41b2dbb7856ece1c840d0ea8d59346ed9e88f6f609edb543d7e"
}

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT app.versions[array_upper(app.versions, 1)] FROM app\n WHERE app.path = $1 AND app.workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "versions",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "fad966db585b91c9ce143c9aa26a826aec1ddb193a7f4988c5f12b1a2d8ce071"
}

View File

@@ -0,0 +1,2 @@
-- Add down migration script here
DROP TABLE app_bundles;

View File

@@ -0,0 +1,8 @@
-- Add up migration script here
CREATE TABLE app_bundles (
app_version_id BIGINT NOT NULL,
w_id VARCHAR(255) NOT NULL,
file_type VARCHAR(10) NOT NULL,
data BYTEA NOT NULL,
PRIMARY KEY (app_version_id, file_type)
);

View File

@@ -7003,6 +7003,23 @@ paths:
schema:
type: string
/w/{workspace}/apps/secret_of_latest_version/{path}:
get:
summary: get public secret of latest version of an app bundle
operationId: getPublicSecretOfLatestVersionOfApp
tags:
- app
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
responses:
"200":
description: app secret
content:
text/plain:
schema:
type: string
/w/{workspace}/apps/get/v/{id}:
get:
summary: get app by version

View File

@@ -88,6 +88,10 @@ pub fn workspaced_service() -> Router {
.route("/get/lite/*path", get(get_app_lite))
.route("/get/draft/*path", get(get_app_w_draft))
.route("/secret_of/*path", get(get_secret_id))
.route(
"/secret_of_latest_version/*path",
get(get_latest_version_secret_id),
)
.route("/get/v/*id", get(get_app_by_id))
.route("/get_data/v/*id", get(get_raw_app_data))
.route("/exists/*path", get(exists_app))
@@ -389,19 +393,85 @@ async fn list_apps(
Ok(Json(rows))
}
async fn get_raw_app_data(Path((w_id, version_id)): Path<(String, String)>) -> Result<Response> {
let file_path = format!("/tmp/wmill/{}/{}", w_id, version_id);
let file = tokio::fs::File::open(file_path).await?;
let stream = tokio_util::io::ReaderStream::new(file);
let res = Response::builder().header(
http::header::CONTENT_TYPE,
if version_id.ends_with(".css") {
"text/css"
} else {
"text/javascript"
},
);
Ok(res.body(Body::from_stream(stream)).unwrap())
async fn get_raw_app_data(
Path((w_id, secret_with_ext)): Path<(String, String)>,
Extension(db): Extension<DB>,
) -> Result<Response> {
#[cfg(all(feature = "enterprise", feature = "parquet"))]
let object_store = windmill_common::s3_helpers::get_object_store().await;
#[cfg(not(all(feature = "enterprise", feature = "parquet")))]
let object_store: Option<()> = None;
// tracing::info!("secret_with_ext: {}", secret_with_ext);
let mut splitted = secret_with_ext.split('.');
let secret_id = splitted.next().unwrap_or("");
if secret_id.is_empty() {
return Err(Error::BadRequest("Invalid secret".to_string()));
}
let id = get_id_from_secret(
&db,
&w_id,
secret_id.to_string(),
Some(BUNDLE_SECRET_PREFIX),
)
.await?;
let file_type = splitted.next().unwrap_or("");
let file_type = if file_type == "css" {
"css"
} else if file_type == "js" {
"js"
} else {
return Err(Error::BadRequest(
"Invalid file type, only .css and .js are supported".to_string(),
));
};
// tracing::info!("file_type: {}", file_type);
let path = format!("/app_bundles/{}/{}.{}", w_id, id, file_type);
#[allow(unused_assignments)]
let mut body: Option<Body> = None;
#[cfg(all(feature = "enterprise", feature = "parquet"))]
if let Some(os) = object_store {
let stream = os
.get(&object_store::path::Path::from(path))
.await?
.bytes()
.await?;
tracing::info!("stream: {}", stream.len());
body = Some(Body::from(stream));
}
if body.is_none() {
let get_raw_app_file = sqlx::query_scalar!(
"SELECT data FROM app_bundles WHERE app_version_id = $1 AND file_type = $2 AND w_id = $3",
id,
file_type,
&w_id,
)
.fetch_optional(&db)
.await?;
if let Some(file) = get_raw_app_file {
body = Some(Body::from(file));
}
}
if let Some(body) = body {
// let stream = tokio_util::io::ReaderStream::new(file);
let res = Response::builder().header(
http::header::CONTENT_TYPE,
if file_type == "css" {
"text/css"
} else {
"text/javascript"
},
);
Ok(res.body(body).unwrap())
} else {
return Err(Error::NotFound("File not found".to_string()));
}
}
// async fn get_app_version(
@@ -692,14 +762,7 @@ async fn get_public_app_by_secret(
Extension(db): Extension<DB>,
Path((w_id, secret)): Path<(String, String)>,
) -> JsonResult<AppWithLastVersion> {
let mc = build_crypt(&db, &w_id).await?;
let decrypted = mc
.decrypt_bytes_to_bytes(&(hex::decode(secret)?))
.map_err(|e| Error::internal_err(e.to_string()))?;
let bytes = str::from_utf8(&decrypted).map_err(to_anyhow)?;
let id: i64 = bytes.parse().map_err(to_anyhow)?;
let id = get_id_from_secret(&db, &w_id, secret, None).await?;
let app_o = sqlx::query_as::<_, AppWithLastVersion>(
"SELECT app.id, app.path, app.summary, app.versions, app.policy, app.custom_path,
@@ -747,6 +810,27 @@ async fn get_public_app_by_secret(
Ok(Json(app))
}
async fn get_id_from_secret(
db: &DB,
w_id: &str,
secret: String,
prefix: Option<&str>,
) -> Result<i64> {
let mc = build_crypt(db, w_id).await?;
let decrypted = mc
.decrypt_bytes_to_bytes(&(hex::decode(secret)?))
.map_err(|e| Error::internal_err(e.to_string()))?;
let mut bytes = str::from_utf8(&decrypted).map_err(to_anyhow)?;
if let Some(prefix) = prefix {
if !bytes.starts_with(prefix) {
return Err(Error::BadRequest("Invalid secret".to_string()));
}
bytes = bytes.strip_prefix(prefix).unwrap_or("");
}
let id: i64 = bytes.parse().map_err(to_anyhow)?;
Ok(id)
}
async fn get_public_resource(
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
@@ -803,16 +887,87 @@ async fn get_secret_id(
Ok(hx)
}
const BUNDLE_SECRET_PREFIX: &str = "bundle_";
async fn get_latest_version_secret_id(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> Result<String> {
let path = path.to_path();
check_scopes(&authed, || format!("apps:read:{}", path))?;
let mut tx = user_db.begin(&authed).await?;
let id_o = sqlx::query_scalar!(
"SELECT app.versions[array_upper(app.versions, 1)] FROM app
WHERE app.path = $1 AND app.workspace_id = $2",
path,
&w_id
)
.fetch_optional(&mut *tx)
.await?
.flatten();
tx.commit().await?;
let id = not_found_if_none(id_o, "App", path.to_string())?;
let mc = build_crypt(&db, &w_id).await?;
let hx = hex::encode(mc.encrypt_str_to_bytes(format!("{}{}", BUNDLE_SECRET_PREFIX, id)));
Ok(hx)
}
use windmill_common::error;
async fn store_raw_app_file<'a>(
w_id: &str,
id: &i64,
file_type: &str,
data: bytes::Bytes,
tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
) -> Result<()> {
#[cfg(all(feature = "enterprise", feature = "parquet"))]
let object_store = windmill_common::s3_helpers::get_object_store().await;
#[cfg(not(all(feature = "enterprise", feature = "parquet")))]
let object_store: Option<()> = None;
let path = format!("/app_bundles/{}/{}.{}", w_id, id, file_type);
#[cfg(all(feature = "enterprise", feature = "parquet"))]
if let Some(os) = object_store {
if let Err(e) = os
.put(&object_store::path::Path::from(path.clone()), data.into())
.await
{
tracing::error!("Failed to put snapshot to s3 at {path}: {:?}", e);
return Err(error::Error::ExecutionErr(format!(
"Failed to put {path} to s3"
)));
}
tracing::info!("Successfully put snapshot to s3 at {path}");
return Ok(());
}
sqlx::query!(
"INSERT INTO app_bundles (app_version_id, w_id, file_type, data) VALUES ($1, $2, $3, $4)",
id,
w_id,
file_type,
data.to_vec()
)
.execute(&mut **tx)
.await?;
Ok(())
}
macro_rules! process_app_multipart {
($authed:expr, $user_db:expr, $db:expr, $w_id:expr, $path:expr, $multipart:expr, $internal_fn:expr) => {
async {
let mut saved_app = None;
let mut uploaded_js = false;
//todo: use s3 instead
let file_path = format!("/tmp/wmill/{}", $w_id);
std::fs::create_dir_all(&file_path).unwrap();
let mut multipart = $multipart;
while let Some(field) = multipart.next_field().await.unwrap() {
let name = field.name().unwrap().to_string();
@@ -831,9 +986,8 @@ macro_rules! process_app_multipart {
.await?;
saved_app = Some((npath, nid, ntx));
} else if name == "js" {
if let Some((_npath, id, _tx)) = saved_app.as_ref() {
let file_path = format!("{}/{}.js", file_path, id);
std::fs::write(file_path, data).unwrap();
if let Some((_npath, id, tx)) = saved_app.as_mut() {
store_raw_app_file($w_id, &id, "js", data, tx).await?;
uploaded_js = true;
} else {
return Err(Error::BadRequest(
@@ -841,9 +995,8 @@ macro_rules! process_app_multipart {
));
}
} else if name == "css" {
if let Some((_npath, id, _tx)) = saved_app.as_ref() {
let file_path = format!("{}/{}.css", file_path, id);
std::fs::write(file_path, data).unwrap();
if let Some((_npath, id, tx)) = saved_app.as_mut() {
store_raw_app_file($w_id, &id, "css", data, tx).await?;
} else {
return Err(Error::BadRequest(
"App payload need to be created first".to_string(),

View File

@@ -20,7 +20,7 @@ console.log('Running postinstall for root project');
import { x } from 'tar'
const tarUrl = 'https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev/ui_builder-d44b577.tar.gz'
const tarUrl = 'https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev/ui_builder-8957900.tar.gz'
const outputTarPath = path.join(process.cwd(), 'ui_builder.tar.gz')
const extractTo = path.join(process.cwd(), 'static/ui_builder/')

View File

@@ -1,13 +1,9 @@
<script lang="ts">
import { createBubbler, stopPropagation } from 'svelte/legacy'
const bubble = createBubbler()
import { Alert, Drawer, DrawerContent, UndoRedo } from '$lib/components/common'
import { Drawer, DrawerContent, UndoRedo } from '$lib/components/common'
import Button from '$lib/components/common/button/Button.svelte'
import Path from '$lib/components/Path.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { AppService, DraftService, SettingService, type Policy } from '$lib/gen'
import { AppService, DraftService, type Policy } from '$lib/gen'
import { redo, undo } from '$lib/history.svelte'
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import type { Item } from '$lib/utils'
@@ -22,7 +18,6 @@
FormInput,
History,
Laptop2,
Loader2,
Save,
Smartphone,
FileClock,
@@ -30,8 +25,7 @@
Moon,
SunMoon,
Zap,
Globe,
AlertTriangle
Globe
} from 'lucide-svelte'
import { getContext, untrack } from 'svelte'
import {
@@ -51,7 +45,6 @@
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import { sendUserToast } from '$lib/toast'
import DeploymentHistory from './DeploymentHistory.svelte'
import Awareness from '$lib/components/Awareness.svelte'
@@ -75,14 +68,14 @@
computeWorkspaceS3FileInputPolicy,
computeS3ImageViewerPolicy
} from './appUtilsS3'
import { isCloudHosted } from '$lib/cloud'
import { base } from '$lib/base'
import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte'
import AppJobsDrawer from './AppJobsDrawer.svelte'
import { collectStaticFields, type TriggerableV2 } from './commonAppUtils'
import LazyModePanel from './contextPanel/LazyModePanel.svelte'
import { Sha256 } from '@aws-crypto/sha256-js'
import type { DiffDrawerI } from '$lib/components/diff_drawer'
import AppEditorHeaderDeploy from './AppEditorHeaderDeploy.svelte'
import AppEditorHeaderDeployInitialDraft from './AppEditorHeaderDeployInitialDraft.svelte'
import { computeSecretUrl } from './appDeploy.svelte'
async function hash(message) {
try {
@@ -158,6 +151,7 @@
let deployedBy: string | undefined = $state(undefined) // Author
let confirmCallback: () => void = $state(() => {}) // What happens when user clicks `override` in warning
let open: boolean = $state(false) // Is confirmation modal open
let customPathError: string = $state('')
const {
app,
@@ -184,7 +178,7 @@
let selectedJobId: string | undefined = $state(undefined)
let pathError: string | undefined = $state(undefined)
let pathError: string = $state('')
let appExport: AppExportButton | undefined = $state()
let draftDrawerOpen = $state(false)
@@ -193,7 +187,7 @@
let historyBrowserDrawerOpen = $state(false)
let debugAppDrawerOpen = $state(false)
let lazyDrawerOpen = $state(false)
let deploymentMsg: string | undefined = $state(undefined)
let deploymentMsg = $state('')
function closeSaveDrawer() {
saveDrawerOpen = false
@@ -533,15 +527,6 @@
}
}
let secretUrl: string | undefined = $state(undefined)
async function getSecretUrl() {
secretUrl = await AppService.getPublicSecretOfApp({
workspace: $workspaceStore!,
path: $appPath
})
}
async function setPublishState() {
await computeTriggerables()
await AppService.updateApp({
@@ -792,15 +777,6 @@
lock = false
}
let dirtyPath = $state(false)
let path: Path | undefined = $state(undefined)
let secretUrlHref = $derived(
secretUrl
? `${window.location.origin}${base}/public/${$workspaceStore}/${secretUrl}`
: undefined
)
let moreItems = $derived([
{
displayName: 'Deployment history',
@@ -820,9 +796,13 @@
{
displayName: 'Public URL',
icon: Globe,
disabled: !secretUrlHref,
href: secretUrlHref,
hrefTarget: '_blank'
action: async () => {
const secretUrl = await AppService.getPublicSecretOfApp({
workspace: $workspaceStore!,
path: $appPath
})
window.open(computeSecretUrl(secretUrl), '_blank')
}
},
// {
// displayName: 'Publish to Hub',
@@ -927,42 +907,7 @@
setTheme($app?.darkMode)
let customPath = $state(savedApp?.custom_path)
let dirtyCustomPath = $state(false)
let customPathError = $state('')
let globalWorkspacedRoute = $state(false)
async function loadGlobalWorkspacedRouteSetting() {
try {
const setting = await SettingService.getGlobal({ key: 'app_workspaced_route' })
globalWorkspacedRoute = (setting as boolean) ?? false
} catch (error) {
globalWorkspacedRoute = false
}
}
async function appExists(customPath: string) {
return await AppService.customPathExists({
workspace: $workspaceStore!,
customPath
})
}
let validateTimeout: number | undefined = undefined
async function validateCustomPath(customPath: string): Promise<void> {
customPathError = ''
if (validateTimeout) {
clearTimeout(validateTimeout)
}
validateTimeout = setTimeout(async () => {
if (!/^[\w-]+(\/[\w-]+)*$/.test(customPath)) {
customPathError = 'Invalid path'
} else if (customPath !== savedApp?.custom_path && (await appExists(customPath))) {
customPathError = 'Path already taken'
} else {
customPathError = ''
}
validateTimeout = undefined
}, 500)
}
$effect(() => {
if ($openDebugRun == undefined) {
$openDebugRun = (jobId: string) => {
@@ -971,23 +916,11 @@
}
}
})
$effect(() => {
$appPath && $appPath != '' && secretUrl == undefined && untrack(() => getSecretUrl())
})
$effect(() => {
saveDrawerOpen && untrack(() => compareVersions())
})
let hasErrors = $derived(Object.keys($errorByComponent).length > 0)
let fullCustomUrl = $derived(
`${window.location.origin}${base}/a/${
isCloudHosted() || globalWorkspacedRoute ? $workspaceStore + '/' : ''
}${customPath}`
)
$effect(() => {
;[customPath]
untrack(() => customPath !== undefined && validateCustomPath(customPath))
})
loadGlobalWorkspacedRouteSetting()
</script>
<svelte:window onkeydown={onKeyDown} />
@@ -1028,45 +961,6 @@
{#if $appPath == ''}
<Drawer bind:open={draftDrawerOpen} size="800px">
<DrawerContent title="Initial draft save" on:close={() => closeDraftDrawer()}>
<Alert bgClass="mb-4" title="Require path" type="info">
Choose a path to save the initial draft of the app.
</Alert>
<h3>Summary</h3>
<div class="w-full pt-2">
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus
type="text"
placeholder="App summary"
class="text-sm w-full font-semibold"
onkeydown={stopPropagation(bubble('keydown'))}
bind:value={$summary}
onkeyup={() => {
if ($appPath == '' && $summary?.length > 0 && !dirtyPath) {
path?.setName(
$summary
.toLowerCase()
.replace(/[^a-z0-9_]/g, '_')
.replace(/-+/g, '_')
.replace(/^-|-$/g, '')
)
}
}}
/>
</div>
<div class="py-2"></div>
<Path
autofocus={false}
bind:this={path}
bind:error={pathError}
bind:path={newEditedPath}
bind:dirty={dirtyPath}
initialPath=""
namePlaceholder="app"
kind="app"
/>
<div class="py-4"></div>
{#snippet actions()}
<div>
<Button
@@ -1078,6 +972,8 @@
</Button>
</div>
{/snippet}
<AppEditorHeaderDeployInitialDraft {summary} {appPath} bind:pathError bind:newEditedPath />
</DrawerContent>
</Drawer>
{/if}
@@ -1100,59 +996,6 @@
/>
<Drawer bind:open={saveDrawerOpen} size="800px">
<DrawerContent title="Deploy" on:close={() => closeSaveDrawer()}>
{#if !onLatest}
<Alert title="You're not on the latest app version. " type="warning">
By deploying, you may overwrite changes made by other users. Press 'Deploy' to see diff.
</Alert>
<div class="py-2"></div>
{/if}
<span class="text-secondary text-sm font-bold">Summary</span>
<div class="w-full pt-2">
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus
type="text"
placeholder="App summary"
class="text-sm w-full"
bind:value={$summary}
onkeydown={stopPropagation(bubble('keydown'))}
onkeyup={() => {
if ($appPath == '' && $summary?.length > 0 && !dirtyPath) {
path?.setName(
$summary
.toLowerCase()
.replace(/[^a-z0-9_]/g, '_')
.replace(/-+/g, '_')
.replace(/^-|-$/g, '')
)
}
}}
/>
</div>
<div class="py-4"></div>
<span class="text-secondary text-sm font-bold">Deployment message</span>
<div class="w-full pt-2">
<!-- svelte-ignore a11y_autofocus -->
<input
type="text"
placeholder="Optional deployment message"
class="text-sm w-full"
bind:value={deploymentMsg}
/>
</div>
<div class="py-4"></div>
<span class="text-secondary text-sm font-bold">Path</span>
<Path
bind:this={path}
bind:dirty={dirtyPath}
bind:error={pathError}
bind:path={newEditedPath}
initialPath={newPath}
namePlaceholder="app"
kind="app"
autofocus={false}
/>
{#snippet actions()}
<div class="flex flex-row gap-4">
<Button
@@ -1212,111 +1055,21 @@
</Button>
</div>
{/snippet}
<div class="py-2"></div>
<Alert title="App executed on behalf of you">
A viewer of the app will execute the runnables of the app on behalf of the publisher (you)
<Tooltip>
It ensures that all required resources/runnable visible for publisher but not for viewer at
time of creating the app would prevent the execution of the app. To guarantee tight
security, a policy is computed at time of deployment of the app which only allow the
scripts/flows referred to in the app to be called on behalf of. Furthermore, static
parameters are not overridable. Hence, users will only be able to use the app as intended by
the publisher without risk for leaking resources not used in the app.
</Tooltip>
</Alert>
<div class="mt-10"></div>
<h2>Public URL</h2>
<div class="my-6">
<div class="flex gap-2 items-center mb-2">
<Toggle
options={{
left: `Require login and read-access`,
right: `No login required`
}}
checked={policy.execution_mode == 'anonymous'}
on:change={(e) => {
policy.execution_mode = e.detail ? 'anonymous' : 'publisher'
setPublishState()
}}
disabled={$appPath == ''}
/>
</div>
{#if $appPath == ''}
<ClipboardPanel content={`Save this app once to get the public secret URL`} size="md" />
{:else if secretUrlHref}
<ClipboardPanel content={secretUrlHref} size="md" />
{:else}<Loader2 class="animate-spin" />
{/if}
<div class="text-xs text-secondary mt-1">
Share this url directly or embed it using an iframe (if requiring login, top-level domain of
embedding app must be the same as the one of Windmill)
</div>
<div class="mt-4">
{#if !($userStore?.is_admin || $userStore?.is_super_admin)}
<Alert type="warning" title="Admin only" size="xs">
Custom path can only be set by workspace admins
</Alert>
<div class="mb-2"></div>
{/if}
{#if !$enterpriseLicense}
<div class="flex text-xs items-center gap-1 text-yellow-500 whitespace-nowrap mb-2">
<AlertTriangle size={16} />
EE only <Tooltip>Enterprise Edition only feature</Tooltip>
</div>
{/if}
<Toggle
on:change={({ detail }) => {
customPath = detail ? '' : undefined
if (customPath === undefined) {
customPathError = ''
}
}}
checked={customPath !== undefined}
options={{
right: 'Use a custom URL'
}}
disabled={!$enterpriseLicense || !($userStore?.is_admin || $userStore?.is_super_admin)}
/>
{#if customPath !== undefined}
<div class="text-secondary text-sm flex items-center gap-1 w-full justify-between">
<div>Custom path</div>
</div>
<input
disabled={!($userStore?.is_admin || $userStore?.is_super_admin)}
type="text"
autocomplete="off"
bind:value={customPath}
class={customPathError === ''
? ''
: 'border border-red-700 bg-red-100 border-opacity-30 focus:border-red-700 focus:border-opacity-30 focus-visible:ring-red-700 focus-visible:ring-opacity-25 focus-visible:border-red-700'}
oninput={() => {
dirtyCustomPath = true
}}
/>
<div class="text-secondary text-sm flex items-center gap-1 mt-2 w-full justify-between">
<div>Custom public URL</div>
</div>
<ClipboardPanel content={fullCustomUrl} size="md" />
<div class="text-red-600 dark:text-red-400 text-2xs mt-1.5"
>{dirtyCustomPath ? customPathError : ''}
</div>
{/if}
</div>
</div>
<Alert type="info" title="Only latest deployed app is publicly available">
You will still need to deploy the app to make visible the latest changes
</Alert>
<a
href="https://www.windmill.dev/docs/advanced/external_auth_with_jwt#embed-public-apps-using-your-own-authentification"
class="mt-4 text-2xs">Embed this app in your own product to be used by your own users</a
>
<AppEditorHeaderDeploy
{newPath}
{policy}
{setPublishState}
appPath={$appPath}
{onLatest}
{savedApp}
bind:summary={$summary}
bind:customPath
bind:deploymentMsg
bind:customPathError
bind:pathError
bind:newEditedPath
hideSecretUrl={false}
/>
</DrawerContent>
</Drawer>

View File

@@ -0,0 +1,274 @@
<script lang="ts">
import { Alert } from '$lib/components/common'
import Toggle from '$lib/components/Toggle.svelte'
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import { Loader2, AlertTriangle } from 'lucide-svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte'
import { untrack } from 'svelte'
import { AppService, SettingService } from '$lib/gen'
import Path from '$lib/components/Path.svelte'
import { computeSecretUrl } from './appDeploy.svelte'
import { base } from '$lib/base'
import { isCloudHosted } from '$lib/cloud'
let {
policy,
setPublishState,
appPath,
customPath = $bindable(),
onLatest,
savedApp,
summary = $bindable(),
deploymentMsg = $bindable(),
customPathError = $bindable(),
pathError = $bindable(),
newEditedPath = $bindable(),
newPath,
hideSecretUrl = false
}: {
policy: any
setPublishState: () => void
appPath: string
customPath: string | undefined
onLatest: boolean
savedApp: any
summary: string
deploymentMsg: string | undefined
customPathError: string
pathError: string
newEditedPath: string
newPath: string
hideSecretUrl: boolean
} = $props()
let dirtyCustomPath = $state(false)
let path: Path | undefined = $state(undefined)
let dirtyPath = $state(false)
async function appExists(customPath: string) {
return await AppService.customPathExists({
workspace: $workspaceStore!,
customPath
})
}
let globalWorkspacedRoute = $state(false)
async function loadGlobalWorkspacedRouteSetting() {
try {
const setting = await SettingService.getGlobal({ key: 'app_workspaced_route' })
globalWorkspacedRoute = (setting as boolean) ?? false
} catch (error) {
globalWorkspacedRoute = false
}
}
loadGlobalWorkspacedRouteSetting()
let secretUrl: string | undefined = $state(undefined)
let secretUrlHref = $derived(secretUrl ? computeSecretUrl(secretUrl) : undefined)
let fullCustomUrl = $derived(
`${window.location.origin}${base}/a/${
isCloudHosted() || globalWorkspacedRoute ? $workspaceStore + '/' : ''
}${customPath}`
)
async function getSecretUrl() {
secretUrl = await AppService.getPublicSecretOfApp({
workspace: $workspaceStore!,
path: appPath
})
}
let validateTimeout: number | undefined = undefined
async function validateCustomPath(customPath: string): Promise<void> {
customPathError = ''
if (validateTimeout) {
clearTimeout(validateTimeout)
}
validateTimeout = setTimeout(async () => {
if (!/^[\w-]+(\/[\w-]+)*$/.test(customPath)) {
customPathError = 'Invalid path'
} else if (customPath !== savedApp?.custom_path && (await appExists(customPath))) {
customPathError = 'Path already taken'
} else {
customPathError = ''
}
validateTimeout = undefined
}, 500)
}
$effect(() => {
;[customPath]
untrack(() => customPath !== undefined && validateCustomPath(customPath))
})
$effect(() => {
appPath && appPath != '' && secretUrl == undefined && untrack(() => getSecretUrl())
})
</script>
{#if !onLatest}
<Alert title="You're not on the latest app version. " type="warning">
By deploying, you may overwrite changes made by other users. Press 'Deploy' to see diff.
</Alert>
<div class="py-2"></div>
{/if}
<span class="text-secondary text-sm font-bold">Summary</span>
<div class="w-full pt-2">
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus
type="text"
placeholder="App summary"
class="text-sm w-full"
bind:value={summary}
onkeydown={(e) => {
e.stopPropagation()
}}
onkeyup={() => {
if (appPath == '' && summary?.length > 0 && !dirtyPath) {
path?.setName(
summary
.toLowerCase()
.replace(/[^a-z0-9_]/g, '_')
.replace(/-+/g, '_')
.replace(/^-|-$/g, '')
)
}
}}
/>
</div>
<div class="py-4"></div>
<span class="text-secondary text-sm font-bold">Deployment message</span>
<div class="w-full pt-2">
<!-- svelte-ignore a11y_autofocus -->
<input
type="text"
placeholder="Optional deployment message"
class="text-sm w-full"
bind:value={deploymentMsg}
/>
</div>
<div class="py-4"></div>
<span class="text-secondary text-sm font-bold">Path</span>
<Path
bind:this={path}
bind:dirty={dirtyPath}
bind:error={pathError}
bind:path={newEditedPath}
initialPath={newPath}
namePlaceholder="app"
kind="app"
autofocus={false}
/>
<div class="py-2"></div>
<Alert title="App executed on behalf of you">
A viewer of the app will execute the runnables of the app on behalf of the publisher (you)
<Tooltip>
It ensures that all required resources/runnable visible for publisher but not for viewer at time
of creating the app would prevent the execution of the app. To guarantee tight security, a
policy is computed at time of deployment of the app which only allow the scripts/flows referred
to in the app to be called on behalf of. Furthermore, static parameters are not overridable.
Hence, users will only be able to use the app as intended by the publisher without risk for
leaking resources not used in the app.
</Tooltip>
</Alert>
<div class="mt-10"></div>
{#if !hideSecretUrl}
<h2>Public URL</h2>
<div class="my-6">
<div class="flex gap-2 items-center mb-2">
<Toggle
options={{
left: `Require login and read-access`,
right: `No login required`
}}
checked={policy.execution_mode == 'anonymous'}
on:change={(e) => {
policy.execution_mode = e.detail ? 'anonymous' : 'publisher'
setPublishState()
}}
disabled={appPath == ''}
/>
</div>
{#if appPath == ''}
<ClipboardPanel content={`Save this app once to get the public secret URL`} size="md" />
{:else if secretUrlHref}
<ClipboardPanel content={secretUrlHref} size="md" />
{:else}<Loader2 class="animate-spin" />
{/if}
<div class="text-xs text-secondary mt-1">
Share this url directly or embed it using an iframe (if requiring login, top-level domain of
embedding app must be the same as the one of Windmill)
</div>
<div class="mt-4">
{#if !($userStore?.is_admin || $userStore?.is_super_admin)}
<Alert type="warning" title="Admin only" size="xs">
Custom path can only be set by workspace admins
</Alert>
<div class="mb-2"></div>
{/if}
{#if !$enterpriseLicense}
<div class="flex text-xs items-center gap-1 text-yellow-500 whitespace-nowrap mb-2">
<AlertTriangle size={16} />
EE only <Tooltip>Enterprise Edition only feature</Tooltip>
</div>
{/if}
<Toggle
on:change={({ detail }) => {
customPath = detail ? '' : undefined
if (customPath === undefined) {
customPathError = ''
}
}}
checked={customPath !== undefined}
options={{
right: 'Use a custom URL'
}}
disabled={!$enterpriseLicense || !($userStore?.is_admin || $userStore?.is_super_admin)}
/>
{#if customPath !== undefined}
<div class="text-secondary text-sm flex items-center gap-1 w-full justify-between">
<div>Custom path</div>
</div>
<input
disabled={!($userStore?.is_admin || $userStore?.is_super_admin)}
type="text"
autocomplete="off"
bind:value={customPath}
class={customPathError === ''
? ''
: 'border border-red-700 bg-red-100 border-opacity-30 focus:border-red-700 focus:border-opacity-30 focus-visible:ring-red-700 focus-visible:ring-opacity-25 focus-visible:border-red-700'}
oninput={() => {
dirtyCustomPath = true
}}
/>
<div class="text-secondary text-sm flex items-center gap-1 mt-2 w-full justify-between">
<div>Custom public URL</div>
</div>
<ClipboardPanel content={fullCustomUrl} size="md" />
<div class="text-red-600 dark:text-red-400 text-2xs mt-1.5"
>{dirtyCustomPath ? customPathError : ''}
</div>
{/if}
</div>
</div>
<Alert type="info" title="Only latest deployed app is publicly available">
You will still need to deploy the app to make visible the latest changes
</Alert>
<a
href="https://www.windmill.dev/docs/advanced/external_auth_with_jwt#embed-public-apps-using-your-own-authentification"
class="mt-4 text-2xs">Embed this app in your own product to be used by your own users</a
>
{/if}

View File

@@ -0,0 +1,50 @@
<script lang="ts">
import { Alert } from '$lib/components/common'
import Path from '$lib/components/Path.svelte'
let { summary, appPath, pathError = $bindable(), newEditedPath = $bindable() } = $props()
let path: Path | undefined = $state(undefined)
let dirtyPath = $state(false)
</script>
<Alert bgClass="mb-4" title="Require path" type="info">
Choose a path to save the initial draft of the app.
</Alert>
<h3>Summary</h3>
<div class="w-full pt-2">
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus
type="text"
placeholder="App summary"
class="text-sm w-full font-semibold"
onkeydown={(e) => {
e.stopPropagation()
}}
bind:value={$summary}
onkeyup={() => {
if ($appPath == '' && $summary?.length > 0 && !dirtyPath) {
path?.setName(
$summary
.toLowerCase()
.replace(/[^a-z0-9_]/g, '_')
.replace(/-+/g, '_')
.replace(/^-|-$/g, '')
)
}
}}
/>
</div>
<div class="py-2"></div>
<Path
autofocus={false}
bind:this={path}
bind:error={pathError}
bind:path={newEditedPath}
bind:dirty={dirtyPath}
initialPath=""
namePlaceholder="app"
kind="app"
/>
<div class="py-4"></div>

View File

@@ -0,0 +1,7 @@
import { base } from "$lib/base"
import { workspaceStore } from "$lib/stores"
import { get } from "svelte/store"
export function computeSecretUrl(secretUrl: string) {
return `${window.location.origin}${base}/public/${get(workspaceStore)}/${secretUrl}`
}

View File

@@ -6,7 +6,11 @@
import SvelteIcon from '../icons/SvelteIcon.svelte'
import VueIcon from '../icons/VueIcon.svelte'
export let file: string
interface Props {
file: string
}
let { file }: Props = $props()
</script>
{#if file.endsWith('.tsx')}

View File

@@ -5,13 +5,25 @@
import type { HiddenRunnable, JobById } from '../apps/types'
import { JobService } from '$lib/gen'
export let iframe: HTMLIFrameElement | undefined
export let path: string
export let runnables: Record<string, HiddenRunnable>
export let jobs: string[] = []
export let jobsById: Record<string, JobById> = {}
export let editor: boolean
export let workspace: string
interface Props {
iframe: HTMLIFrameElement | undefined
path: string
runnables: Record<string, HiddenRunnable>
jobs?: string[]
jobsById?: Record<string, JobById>
editor: boolean
workspace: string
}
let {
iframe,
path,
runnables,
jobs = $bindable([]),
jobsById = $bindable({}),
editor,
workspace
}: Props = $props()
let listener = async (event) => {
const data = event.data
@@ -87,4 +99,4 @@
}
</script>
<svelte:window on:message={listener} />
<svelte:window onmessage={listener} />

View File

@@ -2,7 +2,6 @@
import { run } from 'svelte/legacy'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { writable } from 'svelte/store'
import RawAppInlineScriptsPanel from './RawAppInlineScriptsPanel.svelte'
import type { HiddenRunnable, JobById } from '../apps/types'
import RawAppEditorHeader from './RawAppEditorHeader.svelte'
@@ -52,7 +51,7 @@
}: Props = $props()
export const version: number | undefined = undefined
let runnables = writable(initRunnables)
let runnables = $state(initRunnables)
let files: Record<string, string> | undefined = $state(initFiles)
@@ -65,7 +64,7 @@
path != '' ? `rawapp-${path}` : 'rawapp',
encodeState({
files,
runnables: $runnables
runnables: runnables
})
)
} catch (err) {
@@ -97,7 +96,7 @@
iframe?.contentWindow?.postMessage(
{
type: 'setRunnables',
dts: genWmillTs($runnables)
dts: genWmillTs(runnables)
},
'*'
)
@@ -129,7 +128,7 @@
let darkMode: boolean = $state(false)
run(() => {
$runnables && files && saveFrontendDraft()
runnables && files && saveFrontendDraft()
})
run(() => {
iframe?.addEventListener('load', () => {
@@ -140,7 +139,7 @@
iframe && iframeLoaded && initFiles && populateFiles()
})
run(() => {
iframe && iframeLoaded && $runnables && populateRunnables()
iframe && iframeLoaded && runnables && populateRunnables()
})
</script>
@@ -153,7 +152,7 @@
{iframe}
bind:jobs
bind:jobsById
runnables={$runnables}
{runnables}
{path}
/>
<div class="max-h-screen overflow-hidden h-screen min-h-0 flex flex-col">

View File

@@ -1,22 +1,10 @@
<script lang="ts">
import { Alert, Badge, Drawer, DrawerContent } from '$lib/components/common'
import { Badge, Drawer, DrawerContent } from '$lib/components/common'
import Button from '$lib/components/common/button/Button.svelte'
import Path from '$lib/components/Path.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { AppService, DraftService, type Policy } from '$lib/gen'
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import {
Bug,
DiffIcon,
FileJson,
FileUp,
History,
Loader2,
MoreVertical,
Pen,
Save
} from 'lucide-svelte'
import { Bug, DiffIcon, FileJson, FileUp, History, MoreVertical, Pen, Save } from 'lucide-svelte'
import { createEventDispatcher } from 'svelte'
import {
cleanValueProperties,
@@ -30,7 +18,6 @@
import AppExportButton from '../apps/editor/AppExportButton.svelte'
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import { sendUserToast } from '$lib/toast'
import DeploymentHistory from '../apps/editor/DeploymentHistory.svelte'
import Awareness from '$lib/components/Awareness.svelte'
@@ -38,17 +25,16 @@
import Summary from '$lib/components/Summary.svelte'
import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte'
import { isCloudHosted } from '$lib/cloud'
import { base } from '$lib/base'
import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte'
import type { HiddenRunnable } from '../apps/types'
import type { Writable } from 'svelte/store'
import AppJobsDrawer from '../apps/editor/AppJobsDrawer.svelte'
import type { Runnable } from '../apps/inputType'
import { collectStaticFields, hash, type TriggerableV2 } from '../apps/editor/commonAppUtils'
import type { SavedAndModifiedValue } from '../common/confirmationModal/unsavedTypes'
import DropdownV2 from '../DropdownV2.svelte'
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
import AppEditorHeaderDeployInitialDraft from '../apps/editor/AppEditorHeaderDeployInitialDraft.svelte'
import AppEditorHeaderDeploy from '../apps/editor/AppEditorHeaderDeploy.svelte'
// async function hash(message) {
// try {
@@ -65,60 +51,75 @@
// const hex = result.map((b) => b.toString(16).padStart(2, '0')).join('') // convert bytes to hex string
// return hex
// }
// }
export let summary: string
export let policy: Policy
export let diffDrawer: DiffDrawer | undefined = undefined
export let savedApp:
| {
value: any
draft?: any
path: string
summary: string
policy: any
draft_only?: boolean
custom_path?: string
}
| undefined = undefined
export let version: number | undefined = undefined
interface Props {
// }
summary: string
policy: Policy
diffDrawer?: DiffDrawer | undefined
savedApp?:
| {
value: any
draft?: any
path: string
summary: string
policy: any
draft_only?: boolean
custom_path?: string
}
| undefined
version?: number | undefined
newApp: boolean
newPath?: string
appPath: string
runnables: Record<string, HiddenRunnable>
files: Record<string, string> | undefined
jobs: string[]
jobsById: Record<string, any>
getBundle: () => Promise<{
js: string
css: string
}>
}
export let newApp: boolean
export let newPath: string = ''
export let appPath: string
export let runnables: Writable<Record<string, HiddenRunnable>>
export let files: Record<string, string> | undefined
export let jobs: string[]
export let jobsById: Record<string, any>
export let getBundle: () => Promise<{
js: string
css: string
}>
let {
summary = $bindable(),
policy = $bindable(),
diffDrawer = undefined,
savedApp = $bindable(undefined),
version = $bindable(undefined),
newApp,
newPath = '',
appPath,
runnables,
files,
jobs = $bindable(),
jobsById = $bindable(),
getBundle
}: Props = $props()
let newEditedPath = ''
let newEditedPath = $state('')
$: app = files ? { runnables: $runnables, files } : undefined
let deployedValue: Value | undefined = undefined // Value to diff against
let deployedBy: string | undefined = undefined // Author
let confirmCallback: () => void = () => {} // What happens when user clicks `override` in warning
let open: boolean = false // Is confirmation modal open
let deployedValue: Value | undefined = $state(undefined) // Value to diff against
let deployedBy: string | undefined = $state(undefined) // Author
let confirmCallback: () => void = $state(() => {}) // What happens when user clicks `override` in warning
let open: boolean = $state(false) // Is confirmation modal open
// const { app, summary, appPath, jobs, jobsById, staticExporter } = getContext('AppViewerContext')
const loading = {
const loading = $state({
publish: false,
save: false,
saveDraft: false
}
})
let pathError: string | undefined = undefined
let appExport: AppExportButton
let pathError: string = $state('')
let appExport = $state() as AppExportButton | undefined
let draftDrawerOpen = false
let saveDrawerOpen = false
let historyBrowserDrawerOpen = false
let deploymentMsg: string | undefined = undefined
let draftDrawerOpen = $state(false)
let saveDrawerOpen = $state(false)
let historyBrowserDrawerOpen = $state(false)
let deploymentMsg: string | undefined = $state(undefined)
function closeSaveDrawer() {
saveDrawerOpen = false
@@ -136,7 +137,7 @@
: `u/${$userStore?.username}`
policy.triggerables_v2 = Object.fromEntries(
(await Promise.all(
Object.values($runnables).map(async (runnable) => {
Object.values(runnables).map(async (runnable) => {
return await processRunnable(runnable.name, runnable, runnable.fields)
})
)) as [string, TriggerableV2][]
@@ -333,17 +334,6 @@
}
}
let secretUrl: string | undefined = undefined
$: appPath && appPath != '' && secretUrl == undefined && getSecretUrl()
async function getSecretUrl() {
secretUrl = await AppService.getPublicSecretOfApp({
workspace: $workspaceStore!,
path: appPath
})
}
async function setPublishState() {
await computeTriggerables()
await AppService.updateApp({
@@ -532,7 +522,7 @@
}
}
let onLatest = true
let onLatest = $state(true)
async function compareVersions() {
if (version === undefined) {
return
@@ -549,11 +539,6 @@
}
}
$: saveDrawerOpen && compareVersions()
let dirtyPath = false
let path: Path | undefined = undefined
let moreItems = [
{
displayName: 'Deployment history',
@@ -567,7 +552,7 @@
displayName: 'Export',
icon: FileJson,
action: () => {
appExport.open(app)
appExport?.open(app)
}
},
{
@@ -609,38 +594,10 @@
const dispatch = createEventDispatcher()
let customPath = savedApp?.custom_path
let dirtyCustomPath = false
let customPathError = ''
$: fullCustomUrl = `${window.location.origin}${base}/a/${
isCloudHosted() ? $workspaceStore + '/' : ''
}${customPath}`
async function appExists(customPath: string) {
return await AppService.customPathExists({
workspace: $workspaceStore!,
customPath
})
}
let validateTimeout: number | undefined = undefined
async function validateCustomPath(customPath: string): Promise<void> {
customPathError = ''
if (validateTimeout) {
clearTimeout(validateTimeout)
}
validateTimeout = setTimeout(async () => {
if (!/^[\w-]+(\/[\w-]+)*$/.test(customPath)) {
customPathError = 'Invalid path'
} else if (customPath !== savedApp?.custom_path && (await appExists(customPath))) {
customPathError = 'Path already taken'
} else {
customPathError = ''
}
validateTimeout = undefined
}, 500)
}
$: customPath !== undefined && validateCustomPath(customPath)
let customPath = $state(savedApp?.custom_path)
let customPathError = $state('')
let jobsDrawerOpen = false
let jobsDrawerOpen = $state(false)
function getInitialAndModifiedValues(): SavedAndModifiedValue {
return {
@@ -654,6 +611,11 @@
}
}
}
let app = $derived(files ? { runnables: runnables, files } : undefined)
$effect(() => {
saveDrawerOpen && compareVersions()
})
</script>
<UnsavedConfirmationModal {diffDrawer} {getInitialAndModifiedValues} />
@@ -676,45 +638,6 @@
{#if appPath == ''}
<Drawer bind:open={draftDrawerOpen} size="800px">
<DrawerContent title="Initial draft save" on:close={() => closeDraftDrawer()}>
<Alert bgClass="mb-4" title="Require path" type="info">
Choose a path to save the initial draft of the app.
</Alert>
<h3>Summary</h3>
<div class="w-full pt-2">
<!-- svelte-ignore a11y-autofocus -->
<input
autofocus
type="text"
placeholder="App summary"
class="text-sm w-full font-semibold"
on:keydown|stopPropagation
bind:value={summary}
on:keyup={() => {
if (appPath == '' && summary?.length > 0 && !dirtyPath) {
path?.setName(
summary
.toLowerCase()
.replace(/[^a-z0-9_]/g, '_')
.replace(/-+/g, '_')
.replace(/^-|-$/g, '')
)
}
}}
/>
</div>
<div class="py-2"></div>
<Path
autofocus={false}
bind:this={path}
bind:error={pathError}
bind:path={newEditedPath}
bind:dirty={dirtyPath}
initialPath=""
namePlaceholder="app"
kind="app"
/>
<div class="py-4"></div>
{#snippet actions()}
<div>
<Button
@@ -726,64 +649,12 @@
</Button>
</div>
{/snippet}
<AppEditorHeaderDeployInitialDraft {summary} {appPath} bind:pathError bind:newEditedPath />
</DrawerContent>
</Drawer>
{/if}
<Drawer bind:open={saveDrawerOpen} size="800px">
<DrawerContent title="Deploy" on:close={() => closeSaveDrawer()}>
{#if !onLatest}
<Alert title="You're not on the latest app version. " type="warning">
By deploying, you may overwrite changes made by other users. Press 'Deploy' to see diff.
</Alert>
<div class="py-2"></div>
{/if}
<span class="text-secondary text-sm font-bold">Summary</span>
<div class="w-full pt-2">
<!-- svelte-ignore a11y-autofocus -->
<input
autofocus
type="text"
placeholder="App summary"
class="text-sm w-full"
bind:value={summary}
on:keydown|stopPropagation
on:keyup={() => {
if (appPath == '' && summary?.length > 0 && !dirtyPath) {
path?.setName(
summary
.toLowerCase()
.replace(/[^a-z0-9_]/g, '_')
.replace(/-+/g, '_')
.replace(/^-|-$/g, '')
)
}
}}
/>
</div>
<div class="py-4"></div>
<span class="text-secondary text-sm font-bold">Deployment message</span>
<div class="w-full pt-2">
<!-- svelte-ignore a11y-autofocus -->
<input
type="text"
placeholder="Optional deployment message"
class="text-sm w-full"
bind:value={deploymentMsg}
/>
</div>
<div class="py-4"></div>
<span class="text-secondary text-sm font-bold">Path</span>
<Path
bind:this={path}
bind:dirty={dirtyPath}
bind:error={pathError}
bind:path={newEditedPath}
initialPath={newPath}
namePlaceholder="app"
kind="app"
autofocus={false}
/>
{#snippet actions()}
<div class="flex flex-row gap-4">
<Button
@@ -843,116 +714,22 @@
</Button>
</div>
{/snippet}
<div class="py-2"></div>
{#if appPath == ''}
<Alert title="Require saving" type="error">
Save this app once before you can publish it
</Alert>
{:else}
<Alert title="App executed on behalf of you">
A viewer of the app will execute the runnables of the app on behalf of the publisher (you)
<Tooltip>
It ensures that all required resources/runnable visible for publisher but not for viewer
at time of creating the app would prevent the execution of the app. To guarantee tight
security, a policy is computed at time of deployment of the app which only allow the
scripts/flows referred to in the app to be called on behalf of. Furthermore, static
parameters are not overridable. Hence, users will only be able to use the app as intended
by the publisher without risk for leaking resources not used in the app.
</Tooltip>
</Alert>
<div class="mt-10"></div>
<h2>Public URL</h2>
<div class="mt-4"></div>
<div class="flex gap-2 items-center">
<Toggle
options={{
left: `Require login and read-access`,
right: `No login required`
}}
checked={policy.execution_mode == 'anonymous'}
on:change={(e) => {
policy.execution_mode = e.detail ? 'anonymous' : 'publisher'
setPublishState()
}}
/>
</div>
<div class="my-6 box">
<div class="text-secondary">
<div>Public URL</div>
</div>
{#if secretUrl}
{@const href = `${window.location.origin}${base}/public/${$workspaceStore}/${secretUrl}`}
<ClipboardPanel content={href} size="md" />
{:else}<Loader2 class="animate-spin" />
{/if}
<div class="text-xs text-secondary mt-1">
Share this url directly or embed it using an iframe (if requiring login, top-level domain
of embedding app must be the same as the one of Windmill)
</div>
<div class="mt-4">
{#if !$enterpriseLicense}
<Alert title="EE Only" type="warning" size="xs">
Custom path is an enterprise only feature.
</Alert>
<div class="mb-2"></div>
{:else if !($userStore?.is_admin || $userStore?.is_super_admin)}
<Alert type="warning" title="Admin only" size="xs">
Custom path can only be set by workspace admins
</Alert>
<div class="mb-2"></div>
{/if}
<Toggle
on:change={({ detail }) => {
customPath = detail ? '' : undefined
}}
checked={customPath !== undefined}
options={{
right: 'Use a custom URL'
}}
disabled={!$enterpriseLicense || !($userStore?.is_admin || $userStore?.is_super_admin)}
/>
{#if customPath !== undefined}
<div class="text-secondary text-sm flex items-center gap-1 w-full justify-between">
<div>Custom path</div>
</div>
<input
disabled={!($userStore?.is_admin || $userStore?.is_super_admin)}
type="text"
autocomplete="off"
bind:value={customPath}
class={customPathError === ''
? ''
: 'border border-red-700 bg-red-100 border-opacity-30 focus:border-red-700 focus:border-opacity-30 focus-visible:ring-red-700 focus-visible:ring-opacity-25 focus-visible:border-red-700'}
on:input={() => {
dirtyCustomPath = true
}}
/>
<div class="text-secondary text-sm flex items-center gap-1 mt-2 w-full justify-between">
<div>Custom public URL</div>
</div>
<ClipboardPanel content={fullCustomUrl} size="md" />
<div class="text-red-600 dark:text-red-400 text-2xs mt-1.5"
>{dirtyCustomPath ? customPathError : ''}
</div>
{/if}
</div>
</div>
<Alert type="info" title="Only latest deployed app is publicly available">
You will still need to deploy the app to make visible the latest changes
</Alert>
<a
href="https://www.windmill.dev/docs/advanced/external_auth_with_jwt#embed-public-apps-using-your-own-authentification"
class="mt-4 text-2xs">Embed this app in your own product to be used by your own users</a
>
{/if}
<AppEditorHeaderDeploy
{newPath}
{policy}
{setPublishState}
{appPath}
{onLatest}
{savedApp}
{summary}
bind:customPath
bind:deploymentMsg
bind:customPathError
bind:pathError
bind:newEditedPath
hideSecretUrl={true}
/>
</DrawerContent>
</Drawer>
@@ -988,7 +765,7 @@
<div class="flex justify-start w-full border rounded-md overflow-hidden">
<div>
<button
on:click={async () => {
onclick={async () => {
saveDrawerOpen = true
setTimeout(() => {
document.getElementById('path')?.focus()
@@ -1009,7 +786,7 @@
value={defaultIfEmptyString(newEditedPath, newPath)}
size={defaultIfEmptyString(newEditedPath, newPath)?.length || 50}
class="font-mono !text-xs !min-w-[96px] !max-w-[300px] !w-full !h-[28px] !my-0 !py-0 !border-l-0 !rounded-l-none !border-0 !shadow-none"
on:focus={({ currentTarget }) => {
onfocus={({ currentTarget }) => {
currentTarget.select()
}}
/>
@@ -1021,13 +798,13 @@
{/if}
<div class="flex flex-row gap-2 justify-end items-center overflow-visible">
<DropdownV2 items={moreItems} class="h-auto">
<svelte:fragment slot="buttonReplacement">
{#snippet buttonReplacement()}
<Button nonCaptureEvent size="xs" color="light">
<div class="flex flex-row items-center">
<MoreVertical size={14} />
</div>
</Button>
</svelte:fragment>
{/snippet}
</DropdownV2>
<div class="hidden md:inline relative overflow-visible">

View File

@@ -1,4 +1,7 @@
<script lang="ts">
import { createBubbler, stopPropagation } from 'svelte/legacy'
const bubble = createBubbler()
import Button from '$lib/components/common/button/Button.svelte'
import type { Preview } from '$lib/gen'
import { createEventDispatcher, onMount } from 'svelte'
@@ -19,20 +22,33 @@
import RunButton from '$lib/components/RunButton.svelte'
import { computeFields } from '../apps/editor/inlineScriptsPanel/utils'
let inlineScriptEditorDrawer: InlineScriptEditorDrawer
let inlineScriptEditorDrawer = $state() as InlineScriptEditorDrawer | undefined
export let inlineScript: InlineScript | undefined
export let name: string | undefined = undefined
export let id: string
export let fields: Record<string, AppInput> = {}
export let path: string
export let isLoading: boolean = false
export let onRun: () => Promise<void>
export let onCancel: () => Promise<void>
interface Props {
inlineScript: InlineScript | undefined
name?: string | undefined
id: string
fields?: Record<string, AppInput>
path: string
isLoading?: boolean
onRun: () => Promise<void>
onCancel: () => Promise<void>
editor?: Editor | undefined
}
export let editor: Editor | undefined = undefined
let diffEditor: DiffEditor
let validCode = true
let {
inlineScript = $bindable(),
name = $bindable(undefined),
id,
fields = $bindable({}),
path,
isLoading = false,
onRun,
onCancel,
editor = $bindable(undefined)
}: Props = $props()
let diffEditor = $state() as DiffEditor | undefined
let validCode = $state(true)
async function inferInlineScriptSchema(
language: Preview['language'],
@@ -72,7 +88,7 @@
const dispatch = createEventDispatcher()
let drawerIsOpen: boolean | undefined = undefined
let drawerIsOpen: boolean | undefined = $state(undefined)
</script>
{#if inlineScript}
@@ -95,11 +111,11 @@
{#if name !== undefined}
<div class="flex flex-row gap-2 w-full items-center">
<input
on:keydown|stopPropagation
onkeydown={stopPropagation(bubble('keydown'))}
bind:value={name}
placeholder="Inline script name"
class="!text-xs !rounded-sm !shadow-none"
on:keyup={() => {
onkeyup={() => {
// $app = $app
// if (stateId) {
// $stateId++

View File

@@ -5,28 +5,25 @@
import PanelSection from '../apps/editor/settingsPanel/common/PanelSection.svelte'
import DocLink from '../apps/editor/settingsPanel/DocLink.svelte'
import HideButton from '../apps/editor/settingsPanel/HideButton.svelte'
import type { Writable } from 'svelte/store'
import type { Runnable } from '../apps/inputType'
import { getNextId } from '$lib/components/flows/idUtils'
interface Props {
selectedRunnable: string | undefined
runnables: Writable<Record<string, Runnable>>
runnables: Record<string, Runnable>
}
let { selectedRunnable = $bindable(), runnables }: Props = $props()
function createBackgroundScript() {
const nid = getNextId(Object.keys($runnables ?? {}))
const nid = getNextId(Object.keys(runnables ?? {}))
const newScriptPath = `Backend Runnable ${nid}`
runnables.update((r) => {
r[nid] = {
name: newScriptPath,
inlineScript: undefined,
type: 'runnableByName'
}
return r
})
runnables[nid] = {
name: newScriptPath,
inlineScript: undefined,
type: 'runnableByName'
}
selectedRunnable = nid
}
@@ -62,8 +59,8 @@
<div class="w-full flex flex-col gap-6 py-1">
<div>
<div class="flex flex-col gap-1 w-full">
{#if Object.keys($runnables ?? {}).length > 0}
{#each Object.entries($runnables ?? {}) as [id, runnable]}
{#if Object.keys(runnables ?? {}).length > 0}
{#each Object.entries(runnables ?? {}) as [id, runnable]}
{#if runnable}
<button
{id}

View File

@@ -2,18 +2,20 @@
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { twMerge } from 'tailwind-merge'
import type { Writable } from 'svelte/store'
import { workspaceStore } from '$lib/stores'
import RawAppInlineScriptPanelList from './RawAppInlineScriptPanelList.svelte'
import RawAppInlineScripRunnable from './RawAppInlineScriptRunnable.svelte'
import { createScriptFromInlineScript } from '../apps/editor/inlineScriptsPanel/utils'
import type { Runnable } from '../apps/inputType'
export let runnables: Writable<Record<string, Runnable>>
export let selectedRunnable: string | undefined
export let appPath: string
interface Props {
runnables: Record<string, Runnable>
selectedRunnable: string | undefined
appPath: string
width?: number | undefined
}
export let width: number | undefined = undefined
let { runnables, selectedRunnable = $bindable(), appPath, width = undefined }: Props = $props()
</script>
<Splitpanes
@@ -28,7 +30,7 @@
<div class="text-sm text-secondary text-center py-8 px-2">
Select a runnable on the left panel
</div>
{:else if $runnables?.[selectedRunnable]}
{:else if runnables?.[selectedRunnable]}
{#key selectedRunnable}
<RawAppInlineScripRunnable
{appPath}
@@ -41,16 +43,13 @@
)
}}
on:delete={() => {
runnables.update((runnables) => {
if (selectedRunnable) {
delete runnables[selectedRunnable]
}
selectedRunnable = undefined
return { ...runnables }
})
if (selectedRunnable) {
delete runnables[selectedRunnable]
}
selectedRunnable = undefined
}}
id={selectedRunnable}
bind:runnable={$runnables[selectedRunnable]}
bind:runnable={runnables[selectedRunnable]}
/>{/key}
{:else}
<div class="text-sm text-tertiary text-center py-8 px-2">

View File

@@ -4,13 +4,17 @@
import RawAppBackgroundRunner from './RawAppBackgroundRunner.svelte'
import { htmlContent } from './utils'
export let workspace: string
export let user: UserExt | undefined
export let version: number
export let path: string
export let runnables: Record<string, HiddenRunnable>
interface Props {
workspace: string
user: UserExt | undefined
secret: string | undefined
path: string
runnables: Record<string, HiddenRunnable>
}
let iframe: HTMLIFrameElement
let { workspace, user, secret, path, runnables }: Props = $props()
let iframe = $state() as HTMLIFrameElement | undefined
</script>
<RawAppBackgroundRunner {workspace} editor={false} {iframe} {runnables} {path} />
@@ -18,6 +22,6 @@
<iframe
bind:this={iframe}
title="raw-app"
srcDoc={htmlContent(workspace, version, { ctx: user, workspace })}
srcDoc={htmlContent(workspace, secret, { ctx: user, workspace })}
class="w-full h-full min-h-screen bg-white border-none"
></iframe>

View File

@@ -7,21 +7,21 @@ export type RawApp = {
files: string[]
}
export function htmlContent(workspace: string, version: number, ctx: any) {
export function htmlContent(workspace: string, secret: string | undefined, ctx: any) {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>App Preview</title>
<link rel="stylesheet" href="/api/w/${workspace}/apps/get_data/v/${version}.css" />
<link rel="stylesheet" href="/api/w/${workspace}/apps/get_data/v/${secret}.css" />
<script>
window.ctx = ${ctx ? JSON.stringify(ctx) : 'undefined'}
</script>
</head>
<body>
<div id="root"></div>
<script src="/api/w/${workspace}/apps/get_data/v/${version}.js"></script>
<script src="/api/w/${workspace}/apps/get_data/v/${secret}.js"></script>
</body>
</html>
`
@@ -58,20 +58,20 @@ export function genWmillTs(runnables: Record<string, HiddenRunnable>) {
// AND GENERATED AUTOMATICALLY FROM YOUR RUNNABLES
${Object.entries(runnables)
.map(([k, v]) => `export type RunBg${capitalize(k)} = ${hiddenRunnableToTsType(v)}\n`)
.map(([k, v]) => `export type RunBg${capitalize(k)} = ${hiddenRunnableToTsType(v)}\n`)
.join('\n')}
.join('\n')}
export const runBg = {
${Object.keys(runnables)
.map((k) => ` ${k}: null as unknown as (data: RunBg${capitalize(k)}) => Promise<any>`)
.join(',\n')}
.map((k) => ` ${k}: null as unknown as (data: RunBg${capitalize(k)}) => Promise<any>`)
.join(',\n')}
}
export const runBgAsync = {
${Object.keys(runnables)
.map((k) => ` ${k}: null as unknown as (data: RunBg${capitalize(k)}) => Promise<string>`)
.join(',\n')}
.map((k) => ` ${k}: null as unknown as (data: RunBg${capitalize(k)}) => Promise<string>`)
.join(',\n')}
}

View File

@@ -13,6 +13,7 @@
let app = $state(undefined) as AppWithLastVersion | undefined
let secret = $state(undefined) as string | undefined
async function loadApp() {
console.log('Loading app')
app = await AppService.getAppLiteByPath({
@@ -21,17 +22,22 @@
})
}
async function loadSecret() {
secret = await AppService.getPublicSecretOfLatestVersionOfApp({
workspace: $workspaceStore!,
path: page.params.path ?? ''
})
}
$effect(() => {
$workspaceStore && loadApp()
$workspaceStore && loadSecret()
})
let can_write = $derived(canWrite(page.params.path ?? '', app?.extra_perms ?? {}, $userStore))
function getRunnables(app: AppWithLastVersion) {
return (app?.value?.runnables ?? {}) as Record<string, HiddenRunnable>
}
function getVersion(app: AppWithLastVersion) {
return app?.value?.version as number
}
</script>
<div class="h-full min-h-[600px] w-full relative p-2bg-white">
@@ -43,7 +49,7 @@
workspace={$workspaceStore}
user={$userStore}
runnables={getRunnables(app)}
version={getVersion(app)}
{secret}
/>
{/if}
{#if can_write && !hideEditBtn}

View File

@@ -1,5 +1,5 @@
cd ../../windmill-ui-code-builder
cd ../../../windmill-code-ui-builder
HASH=$(git rev-parse --short HEAD)
HASH=${HASH::-1}
sed -i "s/ui_builder-[^.]*\.tar\.gz/ui_builder-${HASH}.tar.gz/" ../windmill/frontend/scripts/untar_ui_builder.js
sed -i "s/ui_builder-[^.]*\.tar\.gz/ui_builder-${HASH}.tar.gz/" ../git/windmill/frontend/scripts/untar_ui_builder.js