Skip to content

Commit c04de47

Browse files
authored
Merge pull request #2538 from objectstack-ai/claude/attachments-v1-followups-8jhvin
fix(attachments): download via authenticated signed URL (framework #2970)
2 parents 5b52624 + d018ef8 commit c04de47

3 files changed

Lines changed: 139 additions & 18 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@object-ui/app-shell": patch
3+
---
4+
5+
fix(attachments): download attachments via authenticated signed URL (framework #2970)
6+
7+
The framework now requires an authenticated session to download an
8+
attachments-scope file (the stable `/storage/files/:fileId` endpoint returns
9+
`401`/`403` for them). `RecordAttachmentsPanel`'s download control no longer
10+
uses a bare `<a href>` (which cannot carry the console's Bearer token) — it
11+
fetches a short-lived signed URL from `/storage/files/:fileId/url` with
12+
`createAuthenticatedFetch`, then opens it. `403 ATTACHMENT_DOWNLOAD_DENIED` and
13+
`401 AUTH_REQUIRED` map to friendly copy instead of a broken link.

packages/app-shell/src/views/RecordAttachmentsPanel.tsx

Lines changed: 59 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,9 @@ import { useObjectTranslation } from '@object-ui/react';
2525
* Storage model: one `sys_file` row per uploaded blob (three-step
2626
* presigned upload via @object-ui/providers' ObjectStack adapter), one
2727
* `sys_attachment` join row linking it to `(parent_object, parent_id)`.
28-
* Downloads go through the stable `/storage/files/:fileId` endpoint,
29-
* which 302-redirects to a freshly signed URL on every request.
28+
* Downloads fetch a short-lived signed URL from `/storage/files/:fileId/url`
29+
* with the console's Bearer token (the endpoint requires an authenticated
30+
* session for attachments-scope files, #2970), then open it.
3031
*/
3132

3233
interface AttachmentRow {
@@ -74,16 +75,16 @@ export const RecordAttachmentsPanel: React.FC<RecordAttachmentsPanelProps> = ({
7475
// Vite dev console proxies same-origin `/api` unless VITE_SERVER_URL
7576
// points elsewhere.
7677
const baseUrl = (import.meta as any).env?.VITE_SERVER_URL || '';
78+
// One authenticated fetch (Bearer token from localStorage) reused for the
79+
// upload adapter and the download-URL fetch — the storage routes require a
80+
// session and there is no cookie for `credentials: 'include'` to carry.
81+
const authFetch = React.useMemo(() => createAuthenticatedFetch(), []);
7782
const adapter = React.useMemo(
78-
// `fetchImpl`: the storage upload routes require an authenticated
79-
// session when the server has an auth service (#2755), and the console
80-
// authenticates with a Bearer token (localStorage) — there is no session
81-
// cookie for `credentials: 'include'` to carry.
82-
() => createObjectStackUploadAdapter({ baseUrl, scope: 'attachments', fetchImpl: createAuthenticatedFetch() }),
83-
[baseUrl],
83+
() => createObjectStackUploadAdapter({ baseUrl, scope: 'attachments', fetchImpl: authFetch }),
84+
[baseUrl, authFetch],
8485
);
8586

86-
/** Map the server's fail-closed 403 codes (#2755) to friendly copy. */
87+
/** Map the server's fail-closed 40x codes (#2755, #2970) to friendly copy. */
8788
const friendlyError = React.useCallback(
8889
(err: unknown): string => {
8990
const anyErr = err as { code?: string; message?: unknown } | null;
@@ -99,6 +100,16 @@ export const RecordAttachmentsPanel: React.FC<RecordAttachmentsPanelProps> = ({
99100
defaultValue: "You don't have access to attach files to this record.",
100101
});
101102
}
103+
if (has('ATTACHMENT_DOWNLOAD_DENIED')) {
104+
return t('detail.attachmentDownloadDenied', {
105+
defaultValue: "You don't have access to download this attachment.",
106+
});
107+
}
108+
if (has('AUTH_REQUIRED')) {
109+
return t('detail.attachmentAuthRequired', {
110+
defaultValue: 'Please sign in to download this attachment.',
111+
});
112+
}
102113
if (has('PERMISSION_DENIED')) {
103114
return t('detail.attachmentPermissionDenied', {
104115
defaultValue: "You don't have permission to do that.",
@@ -186,6 +197,38 @@ export const RecordAttachmentsPanel: React.FC<RecordAttachmentsPanelProps> = ({
186197
[dataSource, friendlyError],
187198
);
188199

200+
const handleDownload = React.useCallback(
201+
async (row: AttachmentRow) => {
202+
setError(null);
203+
try {
204+
// The stable `/files/:fileId` endpoint now requires an authenticated
205+
// session for attachments-scope files (#2970) — an <a href> can't
206+
// carry the Bearer token. Fetch a short-lived signed URL with auth,
207+
// then open it (the signed URL itself needs no credentials).
208+
const res = await authFetch(
209+
`${baseUrl}/api/v1/storage/files/${encodeURIComponent(row.file_id)}/url`,
210+
);
211+
if (!res.ok) {
212+
let code: string | undefined;
213+
try {
214+
code = (await res.json())?.code;
215+
} catch {
216+
/* non-JSON body */
217+
}
218+
throw Object.assign(new Error(code ?? `Download failed (${res.status})`), { code });
219+
}
220+
const body = await res.json();
221+
const url: string | undefined = body?.url ?? body?.data?.url;
222+
if (!url) throw new Error('Download URL missing from response');
223+
const target = /^https?:/i.test(url) ? url : `${baseUrl}${url}`;
224+
window.open(target, '_blank', 'noopener,noreferrer');
225+
} catch (err: any) {
226+
setError(friendlyError(err));
227+
}
228+
},
229+
[authFetch, baseUrl, friendlyError],
230+
);
231+
189232
return (
190233
<div className={cn('rounded-lg border bg-card', className)} data-testid="record-attachments-panel">
191234
<div className="flex items-center justify-between px-4 py-3 border-b">
@@ -248,17 +291,15 @@ export const RecordAttachmentsPanel: React.FC<RecordAttachmentsPanelProps> = ({
248291
.join(' · ')}
249292
</div>
250293
</div>
251-
<a
252-
href={`${baseUrl}/api/v1/storage/files/${encodeURIComponent(row.file_id)}`}
253-
target="_blank"
254-
rel="noreferrer"
255-
className="inline-flex"
294+
<Button
295+
variant="ghost"
296+
size="icon"
297+
className="h-8 w-8"
256298
aria-label={t('detail.downloadAttachment', { defaultValue: 'Download' })}
299+
onClick={() => void handleDownload(row)}
257300
>
258-
<Button variant="ghost" size="icon" className="h-8 w-8">
259-
<Download className="h-4 w-4" />
260-
</Button>
261-
</a>
301+
<Download className="h-4 w-4" />
302+
</Button>
262303
<Button
263304
variant="ghost"
264305
size="icon"

packages/app-shell/src/views/__tests__/RecordAttachmentsPanel.test.tsx

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,70 @@ describe('RecordAttachmentsPanel — server-denial error mapping (#2755)', () =>
9393
expect(dataSource.delete).toHaveBeenCalledWith('sys_attachment', 'a1');
9494
});
9595
});
96+
97+
describe('RecordAttachmentsPanel — authenticated signed-URL download (#2970)', () => {
98+
it('fetches /files/:id/url with auth and opens the signed URL', async () => {
99+
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
100+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
101+
new Response(JSON.stringify({ url: '/api/v1/storage/_local/raw/tok123' }), {
102+
status: 200,
103+
headers: { 'Content-Type': 'application/json' },
104+
}),
105+
);
106+
setup(makeDataSource());
107+
await waitFor(() => expect(screen.getByText('report.pdf')).toBeInTheDocument());
108+
109+
await userEvent.setup().click(screen.getByRole('button', { name: 'Download' }));
110+
111+
await waitFor(() => expect(fetchSpy).toHaveBeenCalled());
112+
const calledUrl = String(fetchSpy.mock.calls[0][0]);
113+
expect(calledUrl).toContain('/api/v1/storage/files/f1/url');
114+
await waitFor(() =>
115+
expect(openSpy).toHaveBeenCalledWith(
116+
expect.stringContaining('/api/v1/storage/_local/raw/tok123'),
117+
'_blank',
118+
'noopener,noreferrer',
119+
),
120+
);
121+
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
122+
});
123+
124+
it('maps a 403 ATTACHMENT_DOWNLOAD_DENIED to friendly copy and does not open a tab', async () => {
125+
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
126+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
127+
new Response(JSON.stringify({ code: 'ATTACHMENT_DOWNLOAD_DENIED' }), {
128+
status: 403,
129+
headers: { 'Content-Type': 'application/json' },
130+
}),
131+
);
132+
setup(makeDataSource());
133+
await waitFor(() => expect(screen.getByText('report.pdf')).toBeInTheDocument());
134+
135+
await userEvent.setup().click(screen.getByRole('button', { name: 'Download' }));
136+
137+
await waitFor(() =>
138+
expect(screen.getByRole('alert')).toHaveTextContent(
139+
"You don't have access to download this attachment.",
140+
),
141+
);
142+
expect(openSpy).not.toHaveBeenCalled();
143+
});
144+
145+
it('maps a 401 AUTH_REQUIRED to friendly copy', async () => {
146+
vi.spyOn(window, 'open').mockImplementation(() => null);
147+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
148+
new Response(JSON.stringify({ code: 'AUTH_REQUIRED' }), {
149+
status: 401,
150+
headers: { 'Content-Type': 'application/json' },
151+
}),
152+
);
153+
setup(makeDataSource());
154+
await waitFor(() => expect(screen.getByText('report.pdf')).toBeInTheDocument());
155+
156+
await userEvent.setup().click(screen.getByRole('button', { name: 'Download' }));
157+
158+
await waitFor(() =>
159+
expect(screen.getByRole('alert')).toHaveTextContent('Please sign in to download this attachment.'),
160+
);
161+
});
162+
});

0 commit comments

Comments
 (0)