feat: add S3 support to download button and PDF preview components (#7271)

* feat: add S3 support to download button and PDF preview components

Add S3 object and s3:// URL support to AppDownload and AppPdf components,
following the same pattern used in AppImage component. Both components now:
- Handle partial S3 objects with storage and presigned URL support
- Handle s3:// URL format
- Construct proper API endpoints for S3 file downloads

Fixes #7240

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>

* handle policy + fix s3 picker

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
hugocasa
2025-12-02 13:53:35 +01:00
committed by GitHub
parent 69c550bca6
commit a23d4f015a
13 changed files with 215 additions and 78 deletions
+2 -2
View File
@@ -757,8 +757,8 @@ JsonNode ${windmillPathToCamelCaseName(path)} = JsonNode.Parse(await client.GetS
<S3FilePicker
bind:this={s3FilePicker}
readOnlyMode={false}
on:selectAndClose={(s3obj) => {
let s = `'${formatS3Object(s3obj.detail)}'`
onSelectAndClose={(s3obj) => {
let s = `'${formatS3Object(s3obj)}'`
if (lang === 'duckdb') {
editor?.insertAtCursor(`SELECT * FROM ${s}`)
} else if (lang === 'python3') {
@@ -2,7 +2,7 @@
import { emptyString, type S3Object } from '$lib/utils'
import { Button, Drawer } from './common'
import DrawerContent from './common/drawer/DrawerContent.svelte'
import { createEventDispatcher, tick, untrack } from 'svelte'
import { tick, untrack } from 'svelte'
import S3FilePickerInner from './S3FilePickerInner.svelte'
import Select from './select/Select.svelte'
import { FileUp } from 'lucide-svelte'
@@ -10,11 +10,6 @@
import { SettingService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
let dispatch = createEventDispatcher<{
close: { s3: string; storage: string | undefined } | undefined
selectAndClose: { s3: string; storage: string | undefined }
}>()
interface Props {
fromWorkspaceSettings?: boolean
readOnlyMode: boolean
@@ -22,6 +17,8 @@
selectedFileKey?: { s3: string; storage?: string } | undefined
folderOnly?: boolean
regexFilter?: RegExp | undefined
onClose?: () => void
onSelectAndClose?: (selected: { s3: string; storage: string | undefined }) => void
}
let {
@@ -30,7 +27,9 @@
initialFileKey = $bindable(undefined),
selectedFileKey = $bindable(undefined),
folderOnly = false,
regexFilter = undefined
regexFilter = undefined,
onClose,
onSelectAndClose
}: Props = $props()
let drawer: Drawer | undefined = $state()
@@ -76,7 +75,7 @@
<Drawer
bind:this={drawer}
on:close={() => {
dispatch('close')
onClose?.()
s3FilePickerInner?.close?.()
}}
size="1200px"
@@ -93,7 +92,7 @@
<S3FilePickerInner
bind:this={s3FilePickerInner}
on:selectAndClose={(e) => {
dispatch('selectAndClose', e.detail)
onSelectAndClose?.(e.detail)
drawer?.closeDrawer?.()
}}
{fromWorkspaceSettings}
@@ -39,7 +39,7 @@
<S3FilePicker
bind:this={s3FilePicker}
bind:selectedFileKey={value}
on:close={() => {
onClose={() => {
rawValue = JSON.stringify(value, null, 2)
editor?.setCode(rawValue)
}}
@@ -12,6 +12,8 @@
import ComponentErrorHandler from '../helpers/ComponentErrorHandler.svelte'
import ResolveStyle from '../helpers/ResolveStyle.svelte'
import AlignWrapper from '../helpers/AlignWrapper.svelte'
import { userStore } from '$lib/stores'
import { isPartialS3Object, getS3File } from '../../editor/appUtilsS3'
interface Props {
id: string
@@ -37,7 +39,8 @@
initConfig(components['downloadcomponent'].initialData.configuration, configuration)
)
const { app, worldStore } = getContext<AppViewerContext>('AppViewerContext')
const { app, worldStore, appPath, workspace, isEditor } =
getContext<AppViewerContext>('AppViewerContext')
//used so that we can count number of outputs setup for first refresh
initOutput($worldStore, id, {})
@@ -45,6 +48,10 @@
let beforeIconComponent: any = $state()
let afterIconComponent: any = $state()
let downloadUrl: string | undefined = $state(undefined)
let token = getContext<{ token?: string }>('AuthToken')
async function handleBeforeIcon() {
if (resolvedConfig.beforeIcon) {
beforeIconComponent = await loadIcon(
@@ -69,6 +76,36 @@
}
}
async function loadSource() {
if (isPartialS3Object(resolvedConfig.source)) {
downloadUrl = await getS3File({
source: resolvedConfig.source.s3,
storage: resolvedConfig.source.storage,
presigned: resolvedConfig.source.presigned,
appPath: $appPath,
username: $userStore?.username,
workspace,
token: token?.token,
isEditor,
configuration
})
} else if (resolvedConfig.source && typeof resolvedConfig.source !== 'string') {
throw new Error('Invalid source object' + typeof resolvedConfig.source)
} else if (resolvedConfig.source?.startsWith('s3://')) {
downloadUrl = await getS3File({
source: resolvedConfig.source?.replace('s3://', ''),
appPath: $appPath,
username: $userStore?.username,
workspace,
token: token?.token,
isEditor,
configuration
})
} else {
downloadUrl = transformBareBase64IfNecessary(resolvedConfig.source)
}
}
let css = $state(initCss($app.css?.downloadcomponent, customCss))
$effect(() => {
resolvedConfig.beforeIcon && beforeIconComponent && untrack(() => handleBeforeIcon())
@@ -76,6 +113,9 @@
$effect(() => {
resolvedConfig.afterIcon && afterIconComponent && untrack(() => handleAfterIcon())
})
$effect(() => {
resolvedConfig && loadSource()
})
</script>
<InitializeComponent {id} />
@@ -102,7 +142,9 @@
{#if render}
<AlignWrapper {noWFull} {horizontalAlignment} {verticalAlignment}>
<ComponentErrorHandler
hasError={resolvedConfig?.source != undefined && typeof resolvedConfig.source !== 'string'}
hasError={resolvedConfig?.source != undefined &&
typeof resolvedConfig.source !== 'string' &&
!isPartialS3Object(resolvedConfig.source)}
>
<Button
on:pointerdown={(e) => e.stopPropagation()}
@@ -122,7 +164,7 @@
extendedSize={resolvedConfig.size}
color={resolvedConfig.color}
download={resolvedConfig.filename}
href={transformBareBase64IfNecessary(resolvedConfig.source)}
href={downloadUrl}
target="_blank"
ref="external"
nonCaptureEvent
@@ -12,9 +12,9 @@
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, isPartialS3Object } from '../../editor/appUtilsS3'
import { isPartialS3Object, getS3File } from '../../editor/appUtilsS3'
interface Props {
id: string
@@ -25,14 +25,6 @@
let { id, configuration, customCss = undefined, render }: Props = $props()
function computeForceViewerPolicies() {
if (!isEditor) {
return undefined
}
const policy = computeS3ImageViewerPolicy(configuration)
return policy
}
const resolvedConfig = $state(
initConfig(components['imagecomponent'].initialData.configuration, configuration)
)
@@ -54,43 +46,34 @@
let token = getContext<{ token?: string }>('AuthToken')
async function getS3Image(source: string | undefined, storage?: string, presigned?: string) {
if (!source) return ''
const appPathOrUser = defaultIfEmptyString(
$appPath,
`u/${$userStore?.username ?? 'unknown'}/newapp`
)
const params = new URLSearchParams()
params.append('s3', source)
if (storage) {
params.append('storage', storage)
}
if (token?.token && token.token != '') {
params.append('token', token.token)
}
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()}${presigned ? `&${presigned}` : ''}`
}
async function loadImage() {
if (isPartialS3Object(resolvedConfig.source)) {
imageUrl = await getS3Image(
resolvedConfig.source.s3,
resolvedConfig.source.storage,
resolvedConfig.source.presigned
)
imageUrl = await getS3File({
source: resolvedConfig.source.s3,
storage: resolvedConfig.source.storage,
presigned: resolvedConfig.source.presigned,
appPath: $appPath,
username: $userStore?.username,
workspace,
token: token?.token,
isEditor,
configuration
})
} else if (resolvedConfig.source && typeof resolvedConfig.source !== 'string') {
throw new Error('Invalid image object' + typeof resolvedConfig.source)
} else if (
resolvedConfig.sourceKind === 's3 (workspace storage)' ||
resolvedConfig.source?.startsWith('s3://')
) {
imageUrl = await getS3Image(resolvedConfig.source?.replace('s3://', ''))
imageUrl = await getS3File({
source: resolvedConfig.source?.replace('s3://', ''),
appPath: $appPath,
username: $userStore?.username,
workspace,
token: token?.token,
isEditor,
configuration
})
} else if (resolvedConfig.sourceKind === 'png encoded as base64') {
imageUrl = 'data:image/png;base64,' + resolvedConfig.source
} else if (resolvedConfig.sourceKind === 'jpeg encoded as base64') {
@@ -7,6 +7,8 @@
import InitializeComponent from '../helpers/InitializeComponent.svelte'
import ResolveStyle from '../helpers/ResolveStyle.svelte'
import { Loader2 } from 'lucide-svelte'
import { userStore } from '$lib/stores'
import { isPartialS3Object, getS3File } from '../../editor/appUtilsS3'
interface Props {
id: string
@@ -17,7 +19,8 @@
let { id, configuration, customCss = undefined, render }: Props = $props()
const { app, worldStore } = getContext<AppViewerContext>('AppViewerContext')
const { app, worldStore, appPath, workspace, isEditor } =
getContext<AppViewerContext>('AppViewerContext')
const outputs = initOutput($worldStore, id, {
loading: false
@@ -26,6 +29,44 @@
let source: string | ArrayBuffer | undefined = $state(undefined)
let zoom: number | undefined = $state(undefined)
let pdfSource: string | ArrayBuffer | undefined = $state(undefined)
let token = getContext<{ token?: string }>('AuthToken')
async function loadSource() {
if (isPartialS3Object(source)) {
pdfSource = await getS3File({
source: source.s3,
storage: source.storage,
presigned: source.presigned,
appPath: $appPath,
username: $userStore?.username,
workspace,
token: token?.token,
isEditor,
configuration
})
} else if (source && typeof source !== 'string' && !(source instanceof ArrayBuffer)) {
throw new Error('Invalid PDF source object' + typeof source)
} else if (typeof source === 'string' && source?.startsWith('s3://')) {
pdfSource = await getS3File({
source: source?.replace('s3://', ''),
appPath: $appPath,
username: $userStore?.username,
workspace,
token: token?.token,
isEditor,
configuration
})
} else {
pdfSource = source
}
}
$effect(() => {
source && loadSource()
})
let css = $state(initCss($app.css?.pdfcomponent, customCss))
</script>
@@ -50,7 +91,7 @@
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
{source}
source={pdfSource}
{zoom}
class={css?.container?.class}
style={css?.container?.style}
@@ -10,7 +10,7 @@ import type { App } from '../types'
import {
computeS3FileInputPolicy,
computeWorkspaceS3FileInputPolicy,
computeS3ImageViewerPolicy
computeS3FileViewerPolicy
} from './appUtilsS3'
import { collectStaticFields, type TriggerableV2 } from './commonAppUtils'
import type { Policy } from '$lib/gen'
@@ -165,11 +165,16 @@ export async function updatePolicy(app: App, currentPolicy: Policy | undefined):
}
const s3FileKeys = items
.filter((x) => (x.data as AppComponent).type === 'imagecomponent')
.filter(
(x) =>
(x.data as AppComponent).type === 'imagecomponent' ||
(x.data as AppComponent).type === 'pdfcomponent' ||
(x.data as AppComponent).type === 'downloadcomponent'
)
.map((x) => {
const c = x.data as AppComponent
const config = c.configuration
return computeS3ImageViewerPolicy(config)
return computeS3FileViewerPolicy(config)
})
.filter(Boolean) as { s3_path: string; storage?: string | undefined }[]
@@ -1,3 +1,4 @@
import { defaultIfEmptyString } from '$lib/utils'
import type { AppInput, EvalInputV2 } from '../inputType'
import type { App, RichConfigurations } from '../types'
import { collectOneOfFields } from './appUtilsCore'
@@ -86,7 +87,61 @@ export function isPartialS3Object(
return input != undefined && typeof input === 'object' && typeof input['s3'] === 'string'
}
export function computeS3ImageViewerPolicy(config: RichConfigurations) {
function computeForceViewerPolicies({
isEditor,
configuration
}: {
isEditor: boolean
configuration: RichConfigurations
}) {
if (!isEditor) {
return undefined
}
const policy = computeS3FileViewerPolicy(configuration)
return policy
}
export async function getS3File({
source,
storage,
presigned,
appPath,
username,
workspace,
token,
isEditor,
configuration
}: {
source: string | undefined
storage?: string
presigned?: string
appPath: string
username: string | undefined
workspace: string
token: string | undefined
isEditor: boolean
configuration: RichConfigurations
}) {
if (!source) return ''
const appPathOrUser = defaultIfEmptyString(appPath, `u/${username ?? 'unknown'}/newapp`)
const params = new URLSearchParams()
params.append('s3', source)
if (storage) {
params.append('storage', storage)
}
if (token && token != '') {
params.append('token', token)
}
const forceViewerPolicies = computeForceViewerPolicies({ isEditor, configuration })
if (forceViewerPolicies) {
params.append('force_viewer_allowed_s3_keys', JSON.stringify([forceViewerPolicies]))
}
return `/api/w/${workspace}/apps_u/download_s3_file/${appPathOrUser}?${params.toString()}${presigned ? `&${presigned}` : ''}`
}
export function computeS3FileViewerPolicy(config: RichConfigurations) {
if (config.source.type === 'uploadS3' && isPartialS3Object(config.source.value)) {
return {
s3_path: config.source.value.s3,
@@ -1642,6 +1642,9 @@ export const components = {
accept: '*',
convertTo: 'base64'
},
fileUploadS3: {
accept: '*'
},
placeholder: 'Enter URL or upload file (base64)'
},
filename: {
@@ -3459,8 +3462,7 @@ See date-fns format for more information. By default, it is 'dd.MM.yyyy HH:mm'
convertTo: 'base64'
},
fileUploadS3: {
accept: 'image/*',
convertTo: 'base64'
accept: 'image/*'
}
},
sourceKind: {
@@ -3660,6 +3662,9 @@ See date-fns format for more information. By default, it is 'dd.MM.yyyy HH:mm'
accept: 'application/pdf',
convertTo: 'base64'
},
fileUploadS3: {
accept: 'application/pdf'
},
placeholder: 'Enter URL or upload file (base64)'
},
zoom: {
@@ -28,6 +28,7 @@
import ConnectionButton from '$lib/components/common/button/ConnectionButton.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import type SimpleEditor from '$lib/components/SimpleEditor.svelte'
interface Props {
id: string
@@ -108,6 +109,7 @@
let s3PickerSelection: { s3: string; storage?: string } | undefined = $state(undefined)
let s3FolderPrefix: string = $state('')
let s3FileUploadRawMode = $state(componentInput?.type == 'uploadS3' && !!componentInput.value?.s3)
let s3JsonEditor: SimpleEditor | undefined = $state()
function updateSelectedS3File() {
if (s3PickerSelection) {
@@ -115,6 +117,7 @@
componentInput.value = {
...s3PickerSelection
}
s3JsonEditor?.setCode(JSON.stringify(s3PickerSelection, null, 2))
}
s3FileUploadRawMode = true
}
@@ -317,6 +320,7 @@
<Module.default
code={JSON.stringify(componentInput.value ?? { s3: '' }, null, 2)}
bind:value={componentInput.value}
bind:editor={s3JsonEditor}
/>
{/await}
{:else}
@@ -351,8 +355,8 @@
<S3FilePicker
bind:this={s3FilePicker}
folderOnly={false}
on:close={(e) => {
s3PickerSelection = e.detail
onSelectAndClose={(selected) => {
s3PickerSelection = selected
updateSelectedS3File()
}}
readOnlyMode={false}
@@ -29,6 +29,7 @@
import S3FilePicker from '$lib/components/S3FilePicker.svelte'
import FileUpload from '$lib/components/common/fileUpload/FileUpload.svelte'
import DucklakePicker from '$lib/components/DucklakePicker.svelte'
import type SimpleEditor from '$lib/components/SimpleEditor.svelte'
interface Props {
componentInput: StaticInput<any> | undefined
@@ -56,8 +57,11 @@
componentInput && appContext?.onchange?.()
})
let s3FileUploadRawMode = $state(false)
let s3FileUploadRawMode = $state(
componentInput?.value && typeof componentInput.value == 'object' && !!componentInput.value?.s3
)
let s3FilePicker: S3FilePicker | undefined = $state(undefined)
let s3JsonEditor: SimpleEditor | undefined = $state()
</script>
{#key subFieldType}
@@ -216,6 +220,7 @@
<Module.default
code={JSON.stringify(componentInput.value ?? { s3: '' }, null, 2)}
bind:value={componentInput.value}
bind:editor={s3JsonEditor}
/>
{/await}
{:else}
@@ -255,13 +260,12 @@
<S3FilePicker
bind:this={s3FilePicker}
readOnlyMode={false}
on:close={(e) => {
if (e.detail) {
if (componentInput) {
componentInput.value = e.detail
}
s3FileUploadRawMode = true
onSelectAndClose={(selected) => {
if (componentInput) {
componentInput.value = selected
s3JsonEditor?.setCode(JSON.stringify(selected, null, 2))
}
s3FileUploadRawMode = true
}}
/>
{:else if format?.startsWith('resource-') && (componentInput.value == undefined || typeof componentInput.value == 'string')}
@@ -51,16 +51,16 @@
{#if $userStore}
<S3FilePicker
bind:this={s3FilePicker}
on:selectAndClose={(ev) => {
onSelectAndClose={(selected) => {
if (multiple) {
if (Array.isArray(value)) {
value.push(ev.detail)
value.push(selected)
} else {
value = [ev.detail]
value = [selected]
}
fileUpload?.addUpload(ev.detail)
fileUpload?.addUpload(selected)
} else {
value = ev.detail
value = selected
fileUpload?.setUpload(value)
}
editor?.setCode(JSON.stringify(value))
@@ -377,7 +377,6 @@
}
}
// Update config for captures
function getCaptureConfig() {
const newCaptureConfig = {
@@ -408,7 +407,7 @@
bind:this={s3FilePicker}
folderOnly={is_static_website}
bind:selectedFileKey={static_asset_config}
on:close={() => {
onClose={() => {
s3Editor?.setCode(JSON.stringify(static_asset_config, null, 2))
s3FileUploadRawMode = true
}}