feat: storage-uploadthing package (#6316)

Co-authored-by: James <james@trbl.design>
This commit is contained in:
Elliot DeNolf
2024-05-10 17:05:35 -04:00
committed by GitHub
parent ea84e82ad5
commit ed880d5018
34 changed files with 774 additions and 17 deletions

View File

@@ -0,0 +1,10 @@
import type { GenerateURL } from '@payloadcms/plugin-cloud-storage/types'
import path from 'path'
import { getKeyFromFilename } from './utilities.js'
export const generateURL: GenerateURL = ({ data, filename, prefix = '' }) => {
const key = getKeyFromFilename(data, filename)
return `https://utfs.io/f/${path.posix.join(prefix, key || '')}`
}

View File

@@ -0,0 +1,35 @@
import type { HandleDelete } from '@payloadcms/plugin-cloud-storage/types'
import type { UTApi } from 'uploadthing/server'
import { APIError } from 'payload/errors'
import { getKeyFromFilename } from './utilities.js'
type Args = {
utApi: UTApi
}
export const getHandleDelete = ({ utApi }: Args): HandleDelete => {
return async ({ doc, filename, req }) => {
const key = getKeyFromFilename(doc, filename)
if (!key) {
req.payload.logger.error({
msg: `Error deleting file: ${filename} - unable to extract key from doc`,
})
throw new APIError(`Error deleting file: ${filename}`)
}
try {
if (key) {
await utApi.deleteFiles(key)
}
} catch (err) {
req.payload.logger.error({
err,
msg: `Error deleting file with key: ${filename} - key: ${key}`,
})
throw new APIError(`Error deleting file: ${filename}`)
}
}
}

View File

@@ -0,0 +1,57 @@
import type { HandleUpload } from '@payloadcms/plugin-cloud-storage/types'
import type { UTApi } from 'uploadthing/server'
import { APIError } from 'payload/errors'
import { UTFile } from 'uploadthing/server'
import type { ACL } from './index.js'
type HandleUploadArgs = {
acl: ACL
utApi: UTApi
}
export const getHandleUpload = ({ acl, utApi }: HandleUploadArgs): HandleUpload => {
return async ({ data, file }) => {
try {
const { buffer, filename, mimeType } = file
const blob = new Blob([buffer], { type: mimeType })
const res = await utApi.uploadFiles(new UTFile([blob], filename), { acl })
if (res.error) {
throw new APIError(`Error uploading file: ${res.error.code} - ${res.error.message}`)
}
// Find matching data.sizes entry
const foundSize = Object.keys(data.sizes || {}).find(
(key) => data.sizes?.[key]?.filename === filename,
)
if (foundSize) {
data.sizes[foundSize]._key = res.data?.key
} else {
data._key = res.data?.key
data.filename = res.data?.name
}
return data
} catch (error: unknown) {
if (error instanceof Error) {
// Interrogate uploadthing error which returns FiberFailure
if ('toJSON' in error && typeof error.toJSON === 'function') {
const json = error.toJSON() as {
cause?: { defect?: { _id?: string; data?: { error?: string }; error?: string } }
}
if (json.cause?.defect?.error && json.cause.defect.data?.error) {
throw new APIError(
`Error uploading file with uploadthing: ${json.cause.defect.error} - ${json.cause.defect.data.error}`,
)
}
} else {
throw new APIError(`Error uploading file with uploadthing: ${error.message}`)
}
}
}
}
}

View File

@@ -0,0 +1,132 @@
import type {
Adapter,
PluginOptions as CloudStoragePluginOptions,
CollectionOptions,
GeneratedAdapter,
} from '@payloadcms/plugin-cloud-storage/types'
import type { Config, Plugin } from 'payload/config'
import type { Field } from 'payload/types'
import type { UTApiOptions } from 'uploadthing/types'
import { cloudStoragePlugin } from '@payloadcms/plugin-cloud-storage'
import { UTApi } from 'uploadthing/server'
import { generateURL } from './generateURL.js'
import { getHandleDelete } from './handleDelete.js'
import { getHandleUpload } from './handleUpload.js'
import { getHandler } from './staticHandler.js'
export type UploadthingStorageOptions = {
/**
* Collection options to apply the adapter to.
*/
collections: Record<string, Omit<CollectionOptions, 'adapter'> | true>
/**
* Whether or not to enable the plugin
*
* Default: true
*/
enabled?: boolean
/**
* Uploadthing Options
*/
options: UTApiOptions & {
/**
* @default 'public-read'
*/
acl?: ACL
}
}
type UploadthingPlugin = (uploadthingStorageOptions: UploadthingStorageOptions) => Plugin
/** NOTE: not synced with uploadthing's internal types. Need to modify if more options added */
export type ACL = 'private' | 'public-read'
export const uploadthingStorage: UploadthingPlugin =
(uploadthingStorageOptions: UploadthingStorageOptions) =>
(incomingConfig: Config): Config => {
if (uploadthingStorageOptions.enabled === false) {
return incomingConfig
}
// Default ACL to public-read
if (!uploadthingStorageOptions.options.acl) {
uploadthingStorageOptions.options.acl = 'public-read'
}
const adapter = uploadthingInternal(uploadthingStorageOptions, incomingConfig)
// Add adapter to each collection option object
const collectionsWithAdapter: CloudStoragePluginOptions['collections'] = Object.entries(
uploadthingStorageOptions.collections,
).reduce(
(acc, [slug, collOptions]) => ({
...acc,
[slug]: {
...(collOptions === true ? {} : collOptions),
// Disable payload access control if the ACL is public-read or not set
// ...(uploadthingStorageOptions.options.acl === 'public-read'
// ? { disablePayloadAccessControl: true }
// : {}),
adapter,
},
}),
{} as Record<string, CollectionOptions>,
)
// Set disableLocalStorage: true for collections specified in the plugin options
const config = {
...incomingConfig,
collections: (incomingConfig.collections || []).map((collection) => {
if (!collectionsWithAdapter[collection.slug]) {
return collection
}
return {
...collection,
upload: {
...(typeof collection.upload === 'object' ? collection.upload : {}),
disableLocalStorage: true,
},
}
}),
}
return cloudStoragePlugin({
collections: collectionsWithAdapter,
})(config)
}
function uploadthingInternal(options: UploadthingStorageOptions, incomingConfig: Config): Adapter {
const fields: Field[] = [
{
name: '_key',
type: 'text',
admin: {
hidden: true,
},
},
]
return (): GeneratedAdapter => {
const {
options: { acl = 'public-read', ...utOptions },
} = options
const utApi = new UTApi(utOptions)
return {
name: 'uploadthing',
fields,
generateURL,
handleDelete: getHandleDelete({ utApi }),
handleUpload: getHandleUpload({ acl, utApi }),
staticHandler: getHandler({ utApi }),
}
}
}

View File

@@ -0,0 +1,80 @@
import type { StaticHandler } from '@payloadcms/plugin-cloud-storage/types'
import type { Where } from 'payload/types'
import type { UTApi } from 'uploadthing/server'
import { getKeyFromFilename } from './utilities.js'
type Args = {
utApi: UTApi
}
export const getHandler = ({ utApi }: Args): StaticHandler => {
return async (req, { doc, params: { collection, filename } }) => {
try {
const collectionConfig = req.payload.collections[collection]?.config
let retrievedDoc = doc
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
}
if (!retrievedDoc) {
return new Response(null, { status: 404, statusText: 'Not Found' })
}
const key = getKeyFromFilename(retrievedDoc, filename)
if (!key) {
return new Response(null, { status: 404, statusText: 'Not Found' })
}
const { url: signedURL } = await utApi.getSignedURL(key)
if (!signedURL) {
return new Response(null, { status: 404, statusText: 'Not Found' })
}
const response = await fetch(signedURL)
if (!response.ok) {
return new Response(null, { status: 404, statusText: 'Not Found' })
}
const blob = await response.blob()
return new Response(blob, {
headers: new Headers({
'Content-Length': String(blob.size),
'Content-Type': blob.type,
}),
status: 200,
})
} catch (err) {
req.payload.logger.error({ err, msg: 'Unexpected error in staticHandler' })
return new Response('Internal Server Error', { status: 500 })
}
}
}

View File

@@ -0,0 +1,24 @@
/**
* Extract '_key' value from the doc safely
*/
export const getKeyFromFilename = (doc: unknown, filename: string) => {
if (
doc &&
typeof doc === 'object' &&
'filename' in doc &&
doc.filename === filename &&
'_key' in doc
) {
return doc._key as string
}
if (doc && typeof doc === 'object' && 'sizes' in doc) {
const sizes = doc.sizes
if (typeof sizes === 'object' && sizes !== null) {
for (const size of Object.values(sizes)) {
if (size?.filename === filename && '_key' in size) {
return size._key as string
}
}
}
}
}