mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-19 00:02:03 +00:00
feat: apps can be published publicly
This commit is contained in:
@@ -14,6 +14,27 @@
|
||||
},
|
||||
"query": "UPDATE usr SET disabled = $1 WHERE username = $2 AND workspace_id = $3"
|
||||
},
|
||||
"019258392434b3c8dfabfe53d61ad766626fe4ad67f101c1a58c9c9524531621": {
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
}
|
||||
},
|
||||
"query": "SELECT app.id FROM app\n WHERE app.path = $1 AND app.workspace_id = $2"
|
||||
},
|
||||
"0355b53b1d45955ca56b2829372ce9c656d7f0ad7b8d0709161047f0d8cdc4f4": {
|
||||
"describe": {
|
||||
"columns": [
|
||||
@@ -3430,6 +3451,75 @@
|
||||
},
|
||||
"query": "SELECT set_config('session.folders_write', $1, true)"
|
||||
},
|
||||
"9ae98fbcea508dfc7113621c00b856f98f38dfb701a4660ea1a4058a6d7564f2": {
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"name": "path",
|
||||
"ordinal": 1,
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"name": "summary",
|
||||
"ordinal": 2,
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"name": "versions",
|
||||
"ordinal": 3,
|
||||
"type_info": "Int8Array"
|
||||
},
|
||||
{
|
||||
"name": "policy",
|
||||
"ordinal": 4,
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"name": "extra_perms",
|
||||
"ordinal": 5,
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"name": "value",
|
||||
"ordinal": 6,
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 7,
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"name": "created_by",
|
||||
"ordinal": 8,
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Text"
|
||||
]
|
||||
}
|
||||
},
|
||||
"query": "SELECT app.id, app.path, app.summary, app.versions, app.policy,\n app.extra_perms, app_version.value, \n app_version.created_at, app_version.created_by from app, app_version \n WHERE app.id = $1 AND app.workspace_id = $2 AND app_version.id = app.versions[array_upper(app.versions, 1)]"
|
||||
},
|
||||
"9db64c9ff790d8c833c1e831a87803c32103ce9de68cc9c08d6f56cc988d7e37": {
|
||||
"describe": {
|
||||
"columns": [
|
||||
|
||||
@@ -2528,6 +2528,41 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AppWithLastVersion"
|
||||
|
||||
/w/{workspace}/apps/public_app/{path}:
|
||||
get:
|
||||
summary: get public app by secret
|
||||
operationId: getPublicAppBySecret
|
||||
tags:
|
||||
- app
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/Path"
|
||||
responses:
|
||||
"200":
|
||||
description: app details
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AppWithLastVersion"
|
||||
|
||||
|
||||
/w/{workspace}/apps/secret_of/{path}:
|
||||
get:
|
||||
summary: get public secret of app
|
||||
operationId: getPublicSecretOfApp
|
||||
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
|
||||
|
||||
@@ -11,6 +11,7 @@ use crate::{
|
||||
db::{UserDB, DB},
|
||||
jobs::script_path_to_payload,
|
||||
users::{require_owner_of_path, Authed, OptAuthed},
|
||||
variables::build_crypt,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Extension, Path, Query},
|
||||
@@ -18,11 +19,13 @@ use axum::{
|
||||
Json, Router,
|
||||
};
|
||||
use hyper::StatusCode;
|
||||
use magic_crypt::MagicCryptTrait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Map, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use sql_builder::{bind::Bind, SqlBuilder};
|
||||
use sqlx::{types::Uuid, FromRow};
|
||||
use std::str;
|
||||
use windmill_audit::{audit_log, ActionKind};
|
||||
use windmill_common::{
|
||||
apps::ListAppQuery,
|
||||
@@ -36,6 +39,7 @@ pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
.route("/list", get(list_apps))
|
||||
.route("/get/p/*path", get(get_app))
|
||||
.route("/secret_of/*path", get(get_secret_id))
|
||||
.route("/get/v/*id", get(get_app_by_id))
|
||||
.route("/exists/*path", get(exists_app))
|
||||
.route("/update/*path", post(update_app))
|
||||
@@ -44,7 +48,9 @@ pub fn workspaced_service() -> Router {
|
||||
}
|
||||
|
||||
pub fn unauthed_service() -> Router {
|
||||
Router::new().route("/execute_component/*path", post(execute_component))
|
||||
Router::new()
|
||||
.route("/execute_component/*path", post(execute_component))
|
||||
.route("/public_app/:secret", get(get_public_app_by_secret))
|
||||
}
|
||||
|
||||
#[derive(FromRow, Deserialize, Serialize)]
|
||||
@@ -223,19 +229,27 @@ async fn get_app_by_id(
|
||||
Ok(Json(app))
|
||||
}
|
||||
|
||||
async fn get_public_app_by_secret_(
|
||||
authed: Authed,
|
||||
async fn get_public_app_by_secret(
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, id)): Path<(String, i64)>,
|
||||
Path((w_id, secret)): Path<(String, String)>,
|
||||
) -> JsonResult<AppWithLastVersion> {
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
let mc = build_crypt(&mut tx, &w_id).await?;
|
||||
|
||||
let decrypted = mc
|
||||
.decrypt_bytes_to_bytes(&(hex::decode(secret)?))
|
||||
.map_err(|e| Error::InternalErr(e.to_string()))?;
|
||||
let bytes = str::from_utf8(&decrypted).map_err(to_anyhow)?;
|
||||
|
||||
let id: i64 = bytes.parse().map_err(to_anyhow)?;
|
||||
|
||||
let app_o = sqlx::query_as!(
|
||||
AppWithLastVersion,
|
||||
"SELECT app.id, app.path, app.summary, app.versions, app.policy,
|
||||
app.extra_perms, app_version.value,
|
||||
app_version.created_at, app_version.created_by from app, app_version
|
||||
WHERE app_version.id = $1 AND app.id = app_version.app_id AND app.workspace_id = $2",
|
||||
WHERE app.id = $1 AND app.workspace_id = $2 AND app_version.id = app.versions[array_upper(app.versions, 1)]",
|
||||
id,
|
||||
&w_id
|
||||
)
|
||||
@@ -247,6 +261,34 @@ async fn get_public_app_by_secret_(
|
||||
Ok(Json(app))
|
||||
}
|
||||
|
||||
async fn get_secret_id(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
) -> Result<String> {
|
||||
let path = path.to_path();
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let id_o = sqlx::query_scalar!(
|
||||
"SELECT app.id FROM app
|
||||
WHERE app.path = $1 AND app.workspace_id = $2",
|
||||
path,
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&mut tx)
|
||||
.await?;
|
||||
|
||||
let id = not_found_if_none(id_o, "App", path.to_string())?;
|
||||
|
||||
let mc = build_crypt(&mut tx, &w_id).await?;
|
||||
|
||||
let hx = hex::encode(mc.encrypt_str_to_bytes(id.to_string()));
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(hx)
|
||||
}
|
||||
|
||||
async fn create_app(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
<body data-sveltekit-preload-code="viewport" data-sveltekit-preload-data="hover" class="outline-none focus:outline-none">
|
||||
<body data-sveltekit-preload-code="viewport" class="outline-none focus:outline-none">
|
||||
<div style="display: contents">
|
||||
%sveltekit.body%
|
||||
</div>
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
{#if !$userStore?.operator}
|
||||
<UnsavedConfirmationModal />
|
||||
{#if initialMode !== 'preview'}
|
||||
<AppEditorHeader />
|
||||
<AppEditorHeader {policy} />
|
||||
{/if}
|
||||
|
||||
{#if previewing}
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import { page } from '$app/stores'
|
||||
import { Drawer, DrawerContent } from '$lib/components/common'
|
||||
import { Alert, Drawer, DrawerContent } from '$lib/components/common'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import { dirtyStore } from '$lib/components/common/confirmationModal/dirtyStore'
|
||||
import ToggleButton from '$lib/components/common/toggleButton/ToggleButton.svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton/ToggleButtonGroup.svelte'
|
||||
import Path from '$lib/components/Path.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import { AppService, Policy } from '$lib/gen'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { faExternalLink, faSave } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faClipboard, faExternalLink, faSave } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Eye, Laptop2, Pencil, PenTool, Smartphone } from 'lucide-svelte'
|
||||
import { getContext } from 'svelte'
|
||||
import { sendUserToast } from '../../../utils'
|
||||
import { Icon } from 'svelte-awesome'
|
||||
import { copyToClipboard, sendUserToast } from '../../../utils'
|
||||
import type { AppEditorContext, EditorBreakpoint, EditorMode } from '../types'
|
||||
import AppExportButton from './AppExportButton.svelte'
|
||||
|
||||
const { app, summary, mode, breakpoint } = getContext<AppEditorContext>('AppEditorContext')
|
||||
export let policy: Policy
|
||||
|
||||
const { app, summary, mode, breakpoint, appPath } =
|
||||
getContext<AppEditorContext>('AppEditorContext')
|
||||
const loading = {
|
||||
publish: false,
|
||||
save: false
|
||||
@@ -26,10 +31,11 @@
|
||||
let newPath: string = ''
|
||||
let pathError: string | undefined = undefined
|
||||
|
||||
let drawerOpen = false
|
||||
let saveDrawerOpen = false
|
||||
let publishDrawerOpen = false
|
||||
|
||||
function closeDrawer() {
|
||||
drawerOpen = false
|
||||
function closeSaveDrawer() {
|
||||
saveDrawerOpen = false
|
||||
}
|
||||
|
||||
async function createApp(path: string) {
|
||||
@@ -54,10 +60,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
let secretUrl: string | undefined = undefined
|
||||
|
||||
$: secretUrl == undefined && policy.execution_mode == 'anonymous' && getSecretUrl()
|
||||
|
||||
async function getSecretUrl() {
|
||||
secretUrl = await AppService.getPublicSecretOfApp({
|
||||
workspace: $workspaceStore!,
|
||||
path: appPath
|
||||
})
|
||||
}
|
||||
|
||||
async function setPublishState() {
|
||||
await AppService.updateApp({
|
||||
workspace: $workspaceStore!,
|
||||
path: appPath,
|
||||
requestBody: { policy }
|
||||
})
|
||||
}
|
||||
|
||||
async function save() {
|
||||
$dirtyStore = false
|
||||
if ($page.params.path == undefined) {
|
||||
drawerOpen = true
|
||||
saveDrawerOpen = true
|
||||
return
|
||||
}
|
||||
loading.save = true
|
||||
@@ -86,8 +111,8 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Drawer bind:open={drawerOpen} size="800px">
|
||||
<DrawerContent title="Create an App" on:close={() => closeDrawer()}>
|
||||
<Drawer bind:open={saveDrawerOpen} size="800px">
|
||||
<DrawerContent title="Create an App" on:close={() => closeSaveDrawer()}>
|
||||
<Path
|
||||
bind:error={pathError}
|
||||
bind:path={newPath}
|
||||
@@ -106,8 +131,62 @@
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<div class="border-b flex flex-row justify-between py-1 gap-1 gap-y-2 px-4 items-center flex-wrap">
|
||||
<input type="text" placeholder="App summary" class="text-sm w-64" bind:value={$summary} />
|
||||
<Drawer bind:open={publishDrawerOpen} size="800px">
|
||||
<DrawerContent title="Publish an App" on:close={() => (publishDrawerOpen = false)}>
|
||||
{#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 publisher"
|
||||
>Every runnable will run with the permissions of the publisher of the app. This ensures that
|
||||
every users gets the same experience. Make sure that the app does not expose actions that
|
||||
are too sensitive to be exposed publicly.</Alert
|
||||
>
|
||||
<div class="mt-4" />
|
||||
<Toggle
|
||||
options={{
|
||||
left: `Require read-access`,
|
||||
right: `Publish publicly for anyone knowing the secret url`
|
||||
}}
|
||||
checked={policy.execution_mode == 'anonymous'}
|
||||
on:change={(e) => {
|
||||
policy.execution_mode = e.detail
|
||||
? Policy.execution_mode.ANONYMOUS
|
||||
: Policy.execution_mode.PUBLISHER
|
||||
setPublishState()
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if policy.execution_mode == 'anonymous' && secretUrl}
|
||||
{@const url = `${$page.url.hostname}/public/${$workspaceStore}/${secretUrl}`}
|
||||
{@const href = $page.url.protocol + '//' + url}
|
||||
<div class="mt-6 box">
|
||||
Public url:
|
||||
<a
|
||||
on:click={(e) => {
|
||||
e.preventDefault()
|
||||
copyToClipboard(href)
|
||||
}}
|
||||
{href}
|
||||
class="whitespace-nowrap text-ellipsis overflow-hidden mr-1"
|
||||
>
|
||||
{url}
|
||||
<span class="text-gray-700 ml-2">
|
||||
<Icon data={faClipboard} />
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div />
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<div class="border-b flex flex-row justify-between py-1 gap-1 flex-wrap gap-y-2 px-4 items-center">
|
||||
<div class="w-64">
|
||||
<input type="text" placeholder="App summary" class="text-sm w-full" bind:value={$summary} />
|
||||
</div>
|
||||
<div class="flex gap-8 items-center">
|
||||
<div>
|
||||
<ToggleButtonGroup bind:selected={$mode}>
|
||||
@@ -141,11 +220,11 @@
|
||||
<ToggleButton position="right" value={true} size="xs">Full</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
<div class="flex flex-row gap-4 justify-end">
|
||||
<div class="flex flex-row grow gap-4 justify-end ">
|
||||
<AppExportButton app={$app} />
|
||||
|
||||
<Button
|
||||
on:click={() => sendUserToast('Publishing apps publically at secret urls is coming soon')}
|
||||
on:click={() => (publishDrawerOpen = true)}
|
||||
color="dark"
|
||||
size="xs"
|
||||
variant="border"
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="bg-white px-2 relative">
|
||||
<div class="bg-white px-2 pb-2 relative">
|
||||
<div class="w-full flex justify-between border-b px-4 py-2 mb-4 items-center gap-4">
|
||||
<h2 class="truncate">{$summary}</h2>
|
||||
<RecomputeAllComponents />
|
||||
|
||||
@@ -16,6 +16,7 @@ const gridColumns = columnConfiguration.map((value) => value[1])
|
||||
|
||||
function disableDrag(component: GridItem): GridItem {
|
||||
gridColumns.forEach((column: number) => {
|
||||
console.log(component, column)
|
||||
component[column].customDragger = true
|
||||
component[column].customResizer = true
|
||||
})
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export function load({ params }) {
|
||||
return {
|
||||
stuff: { title: `Public App` }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores'
|
||||
import AppPreview from '$lib/components/apps/editor/AppPreview.svelte'
|
||||
import type { EditorBreakpoint } from '$lib/components/apps/types'
|
||||
|
||||
import { Skeleton } from '$lib/components/common'
|
||||
import { AppService, AppWithLastVersion } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { writable } from 'svelte/store'
|
||||
|
||||
let app: AppWithLastVersion | undefined = undefined
|
||||
|
||||
async function loadApp() {
|
||||
app = await AppService.getPublicAppBySecret({
|
||||
workspace: $page.params.workspace,
|
||||
path: $page.params.secret
|
||||
})
|
||||
}
|
||||
|
||||
$: if ($workspaceStore) {
|
||||
loadApp()
|
||||
}
|
||||
|
||||
const breakpoint = writable<EditorBreakpoint>('lg')
|
||||
</script>
|
||||
|
||||
<!-- {JSON.stringify(app)} -->
|
||||
{#if app}
|
||||
<div class="border rounded-md p-2 w-full">
|
||||
<AppPreview
|
||||
summary={app.summary}
|
||||
app={app.value}
|
||||
appPath={app.path}
|
||||
{breakpoint}
|
||||
policy={app.policy}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<Skeleton layout={[10]} />
|
||||
{/if}
|
||||
Reference in New Issue
Block a user