From 8732fc3bd95ed9f99882dcb0ce70e7d4fcda3fe5 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:49:59 -0400 Subject: [PATCH] fix: add multi-object download signing for storage (#43576) Bug fix ## What is the current behavior? Read-only users cannot download files because the download feature requires minting a temporary API key, which is properly blocked for read-only users. ## What is the new behavior? Instead of using temporary API keys, we now create signed URLs for the files to be downloaded. We batch-create signed URLs for an entire folder's worth of files, requiring only a single management API call, then use those signed URLs to download the files. This allows read-only users to download files without needing elevated permissions. ## Additional context Resolves FE-2737 --- .../StorageExplorer/FileExplorerRow.tsx | 10 +-- .../StorageExplorer/ItemContextMenu.tsx | 8 +- .../storage/bucket-object-sign-mutation.ts | 27 ++++++ .../[ref]/buckets/[id]/objects/sign-multi.ts | 46 ++++++++++ apps/studio/state/storage-explorer.tsx | 85 ++++++++++++------- 5 files changed, 137 insertions(+), 39 deletions(-) create mode 100644 apps/studio/pages/api/platform/storage/[ref]/buckets/[id]/objects/sign-multi.ts diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx index 582d74e3c04..a65afc9403b 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx @@ -237,6 +237,11 @@ export const FileExplorerRow = ({ ], }, ]), + { + name: 'Download', + icon: , + onClick: () => downloadFile(itemWithColumnIndex), + }, ...(canUpdateFiles ? [ { @@ -249,11 +254,6 @@ export const FileExplorerRow = ({ icon: , onClick: () => setSelectedItemsToMove([itemWithColumnIndex]), }, - { - name: 'Download', - icon: , - onClick: () => downloadFile(itemWithColumnIndex), - }, { name: 'Separator', icon: undefined, onClick: undefined }, ] : []), diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/ItemContextMenu.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/ItemContextMenu.tsx index 2ad5d81009f..270c5ec3cd2 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/ItemContextMenu.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/ItemContextMenu.tsx @@ -83,6 +83,10 @@ export const ItemContextMenu = ({ id = '' }: ItemContextMenuProps) => { )} + onHandleClick('download', props.item)}> + + Download + {canUpdateFiles && [ onHandleClick('rename', props.item)}> @@ -92,10 +96,6 @@ export const ItemContextMenu = ({ id = '' }: ItemContextMenuProps) => { Move , - onHandleClick('download', props.item)}> - - Download - , , setSelectedItemsToDelete([props.item])}> diff --git a/apps/studio/data/storage/bucket-object-sign-mutation.ts b/apps/studio/data/storage/bucket-object-sign-mutation.ts index cfb00628e44..360f0e6d913 100644 --- a/apps/studio/data/storage/bucket-object-sign-mutation.ts +++ b/apps/studio/data/storage/bucket-object-sign-mutation.ts @@ -35,6 +35,33 @@ export const signBucketObject = async ( type SignBucketObjectData = Awaited> +type SignBucketObjectsParams = { + projectRef: string + bucketId?: string + paths: Array + expiresIn: number +} +export const signBucketObjects = async ( + { projectRef, bucketId, paths, expiresIn }: SignBucketObjectsParams, + signal?: AbortSignal +) => { + if (!bucketId) throw new Error('bucketId is required') + + const { data, error } = await post('/platform/storage/{ref}/buckets/{id}/objects/sign-multi', { + params: { + path: { + ref: projectRef, + id: bucketId, + }, + }, + body: { path: paths, expiresIn }, + signal, + }) + + if (error) handleError(error) + return data ?? [] +} + export const useGetSignBucketObjectMutation = ({ onSuccess, onError, diff --git a/apps/studio/pages/api/platform/storage/[ref]/buckets/[id]/objects/sign-multi.ts b/apps/studio/pages/api/platform/storage/[ref]/buckets/[id]/objects/sign-multi.ts new file mode 100644 index 00000000000..327c7d1dd9f --- /dev/null +++ b/apps/studio/pages/api/platform/storage/[ref]/buckets/[id]/objects/sign-multi.ts @@ -0,0 +1,46 @@ +import { createClient } from '@supabase/supabase-js' +import apiWrapper from 'lib/api/apiWrapper' +import { NextApiRequest, NextApiResponse } from 'next' + +const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!) + +const wrappedHandler = (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const { method } = req + + switch (method) { + case 'POST': + return handlePost(req, res) + default: + res.setHeader('Allow', ['POST']) + res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } }) + } +} + +const handlePost = async (req: NextApiRequest, res: NextApiResponse) => { + const { id } = req.query + const { path, expiresIn = 60 * 60 * 24 } = req.body + + const { data, error } = await supabase.storage + .from(id as string) + .createSignedUrls(path, expiresIn) + + if (error) { + return res.status(400).json({ error: { message: error.message } }) + } + + const parsed = new URL(process.env.SUPABASE_PUBLIC_URL!) + const remapped = (data ?? []).map((item) => { + if (!item.signedUrl) return item + const signedUrl = new URL(item.signedUrl) + signedUrl.protocol = parsed.protocol + signedUrl.host = parsed.host + signedUrl.port = parsed.port + return { ...item, signedUrl: signedUrl.href } + }) + + return res.status(201).json(remapped) +} + +export default wrappedHandler diff --git a/apps/studio/state/storage-explorer.tsx b/apps/studio/state/storage-explorer.tsx index b72295411dd..4448c501edf 100644 --- a/apps/studio/state/storage-explorer.tsx +++ b/apps/studio/state/storage-explorer.tsx @@ -33,6 +33,7 @@ import { sanitizeNameForDuplicateInColumn, validateFolderName, } from '@/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils' +import { fetchFileUrl } from '@/components/interfaces/Storage/StorageExplorer/useFetchFileUrlQuery' import { convertFromBytes } from '@/components/interfaces/Storage/StorageSettings/StorageSettings.utils' import { InlineLink } from '@/components/ui/InlineLink' import { getOrRefreshTemporaryApiKey } from '@/data/api-keys/temp-api-keys-utils' @@ -41,6 +42,7 @@ import { useProjectApiUrl } from '@/data/config/project-endpoint-query' import type { ProjectStorageConfigResponse } from '@/data/config/project-storage-config-query' import { getQueryClient } from '@/data/query-client' import { deleteBucketObject } from '@/data/storage/bucket-object-delete-mutation' +import { signBucketObjects } from '@/data/storage/bucket-object-sign-mutation' import { listBucketObjects, StorageObject } from '@/data/storage/bucket-objects-list-mutation' import { deleteBucketPrefix } from '@/data/storage/bucket-prefix-delete-mutation' import type { Bucket } from '@/data/storage/buckets-query' @@ -795,8 +797,34 @@ function createStorageExplorerState({ { id: toastId, closeButton: false, position: 'top-right' } ) + // Pre-fetch all URLs in a single batch to avoid N management API calls + const filePaths = files.map((file) => `${file.prefix}/${file.name}`) + const urlByPath = new Map() + + if (state.selectedBucket.public) { + for (const filePath of filePaths) { + urlByPath.set( + filePath, + `${clientEndpoint}/storage/v1/object/public/${state.selectedBucket.id}/${filePath}` + ) + } + } else { + const signedUrls = await signBucketObjects({ + projectRef: state.projectRef, + bucketId: state.selectedBucket.id, + paths: filePaths, + expiresIn: 60 * 60, // 1 hour — enough for large folder downloads + }) + for (const item of signedUrls) { + if (item.path && item.signedUrl) { + urlByPath.set(item.path, item.signedUrl) + } + } + } + const promises = files.map((file) => { const fileMimeType = (file.metadata?.mimetype as string) ?? null + const filePath = `${file.prefix}/${file.name}` return () => { return new Promise< | { @@ -807,26 +835,22 @@ function createStorageExplorerState({ | boolean >(async (resolve) => { try { - // Get authenticated Supabase client for Storage API access - const client = await createProjectSupabaseClient(state.projectRef, clientEndpoint) + const url = urlByPath.get(filePath) + if (!url) throw new Error(`Failed to retrieve file ${filePath}`) - // Use Storage API directly instead of Management API to avoid throttling - const { data, error } = await client.storage - .from(state.selectedBucket.id) - .download(`${file.prefix}/${file.name}`) - - if (error) throw error - if (!data) throw new Error('No data returned from download') + const response = await fetch(url) + if (!response.ok) throw new Error(`Failed to retrieve file ${filePath}`) + const data = await response.blob() progress = progress + 1 / files.length resolve({ name: file.name, prefix: file.prefix, - blob: new Blob([data], { type: fileMimeType }), + blob: new Blob([data], { type: fileMimeType ?? data.type }), }) } catch (error) { - console.error('Failed to download file', `${file.prefix}/${file.name}`) + console.error('Failed to download file', filePath) resolve(false) } }) @@ -1530,17 +1554,17 @@ function createStorageExplorerState({ const toastId = showToast ? toast.loading(`Retrieving ${fileName}...`) : undefined try { - const client = await createProjectSupabaseClient(state.projectRef, clientEndpoint) + const url = await fetchFileUrl( + file.path, + state.projectRef, + state.selectedBucket.id, + state.selectedBucket.public + ) + const response = await fetch(url) + if (!response.ok) throw new Error(`Failed to retrieve file ${file.path}`) + const data = await response.blob() - // Use Storage API directly instead of Management API to avoid throttling - const { data, error } = await client.storage - .from(state.selectedBucket.id) - .download(file.path) - - if (error) throw error - if (!data) throw new Error('No data returned from download') - - const newBlob = new Blob([data], { type: fileMimeType }) + const newBlob = new Blob([data], { type: fileMimeType ?? data.type }) const blobUrl = window.URL.createObjectURL(newBlob) const link = document.createElement('a') link.href = blobUrl @@ -1574,17 +1598,18 @@ function createStorageExplorerState({ if (!file.path) return false try { - const client = await createProjectSupabaseClient(state.projectRef, clientEndpoint) - - const { data, error } = await client.storage - .from(state.selectedBucket.id) - .download(file.path) - - if (error) throw error - if (!data) throw new Error('No data returned from download') + const url = await fetchFileUrl( + file.path, + state.projectRef, + state.selectedBucket.id, + state.selectedBucket.public + ) + const response = await fetch(url) + if (!response.ok) throw new Error(`Failed to retrieve file ${file.path}`) + const data = await response.blob() const fileMimeType = file?.metadata?.mimetype ?? undefined - return { name: file.name, blob: new Blob([data], { type: fileMimeType }) } + return { name: file.name, blob: new Blob([data], { type: fileMimeType ?? data.type }) } } catch (err) { console.error('Failed to download file', file.path) return false