diff --git a/.vscode/launch.json b/.vscode/launch.json index 94e6b45a1c..a029e7adda 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -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}", diff --git a/package.json b/package.json index 6fbe53d25b..eca3bc881f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/next/src/routes/rest/files/checkFileAccess.ts b/packages/next/src/routes/rest/files/checkFileAccess.ts index 8d67ac7541..04ec8462c5 100644 --- a/packages/next/src/routes/rest/files/checkFileAccess.ts +++ b/packages/next/src/routes/rest/files/checkFileAccess.ts @@ -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 { 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 } } diff --git a/packages/next/src/routes/rest/files/getFile.ts b/packages/next/src/routes/rest/files/getFile.ts index 3de5625522..c1e6f7b733 100644 --- a/packages/next/src/routes/rest/files/getFile.ts +++ b/packages/next/src/routes/rest/files/getFile.ts @@ -27,16 +27,19 @@ export const getFile = async ({ collection, filename, req }: Args): Promise ...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) { diff --git a/packages/payload/src/uploads/types.ts b/packages/payload/src/uploads/types.ts index f70823c5be..a07937f604 100644 --- a/packages/payload/src/uploads/types.ts +++ b/packages/payload/src/uploads/types.ts @@ -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)[] imageSizes?: ImageSize[] mimeTypes?: string[] diff --git a/packages/plugin-cloud-storage/src/fields/getFields.ts b/packages/plugin-cloud-storage/src/fields/getFields.ts index 593aa24689..167e0843c4 100644 --- a/packages/plugin-cloud-storage/src/fields/getFields.ts +++ b/packages/plugin-cloud-storage/src/fields/getFields.ts @@ -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, diff --git a/packages/plugin-cloud-storage/src/hooks/afterRead.ts b/packages/plugin-cloud-storage/src/hooks/afterRead.ts index f8a463d3b8..0818250e11 100644 --- a/packages/plugin-cloud-storage/src/hooks/afterRead.ts +++ b/packages/plugin-cloud-storage/src/hooks/afterRead.ts @@ -20,6 +20,7 @@ export const getAfterReadHook = if (disablePayloadAccessControl && filename) { url = await adapter.generateURL({ collection, + data, filename, prefix, }) diff --git a/packages/plugin-cloud-storage/src/hooks/beforeChange.ts b/packages/plugin-cloud-storage/src/hooks/beforeChange.ts index 85352552c7..d316a90c1e 100644 --- a/packages/plugin-cloud-storage/src/hooks/beforeChange.ts +++ b/packages/plugin-cloud-storage/src/hooks/beforeChange.ts @@ -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 } diff --git a/packages/plugin-cloud-storage/src/types.ts b/packages/plugin-cloud-storage/src/types.ts index a1fe4eaa5d..a397adfda3 100644 --- a/packages/plugin-cloud-storage/src/types.ts +++ b/packages/plugin-cloud-storage/src/types.ts @@ -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 export type StaticHandler = ( req: PayloadRequestWithData, - args: { params: { collection: string; filename: string } }, + args: { doc?: TypeWithID; params: { collection: string; filename: string } }, ) => Promise | 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 diff --git a/packages/storage-uploadthing/.eslintignore b/packages/storage-uploadthing/.eslintignore new file mode 100644 index 0000000000..247f3f12de --- /dev/null +++ b/packages/storage-uploadthing/.eslintignore @@ -0,0 +1,10 @@ +.tmp +**/.git +**/.hg +**/.pnp.* +**/.svn +**/.yarn/** +**/build +**/dist/** +**/node_modules +**/temp diff --git a/packages/storage-uploadthing/.eslintrc.cjs b/packages/storage-uploadthing/.eslintrc.cjs new file mode 100644 index 0000000000..d6b3a476b8 --- /dev/null +++ b/packages/storage-uploadthing/.eslintrc.cjs @@ -0,0 +1,7 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + parserOptions: { + project: ['./tsconfig.json'], + tsconfigRootDir: __dirname, + }, +} diff --git a/packages/storage-uploadthing/.prettierignore b/packages/storage-uploadthing/.prettierignore new file mode 100644 index 0000000000..247f3f12de --- /dev/null +++ b/packages/storage-uploadthing/.prettierignore @@ -0,0 +1,10 @@ +.tmp +**/.git +**/.hg +**/.pnp.* +**/.svn +**/.yarn/** +**/build +**/dist/** +**/node_modules +**/temp diff --git a/packages/storage-uploadthing/.swcrc b/packages/storage-uploadthing/.swcrc new file mode 100644 index 0000000000..14463f4b08 --- /dev/null +++ b/packages/storage-uploadthing/.swcrc @@ -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" + } +} diff --git a/packages/storage-uploadthing/LICENSE.md b/packages/storage-uploadthing/LICENSE.md new file mode 100644 index 0000000000..05e80b2b48 --- /dev/null +++ b/packages/storage-uploadthing/LICENSE.md @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2018-2022 Payload CMS, LLC +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. diff --git a/packages/storage-uploadthing/README.md b/packages/storage-uploadthing/README.md new file mode 100644 index 0000000000..76015df0f2 --- /dev/null +++ b/packages/storage-uploadthing/README.md @@ -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', + }, + }), + ], +}) +``` diff --git a/packages/storage-uploadthing/package.json b/packages/storage-uploadthing/package.json new file mode 100644 index 0000000000..5ff7aa4a23 --- /dev/null +++ b/packages/storage-uploadthing/package.json @@ -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 (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" + } +} diff --git a/packages/storage-uploadthing/src/generateURL.ts b/packages/storage-uploadthing/src/generateURL.ts new file mode 100644 index 0000000000..b77e15e23d --- /dev/null +++ b/packages/storage-uploadthing/src/generateURL.ts @@ -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 || '')}` +} diff --git a/packages/storage-uploadthing/src/handleDelete.ts b/packages/storage-uploadthing/src/handleDelete.ts new file mode 100644 index 0000000000..bc51c11392 --- /dev/null +++ b/packages/storage-uploadthing/src/handleDelete.ts @@ -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}`) + } + } +} diff --git a/packages/storage-uploadthing/src/handleUpload.ts b/packages/storage-uploadthing/src/handleUpload.ts new file mode 100644 index 0000000000..f140252a6a --- /dev/null +++ b/packages/storage-uploadthing/src/handleUpload.ts @@ -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}`) + } + } + } + } +} diff --git a/packages/storage-uploadthing/src/index.ts b/packages/storage-uploadthing/src/index.ts new file mode 100644 index 0000000000..88e44b643a --- /dev/null +++ b/packages/storage-uploadthing/src/index.ts @@ -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 | 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, + ) + + // 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 }), + } + } +} diff --git a/packages/storage-uploadthing/src/staticHandler.ts b/packages/storage-uploadthing/src/staticHandler.ts new file mode 100644 index 0000000000..91677bd688 --- /dev/null +++ b/packages/storage-uploadthing/src/staticHandler.ts @@ -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 }) + } + } +} diff --git a/packages/storage-uploadthing/src/utilities.ts b/packages/storage-uploadthing/src/utilities.ts new file mode 100644 index 0000000000..02bdbd0abb --- /dev/null +++ b/packages/storage-uploadthing/src/utilities.ts @@ -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 + } + } + } + } +} diff --git a/packages/storage-uploadthing/tsconfig.json b/packages/storage-uploadthing/tsconfig.json new file mode 100644 index 0000000000..5880d1adca --- /dev/null +++ b/packages/storage-uploadthing/tsconfig.json @@ -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" }, + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 726d31c1a2..114922ee5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/test/package.json b/test/package.json index c76e98a8a8..7e80805625 100644 --- a/test/package.json +++ b/test/package.json @@ -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" } } diff --git a/test/storage-uploadthing/.eslintrc.cjs b/test/storage-uploadthing/.eslintrc.cjs new file mode 100644 index 0000000000..39a96642f1 --- /dev/null +++ b/test/storage-uploadthing/.eslintrc.cjs @@ -0,0 +1,8 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + ignorePatterns: ['payload-types.ts'], + parserOptions: { + project: ['./tsconfig.eslint.json'], + tsconfigRootDir: __dirname, + }, +} diff --git a/test/storage-uploadthing/collections/Media.ts b/test/storage-uploadthing/collections/Media.ts new file mode 100644 index 0000000000..4d9acc2a62 --- /dev/null +++ b/test/storage-uploadthing/collections/Media.ts @@ -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', + }, + ], +} diff --git a/test/storage-uploadthing/collections/MediaWithPrefix.ts b/test/storage-uploadthing/collections/MediaWithPrefix.ts new file mode 100644 index 0000000000..cab50b8ba9 --- /dev/null +++ b/test/storage-uploadthing/collections/MediaWithPrefix.ts @@ -0,0 +1,9 @@ +import type { CollectionConfig } from 'payload/types' + +export const MediaWithPrefix: CollectionConfig = { + slug: 'media-with-prefix', + upload: { + disableLocalStorage: false, + }, + fields: [], +} diff --git a/test/storage-uploadthing/collections/Users.ts b/test/storage-uploadthing/collections/Users.ts new file mode 100644 index 0000000000..a621159274 --- /dev/null +++ b/test/storage-uploadthing/collections/Users.ts @@ -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 + ], +} diff --git a/test/storage-uploadthing/config.ts b/test/storage-uploadthing/config.ts new file mode 100644 index 0000000000..5fb5e64d58 --- /dev/null +++ b/test/storage-uploadthing/config.ts @@ -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', + }, + }), + ], +}) diff --git a/test/storage-uploadthing/shared.ts b/test/storage-uploadthing/shared.ts new file mode 100644 index 0000000000..7d74b323ae --- /dev/null +++ b/test/storage-uploadthing/shared.ts @@ -0,0 +1,3 @@ +export const mediaSlug = 'media' +export const mediaWithPrefixSlug = 'media-with-prefix' +export const prefix = 'test-prefix' diff --git a/test/storage-uploadthing/tsconfig.eslint.json b/test/storage-uploadthing/tsconfig.eslint.json new file mode 100644 index 0000000000..b34cc7afbb --- /dev/null +++ b/test/storage-uploadthing/tsconfig.eslint.json @@ -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" + ] +} diff --git a/tsconfig.json b/tsconfig.json index 7943331a4a..d55001935b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -37,7 +37,7 @@ ], "paths": { "@payload-config": [ - "./test/live-preview/config.ts" + "./test/_community/config.ts" ], "@payloadcms/live-preview": [ "./packages/live-preview/src"