feat(storage-*): large file uploads on Vercel (#11382)

Currently, usage of Payload on Vercel has a limitation - uploads are
limited by 4.5MB file size.
This PR allows you to pass `clientUploads: true` to all existing storage
adapters
* Storage S3
* Vercel Blob
* Google Cloud Storage
* Uploadthing
* Azure Blob

And then, Payload will do uploads on the client instead. With the S3
Adapter it uses signed URLs and with Vercel Blob it does this -
https://vercel.com/guides/how-to-bypass-vercel-body-size-limit-serverless-functions#step-2:-create-a-client-upload-route.
Note that it doesn't mean that anyone can now upload files to your
storage, it still does auth checks and you can customize that with
`clientUploads.access`


https://github.com/user-attachments/assets/5083c76c-8f5a-43dc-a88c-9ddc4527d91c

Implements https://github.com/payloadcms/payload/discussions/7569
feature request.
This commit is contained in:
Sasha
2025-02-26 21:59:34 +02:00
committed by GitHub
parent c6ab312286
commit b540da53ec
54 changed files with 1548 additions and 152 deletions

View File

@@ -0,0 +1,34 @@
'use client'
import { createClientUploadHandler } from '@payloadcms/plugin-cloud-storage/client'
import { upload } from '@vercel/blob/client'
export type VercelBlobClientUploadHandlerExtra = {
addRandomSuffix: boolean
baseURL: string
prefix: string
}
export const VercelBlobClientUploadHandler =
createClientUploadHandler<VercelBlobClientUploadHandlerExtra>({
handler: async ({
apiRoute,
collectionSlug,
extra: { addRandomSuffix, baseURL, prefix = '' },
file,
serverHandlerPath,
serverURL,
updateFilename,
}) => {
const result = await upload(`${prefix}${file.name}`, file, {
access: 'public',
clientPayload: collectionSlug,
contentType: file.type,
handleUploadUrl: `${serverURL}${apiRoute}${serverHandlerPath}`,
})
// Update filename with suffix from returned url
if (addRandomSuffix) {
updateFilename(result.url.replace(`${baseURL}/`, ''))
}
},
})

View File

@@ -0,0 +1 @@
export { VercelBlobClientUploadHandler } from '../client/VercelBlobClientUploadHandler.js'

View File

@@ -0,0 +1,50 @@
import type { PayloadHandler, PayloadRequest, UploadCollectionSlug } from 'payload'
import { handleUpload, type HandleUploadBody } from '@vercel/blob/client'
import { APIError, Forbidden } from 'payload'
type Args = {
access?: (args: {
collectionSlug: UploadCollectionSlug
req: PayloadRequest
}) => boolean | Promise<boolean>
addRandomSuffix?: boolean
cacheControlMaxAge?: number
token: string
}
const defaultAccess: Args['access'] = ({ req }) => !!req.user
export const getClientUploadRoute =
({ access = defaultAccess, addRandomSuffix, cacheControlMaxAge, token }: Args): PayloadHandler =>
async (req) => {
const body = (await req.json!()) as HandleUploadBody
try {
const jsonResponse = await handleUpload({
body,
onBeforeGenerateToken: async (_pathname: string, collectionSlug: null | string) => {
if (!collectionSlug) {
throw new APIError('No payload was provided')
}
if (!(await access({ collectionSlug, req }))) {
throw new Forbidden()
}
return Promise.resolve({
addRandomSuffix,
cacheControlMaxAge,
})
},
onUploadCompleted: async () => {},
request: req as Request,
token,
})
return Response.json(jsonResponse)
} catch (error) {
req.payload.logger.error(error)
throw new APIError('storage-vercel-blob client upload route error')
}
}

View File

@@ -1,5 +1,6 @@
import type {
Adapter,
ClientUploadsConfig,
PluginOptions as CloudStoragePluginOptions,
CollectionOptions,
GeneratedAdapter,
@@ -7,8 +8,12 @@ import type {
import type { Config, Plugin, UploadCollectionSlug } from 'payload'
import { cloudStoragePlugin } from '@payloadcms/plugin-cloud-storage'
import { initClientUploads } from '@payloadcms/plugin-cloud-storage/utilities'
import type { VercelBlobClientUploadHandlerExtra } from './client/VercelBlobClientUploadHandler.js'
import { getGenerateUrl } from './generateURL.js'
import { getClientUploadRoute } from './getClientUploadRoute.js'
import { getHandleDelete } from './handleDelete.js'
import { getHandleUpload } from './handleUpload.js'
import { getStaticHandler } from './staticHandler.js'
@@ -32,10 +37,15 @@ export type VercelBlobStorageOptions = {
/**
* Cache-Control max-age in seconds
*
* @defaultvalue 365 * 24 * 60 * 60 (1 Year)
* @default 365 * 24 * 60 * 60 // (1 Year)
*/
cacheControlMaxAge?: number
/**
* Do uploads directly on the client, to bypass limits on Vercel.
*/
clientUploads?: ClientUploadsConfig
/**
* Collections to apply the Vercel Blob adapter to
*/
@@ -91,6 +101,29 @@ export const vercelBlobStorage: VercelBlobStoragePlugin =
const baseUrl = `https://${storeId}.${optionsWithDefaults.access}.blob.vercel-storage.com`
initClientUploads<
VercelBlobClientUploadHandlerExtra,
VercelBlobStorageOptions['collections'][string]
>({
clientHandler: '@payloadcms/storage-vercel-blob/client#VercelBlobClientUploadHandler',
collections: options.collections,
config: incomingConfig,
enabled: !!options.clientUploads,
extraClientHandlerProps: (collection) => ({
addRandomSuffix: !!optionsWithDefaults.addRandomSuffix,
baseURL: baseUrl,
prefix: (typeof collection === 'object' && collection.prefix) || '',
}),
serverHandler: getClientUploadRoute({
access:
typeof options.clientUploads === 'object' ? options.clientUploads.access : undefined,
addRandomSuffix: optionsWithDefaults.addRandomSuffix,
cacheControlMaxAge: options.cacheControlMaxAge,
token: options.token,
}),
serverHandlerPath: '/vercel-blob-client-upload-route',
})
const adapter = vercelBlobStorageInternal({ ...optionsWithDefaults, baseUrl })
// Add adapter to each collection option object

View File

@@ -22,7 +22,6 @@ export const getStaticHandler = (
const fileUrl = `${baseUrl}/${fileKey}`
const etagFromHeaders = req.headers.get('etag') || req.headers.get('if-none-match')
const blobMetadata = await head(fileUrl, { token })
const uploadedAtString = blobMetadata.uploadedAt.toISOString()
const ETag = `"${fileKey}-${uploadedAtString}"`