feat: storage-uploadthing package (#6316)
Co-authored-by: James <james@trbl.design>
This commit is contained in:
8
.vscode/launch.json
vendored
8
.vscode/launch.json
vendored
@@ -16,6 +16,14 @@
|
||||
"request": "launch",
|
||||
"type": "node-terminal"
|
||||
},
|
||||
{
|
||||
"command": "node --no-deprecation test/dev.js storage-uploadthing",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"name": "Uploadthing",
|
||||
"request": "launch",
|
||||
"type": "node-terminal",
|
||||
"envFile": "${workspaceFolder}/test/storage-uploadthing/.env"
|
||||
},
|
||||
{
|
||||
"command": "node --no-deprecation test/dev.js live-preview",
|
||||
"cwd": "${workspaceFolder}",
|
||||
|
||||
@@ -33,6 +33,11 @@
|
||||
"build:plugins": "turbo build --filter \"@payloadcms/plugin-*\"",
|
||||
"build:richtext-lexical": "turbo build --filter richtext-lexical",
|
||||
"build:richtext-slate": "turbo build --filter richtext-slate",
|
||||
"build:storage-azure": "turbo build --filter storage-azure",
|
||||
"build:storage-gcs": "turbo build --filter storage-gcs",
|
||||
"build:storage-s3": "turbo build --filter storage-s3",
|
||||
"build:storage-uploadthing": "turbo build --filter storage-uploadthing",
|
||||
"build:storage-vercel-blob": "turbo build --filter storage-vercel-blob",
|
||||
"build:tests": "pnpm --filter payload-test-suite run typecheck",
|
||||
"build:translations": "turbo build --filter translations",
|
||||
"build:ui": "turbo build --filter ui",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Collection, PayloadRequestWithData, Where } from 'payload/types'
|
||||
import type { Collection, PayloadRequestWithData, TypeWithID, Where } from 'payload/types'
|
||||
|
||||
import { executeAccess } from 'payload/auth'
|
||||
import { Forbidden } from 'payload/errors'
|
||||
@@ -13,7 +13,7 @@ export async function checkFileAccess({
|
||||
collection: Collection
|
||||
filename: string
|
||||
req: PayloadRequestWithData
|
||||
}) {
|
||||
}): Promise<Response | TypeWithID> {
|
||||
const { config } = collection
|
||||
const disableEndpoints = endpointsAreDisabled({ endpoints: config.endpoints, request: req })
|
||||
if (disableEndpoints) return disableEndpoints
|
||||
@@ -55,5 +55,7 @@ export async function checkFileAccess({
|
||||
if (!doc) {
|
||||
throw new Forbidden(req.t)
|
||||
}
|
||||
|
||||
return doc
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,16 +27,19 @@ export const getFile = async ({ collection, filename, req }: Args): Promise<Resp
|
||||
)
|
||||
}
|
||||
|
||||
await checkFileAccess({
|
||||
const accessResult = await checkFileAccess({
|
||||
collection,
|
||||
filename,
|
||||
req,
|
||||
})
|
||||
|
||||
if (accessResult instanceof Response) return accessResult
|
||||
|
||||
let response: Response = null
|
||||
if (collection.config.upload.handlers?.length) {
|
||||
for (const handler of collection.config.upload.handlers) {
|
||||
response = await handler(req, {
|
||||
doc: accessResult,
|
||||
params: {
|
||||
collection: collection.config.slug,
|
||||
filename,
|
||||
|
||||
@@ -129,12 +129,15 @@ export const getBaseUploadFields = ({ collection, config }: Options): Field[] =>
|
||||
...url,
|
||||
hooks: {
|
||||
afterRead: [
|
||||
({ data }) =>
|
||||
generateURL({
|
||||
({ data, value }) => {
|
||||
if (value) return value
|
||||
|
||||
return generateURL({
|
||||
collectionSlug: collection.slug,
|
||||
config,
|
||||
filename: data?.filename,
|
||||
}),
|
||||
})
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -169,7 +172,9 @@ export const getBaseUploadFields = ({ collection, config }: Options): Field[] =>
|
||||
...url,
|
||||
hooks: {
|
||||
afterRead: [
|
||||
({ data }) => {
|
||||
({ data, value }) => {
|
||||
if (value) return value
|
||||
|
||||
const sizeFilename = data?.sizes?.[size.name]?.filename
|
||||
|
||||
if (sizeFilename) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type express from 'express'
|
||||
import type serveStatic from 'serve-static'
|
||||
import type { ResizeOptions, Sharp } from 'sharp'
|
||||
|
||||
import type { TypeWithID } from '../collections/config/types.js'
|
||||
import type { PayloadRequestWithData } from '../types/index.js'
|
||||
|
||||
export type FileSize = {
|
||||
@@ -94,7 +95,7 @@ export type UploadConfig = {
|
||||
formatOptions?: ImageUploadFormatOptions
|
||||
handlers?: ((
|
||||
req: PayloadRequestWithData,
|
||||
args: { params: { collection: string; filename: string } },
|
||||
args: { doc: TypeWithID; params: { collection: string; filename: string } },
|
||||
) => Promise<Response> | Response)[]
|
||||
imageSizes?: ImageSize[]
|
||||
mimeTypes?: string[]
|
||||
|
||||
@@ -41,7 +41,7 @@ export const getFields = ({
|
||||
},
|
||||
}
|
||||
|
||||
const fields = [...collection.fields]
|
||||
const fields = [...collection.fields, ...(adapter.fields || [])]
|
||||
|
||||
// Inject a hook into all URL fields to generate URLs
|
||||
|
||||
@@ -107,6 +107,7 @@ export const getFields = ({
|
||||
name: size.name,
|
||||
type: 'group',
|
||||
fields: [
|
||||
...(adapter.fields || []),
|
||||
{
|
||||
...(existingSizeURLField || {}),
|
||||
...baseURLField,
|
||||
|
||||
@@ -20,6 +20,7 @@ export const getAfterReadHook =
|
||||
if (disablePayloadAccessControl && filename) {
|
||||
url = await adapter.generateURL({
|
||||
collection,
|
||||
data,
|
||||
filename,
|
||||
prefix,
|
||||
})
|
||||
|
||||
@@ -54,7 +54,8 @@ export const getBeforeChangeHook =
|
||||
req.payload.logger.error(
|
||||
`There was an error while uploading files corresponding to the collection ${collection.slug} with filename ${data.filename}:`,
|
||||
)
|
||||
req.payload.logger.error(err)
|
||||
req.payload.logger.error({ err })
|
||||
throw err
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FileData, ImageSize } from 'payload/types'
|
||||
import type { Field, FileData, ImageSize } from 'payload/types'
|
||||
import type { TypeWithID } from 'payload/types'
|
||||
import type { CollectionConfig, PayloadRequestWithData } from 'payload/types'
|
||||
|
||||
@@ -30,17 +30,25 @@ export type HandleDelete = (args: {
|
||||
|
||||
export type GenerateURL = (args: {
|
||||
collection: CollectionConfig
|
||||
data: any
|
||||
filename: string
|
||||
prefix?: string
|
||||
}) => Promise<string> | string
|
||||
|
||||
export type StaticHandler = (
|
||||
req: PayloadRequestWithData,
|
||||
args: { params: { collection: string; filename: string } },
|
||||
args: { doc?: TypeWithID; params: { collection: string; filename: string } },
|
||||
) => Promise<Response> | Response
|
||||
|
||||
export interface GeneratedAdapter {
|
||||
generateURL: GenerateURL
|
||||
/**
|
||||
* Additional fields to be injected into the base collection and image sizes
|
||||
*/
|
||||
fields?: Field[]
|
||||
/**
|
||||
* Generates the public URL for a file
|
||||
*/
|
||||
generateURL?: GenerateURL
|
||||
handleDelete: HandleDelete
|
||||
handleUpload: HandleUpload
|
||||
name: string
|
||||
|
||||
10
packages/storage-uploadthing/.eslintignore
Normal file
10
packages/storage-uploadthing/.eslintignore
Normal file
@@ -0,0 +1,10 @@
|
||||
.tmp
|
||||
**/.git
|
||||
**/.hg
|
||||
**/.pnp.*
|
||||
**/.svn
|
||||
**/.yarn/**
|
||||
**/build
|
||||
**/dist/**
|
||||
**/node_modules
|
||||
**/temp
|
||||
7
packages/storage-uploadthing/.eslintrc.cjs
Normal file
7
packages/storage-uploadthing/.eslintrc.cjs
Normal file
@@ -0,0 +1,7 @@
|
||||
/** @type {import('eslint').Linter.Config} */
|
||||
module.exports = {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
}
|
||||
10
packages/storage-uploadthing/.prettierignore
Normal file
10
packages/storage-uploadthing/.prettierignore
Normal file
@@ -0,0 +1,10 @@
|
||||
.tmp
|
||||
**/.git
|
||||
**/.hg
|
||||
**/.pnp.*
|
||||
**/.svn
|
||||
**/.yarn/**
|
||||
**/build
|
||||
**/dist/**
|
||||
**/node_modules
|
||||
**/temp
|
||||
15
packages/storage-uploadthing/.swcrc
Normal file
15
packages/storage-uploadthing/.swcrc
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/swcrc",
|
||||
"sourceMaps": true,
|
||||
"jsc": {
|
||||
"target": "esnext",
|
||||
"parser": {
|
||||
"syntax": "typescript",
|
||||
"tsx": true,
|
||||
"dts": true
|
||||
}
|
||||
},
|
||||
"module": {
|
||||
"type": "es6"
|
||||
}
|
||||
}
|
||||
22
packages/storage-uploadthing/LICENSE.md
Normal file
22
packages/storage-uploadthing/LICENSE.md
Normal file
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018-2022 Payload CMS, LLC <info@payloadcms.com>
|
||||
Portions Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
32
packages/storage-uploadthing/README.md
Normal file
32
packages/storage-uploadthing/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Uploadthing Storage for Payload (beta)
|
||||
|
||||
This package provides a way to use [uploadthing](https://uploadthing.com) with Payload.
|
||||
|
||||
## Installation
|
||||
|
||||
```sh
|
||||
pnpm add @paylaodcms/storage-uploadthing
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
- 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`.
|
||||
|
||||
```ts
|
||||
export default buildConfig({
|
||||
collections: [Media],
|
||||
plugins: [
|
||||
uploadthingStorage({
|
||||
collections: {
|
||||
[mediaSlug]: true,
|
||||
},
|
||||
options: {
|
||||
apiKey: process.env.UPLOADTHING_SECRET,
|
||||
acl: 'public-read',
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
```
|
||||
58
packages/storage-uploadthing/package.json
Normal file
58
packages/storage-uploadthing/package.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "@payloadcms/storage-uploadthing",
|
||||
"version": "3.0.0-beta.25",
|
||||
"description": "Payload storage adapter for uploadthing",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/payloadcms/payload.git",
|
||||
"directory": "packages/storage-uploadthing"
|
||||
},
|
||||
"license": "MIT",
|
||||
"author": "Payload <dev@payloadcms.com> (https://payloadcms.com)",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./src/index.ts",
|
||||
"require": "./src/index.ts",
|
||||
"types": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "pnpm build:swc && pnpm build:types",
|
||||
"build:clean": "find . \\( -type d \\( -name build -o -name dist -o -name .cache \\) -o -type f -name tsconfig.tsbuildinfo \\) -exec rm -rf {} + && pnpm build",
|
||||
"build:swc": "swc ./src -d ./dist --config-file .swcrc",
|
||||
"build:types": "tsc --emitDeclarationOnly --outDir dist",
|
||||
"clean": "rimraf {dist,*.tsbuildinfo}",
|
||||
"prepublishOnly": "pnpm clean && pnpm turbo build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@payloadcms/plugin-cloud-storage": "workspace:*",
|
||||
"uploadthing": "^6.10.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"payload": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"payload": "workspace:*"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.20.2"
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
20
packages/storage-uploadthing/tsconfig.json
Normal file
20
packages/storage-uploadthing/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true, // Make sure typescript knows that this module depends on their references
|
||||
"noEmit": false /* Do not emit outputs. */,
|
||||
"emitDeclarationOnly": true,
|
||||
"outDir": "./dist" /* Specify an output folder for all emitted files. */,
|
||||
"rootDir": "./src" /* Specify the root folder within your source files. */,
|
||||
"strict": true,
|
||||
},
|
||||
"exclude": [
|
||||
"dist",
|
||||
"node_modules",
|
||||
],
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts", "src/**/*.json"],
|
||||
"references": [
|
||||
{ "path": "../payload" },
|
||||
{ "path": "../plugin-cloud-storage" },
|
||||
]
|
||||
}
|
||||
86
pnpm-lock.yaml
generated
86
pnpm-lock.yaml
generated
@@ -1421,6 +1421,19 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../payload
|
||||
|
||||
packages/storage-uploadthing:
|
||||
dependencies:
|
||||
'@payloadcms/plugin-cloud-storage':
|
||||
specifier: workspace:*
|
||||
version: link:../plugin-cloud-storage
|
||||
uploadthing:
|
||||
specifier: ^6.10.1
|
||||
version: 6.10.1(next@14.3.0-canary.7)
|
||||
devDependencies:
|
||||
payload:
|
||||
specifier: workspace:*
|
||||
version: link:../payload
|
||||
|
||||
packages/storage-vercel-blob:
|
||||
dependencies:
|
||||
'@payloadcms/plugin-cloud-storage':
|
||||
@@ -1662,6 +1675,9 @@ importers:
|
||||
'@payloadcms/storage-s3':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/storage-s3
|
||||
'@payloadcms/storage-uploadthing':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/storage-uploadthing
|
||||
'@payloadcms/storage-vercel-blob':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/storage-vercel-blob
|
||||
@@ -1704,6 +1720,9 @@ importers:
|
||||
typescript:
|
||||
specifier: 5.4.5
|
||||
version: 5.4.5
|
||||
uploadthing:
|
||||
specifier: ^6.10.1
|
||||
version: 6.10.1(next@14.3.0-canary.7)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -3323,6 +3342,15 @@ packages:
|
||||
dependencies:
|
||||
superjson: 2.2.1
|
||||
|
||||
/@effect/schema@0.66.14(effect@3.1.2)(fast-check@3.18.0):
|
||||
resolution: {integrity: sha512-2Yc6gnXpcMmwQnbU2JUwDl0ckeOJmFZzteXn2jjVWuNi9PGv+jp2yK7jxv0pALcieuYwdR5tKkCRI7STuhEwfg==}
|
||||
peerDependencies:
|
||||
effect: ^3.1.2
|
||||
fast-check: ^3.13.2
|
||||
dependencies:
|
||||
effect: 3.1.2
|
||||
fast-check: 3.18.0
|
||||
|
||||
/@emotion/babel-plugin@11.11.0:
|
||||
resolution: {integrity: sha512-m4HEDZleaaCH+XgDDsPF15Ht6wTLsgDTeR3WYj9Q/k76JtWhrJjcP4+/XlG8LGT/Rol9qUfOIztXeA84ATpqPQ==}
|
||||
dependencies:
|
||||
@@ -6470,6 +6498,23 @@ packages:
|
||||
/@ungap/structured-clone@1.2.0:
|
||||
resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
|
||||
|
||||
/@uploadthing/mime-types@0.2.9:
|
||||
resolution: {integrity: sha512-7Ap2evP+niXNSXoOck4DUZUAKYrMRRpJ85n+ZxCuRsnr3iziOgv/Rt6es5SnMVgE/aOObxddOxbrxyOhISQWHQ==}
|
||||
|
||||
/@uploadthing/shared@6.7.1(@uploadthing/mime-types@0.2.9):
|
||||
resolution: {integrity: sha512-4Imk46n+rwFaobfDuDSclYcH/OcpJA048Ww0il6nqT7EoXQ7Pa9NJfGkoNkS8+K8rT71IVljdaiaLEapgRxj0Q==}
|
||||
peerDependencies:
|
||||
'@uploadthing/mime-types': 0.2.9
|
||||
peerDependenciesMeta:
|
||||
'@uploadthing/mime-types':
|
||||
optional: true
|
||||
dependencies:
|
||||
'@effect/schema': 0.66.14(effect@3.1.2)(fast-check@3.18.0)
|
||||
'@uploadthing/mime-types': 0.2.9
|
||||
effect: 3.1.2
|
||||
fast-check: 3.18.0
|
||||
std-env: 3.7.0
|
||||
|
||||
/@vercel/blob@0.22.3:
|
||||
resolution: {integrity: sha512-l0t2KhbOO/I8ZNOl9zypYf1NE0837aO4/CPQNGR/RAxtj8FpdYKjhyUADUXj2gERLQmnhun+teaVs/G7vZJ/TQ==}
|
||||
engines: {node: '>=16.14'}
|
||||
@@ -7847,7 +7892,6 @@ packages:
|
||||
/consola@3.2.3:
|
||||
resolution: {integrity: sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==}
|
||||
engines: {node: ^14.18.0 || >=16.10.0}
|
||||
dev: true
|
||||
|
||||
/console-table-printer@2.11.2:
|
||||
resolution: {integrity: sha512-uuUHie0sfPP542TKGzPFal0W1wo1beuKAqIZdaavcONx8OoqdnJRKjkinbRTOta4FaCa1RcIL+7mMJWX3pQGVg==}
|
||||
@@ -8782,6 +8826,9 @@ packages:
|
||||
/ee-first@1.1.1:
|
||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||
|
||||
/effect@3.1.2:
|
||||
resolution: {integrity: sha512-XakSWck6w6ROqKyEys0tKE9K6Gx2p8W/09u2ZTEZZrneO5Z3QEdPhXzWTyC73kD5zUvfJinZLVIas8I1xoHaTg==}
|
||||
|
||||
/electron-to-chromium@1.4.730:
|
||||
resolution: {integrity: sha512-oJRPo82XEqtQAobHpJIR3zW5YO3sSRRkPz2an4yxi1UvqhsGm54vR/wzTFV74a3soDOJ8CKW7ajOOX5ESzddwg==}
|
||||
|
||||
@@ -9613,6 +9660,12 @@ packages:
|
||||
resolution: {integrity: sha512-qwaScUgUGBYeDNRnbc/KyllVU88Jk1pRHPStuF/lO7B0/RTRLj7U0lkdTAutlBblY08rwZDff6tNU9cjv6j//Q==}
|
||||
dev: false
|
||||
|
||||
/fast-check@3.18.0:
|
||||
resolution: {integrity: sha512-/951xaT0kA40w0GXRsZXEwSTE7LugjZtSA/8vPgFkiPQ8wNp8tRvqWuNDHBgLxJYXtsK11e/7Q4ObkKW5BdTFQ==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
dependencies:
|
||||
pure-rand: 6.1.0
|
||||
|
||||
/fast-copy@3.0.2:
|
||||
resolution: {integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==}
|
||||
|
||||
@@ -15050,7 +15103,6 @@ packages:
|
||||
|
||||
/std-env@3.7.0:
|
||||
resolution: {integrity: sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==}
|
||||
dev: true
|
||||
|
||||
/stop-iteration-iterator@1.0.0:
|
||||
resolution: {integrity: sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==}
|
||||
@@ -15982,6 +16034,36 @@ packages:
|
||||
escalade: 3.1.2
|
||||
picocolors: 1.0.0
|
||||
|
||||
/uploadthing@6.10.1(next@14.3.0-canary.7):
|
||||
resolution: {integrity: sha512-xqgEauaDYLlQ6NEsQWF+fpsUOuniWazd8ytSK2bCxcKFGakCkXO32GkFolW4iZZgkl7ATnHwN+JXG95FPdo74w==}
|
||||
engines: {node: '>=18.13.0'}
|
||||
peerDependencies:
|
||||
express: '*'
|
||||
fastify: '*'
|
||||
h3: '*'
|
||||
next: '*'
|
||||
tailwindcss: '*'
|
||||
peerDependenciesMeta:
|
||||
express:
|
||||
optional: true
|
||||
fastify:
|
||||
optional: true
|
||||
h3:
|
||||
optional: true
|
||||
next:
|
||||
optional: true
|
||||
tailwindcss:
|
||||
optional: true
|
||||
dependencies:
|
||||
'@effect/schema': 0.66.14(effect@3.1.2)(fast-check@3.18.0)
|
||||
'@uploadthing/mime-types': 0.2.9
|
||||
'@uploadthing/shared': 6.7.1(@uploadthing/mime-types@0.2.9)
|
||||
consola: 3.2.3
|
||||
effect: 3.1.2
|
||||
fast-check: 3.18.0
|
||||
next: 14.3.0-canary.7(@babel/core@7.24.4)(@playwright/test@1.43.0)(react-dom@18.2.0)(react@18.2.0)(sass@1.74.1)
|
||||
std-env: 3.7.0
|
||||
|
||||
/uri-js@4.4.1:
|
||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||
dependencies:
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"@payloadcms/storage-azure": "workspace:*",
|
||||
"@payloadcms/storage-gcs": "workspace:*",
|
||||
"@payloadcms/storage-s3": "workspace:*",
|
||||
"@payloadcms/storage-uploadthing": "workspace:*",
|
||||
"@payloadcms/storage-vercel-blob": "workspace:*",
|
||||
"@payloadcms/translations": "workspace:*",
|
||||
"@payloadcms/ui": "workspace:*",
|
||||
@@ -49,6 +50,7 @@
|
||||
"payload": "workspace:*",
|
||||
"tempy": "^1.0.1",
|
||||
"ts-essentials": "7.0.3",
|
||||
"typescript": "5.4.5"
|
||||
"typescript": "5.4.5",
|
||||
"uploadthing": "^6.10.1"
|
||||
}
|
||||
}
|
||||
|
||||
8
test/storage-uploadthing/.eslintrc.cjs
Normal file
8
test/storage-uploadthing/.eslintrc.cjs
Normal file
@@ -0,0 +1,8 @@
|
||||
/** @type {import('eslint').Linter.Config} */
|
||||
module.exports = {
|
||||
ignorePatterns: ['payload-types.ts'],
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.eslint.json'],
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
}
|
||||
34
test/storage-uploadthing/collections/Media.ts
Normal file
34
test/storage-uploadthing/collections/Media.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { CollectionConfig } from 'payload/types'
|
||||
|
||||
export const Media: CollectionConfig = {
|
||||
slug: 'media',
|
||||
upload: {
|
||||
disableLocalStorage: true,
|
||||
resizeOptions: {
|
||||
position: 'center',
|
||||
width: 200,
|
||||
height: 200,
|
||||
},
|
||||
imageSizes: [
|
||||
{
|
||||
height: 400,
|
||||
width: 400,
|
||||
crop: 'center',
|
||||
name: 'square',
|
||||
},
|
||||
{
|
||||
width: 900,
|
||||
height: 450,
|
||||
crop: 'center',
|
||||
name: 'sixteenByNineMedium',
|
||||
},
|
||||
],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'alt',
|
||||
label: 'Alt Text',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
}
|
||||
9
test/storage-uploadthing/collections/MediaWithPrefix.ts
Normal file
9
test/storage-uploadthing/collections/MediaWithPrefix.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { CollectionConfig } from 'payload/types'
|
||||
|
||||
export const MediaWithPrefix: CollectionConfig = {
|
||||
slug: 'media-with-prefix',
|
||||
upload: {
|
||||
disableLocalStorage: false,
|
||||
},
|
||||
fields: [],
|
||||
}
|
||||
16
test/storage-uploadthing/collections/Users.ts
Normal file
16
test/storage-uploadthing/collections/Users.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { CollectionConfig } from 'payload/types'
|
||||
|
||||
export const Users: CollectionConfig = {
|
||||
slug: 'users',
|
||||
auth: true,
|
||||
admin: {
|
||||
useAsTitle: 'email',
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
},
|
||||
fields: [
|
||||
// Email added by default
|
||||
// Add more fields as needed
|
||||
],
|
||||
}
|
||||
43
test/storage-uploadthing/config.ts
Normal file
43
test/storage-uploadthing/config.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { uploadthingStorage } from '@payloadcms/storage-uploadthing'
|
||||
import dotenv from 'dotenv'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'path'
|
||||
|
||||
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
|
||||
import { devUser } from '../credentials.js'
|
||||
import { Media } from './collections/Media.js'
|
||||
import { MediaWithPrefix } from './collections/MediaWithPrefix.js'
|
||||
import { Users } from './collections/Users.js'
|
||||
import { mediaSlug } from './shared.js'
|
||||
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
const dirname = path.dirname(filename)
|
||||
|
||||
// Load test env creds
|
||||
dotenv.config({
|
||||
path: path.resolve(dirname, './.env'),
|
||||
})
|
||||
|
||||
export default buildConfigWithDefaults({
|
||||
collections: [Media, MediaWithPrefix, Users],
|
||||
onInit: async (payload) => {
|
||||
await payload.create({
|
||||
collection: 'users',
|
||||
data: {
|
||||
email: devUser.email,
|
||||
password: devUser.password,
|
||||
},
|
||||
})
|
||||
},
|
||||
plugins: [
|
||||
uploadthingStorage({
|
||||
collections: {
|
||||
[mediaSlug]: true,
|
||||
},
|
||||
options: {
|
||||
apiKey: process.env.UPLOADTHING_SECRET,
|
||||
acl: 'public-read',
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
3
test/storage-uploadthing/shared.ts
Normal file
3
test/storage-uploadthing/shared.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const mediaSlug = 'media'
|
||||
export const mediaWithPrefixSlug = 'media-with-prefix'
|
||||
export const prefix = 'test-prefix'
|
||||
13
test/storage-uploadthing/tsconfig.eslint.json
Normal file
13
test/storage-uploadthing/tsconfig.eslint.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
// extend your base config to share compilerOptions, etc
|
||||
//"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
// ensure that nobody can accidentally use this config for a build
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
// whatever paths you intend to lint
|
||||
"./**/*.ts",
|
||||
"./**/*.tsx"
|
||||
]
|
||||
}
|
||||
@@ -37,7 +37,7 @@
|
||||
],
|
||||
"paths": {
|
||||
"@payload-config": [
|
||||
"./test/live-preview/config.ts"
|
||||
"./test/_community/config.ts"
|
||||
],
|
||||
"@payloadcms/live-preview": [
|
||||
"./packages/live-preview/src"
|
||||
|
||||
Reference in New Issue
Block a user