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,18 @@
'use client'
import { createClientUploadHandler } from '@payloadcms/plugin-cloud-storage/client'
import { genUploader } from 'uploadthing/client'
export const UploadthingClientUploadHandler = createClientUploadHandler({
handler: async ({ apiRoute, collectionSlug, file, serverHandlerPath, serverURL }) => {
const { uploadFiles } = genUploader({
package: 'storage-uploadthing',
url: `${serverURL}${apiRoute}${serverHandlerPath}?collectionSlug=${collectionSlug}`,
})
const res = await uploadFiles('uploader', {
files: [file],
})
return { key: res[0].key }
},
})

View File

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

View File

@@ -0,0 +1,62 @@
import {
APIError,
Forbidden,
type PayloadHandler,
type PayloadRequest,
type UploadCollectionSlug,
} from 'payload'
type Args = {
access?: (args: {
collectionSlug: UploadCollectionSlug
req: PayloadRequest
}) => boolean | Promise<boolean>
acl: 'private' | 'public-read'
token?: string
}
const defaultAccess: Args['access'] = ({ req }) => !!req.user
import type { FileRouter } from 'uploadthing/server'
import { createRouteHandler } from 'uploadthing/next'
import { createUploadthing } from 'uploadthing/server'
export const getClientUploadRoute = ({
access = defaultAccess,
acl,
token,
}: Args): PayloadHandler => {
const f = createUploadthing()
const uploadRouter = {
uploader: f({
blob: {
acl,
maxFileCount: 1,
},
})
.middleware(async ({ req: rawReq }) => {
const req = rawReq as PayloadRequest
const collectionSlug = req.searchParams.get('collectionSlug')
if (!collectionSlug) {
throw new APIError('No payload was provided')
}
if (!(await access({ collectionSlug, req }))) {
throw new Forbidden()
}
return {}
})
.onUploadComplete(() => {}),
} satisfies FileRouter
const { POST } = createRouteHandler({ config: { token }, router: uploadRouter })
return async (req) => {
return POST(req)
}
}

View File

@@ -1,5 +1,6 @@
import type {
Adapter,
ClientUploadsConfig,
PluginOptions as CloudStoragePluginOptions,
CollectionOptions,
GeneratedAdapter,
@@ -8,14 +9,22 @@ import type { Config, Field, Plugin, UploadCollectionSlug } from 'payload'
import type { UTApiOptions } from 'uploadthing/types'
import { cloudStoragePlugin } from '@payloadcms/plugin-cloud-storage'
import { UTApi } from 'uploadthing/server'
import { initClientUploads } from '@payloadcms/plugin-cloud-storage/utilities'
import { createRouteHandler } from 'uploadthing/next'
import { createUploadthing, UTApi } from 'uploadthing/server'
import { generateURL } from './generateURL.js'
import { getClientUploadRoute } from './getClientUploadRoute.js'
import { getHandleDelete } from './handleDelete.js'
import { getHandleUpload } from './handleUpload.js'
import { getHandler } from './staticHandler.js'
export type UploadthingStorageOptions = {
/**
* Do uploads directly on the client, to bypass limits on Vercel.
*/
clientUploads?: ClientUploadsConfig
/**
* Collection options to apply the adapter to.
*/
@@ -58,6 +67,22 @@ export const uploadthingStorage: UploadthingPlugin =
const adapter = uploadthingInternal(uploadthingStorageOptions)
initClientUploads({
clientHandler: '@payloadcms/storage-uploadthing/client#UploadthingClientUploadHandler',
collections: uploadthingStorageOptions.collections,
config: incomingConfig,
enabled: !!uploadthingStorageOptions.clientUploads,
serverHandler: getClientUploadRoute({
access:
typeof uploadthingStorageOptions.clientUploads === 'object'
? uploadthingStorageOptions.clientUploads.access
: undefined,
acl: uploadthingStorageOptions.options.acl || 'public-read',
token: uploadthingStorageOptions.options.token,
}),
serverHandlerPath: '/storage-uploadthing-client-upload-route',
})
// Add adapter to each collection option object
const collectionsWithAdapter: CloudStoragePluginOptions['collections'] = Object.entries(
uploadthingStorageOptions.collections,

View File

@@ -9,47 +9,58 @@ type Args = {
}
export const getHandler = ({ utApi }: Args): StaticHandler => {
return async (req, { doc, params: { collection, filename } }) => {
return async (req, { doc, params: { clientUploadContext, collection, filename } }) => {
try {
const collectionConfig = req.payload.collections[collection]?.config
let retrievedDoc = doc
let key: string
if (!retrievedDoc) {
const or: Where[] = [
{
filename: {
equals: filename,
},
},
]
if (
clientUploadContext &&
typeof clientUploadContext === 'object' &&
'key' in clientUploadContext &&
typeof clientUploadContext.key === 'string'
) {
key = clientUploadContext.key
} else {
const collectionConfig = req.payload.collections[collection]?.config
let retrievedDoc = doc
if (collectionConfig.upload.imageSizes) {
collectionConfig.upload.imageSizes.forEach(({ name }) => {
or.push({
[`sizes.${name}.filename`]: {
if (!retrievedDoc) {
const or: Where[] = [
{
filename: {
equals: filename,
},
},
]
if (collectionConfig.upload.imageSizes) {
collectionConfig.upload.imageSizes.forEach(({ name }) => {
or.push({
[`sizes.${name}.filename`]: {
equals: filename,
},
})
})
}
const result = await req.payload.db.findOne({
collection,
req,
where: { or },
})
if (result) {
retrievedDoc = result
}
}
const result = await req.payload.db.findOne({
collection,
req,
where: { or },
})
if (result) {
retrievedDoc = result
if (!retrievedDoc) {
return new Response(null, { status: 404, statusText: 'Not Found' })
}
}
if (!retrievedDoc) {
return new Response(null, { status: 404, statusText: 'Not Found' })
key = getKeyFromFilename(retrievedDoc, filename)
}
const key = getKeyFromFilename(retrievedDoc, filename)
if (!key) {
return new Response(null, { status: 404, statusText: 'Not Found' })
}
@@ -69,7 +80,7 @@ export const getHandler = ({ utApi }: Args): StaticHandler => {
const blob = await response.blob()
const etagFromHeaders = req.headers.get('etag') || req.headers.get('if-none-match')
const objectEtag = response.headers.get('etag') as string
const objectEtag = response.headers.get('etag')
if (etagFromHeaders && etagFromHeaders === objectEtag) {
return new Response(null, {