feat(pcs): vercel blob storage adapter (#5811)

This commit is contained in:
Elliot DeNolf
2024-04-12 09:37:35 -04:00
committed by GitHub
11 changed files with 263 additions and 1 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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": {

View File

@@ -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)}`
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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),
}
}

View File

@@ -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 })
}
}
}

View File

@@ -0,0 +1 @@
export { getFilePrefix } from '../utilities/getFilePrefix.js'

View File

@@ -0,0 +1 @@
export { vercelBlobAdapter } from '../adapters/vercelBlob/index.js'

31
pnpm-lock.yaml generated
View File

@@ -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