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