From d018ef82907a40d57af3218fa0db3bc45c2fa99a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 15:55:27 +0000 Subject: [PATCH] fix(attachments): download attachments via authenticated signed URL (framework #2970) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The framework now requires an authenticated session to download an attachments-scope file — the stable /storage/files/:fileId endpoint returns 401/403 for them. RecordAttachmentsPanel's download control no longer uses a bare (which can't carry the console's Bearer token): it fetches a short-lived signed URL from /storage/files/:fileId/url with createAuthenticatedFetch, then opens it. 403 ATTACHMENT_DOWNLOAD_DENIED / 401 AUTH_REQUIRED map to friendly copy instead of a broken link. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0187NT3Qer9oep5dCRb9b8Lt --- .../attachment-authenticated-downloads.md | 13 ++++ .../src/views/RecordAttachmentsPanel.tsx | 77 ++++++++++++++----- .../__tests__/RecordAttachmentsPanel.test.tsx | 67 ++++++++++++++++ 3 files changed, 139 insertions(+), 18 deletions(-) create mode 100644 .changeset/attachment-authenticated-downloads.md diff --git a/.changeset/attachment-authenticated-downloads.md b/.changeset/attachment-authenticated-downloads.md new file mode 100644 index 000000000..5874a9f20 --- /dev/null +++ b/.changeset/attachment-authenticated-downloads.md @@ -0,0 +1,13 @@ +--- +"@object-ui/app-shell": patch +--- + +fix(attachments): download attachments via authenticated signed URL (framework #2970) + +The framework now requires an authenticated session to download an +attachments-scope file (the stable `/storage/files/:fileId` endpoint returns +`401`/`403` for them). `RecordAttachmentsPanel`'s download control no longer +uses a bare `` (which cannot carry the console's Bearer token) — it +fetches a short-lived signed URL from `/storage/files/:fileId/url` with +`createAuthenticatedFetch`, then opens it. `403 ATTACHMENT_DOWNLOAD_DENIED` and +`401 AUTH_REQUIRED` map to friendly copy instead of a broken link. diff --git a/packages/app-shell/src/views/RecordAttachmentsPanel.tsx b/packages/app-shell/src/views/RecordAttachmentsPanel.tsx index e6e2d9df4..b44c33add 100644 --- a/packages/app-shell/src/views/RecordAttachmentsPanel.tsx +++ b/packages/app-shell/src/views/RecordAttachmentsPanel.tsx @@ -25,8 +25,9 @@ import { useObjectTranslation } from '@object-ui/react'; * Storage model: one `sys_file` row per uploaded blob (three-step * presigned upload via @object-ui/providers' ObjectStack adapter), one * `sys_attachment` join row linking it to `(parent_object, parent_id)`. - * Downloads go through the stable `/storage/files/:fileId` endpoint, - * which 302-redirects to a freshly signed URL on every request. + * Downloads fetch a short-lived signed URL from `/storage/files/:fileId/url` + * with the console's Bearer token (the endpoint requires an authenticated + * session for attachments-scope files, #2970), then open it. */ interface AttachmentRow { @@ -74,16 +75,16 @@ export const RecordAttachmentsPanel: React.FC = ({ // Vite dev console proxies same-origin `/api` unless VITE_SERVER_URL // points elsewhere. const baseUrl = (import.meta as any).env?.VITE_SERVER_URL || ''; + // One authenticated fetch (Bearer token from localStorage) reused for the + // upload adapter and the download-URL fetch — the storage routes require a + // session and there is no cookie for `credentials: 'include'` to carry. + const authFetch = React.useMemo(() => createAuthenticatedFetch(), []); const adapter = React.useMemo( - // `fetchImpl`: the storage upload routes require an authenticated - // session when the server has an auth service (#2755), and the console - // authenticates with a Bearer token (localStorage) — there is no session - // cookie for `credentials: 'include'` to carry. - () => createObjectStackUploadAdapter({ baseUrl, scope: 'attachments', fetchImpl: createAuthenticatedFetch() }), - [baseUrl], + () => createObjectStackUploadAdapter({ baseUrl, scope: 'attachments', fetchImpl: authFetch }), + [baseUrl, authFetch], ); - /** Map the server's fail-closed 403 codes (#2755) to friendly copy. */ + /** Map the server's fail-closed 40x codes (#2755, #2970) to friendly copy. */ const friendlyError = React.useCallback( (err: unknown): string => { const anyErr = err as { code?: string; message?: unknown } | null; @@ -99,6 +100,16 @@ export const RecordAttachmentsPanel: React.FC = ({ defaultValue: "You don't have access to attach files to this record.", }); } + if (has('ATTACHMENT_DOWNLOAD_DENIED')) { + return t('detail.attachmentDownloadDenied', { + defaultValue: "You don't have access to download this attachment.", + }); + } + if (has('AUTH_REQUIRED')) { + return t('detail.attachmentAuthRequired', { + defaultValue: 'Please sign in to download this attachment.', + }); + } if (has('PERMISSION_DENIED')) { return t('detail.attachmentPermissionDenied', { defaultValue: "You don't have permission to do that.", @@ -186,6 +197,38 @@ export const RecordAttachmentsPanel: React.FC = ({ [dataSource, friendlyError], ); + const handleDownload = React.useCallback( + async (row: AttachmentRow) => { + setError(null); + try { + // The stable `/files/:fileId` endpoint now requires an authenticated + // session for attachments-scope files (#2970) — an can't + // carry the Bearer token. Fetch a short-lived signed URL with auth, + // then open it (the signed URL itself needs no credentials). + const res = await authFetch( + `${baseUrl}/api/v1/storage/files/${encodeURIComponent(row.file_id)}/url`, + ); + if (!res.ok) { + let code: string | undefined; + try { + code = (await res.json())?.code; + } catch { + /* non-JSON body */ + } + throw Object.assign(new Error(code ?? `Download failed (${res.status})`), { code }); + } + const body = await res.json(); + const url: string | undefined = body?.url ?? body?.data?.url; + if (!url) throw new Error('Download URL missing from response'); + const target = /^https?:/i.test(url) ? url : `${baseUrl}${url}`; + window.open(target, '_blank', 'noopener,noreferrer'); + } catch (err: any) { + setError(friendlyError(err)); + } + }, + [authFetch, baseUrl, friendlyError], + ); + return (
@@ -248,17 +291,15 @@ export const RecordAttachmentsPanel: React.FC = ({ .join(' · ')}
-
void handleDownload(row)} > - - + +