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:
Ruben Fiszel
2026-05-12 10:42:05 +00:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 110384580e
commit 07a4cb6872
6 changed files with 92 additions and 80 deletions
@@ -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)}
+15 -13
View File
@@ -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={() => {
+9 -14
View File
@@ -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)