feat: storage-uploadthing package (#6316)
Co-authored-by: James <james@trbl.design>
This commit is contained in:
10
packages/storage-uploadthing/src/generateURL.ts
Normal file
10
packages/storage-uploadthing/src/generateURL.ts
Normal 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 || '')}`
|
||||
}
|
||||
35
packages/storage-uploadthing/src/handleDelete.ts
Normal file
35
packages/storage-uploadthing/src/handleDelete.ts
Normal 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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
57
packages/storage-uploadthing/src/handleUpload.ts
Normal file
57
packages/storage-uploadthing/src/handleUpload.ts
Normal 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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
132
packages/storage-uploadthing/src/index.ts
Normal file
132
packages/storage-uploadthing/src/index.ts
Normal 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 }),
|
||||
}
|
||||
}
|
||||
}
|
||||
80
packages/storage-uploadthing/src/staticHandler.ts
Normal file
80
packages/storage-uploadthing/src/staticHandler.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
24
packages/storage-uploadthing/src/utilities.ts
Normal file
24
packages/storage-uploadthing/src/utilities.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user