mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
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:
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
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user