mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-07 16:03:21 +00:00
feat(frontend): pick image from workspace storage bucket (#5382)
* feat(frontend): pick image from workspace storage bucket * also upload * update policy for unauthed s3 download * sqlx prep * sqlx prep * force policy * no need for ee * image picker * Update frontend/src/lib/components/apps/editor/settingsPanel/InputsSpecEditor.svelte Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> * field not needed * feature flag * filter for image files --------- Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
ellipsis-dev[bot]
parent
4fabc2a825
commit
8dbe0fa644
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS (\n SELECT 1 FROM app\n WHERE path = $1\n AND workspace_id = $2\n AND policy @> jsonb_build_object('allowed_s3_keys', jsonb_build_array(jsonb_build_object('s3_path', $3::text)))::jsonb\n )",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "a06d8e9ac6e6a8d6b4b61bdf89ef2a5f75c195c7bf916a9b55be99e5cacdf44f"
|
||||
}
|
||||
@@ -11398,7 +11398,7 @@ paths:
|
||||
|
||||
/w/{workspace}/job_helpers/download_s3_file:
|
||||
get:
|
||||
summary: Download file to S3 bucket
|
||||
summary: Download file from S3 bucket
|
||||
operationId: fileDownload
|
||||
tags:
|
||||
- helpers
|
||||
@@ -14919,6 +14919,15 @@ components:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
allowed_s3_keys:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
s3_path:
|
||||
type: string
|
||||
resource:
|
||||
type: string
|
||||
execution_mode:
|
||||
type: string
|
||||
enum: [viewer, publisher, anonymous]
|
||||
|
||||
@@ -225,6 +225,12 @@ pub struct S3Input {
|
||||
file_key_regex: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct S3Key {
|
||||
s3_path: String,
|
||||
resource: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||
pub struct Policy {
|
||||
pub on_behalf_of: Option<String>,
|
||||
@@ -239,6 +245,7 @@ pub struct Policy {
|
||||
pub triggerables_v2: Option<HashMap<String, PolicyTriggerableInputs>>,
|
||||
pub execution_mode: ExecutionMode,
|
||||
pub s3_inputs: Option<Vec<S3Input>>,
|
||||
pub allowed_s3_keys: Option<Vec<S3Key>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -1563,6 +1570,7 @@ async fn upload_s3_file_from_app(
|
||||
.map(|s| s.split(',').map(|s| s.to_string()).collect())
|
||||
.unwrap_or_default(),
|
||||
}]),
|
||||
allowed_s3_keys: None,
|
||||
})
|
||||
} else {
|
||||
let policy_o = sqlx::query_scalar!(
|
||||
@@ -1873,26 +1881,41 @@ async fn get_on_behalf_authed_from_app(
|
||||
path: &str,
|
||||
w_id: &str,
|
||||
opt_authed: &Option<ApiAuthed>,
|
||||
) -> Result<ApiAuthed> {
|
||||
let policy_o = sqlx::query_scalar!(
|
||||
"SELECT policy from app WHERE path = $1 AND workspace_id = $2",
|
||||
path,
|
||||
w_id
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
|
||||
let policy = policy_o
|
||||
.map(|p| serde_json::from_value::<Policy>(p).map_err(to_anyhow))
|
||||
.transpose()?
|
||||
.unwrap_or_else(|| Policy {
|
||||
force_allowed_s3_keys: Option<Vec<S3Key>>,
|
||||
) -> Result<(ApiAuthed, Policy)> {
|
||||
let policy = if let Some(force_allowed_s3_keys) = force_allowed_s3_keys {
|
||||
Policy {
|
||||
execution_mode: ExecutionMode::Viewer,
|
||||
triggerables: None,
|
||||
triggerables_v2: None,
|
||||
on_behalf_of: None,
|
||||
on_behalf_of_email: None,
|
||||
s3_inputs: None,
|
||||
});
|
||||
allowed_s3_keys: Some(force_allowed_s3_keys),
|
||||
}
|
||||
} else {
|
||||
// TODO: improve db query to not return uneeded fields
|
||||
let policy_o = sqlx::query_scalar!(
|
||||
"SELECT policy from app WHERE path = $1 AND workspace_id = $2",
|
||||
path,
|
||||
w_id
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
|
||||
policy_o
|
||||
.map(|p| serde_json::from_value::<Policy>(p).map_err(to_anyhow))
|
||||
.transpose()?
|
||||
.unwrap_or_else(|| Policy {
|
||||
execution_mode: ExecutionMode::Viewer,
|
||||
triggerables: None,
|
||||
triggerables_v2: None,
|
||||
on_behalf_of: None,
|
||||
on_behalf_of_email: None,
|
||||
s3_inputs: None,
|
||||
allowed_s3_keys: None,
|
||||
})
|
||||
};
|
||||
|
||||
let (username, permissioned_as, email) =
|
||||
get_on_behalf_details_from_policy_and_authed(&policy, &opt_authed).await?;
|
||||
@@ -1901,7 +1924,7 @@ async fn get_on_behalf_authed_from_app(
|
||||
fetch_api_authed_from_permissioned_as(permissioned_as, email, &w_id, &db, Some(username))
|
||||
.await?;
|
||||
|
||||
Ok(on_behalf_authed)
|
||||
Ok((on_behalf_authed, policy))
|
||||
}
|
||||
|
||||
#[cfg(feature = "parquet")]
|
||||
@@ -1911,6 +1934,7 @@ async fn check_if_allowed_to_access_s3_file_from_app(
|
||||
file_key: &str,
|
||||
w_id: &str,
|
||||
path: &str,
|
||||
policy: &Policy,
|
||||
) -> Result<()> {
|
||||
// if anonymous, check that the file was the result of an app script ran by an anonymous user in the last 3 hours
|
||||
// otherwise, if logged in, allow any file (TODO: change that when we implement better s3 policy)
|
||||
@@ -1932,7 +1956,10 @@ async fn check_if_allowed_to_access_s3_file_from_app(
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
.unwrap_or(false)
|
||||
|
||||
// check if the file is allowed by the allowed_s3_keys policy
|
||||
|| policy.allowed_s3_keys.as_ref().unwrap().iter().any(|key| key.s3_path == file_key);
|
||||
|
||||
if !allowed {
|
||||
Err(Error::BadRequest("File restricted".to_string()))
|
||||
@@ -1941,21 +1968,46 @@ async fn check_if_allowed_to_access_s3_file_from_app(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "parquet")]
|
||||
#[derive(Deserialize)]
|
||||
pub struct DownloadFileQueryWithForceViewerAllowedS3Keys {
|
||||
#[serde(flatten)]
|
||||
pub file_query: DownloadFileQuery,
|
||||
pub force_viewer_allowed_s3_keys: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "parquet")]
|
||||
async fn download_s3_file_from_app(
|
||||
OptAuthed(opt_authed): OptAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
Query(query): Query<DownloadFileQuery>,
|
||||
Query(query): Query<DownloadFileQueryWithForceViewerAllowedS3Keys>,
|
||||
) -> Result<Response> {
|
||||
let path = path.to_path();
|
||||
|
||||
let on_behalf_authed = get_on_behalf_authed_from_app(&db, &path, &w_id, &opt_authed).await?;
|
||||
let force_viewer_allowed_s3_keys = if let Some(force_viewer_allowed_s3_keys) =
|
||||
query.force_viewer_allowed_s3_keys.clone()
|
||||
{
|
||||
Some(serde_json::from_str::<Vec<S3Key>>(&force_viewer_allowed_s3_keys).unwrap_or_default())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
check_if_allowed_to_access_s3_file_from_app(&db, &opt_authed, &query.file_key, &w_id, &path)
|
||||
.await?;
|
||||
let (on_behalf_authed, policy) =
|
||||
get_on_behalf_authed_from_app(&db, &path, &w_id, &opt_authed, force_viewer_allowed_s3_keys)
|
||||
.await?;
|
||||
|
||||
download_s3_file_internal(on_behalf_authed, &db, None, "", &w_id, query).await
|
||||
check_if_allowed_to_access_s3_file_from_app(
|
||||
&db,
|
||||
&opt_authed,
|
||||
&query.file_query.file_key,
|
||||
&w_id,
|
||||
&path,
|
||||
&policy,
|
||||
)
|
||||
.await?;
|
||||
|
||||
download_s3_file_internal(on_behalf_authed, &db, None, "", &w_id, query.file_query).await
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "parquet"))]
|
||||
@@ -1974,10 +2026,18 @@ async fn load_s3_file_image_preview_from_app(
|
||||
) -> Result<Response> {
|
||||
let path = path.to_path();
|
||||
|
||||
let on_behalf_authed = get_on_behalf_authed_from_app(&db, &path, &w_id, &opt_authed).await?;
|
||||
let (on_behalf_authed, policy) =
|
||||
get_on_behalf_authed_from_app(&db, &path, &w_id, &opt_authed, None).await?;
|
||||
|
||||
check_if_allowed_to_access_s3_file_from_app(&db, &opt_authed, &query.file_key, &w_id, &path)
|
||||
.await?;
|
||||
check_if_allowed_to_access_s3_file_from_app(
|
||||
&db,
|
||||
&opt_authed,
|
||||
&query.file_key,
|
||||
&w_id,
|
||||
&path,
|
||||
&policy,
|
||||
)
|
||||
.await?;
|
||||
|
||||
load_image_preview_internal(on_behalf_authed, &db, "", &w_id, query).await
|
||||
}
|
||||
|
||||
@@ -9,18 +9,30 @@
|
||||
import ResolveConfig from '../helpers/ResolveConfig.svelte'
|
||||
import InitializeComponent from '../helpers/InitializeComponent.svelte'
|
||||
import ResolveStyle from '../helpers/ResolveStyle.svelte'
|
||||
import { defaultIfEmptyString } from '$lib/utils'
|
||||
import { userStore } from '$lib/stores'
|
||||
import { computeS3ImageViewerPolicy } from '../../editor/appUtilsS3'
|
||||
|
||||
export let id: string
|
||||
export let configuration: RichConfigurations
|
||||
export let customCss: ComponentCustomCSS<'imagecomponent'> | undefined = undefined
|
||||
export let render: boolean
|
||||
|
||||
function computeForceViewerPolicies() {
|
||||
if (!isEditor) {
|
||||
return undefined
|
||||
}
|
||||
const policy = computeS3ImageViewerPolicy(configuration, $app)
|
||||
return policy
|
||||
}
|
||||
|
||||
const resolvedConfig = initConfig(
|
||||
components['imagecomponent'].initialData.configuration,
|
||||
configuration
|
||||
)
|
||||
|
||||
const { app, worldStore } = getContext<AppViewerContext>('AppViewerContext')
|
||||
const { app, appPath, worldStore, workspace, isEditor } =
|
||||
getContext<AppViewerContext>('AppViewerContext')
|
||||
const fit: Record<string, string> = {
|
||||
cover: 'object-cover',
|
||||
contain: 'object-contain',
|
||||
@@ -31,6 +43,44 @@
|
||||
initOutput($worldStore, id, {})
|
||||
|
||||
let css = initCss($app.css?.imagecomponent, customCss)
|
||||
|
||||
let imageUrl: string | undefined = undefined
|
||||
|
||||
async function getS3Image(source: string | undefined) {
|
||||
if (!source) return ''
|
||||
const appPathOrUser = defaultIfEmptyString(
|
||||
$appPath,
|
||||
`u/${$userStore?.username ?? 'unknown'}/newapp`
|
||||
)
|
||||
const params = new URLSearchParams()
|
||||
params.append('file_key', source)
|
||||
|
||||
const forceViewerPolicies = computeForceViewerPolicies()
|
||||
if (forceViewerPolicies) {
|
||||
params.append('force_viewer_allowed_s3_keys', JSON.stringify([forceViewerPolicies]))
|
||||
}
|
||||
|
||||
return `/api/w/${workspace}/apps_u/download_s3_file/${appPathOrUser}?${params.toString()}`
|
||||
}
|
||||
|
||||
async function loadImage() {
|
||||
if (
|
||||
resolvedConfig.sourceKind === 's3 (workspace storage)' ||
|
||||
resolvedConfig.source?.startsWith('s3://')
|
||||
) {
|
||||
imageUrl = await getS3Image(resolvedConfig.source?.replace('s3://', ''))
|
||||
} else if (resolvedConfig.sourceKind === 'png encoded as base64') {
|
||||
imageUrl = 'data:image/png;base64,' + resolvedConfig.source
|
||||
} else if (resolvedConfig.sourceKind === 'jpeg encoded as base64') {
|
||||
imageUrl = 'data:image/jpeg;base64,' + resolvedConfig.source
|
||||
} else if (resolvedConfig.sourceKind === 'svg encoded as base64') {
|
||||
imageUrl = 'data:image/svg+xml;base64,' + resolvedConfig.source
|
||||
} else {
|
||||
imageUrl = resolvedConfig.source
|
||||
}
|
||||
}
|
||||
|
||||
$: resolvedConfig && loadImage()
|
||||
</script>
|
||||
|
||||
<InitializeComponent {id} />
|
||||
@@ -55,23 +105,19 @@
|
||||
{/each}
|
||||
|
||||
{#if render}
|
||||
<Loader loading={resolvedConfig.source == undefined}>
|
||||
<img
|
||||
on:pointerdown|preventDefault
|
||||
src={resolvedConfig.sourceKind == 'png encoded as base64'
|
||||
? 'data:image/png;base64,' + resolvedConfig.source
|
||||
: resolvedConfig.sourceKind == 'jpeg encoded as base64'
|
||||
? 'data:image/jpeg;base64,' + resolvedConfig.source
|
||||
: resolvedConfig.sourceKind == 'svg encoded as base64'
|
||||
? 'data:image/svg+xml;base64,' + resolvedConfig.source
|
||||
: resolvedConfig.source}
|
||||
alt={resolvedConfig.altText}
|
||||
style={css?.image?.style ?? ''}
|
||||
class={twMerge(
|
||||
`w-full h-full ${fit[resolvedConfig.imageFit || 'cover']}`,
|
||||
css?.image?.class,
|
||||
'wm-image'
|
||||
)}
|
||||
/>
|
||||
<Loader loading={imageUrl === undefined}>
|
||||
{#if imageUrl}
|
||||
<img
|
||||
on:pointerdown|preventDefault
|
||||
src={imageUrl}
|
||||
alt={resolvedConfig.altText}
|
||||
style={css?.image?.style ?? ''}
|
||||
class={twMerge(
|
||||
`w-full h-full ${fit[resolvedConfig.imageFit || 'cover']}`,
|
||||
css?.image?.class,
|
||||
'wm-image'
|
||||
)}
|
||||
/>
|
||||
{/if}
|
||||
</Loader>
|
||||
{/if}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
EvalAppInput,
|
||||
EvalV2AppInput,
|
||||
TemplateV2Input,
|
||||
UploadAppInput
|
||||
UploadAppInput,
|
||||
UploadS3AppInput
|
||||
} from '../../inputType'
|
||||
import type {
|
||||
AppEditorContext,
|
||||
@@ -235,6 +236,8 @@
|
||||
}
|
||||
} else if (lastInput?.type == 'upload') {
|
||||
value = (lastInput as UploadAppInput).value
|
||||
} else if (lastInput?.type == 'uploadS3') {
|
||||
value = (lastInput as UploadS3AppInput).value
|
||||
} else {
|
||||
value = undefined
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import { initConfig, initOutput } from '../../editor/appUtils'
|
||||
import type { AppViewerContext, ComponentCustomCSS, RichConfigurations } from '../../types'
|
||||
import type { FileUploadData } from '../../inputType'
|
||||
import { initCss } from '../../utils'
|
||||
import ResolveStyle from '../helpers/ResolveStyle.svelte'
|
||||
import ResolveConfig from '../helpers/ResolveConfig.svelte'
|
||||
@@ -26,16 +27,6 @@
|
||||
configuration
|
||||
)
|
||||
|
||||
type FileUploadData = {
|
||||
name: string
|
||||
size: number
|
||||
progress: number
|
||||
cancelled?: boolean
|
||||
errorMessage?: string
|
||||
path?: string
|
||||
file?: File
|
||||
}
|
||||
|
||||
let fileUploads: Writable<FileUploadData[]> = writable([])
|
||||
const { app, worldStore, componentControl, runnableComponents, workspace } =
|
||||
getContext<AppViewerContext>('AppViewerContext')
|
||||
|
||||
@@ -85,7 +85,11 @@
|
||||
import Summary from '$lib/components/Summary.svelte'
|
||||
import HideButton from './settingsPanel/HideButton.svelte'
|
||||
import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte'
|
||||
import { computeS3FileInputPolicy, computeWorkspaceS3FileInputPolicy } from './appUtilsS3'
|
||||
import {
|
||||
computeS3FileInputPolicy,
|
||||
computeWorkspaceS3FileInputPolicy,
|
||||
computeS3ImageViewerPolicy
|
||||
} from './appUtilsS3'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import { base } from '$lib/base'
|
||||
import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte'
|
||||
@@ -342,6 +346,17 @@
|
||||
}
|
||||
|
||||
policy.s3_inputs = s3_inputs
|
||||
|
||||
const s3FileKeys = items
|
||||
.filter((x) => (x.data as AppComponent).type === 'imagecomponent')
|
||||
.map((x) => {
|
||||
const c = x.data as AppComponent
|
||||
const config = c.configuration as any
|
||||
return computeS3ImageViewerPolicy(config, $app)
|
||||
})
|
||||
.filter(Boolean) as { s3_path?: string | undefined; resource?: string | undefined }[]
|
||||
|
||||
policy.allowed_s3_keys = s3FileKeys
|
||||
}
|
||||
|
||||
async function processRunnable(
|
||||
|
||||
@@ -80,3 +80,14 @@ export function computeS3FileInputPolicy(s3Config: any, app: App) {
|
||||
file_key_regex
|
||||
}
|
||||
}
|
||||
|
||||
export function computeS3ImageViewerPolicy(config: any, app: App) {
|
||||
if (
|
||||
config.sourceKind.value === 's3 (workspace storage)' ||
|
||||
config.source.value.startsWith('s3://')
|
||||
) {
|
||||
return { s3_path: config.source.value.replace('s3://', ''), resource: 'default' }
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -485,6 +485,7 @@ export const selectOptions = {
|
||||
prose: ['sm', 'Default', 'lg', 'xl', '2xl'],
|
||||
imageSourceKind: [
|
||||
'url',
|
||||
's3 (workspace storage)',
|
||||
'png encoded as base64',
|
||||
'jpeg encoded as base64',
|
||||
'svg encoded as base64'
|
||||
@@ -3073,6 +3074,10 @@ See date-fns format for more information. By default, it is 'dd.MM.yyyy HH:mm'
|
||||
fileUpload: {
|
||||
accept: 'image/*',
|
||||
convertTo: 'base64'
|
||||
},
|
||||
fileUploadS3: {
|
||||
accept: 'image/*',
|
||||
convertTo: 'base64'
|
||||
}
|
||||
},
|
||||
sourceKind: {
|
||||
|
||||
@@ -5,13 +5,24 @@
|
||||
import EvalInputEditor from './inputEditor/EvalInputEditor.svelte'
|
||||
import RowInputEditor from './inputEditor/RowInputEditor.svelte'
|
||||
import StaticInputEditor from './inputEditor/StaticInputEditor.svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import UploadInputEditor from './inputEditor/UploadInputEditor.svelte'
|
||||
import { getContext, createEventDispatcher } from 'svelte'
|
||||
import type { AppViewerContext, RichConfiguration } from '../../types'
|
||||
import type { InputConnection, InputType, UploadAppInput } from '../../inputType'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import S3FilePicker from '$lib/components/S3FilePicker.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import { FunctionSquare, Loader2, Pen, Plug2, Upload, User } from 'lucide-svelte'
|
||||
import {
|
||||
FunctionSquare,
|
||||
Loader2,
|
||||
Pen,
|
||||
Plug2,
|
||||
Upload,
|
||||
UploadCloud,
|
||||
User,
|
||||
Pipette
|
||||
} from 'lucide-svelte'
|
||||
import { fieldTypeToTsType } from '../../utils'
|
||||
import EvalV2InputEditor from './inputEditor/EvalV2InputEditor.svelte'
|
||||
import ConnectionButton from '$lib/components/common/button/ConnectionButton.svelte'
|
||||
@@ -30,6 +41,7 @@
|
||||
export let format: string | undefined
|
||||
export let selectOptions: string[] | undefined
|
||||
export let fileUpload: UploadAppInput['fileUpload'] | undefined = undefined
|
||||
export let fileUploadS3: UploadAppInput['fileUploadS3'] | undefined = undefined
|
||||
export let placeholder: string | undefined
|
||||
export let customTitle: string | undefined = undefined
|
||||
export let displayType: boolean = false
|
||||
@@ -44,11 +56,22 @@
|
||||
export let markdownTooltip: string | undefined = undefined
|
||||
export let securedContext = false
|
||||
|
||||
const { connectingInput, app } = getContext<AppViewerContext>('AppViewerContext')
|
||||
const { connectingInput, app, workspace } = getContext<AppViewerContext>('AppViewerContext')
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let evalV2editor: EvalV2InputEditor
|
||||
let s3FilePicker: S3FilePicker | undefined
|
||||
let s3PickerSelection: { s3: string; storage?: string } | undefined = undefined
|
||||
let s3FolderPrefix: string = ''
|
||||
$: s3PickerSelection && updateSelectedS3File()
|
||||
|
||||
function updateSelectedS3File() {
|
||||
if (s3PickerSelection) {
|
||||
componentInput['value'] = `s3://${s3PickerSelection.s3}`
|
||||
}
|
||||
}
|
||||
|
||||
function applyConnection(connection: InputConnection) {
|
||||
const expr = `${connection.componentId}.${connection.path}`
|
||||
//@ts-ignore
|
||||
@@ -150,6 +173,15 @@
|
||||
{#if fileUpload}
|
||||
<ToggleButton value="upload" icon={Upload} iconOnly tooltip="Upload" {item} />
|
||||
{/if}
|
||||
{#if fileUploadS3}
|
||||
<ToggleButton
|
||||
value="uploadS3"
|
||||
icon={UploadCloud}
|
||||
iconOnly
|
||||
tooltip="Upload S3"
|
||||
{item}
|
||||
/>
|
||||
{/if}
|
||||
{#if componentInput?.type === 'connected'}
|
||||
<ToggleButton value="connected" icon={Plug2} iconOnly tooltip="Connect" {item} />
|
||||
{/if}
|
||||
@@ -201,6 +233,38 @@
|
||||
/>
|
||||
{:else if componentInput?.type === 'upload'}
|
||||
<UploadInputEditor bind:componentInput {fileUpload} />
|
||||
{:else if componentInput?.type === 'uploadS3'}
|
||||
<div class="w-12/12 pb-2 flex flex-row mb-1 gap-1">
|
||||
<input type="text" placeholder="S3 Folder prefix" bind:value={s3FolderPrefix} aria-label="S3 Folder prefix" />
|
||||
</div>
|
||||
<UploadInputEditor
|
||||
bind:componentInput
|
||||
fileUpload={fileUploadS3}
|
||||
s3={true}
|
||||
{workspace}
|
||||
prefix={s3FolderPrefix}
|
||||
/>
|
||||
<Button
|
||||
variant="border"
|
||||
color="light"
|
||||
size="xs"
|
||||
btnClasses="mt-1"
|
||||
on:click={() => {
|
||||
s3PickerSelection = undefined
|
||||
s3FilePicker?.open?.()
|
||||
}}
|
||||
startIcon={{ icon: Pipette }}
|
||||
>
|
||||
Choose an existing file
|
||||
</Button>
|
||||
<S3FilePicker
|
||||
bind:this={s3FilePicker}
|
||||
folderOnly={false}
|
||||
fromWorkspaceSettings={true}
|
||||
bind:selectedFileKey={s3PickerSelection}
|
||||
readOnlyMode={false}
|
||||
regexFilter={/\.(png|jpg|jpeg|svg|webp)$/i}
|
||||
/>
|
||||
{:else if componentInput?.type === 'user'}
|
||||
<span class="text-2xs italic text-tertiary">Field's value is set by the user</span>
|
||||
{/if}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
selectOptions={meta?.['selectOptions']}
|
||||
tooltip={meta?.['tooltip']}
|
||||
fileUpload={meta?.['fileUpload']}
|
||||
fileUploadS3={meta?.['fileUploadS3']}
|
||||
placeholder={meta?.['placeholder']}
|
||||
customTitle={meta?.['customTitle']}
|
||||
loading={meta?.['loading']}
|
||||
|
||||
+54
-20
@@ -1,25 +1,59 @@
|
||||
<script lang="ts">
|
||||
import { FileInput } from '../../../../common'
|
||||
import type { UploadAppInput } from '../../../inputType'
|
||||
import type { UploadAppInput, UploadS3AppInput, FileUploadData } from '../../../inputType'
|
||||
import type { ReadFileAs } from '../../../../common/fileInput/model'
|
||||
import FileUpload from '$lib/components/common/fileUpload/FileUpload.svelte'
|
||||
import { writable, type Writable } from 'svelte/store'
|
||||
|
||||
export let componentInput: UploadAppInput | undefined
|
||||
export let fileUpload: UploadAppInput['fileUpload'] | undefined
|
||||
export let componentInput: UploadAppInput | UploadS3AppInput | undefined
|
||||
export let fileUpload: UploadAppInput['fileUpload'] | UploadS3AppInput['fileUploadS3'] | undefined
|
||||
export let s3: boolean | undefined = false
|
||||
export let prefix: string | undefined = undefined
|
||||
export let workspace: string | undefined = undefined
|
||||
|
||||
let fileUploads: Writable<FileUploadData[]> = writable([])
|
||||
|
||||
function hasConvertTo(upload: any): upload is { convertTo: ReadFileAs } {
|
||||
return upload && 'convertTo' in upload
|
||||
}
|
||||
</script>
|
||||
|
||||
<FileInput
|
||||
accept={fileUpload?.accept}
|
||||
multiple={fileUpload?.multiple}
|
||||
convertTo={fileUpload?.convertTo}
|
||||
iconSize={24}
|
||||
class="text-sm py-4"
|
||||
on:change={({ detail }) => {
|
||||
if (componentInput) {
|
||||
componentInput.value = fileUpload?.multiple ? detail : detail?.[0]
|
||||
}
|
||||
}}
|
||||
>
|
||||
<svelte:fragment slot="selected-title">
|
||||
<!-- Removing the title when there is a selected file -->
|
||||
<span />
|
||||
</svelte:fragment>
|
||||
</FileInput>
|
||||
{#if s3}
|
||||
<FileUpload
|
||||
acceptedFileTypes={[fileUpload?.accept ?? '*']}
|
||||
allowMultiple={fileUpload?.multiple}
|
||||
containerText={'Drag and drop a file'}
|
||||
customResourceType="s3"
|
||||
iconSize={24}
|
||||
customClass="text-sm py-4"
|
||||
{fileUploads}
|
||||
{workspace}
|
||||
pathTransformer={({ file }) => {
|
||||
const cleanPrefix = prefix ? `${prefix.replace(/^\/+|\/+$/g, '')}/` : ''
|
||||
return `${cleanPrefix}${file.name}`
|
||||
}}
|
||||
on:addition={({ detail }) => {
|
||||
if (componentInput) {
|
||||
componentInput.value = `s3://${detail.path}`
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<FileInput
|
||||
accept={fileUpload?.accept}
|
||||
multiple={fileUpload?.multiple}
|
||||
convertTo={hasConvertTo(fileUpload) ? fileUpload.convertTo : undefined}
|
||||
iconSize={24}
|
||||
class="text-sm py-4"
|
||||
on:change={({ detail }) => {
|
||||
if (componentInput) {
|
||||
componentInput.value = fileUpload?.multiple ? detail : detail?.[0]
|
||||
}
|
||||
}}
|
||||
>
|
||||
<svelte:fragment slot="selected-title">
|
||||
<!-- Removing the title when there is a selected file -->
|
||||
<span />
|
||||
</svelte:fragment>
|
||||
</FileInput>
|
||||
{/if}
|
||||
|
||||
@@ -76,6 +76,21 @@ export type UploadInput = {
|
||||
value: string
|
||||
}
|
||||
|
||||
export type UploadS3Input = {
|
||||
type: 'uploadS3'
|
||||
value: string
|
||||
}
|
||||
|
||||
export type FileUploadData = {
|
||||
name: string
|
||||
size: number
|
||||
progress: number
|
||||
cancelled?: boolean
|
||||
errorMessage?: string
|
||||
path?: string
|
||||
file?: File
|
||||
}
|
||||
|
||||
export type EvalInput = {
|
||||
type: 'eval'
|
||||
expr: string
|
||||
@@ -149,6 +164,7 @@ export type AppInputSpec<T extends InputType, U, V extends InputType = never> =
|
||||
| EvalInput
|
||||
| EvalInputV2
|
||||
| UploadInput
|
||||
| UploadS3Input
|
||||
| ResultInput
|
||||
| TemplateInput
|
||||
| TemplateV2Input
|
||||
@@ -174,6 +190,10 @@ type InputConfiguration<T extends InputType, V extends InputType> = {
|
||||
*/
|
||||
convertTo?: ReadFileAs
|
||||
}
|
||||
fileUploadS3?: {
|
||||
accept: string
|
||||
multiple?: boolean
|
||||
}
|
||||
noStatic?: boolean
|
||||
onDemandOnly?: boolean
|
||||
hideRefreshButton?: boolean
|
||||
@@ -243,6 +263,7 @@ export type StaticAppInputOnDemand = Extract<StaticAppInput, { onDemandOnly: tru
|
||||
export type TemplateV2AppInput = Extract<AppInput, { type: 'templatev2' }>
|
||||
|
||||
export type UploadAppInput = Extract<AppInput, { type: 'upload' }>
|
||||
export type UploadS3AppInput = Extract<AppInput, { type: 'uploadS3' }>
|
||||
|
||||
export type RichAppInput =
|
||||
| AppInput
|
||||
|
||||
@@ -22,7 +22,8 @@ import type {
|
||||
StaticAppInput,
|
||||
TemplateV2AppInput,
|
||||
UploadAppInput,
|
||||
UserAppInput
|
||||
UploadS3AppInput,
|
||||
UserAppInput,
|
||||
} from './inputType'
|
||||
import type { World } from './rx'
|
||||
import type { FilledItem } from './svelte-grid/types'
|
||||
@@ -59,6 +60,7 @@ export type Configuration =
|
||||
| EvalAppInput
|
||||
| EvalV2AppInput
|
||||
| UploadAppInput
|
||||
| UploadS3AppInput
|
||||
| ResultAppInput
|
||||
| TemplateV2AppInput
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
export let fileUploads: Writable<FileUploadData[]> = writable([])
|
||||
export let appPath: string | undefined = undefined
|
||||
export let disabled = false
|
||||
export let iconSize: number | undefined = undefined
|
||||
export let initialValue:
|
||||
| {
|
||||
s3: string
|
||||
@@ -534,6 +535,7 @@
|
||||
accept={acceptedFileTypes?.join(',')}
|
||||
multiple={allowMultiple}
|
||||
returnFileNames
|
||||
iconSize={iconSize}
|
||||
on:change={({ detail }) => {
|
||||
forceDisplayUploads = false
|
||||
handleChange(detail)
|
||||
|
||||
Reference in New Issue
Block a user