feat(pcs): vercel blob storage adapter (#5811)
This commit is contained in:
4
packages/plugin-cloud-storage/.gitignore
vendored
4
packages/plugin-cloud-storage/.gitignore
vendored
@@ -4,6 +4,10 @@ gcs.d.ts
|
||||
gcs.js
|
||||
s3.d.ts
|
||||
s3.js
|
||||
vercelBlob.d.ts
|
||||
vercelBlob.js
|
||||
utilities.d.ts
|
||||
utilities.js
|
||||
|
||||
dev/tmp
|
||||
dev/yarn.lock
|
||||
|
||||
@@ -63,6 +63,7 @@ This plugin supports the following adapters:
|
||||
- [Azure Blob Storage](#azure-blob-storage-adapter)
|
||||
- [AWS S3-style Storage](#s3-adapter)
|
||||
- [Google Cloud Storage](#gcs-adapter)
|
||||
- [Vercel Blob Storage](#vercel-blob-adapter)
|
||||
|
||||
However, you can create your own adapter for any third-party service you would like to use.
|
||||
|
||||
@@ -176,6 +177,20 @@ const adapter = gcsAdapter({
|
||||
// Now you can pass this adapter to the plugin
|
||||
```
|
||||
|
||||
### Vercel Blob Adapter
|
||||
|
||||
To use the Vercel Blob adapter, you need to have `@vercel/blob` installed in your project dependencies.
|
||||
|
||||
```ts
|
||||
import { vercelBlobAdapter } from '@payloadcms/plugin-cloud-storage/vercelBlob'
|
||||
|
||||
const adapter = vercelBlobAdapter({
|
||||
token: process.env.BLOB_READ_WRITE_TOKEN || '',
|
||||
})
|
||||
```
|
||||
|
||||
Credit to @JarvisPrestidge for the original implementation of this plugin.
|
||||
|
||||
### Payload Access Control
|
||||
|
||||
Payload ships with access control that runs _even on statically served files_. The same `read` access control property on your `upload`-enabled collections is used, and it allows you to restrict who can request your uploaded files.
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"@azure/abort-controller": "^1.0.0",
|
||||
"@azure/storage-blob": "^12.11.0",
|
||||
"@google-cloud/storage": "^7.7.0",
|
||||
"@vercel/blob": "^0.22.3",
|
||||
"payload": "workspace:*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -43,6 +44,9 @@
|
||||
},
|
||||
"@google-cloud/storage": {
|
||||
"optional": true
|
||||
},
|
||||
"@vercel/blob": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
@@ -56,6 +60,7 @@
|
||||
"@azure/storage-blob": "^12.11.0",
|
||||
"@google-cloud/storage": "^7.7.0",
|
||||
"@types/find-node-modules": "^2.1.2",
|
||||
"@vercel/blob": "^0.22.3",
|
||||
"payload": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { GenerateURL } from '@payloadcms/plugin-cloud-storage/types'
|
||||
|
||||
import path from 'path'
|
||||
|
||||
type GenerateUrlArgs = {
|
||||
baseUrl: string
|
||||
prefix?: string
|
||||
}
|
||||
|
||||
export const getGenerateUrl = ({ baseUrl }: GenerateUrlArgs): GenerateURL => {
|
||||
return ({ filename, prefix = '' }) => {
|
||||
return `${baseUrl}/${path.posix.join(prefix, filename)}`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { HandleDelete } from '@payloadcms/plugin-cloud-storage/types'
|
||||
|
||||
import { del } from '@vercel/blob'
|
||||
import path from 'path'
|
||||
|
||||
type HandleDeleteArgs = {
|
||||
baseUrl: string
|
||||
prefix?: string
|
||||
token: string
|
||||
}
|
||||
|
||||
export const getHandleDelete = ({ baseUrl, token }: HandleDeleteArgs): HandleDelete => {
|
||||
return async ({ doc: { prefix = '' }, filename }) => {
|
||||
const fileUrl = `${baseUrl}/${path.posix.join(prefix, filename)}`
|
||||
const deletedBlob = await del(fileUrl, { token })
|
||||
|
||||
return deletedBlob
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { HandleUpload } from '@payloadcms/plugin-cloud-storage/types'
|
||||
|
||||
import { put } from '@vercel/blob'
|
||||
import path from 'path'
|
||||
|
||||
import type { VercelBlobAdapterUploadOptions } from './index.js'
|
||||
|
||||
type HandleUploadArgs = VercelBlobAdapterUploadOptions & {
|
||||
baseUrl: string
|
||||
prefix?: string
|
||||
token: string
|
||||
}
|
||||
|
||||
export const getHandleUpload = ({
|
||||
access = 'public',
|
||||
addRandomSuffix,
|
||||
baseUrl,
|
||||
cacheControlMaxAge,
|
||||
prefix = '',
|
||||
token,
|
||||
}: HandleUploadArgs): HandleUpload => {
|
||||
return async ({ data, file: { buffer, filename, mimeType } }) => {
|
||||
const fileKey = path.posix.join(data.prefix || prefix, filename)
|
||||
|
||||
const result = await put(fileKey, buffer, {
|
||||
access,
|
||||
addRandomSuffix,
|
||||
cacheControlMaxAge,
|
||||
contentType: mimeType,
|
||||
token,
|
||||
})
|
||||
|
||||
// Get filename with suffix from returned url
|
||||
if (addRandomSuffix) {
|
||||
data.filename = result.url.replace(`${baseUrl}/`, '')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { Adapter, GeneratedAdapter } from '@payloadcms/plugin-cloud-storage/types'
|
||||
|
||||
import { getGenerateUrl } from './generateURL.js'
|
||||
import { getHandleDelete } from './handleDelete.js'
|
||||
import { getHandleUpload } from './handleUpload.js'
|
||||
import { getStaticHandler } from './staticHandler.js'
|
||||
|
||||
export interface VercelBlobAdapterArgs {
|
||||
options?: VercelBlobAdapterUploadOptions
|
||||
|
||||
/**
|
||||
* Vercel Blob storage read/write token
|
||||
*
|
||||
* Usually process.env.BLOB_READ_WRITE_TOKEN set by Vercel
|
||||
*/
|
||||
token: string
|
||||
}
|
||||
|
||||
export interface VercelBlobAdapterUploadOptions {
|
||||
/**
|
||||
* Access control level
|
||||
*
|
||||
* @default 'public'
|
||||
*/
|
||||
access?: 'public'
|
||||
/**
|
||||
* Add a random suffix to the uploaded file name
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
addRandomSuffix?: boolean
|
||||
/**
|
||||
* Cache-Control max-age in seconds
|
||||
*
|
||||
* @default 31536000 (1 year)
|
||||
*/
|
||||
cacheControlMaxAge?: number
|
||||
}
|
||||
|
||||
const defaultUploadOptions: VercelBlobAdapterUploadOptions = {
|
||||
access: 'public',
|
||||
addRandomSuffix: false,
|
||||
cacheControlMaxAge: 60 * 60 * 24 * 365, // 1 year
|
||||
}
|
||||
|
||||
export const vercelBlobAdapter =
|
||||
({ options = {}, token }: VercelBlobAdapterArgs): Adapter =>
|
||||
({ collection, prefix }): GeneratedAdapter => {
|
||||
if (!token) {
|
||||
throw new Error('The token argument is required for the Vercel Blob adapter.')
|
||||
}
|
||||
|
||||
// Parse storeId from token
|
||||
const storeId = token.match(/^vercel_blob_rw_([a-z\d]+)_[a-z\d]+$/i)?.[1].toLowerCase()
|
||||
|
||||
if (!storeId) {
|
||||
throw new Error(
|
||||
'Invalid token format for Vercel Blob adapter. Should be vercel_blob_rw_<store_id>_<random_string>.',
|
||||
)
|
||||
}
|
||||
|
||||
const { access, addRandomSuffix, cacheControlMaxAge } = {
|
||||
...defaultUploadOptions,
|
||||
...options,
|
||||
}
|
||||
|
||||
const baseUrl = `https://${storeId}.${access}.blob.vercel-storage.com`
|
||||
|
||||
return {
|
||||
generateURL: getGenerateUrl({ baseUrl, prefix }),
|
||||
handleDelete: getHandleDelete({ baseUrl, prefix, token }),
|
||||
handleUpload: getHandleUpload({
|
||||
access,
|
||||
addRandomSuffix,
|
||||
baseUrl,
|
||||
cacheControlMaxAge,
|
||||
prefix,
|
||||
token,
|
||||
}),
|
||||
staticHandler: getStaticHandler({ baseUrl, token }, collection),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { StaticHandler } from '@payloadcms/plugin-cloud-storage/types'
|
||||
import type { CollectionConfig, PayloadRequest, UploadConfig } from 'payload/types'
|
||||
|
||||
import { head } from '@vercel/blob'
|
||||
import path from 'path'
|
||||
|
||||
import { getFilePrefix } from '../../utilities/getFilePrefix.js'
|
||||
|
||||
type StaticHandlerArgs = {
|
||||
baseUrl: string
|
||||
token: string
|
||||
}
|
||||
|
||||
export const getStaticHandler = (
|
||||
{ baseUrl, token }: StaticHandlerArgs,
|
||||
collection: CollectionConfig,
|
||||
): StaticHandler => {
|
||||
return async (req, { params: { filename } }) => {
|
||||
try {
|
||||
const prefix = await getFilePrefix({ collection, req })
|
||||
|
||||
const fileUrl = `${baseUrl}/${path.posix.join(prefix, filename)}`
|
||||
|
||||
const blobMetadata = await head(fileUrl, { token })
|
||||
if (!blobMetadata) {
|
||||
return new Response(null, { status: 404, statusText: 'Not Found' })
|
||||
}
|
||||
|
||||
const { contentDisposition, contentType, size } = blobMetadata
|
||||
const response = await fetch(fileUrl)
|
||||
const blob = await response.blob()
|
||||
|
||||
if (!blob) {
|
||||
return new Response(null, { status: 204, statusText: 'No Content' })
|
||||
}
|
||||
|
||||
const bodyBuffer = await blob.arrayBuffer()
|
||||
|
||||
return new Response(bodyBuffer, {
|
||||
headers: new Headers({
|
||||
'Content-Disposition': contentDisposition,
|
||||
'Content-Length': String(size),
|
||||
'Content-Type': contentType,
|
||||
}),
|
||||
status: 200,
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
req.payload.logger.error({ err, msg: 'Unexpected error in staticHandler' })
|
||||
return new Response('Internal Server Error', { status: 500 })
|
||||
}
|
||||
}
|
||||
}
|
||||
1
packages/plugin-cloud-storage/src/exports/utilities.ts
Normal file
1
packages/plugin-cloud-storage/src/exports/utilities.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { getFilePrefix } from '../utilities/getFilePrefix.js'
|
||||
1
packages/plugin-cloud-storage/src/exports/vercelBlob.ts
Normal file
1
packages/plugin-cloud-storage/src/exports/vercelBlob.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { vercelBlobAdapter } from '../adapters/vercelBlob/index.js'
|
||||
31
pnpm-lock.yaml
generated
31
pnpm-lock.yaml
generated
@@ -982,6 +982,9 @@ importers:
|
||||
'@types/find-node-modules':
|
||||
specifier: ^2.1.2
|
||||
version: 2.1.2
|
||||
'@vercel/blob':
|
||||
specifier: ^0.22.3
|
||||
version: 0.22.3
|
||||
payload:
|
||||
specifier: workspace:*
|
||||
version: link:../payload
|
||||
@@ -3743,6 +3746,11 @@ packages:
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/@fastify/busboy@2.1.1:
|
||||
resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==}
|
||||
engines: {node: '>=14'}
|
||||
dev: true
|
||||
|
||||
/@floating-ui/core@1.6.0:
|
||||
resolution: {integrity: sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==}
|
||||
dependencies:
|
||||
@@ -6512,6 +6520,16 @@ packages:
|
||||
/@ungap/structured-clone@1.2.0:
|
||||
resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
|
||||
|
||||
/@vercel/blob@0.22.3:
|
||||
resolution: {integrity: sha512-l0t2KhbOO/I8ZNOl9zypYf1NE0837aO4/CPQNGR/RAxtj8FpdYKjhyUADUXj2gERLQmnhun+teaVs/G7vZJ/TQ==}
|
||||
engines: {node: '>=16.14'}
|
||||
dependencies:
|
||||
async-retry: 1.3.3
|
||||
bytes: 3.1.2
|
||||
is-buffer: 2.0.5
|
||||
undici: 5.28.4
|
||||
dev: true
|
||||
|
||||
/@webassemblyjs/ast@1.12.1:
|
||||
resolution: {integrity: sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==}
|
||||
dependencies:
|
||||
@@ -7385,7 +7403,6 @@ packages:
|
||||
/bytes@3.1.2:
|
||||
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
dev: false
|
||||
|
||||
/cacheable-lookup@5.0.4:
|
||||
resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==}
|
||||
@@ -10648,6 +10665,11 @@ packages:
|
||||
resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==}
|
||||
dev: false
|
||||
|
||||
/is-buffer@2.0.5:
|
||||
resolution: {integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==}
|
||||
engines: {node: '>=4'}
|
||||
dev: true
|
||||
|
||||
/is-callable@1.2.7:
|
||||
resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -15951,6 +15973,13 @@ packages:
|
||||
/undici-types@5.26.5:
|
||||
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
|
||||
|
||||
/undici@5.28.4:
|
||||
resolution: {integrity: sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==}
|
||||
engines: {node: '>=14.0'}
|
||||
dependencies:
|
||||
'@fastify/busboy': 2.1.1
|
||||
dev: true
|
||||
|
||||
/unfetch@4.2.0:
|
||||
resolution: {integrity: sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==}
|
||||
dev: false
|
||||
|
||||
Reference in New Issue
Block a user