feat: apps can be published publicly

This commit is contained in:
Ruben Fiszel
2023-01-01 19:16:13 +01:00
parent e24dc6de4f
commit be14aab9b1
10 changed files with 314 additions and 22 deletions

View File

@@ -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": [

View File

@@ -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

View File

@@ -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>,

View File

@@ -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>

View File

@@ -97,7 +97,7 @@
{#if !$userStore?.operator}
<UnsavedConfirmationModal />
{#if initialMode !== 'preview'}
<AppEditorHeader />
<AppEditorHeader {policy} />
{/if}
{#if previewing}

View File

@@ -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"

View File

@@ -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 />

View File

@@ -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
})

View File

@@ -0,0 +1,5 @@
export function load({ params }) {
return {
stuff: { title: `Public App` }
}
}

View File

@@ -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}