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
This commit is contained in:
Charis authored and GitHub committed 2026-03-09 16:49:59 -04:00
1 parent f7d3d2d3c3
commit 8732fc3bd9
5 files changed
+137 -39

No files matched your search

@@ -237,6 +237,11 @@ export const FileExplorerRow = ({
],
},
]),
{
name: 'Download',
icon: <Download size={14} strokeWidth={1} />,
onClick: () => downloadFile(itemWithColumnIndex),
},
...(canUpdateFiles
? [
{
@@ -249,11 +254,6 @@ export const FileExplorerRow = ({
icon: <Move size={14} strokeWidth={1} />,
onClick: () => setSelectedItemsToMove([itemWithColumnIndex]),
},
{
name: 'Download',
icon: <Download size={14} strokeWidth={1} />,
onClick: () => downloadFile(itemWithColumnIndex),
},
{ name: 'Separator', icon: undefined, onClick: undefined },
]
: []),
@@ -83,6 +83,10 @@ export const ItemContextMenu = ({ id = '' }: ItemContextMenuProps) => {
</Item>
</Submenu>
)}
<Item onClick={({ props }) => onHandleClick('download', props.item)}>
<Download size={14} strokeWidth={1} />
<span className="ml-2 text-xs">Download</span>
</Item>
{canUpdateFiles && [
<Item key="rename-file" onClick={({ props }) => onHandleClick('rename', props.item)}>
<Edit size={14} strokeWidth={1} />
@@ -92,10 +96,6 @@ export const ItemContextMenu = ({ id = '' }: ItemContextMenuProps) => {
<Move size={14} strokeWidth={1} />
<span className="ml-2 text-xs">Move</span>
</Item>,
<Item key="download-file" onClick={({ props }) => onHandleClick('download', props.item)}>
<Download size={14} strokeWidth={1} />
<span className="ml-2 text-xs">Download</span>
</Item>,
<Separator key="file-separator" />,
<Item key="delete-file" onClick={({ props }) => setSelectedItemsToDelete([props.item])}>
<Trash2 size={14} strokeWidth={1} stroke="red" />
@@ -35,6 +35,33 @@ export const signBucketObject = async (
type SignBucketObjectData = Awaited<ReturnType<typeof signBucketObject>>
type SignBucketObjectsParams = {
projectRef: string
bucketId?: string
paths: Array<string>
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,
@@ -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
+55 -30
View File
@@ -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<string, string>()
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