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:
@@ -13,6 +13,7 @@ pnpm add @payloadcms/storage-uploadthing
|
||||
- Configure the `collections` object to specify which collections should use uploadthing. The slug _must_ match one of your existing collection slugs and be an `upload` type.
|
||||
- Get an API key from Uploadthing and set it as `apiKey` in the `options` object.
|
||||
- `acl` is optional and defaults to `public-read`.
|
||||
- When deploying to Vercel, server uploads are limited with 4.5MB. Set `clientUploads` to `true` to do uploads directly on the client.
|
||||
|
||||
```ts
|
||||
export default buildConfig({
|
||||
|
||||
@@ -23,6 +23,11 @@
|
||||
"import": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./client": {
|
||||
"import": "./src/exports/client.ts",
|
||||
"types": "./src/exports/client.ts",
|
||||
"default": "./src/exports/client.ts"
|
||||
}
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
@@ -59,6 +64,11 @@
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./client": {
|
||||
"import": "./dist/exports/client.js",
|
||||
"types": "./dist/exports/client.d.ts",
|
||||
"default": "./dist/exports/client.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -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 }
|
||||
},
|
||||
})
|
||||
1
packages/storage-uploadthing/src/exports/client.ts
Normal file
1
packages/storage-uploadthing/src/exports/client.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { UploadthingClientUploadHandler } from '../client/UploadthingClientUploadHandler.js'
|
||||
62
packages/storage-uploadthing/src/getClientUploadRoute.ts
Normal file
62
packages/storage-uploadthing/src/getClientUploadRoute.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user