From 330f64ceddf33daab6282dc414cc1cc8cb1d1e00 Mon Sep 17 00:00:00 2001 From: vsxd Date: Mon, 16 Mar 2026 21:03:37 +0800 Subject: [PATCH] fix(web): avoid CSP errors when downloading skills --- web/src/api/client.ts | 45 ++++++++++++---------------------- web/src/pages/skill-detail.tsx | 14 +++-------- 2 files changed, 20 insertions(+), 39 deletions(-) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index bb0da6d9..7979d600 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -37,9 +37,8 @@ export { ApiError } export const WEB_API_PREFIX = '/api/web' -export type DownloadedFile = { - blob: Blob - fileName?: string +export type SkillDownloadRequest = { + url: string } type RuntimeConfig = { @@ -278,20 +277,6 @@ function ensureTrailingSlash(value: string): string { return value.endsWith('/') ? value : `${value}/` } -function parseDownloadFileName(contentDisposition: string | null): string | undefined { - if (!contentDisposition) { - return undefined - } - - const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i) - if (utf8Match) { - return decodeURIComponent(utf8Match[1]) - } - - const basicMatch = contentDisposition.match(/filename="?([^";]+)"?/i) - return basicMatch?.[1] -} - export async function getCurrentUser(): Promise { try { const user = await unwrap(client.GET('/api/v1/auth/me', { @@ -442,23 +427,25 @@ export const accountApi = { } export const skillDownloadApi = { - async downloadVersion(namespace: string, slug: string, version: string): Promise { + async downloadVersion(namespace: string, slug: string, version: string): Promise { const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace - const response = await fetch( - withBaseUrl(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${version}/download`), - { - headers: withRequestHeaders(), - }, - ) + const requestUrl = withBaseUrl(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${version}/download`) + const url = typeof requestUrl === 'string' ? requestUrl : requestUrl.toString() - if (!response.ok) { + const response = await fetch(requestUrl, { + headers: withRequestHeaders(), + redirect: 'manual', + }) + + if (response.type === 'opaqueredirect') { + return { url } + } + + if (!response.ok && response.status !== 0 && response.status !== 302) { throw new ApiError(`HTTP ${response.status}`, response.status) } - return { - blob: await response.blob(), - fileName: parseDownloadFileName(response.headers.get('content-disposition')), - } + return { url } }, } diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index 184c5252..46c6013b 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -137,15 +137,12 @@ export function SkillDetailPage() { const submitPromotionMutation = useSubmitPromotion() const reportMutation = useSubmitSkillReport(namespace, slug) - const triggerBrowserDownload = (blob: Blob, fileName: string) => { - const objectUrl = window.URL.createObjectURL(blob) + const triggerBrowserDownload = (url: string) => { const link = document.createElement('a') - link.href = objectUrl - link.download = fileName + link.href = url document.body.appendChild(link) link.click() link.remove() - window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 0) } const handleDownload = async () => { @@ -158,11 +155,8 @@ export function SkillDetailPage() { } try { - const downloadedFile = await skillDownloadApi.downloadVersion(namespace, slug, selectedVersionEntry.version) - triggerBrowserDownload( - downloadedFile.blob, - downloadedFile.fileName ?? `${slug}-${selectedVersionEntry.version}.zip`, - ) + const downloadRequest = await skillDownloadApi.downloadVersion(namespace, slug, selectedVersionEntry.version) + triggerBrowserDownload(downloadRequest.url) incrementSkillDownloadCount(queryClient, { namespace, slug }) queryClient.invalidateQueries({ queryKey: ['skills', namespace, slug] }) queryClient.invalidateQueries({ queryKey: ['skills', 'my'] })