mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-08 00:03:07 +00:00
branch download UI on shouldDownloadViaClient instead of onclick interception (#9118)
* fix(frontend): branch download UI on shouldDownloadViaClient instead of intercepting in onclick When OpenAPI.TOKEN is set, several download links rendered an `<a href>` to the API and relied on an `onclick` handler to call `e.preventDefault()` and route the request through `downloadViaClient`. This is fragile in embedded contexts (e.g. the whitelabel React SDK) where Svelte's hydrated event listener may not intercept the click in time, so the browser follows the unauthenticated `href` straight to the API. Mirror the drawer pattern already used in `LogViewer` and `FlowStatusViewerInner`: render a `<button>` calling `downloadViaClient` when `shouldDownloadViaClient()` is true, and fall back to the plain `<a href download>` otherwise. Affects the LogViewer top bar, the large- result download in DisplayResult, the inline S3 link in ObjectViewer, the CSV link in ParqetCsvTableRenderer, and FileDownload. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(frontend): treat custom HEADERS / basic auth as token equivalents in shouldDownloadViaClient Customers wiring the SDK with cookie-bypass auth via custom request headers (e.g. `OpenAPI.HEADERS = getAuthHeaders()` returning a Bearer header) had `shouldDownloadViaClient()` return false because it only checked `OpenAPI.TOKEN`. The plain `<a href download>` branch then followed the link without those headers, so authenticated downloads silently degraded to the cookie path (or failed when there is no cookie). Widen the check to any non-cookie auth: TOKEN, HEADERS, or USERNAME (basic auth). Route `downloadViaClient` through the generated client's `getHeaders` so all configured auth schemes are applied consistently instead of hand-building an Authorization header for TOKEN only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
110384580e
commit
07a4cb6872
@@ -187,11 +187,6 @@
|
||||
: `data:text/json;charset=utf-8,${encodeURIComponent(toJsonStr(result))}`
|
||||
)
|
||||
let resultDownloadName = $derived(`${filename ?? 'result'}.json`)
|
||||
async function onResultDownload(e: MouseEvent) {
|
||||
if (!resultApiPath || !shouldDownloadViaClient()) return
|
||||
e.preventDefault()
|
||||
await downloadViaClient(resultApiPath, resultDownloadName)
|
||||
}
|
||||
|
||||
function checkIfS3(result: any, keys: string[]) {
|
||||
return keys.includes('s3') && typeof result.s3 === 'string'
|
||||
@@ -1020,13 +1015,17 @@
|
||||
{:else}
|
||||
{#if largeObject}
|
||||
<div class="text-xs text-emphasis"
|
||||
><a
|
||||
download={resultDownloadName}
|
||||
href={resultDownloadHref}
|
||||
onclick={onResultDownload}
|
||||
>
|
||||
Download {filename ? '' : 'as JSON'}
|
||||
</a>
|
||||
>{#if resultApiPath && shouldDownloadViaClient()}
|
||||
<button
|
||||
onclick={() => downloadViaClient(resultApiPath!, resultDownloadName)}
|
||||
>
|
||||
Download {filename ? '' : 'as JSON'}
|
||||
</button>
|
||||
{:else}
|
||||
<a download={resultDownloadName} href={resultDownloadHref}>
|
||||
Download {filename ? '' : 'as JSON'}
|
||||
</a>
|
||||
{/if}
|
||||
{#if download_as_csv}
|
||||
<DownloadCsv
|
||||
getContent={() => convertJsonToCsv(result)}
|
||||
|
||||
@@ -208,11 +208,6 @@
|
||||
let logsApiPath = $derived(`/w/${$workspaceStore}/jobs_u/get_logs/${jobId}`)
|
||||
let downloadHref = $derived(withExternalDomain(`${base}/api${logsApiPath}`))
|
||||
let downloadName = $derived(`windmill_logs_${jobId}.txt`)
|
||||
async function onDownloadClick(e: MouseEvent) {
|
||||
if (!shouldDownloadViaClient()) return
|
||||
e.preventDefault()
|
||||
await downloadViaClient(logsApiPath, downloadName)
|
||||
}
|
||||
let truncatedContent = $derived(truncateContent(content, loadedFromObjectStore, LOG_LIMIT))
|
||||
let prefixInfo = $derived(findPrefixInfo(truncatedContent))
|
||||
let downloadStartUrl = $derived(findStartUrl(truncatedContent, prefixInfo))
|
||||
@@ -357,14 +352,21 @@
|
||||
<div class="flex gap-2 justify-end flex-1">
|
||||
{#if jobId && download}
|
||||
<div class="flex items-center">
|
||||
<a
|
||||
class="text-primary pb-0.5"
|
||||
target="_blank"
|
||||
href={downloadHref}
|
||||
download={downloadName}
|
||||
onclick={onDownloadClick}
|
||||
><Download size="14" />
|
||||
</a>
|
||||
{#if shouldDownloadViaClient()}
|
||||
<button
|
||||
class="text-primary pb-0.5"
|
||||
onclick={() => downloadViaClient(logsApiPath, downloadName)}
|
||||
><Download size="14" />
|
||||
</button>
|
||||
{:else}
|
||||
<a
|
||||
class="text-primary pb-0.5"
|
||||
target="_blank"
|
||||
href={downloadHref}
|
||||
download={downloadName}
|
||||
><Download size="14" />
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<button onclick={logViewer.openDrawer}><Expand size="12" /></button>
|
||||
|
||||
@@ -203,16 +203,24 @@
|
||||
{#if !disable_download && !s3resource.endsWith('.csv')}
|
||||
{@const csvApiPath = `/w/${workspaceId}/job_helpers/download_s3_parquet_file_as_csv?file_key=${encodeURIComponent(s3resource)}${storage ? `&storage=${storage}` : ''}`}
|
||||
{@const csvName = (s3resource.split('/').pop() ?? 'download') + '.csv'}
|
||||
<a
|
||||
target="_blank"
|
||||
href="{base}/api{csvApiPath}"
|
||||
class="text-secondary w-full text-right underline text-2xs whitespace-nowrap"
|
||||
onclick={async (e) => {
|
||||
if (!shouldDownloadViaClient()) return
|
||||
e.preventDefault()
|
||||
await downloadViaClient(csvApiPath, csvName)
|
||||
}}><div class="flex flex-row-reverse gap-2 items-center"><Download size={12} /> CSV</div></a
|
||||
>
|
||||
{#if shouldDownloadViaClient()}
|
||||
<button
|
||||
class="text-secondary w-full text-right underline text-2xs whitespace-nowrap"
|
||||
onclick={() => downloadViaClient(csvApiPath, csvName)}
|
||||
><div class="flex flex-row-reverse gap-2 items-center"
|
||||
><Download size={12} /> CSV</div
|
||||
></button
|
||||
>
|
||||
{:else}
|
||||
<a
|
||||
target="_blank"
|
||||
href="{base}/api{csvApiPath}"
|
||||
class="text-secondary w-full text-right underline text-2xs whitespace-nowrap"
|
||||
><div class="flex flex-row-reverse gap-2 items-center"
|
||||
><Download size={12} /> CSV</div
|
||||
></a
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if nbRows != undefined}
|
||||
|
||||
@@ -26,26 +26,26 @@
|
||||
)
|
||||
let href = $derived(`${base}/api${apiPath}`)
|
||||
|
||||
async function onclick(e: MouseEvent) {
|
||||
if (!shouldDownloadViaClient()) return
|
||||
e.preventDefault()
|
||||
await downloadViaClient(apiPath, filename)
|
||||
}
|
||||
const sharedClass = `relative center-center flex w-full text-center font-normal text-primary text-sm
|
||||
border border-dashed border-gray-400 hover:border-blue-500
|
||||
focus-within:border-blue-500 hover:bg-blue-50 dark:hover:bg-frost-900 focus-within:bg-blue-50
|
||||
duration-200 rounded-lg p-1 gap-2`
|
||||
</script>
|
||||
|
||||
{#if s3object && s3object?.s3}
|
||||
<a
|
||||
class="relative center-center flex w-full text-center font-normal text-primary text-sm
|
||||
border border-dashed border-gray-400 hover:border-blue-500
|
||||
focus-within:border-blue-500 hover:bg-blue-50 dark:hover:bg-frost-900 focus-within:bg-blue-50
|
||||
duration-200 rounded-lg p-1 gap-2"
|
||||
{href}
|
||||
download={filename}
|
||||
{onclick}
|
||||
>
|
||||
<Download />
|
||||
<span>
|
||||
{s3object?.storage ? `s3://${s3object.storage}/${s3object.s3}` : `s3:///${s3object.s3}`}
|
||||
</span>
|
||||
</a>
|
||||
{#if shouldDownloadViaClient()}
|
||||
<button class={sharedClass} onclick={() => downloadViaClient(apiPath, filename)}>
|
||||
<Download />
|
||||
<span>
|
||||
{s3object?.storage ? `s3://${s3object.storage}/${s3object.s3}` : `s3:///${s3object.s3}`}
|
||||
</span>
|
||||
</button>
|
||||
{:else}
|
||||
<a class={sharedClass} {href} download={filename}>
|
||||
<Download />
|
||||
<span>
|
||||
{s3object?.storage ? `s3://${s3object.storage}/${s3object.s3}` : `s3:///${s3object.s3}`}
|
||||
</span>
|
||||
</a>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -410,18 +410,26 @@
|
||||
{#if getTypeAsString(jsonFiltered) === 's3object'}
|
||||
{@const s3DownloadApiPath = `/w/${$workspaceStore}/job_helpers/download_s3_file?file_key=${encodeURIComponent(jsonFiltered?.s3 ?? '')}${jsonFiltered?.storage ? `&storage=${jsonFiltered.storage}` : ''}`}
|
||||
{@const s3DownloadName = jsonFiltered?.s3.split('/').pop() ?? 'unnamed_download.file'}
|
||||
<a
|
||||
class="text-secondary underline font-semibold text-2xs whitespace-nowrap ml-1 w-fit"
|
||||
href={`/api${s3DownloadApiPath}`}
|
||||
download={s3DownloadName}
|
||||
onclick={async (e) => {
|
||||
if (!shouldDownloadViaClient()) return
|
||||
e.preventDefault()
|
||||
await downloadViaClient(s3DownloadApiPath, s3DownloadName)
|
||||
}}
|
||||
>
|
||||
<span class="flex items-center gap-1"><Download size={12} />download</span>
|
||||
</a>
|
||||
{#if shouldDownloadViaClient()}
|
||||
<button
|
||||
class="text-secondary underline font-semibold text-2xs whitespace-nowrap ml-1 w-fit"
|
||||
onclick={() => downloadViaClient(s3DownloadApiPath, s3DownloadName)}
|
||||
>
|
||||
<span class="flex items-center gap-1"
|
||||
><Download size={12} />download</span
|
||||
>
|
||||
</button>
|
||||
{:else}
|
||||
<a
|
||||
class="text-secondary underline font-semibold text-2xs whitespace-nowrap ml-1 w-fit"
|
||||
href={`/api${s3DownloadApiPath}`}
|
||||
download={s3DownloadName}
|
||||
>
|
||||
<span class="flex items-center gap-1"
|
||||
><Download size={12} />download</span
|
||||
>
|
||||
</a>
|
||||
{/if}
|
||||
<button
|
||||
class="text-secondary underline text-2xs whitespace-nowrap ml-1"
|
||||
onclick={() => {
|
||||
|
||||
@@ -1,32 +1,27 @@
|
||||
import { OpenAPI } from '$lib/gen'
|
||||
import { getHeaders } from '$lib/gen/core/request'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
|
||||
async function resolveToken(): Promise<string | undefined> {
|
||||
const t = OpenAPI.TOKEN
|
||||
if (!t) return undefined
|
||||
return typeof t === 'string' ? t : await t({} as any)
|
||||
}
|
||||
|
||||
/**
|
||||
* When OpenAPI.TOKEN is set we cannot rely on a plain `<a href>` browser navigation
|
||||
* because the browser does not attach the Authorization header. In that case fetch
|
||||
* the file via the OpenAPI client (which uses OpenAPI.BASE and the Bearer token) and
|
||||
* trigger a download from a blob URL. Otherwise let the default link behavior happen.
|
||||
* When OpenAPI is configured to authenticate via headers (TOKEN, basic auth,
|
||||
* or arbitrary HEADERS) we cannot rely on a plain `<a href>` browser
|
||||
* navigation because the browser does not attach those headers. In that case
|
||||
* fetch the file via the OpenAPI client (which carries the configured auth)
|
||||
* and trigger a download from a blob URL. Cookie-only auth still works with
|
||||
* a plain link.
|
||||
*
|
||||
* `apiPath` should be the path relative to OpenAPI.BASE, starting with `/`
|
||||
* (e.g. `/w/foo/job_helpers/download_s3_file?file_key=...`).
|
||||
*/
|
||||
export function shouldDownloadViaClient(): boolean {
|
||||
return Boolean(OpenAPI.TOKEN)
|
||||
return Boolean(OpenAPI.TOKEN || OpenAPI.HEADERS || OpenAPI.USERNAME)
|
||||
}
|
||||
|
||||
export async function downloadViaClient(apiPath: string, filename: string): Promise<void> {
|
||||
const token = await resolveToken()
|
||||
const url = `${OpenAPI.BASE}${apiPath}`
|
||||
const headers: Record<string, string> = {}
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||
let response: Response
|
||||
try {
|
||||
const headers = await getHeaders(OpenAPI, { method: 'GET', url: apiPath })
|
||||
response = await fetch(url, { headers, credentials: OpenAPI.CREDENTIALS })
|
||||
} catch (e) {
|
||||
sendUserToast(`Download failed: ${e}`, true)
|
||||
|
||||
Reference in New Issue
Block a user