diff --git a/packages/dev/media/dep-comparison.jpg b/packages/dev/media/dep-comparison.jpg new file mode 100644 index 0000000000..17306f9178 Binary files /dev/null and b/packages/dev/media/dep-comparison.jpg differ diff --git a/packages/dev/src/app/(payload)/api/[collection]/file/[filename]/route.ts b/packages/dev/src/app/(payload)/api/[collection]/file/[filename]/route.ts new file mode 100644 index 0000000000..755677b28e --- /dev/null +++ b/packages/dev/src/app/(payload)/api/[collection]/file/[filename]/route.ts @@ -0,0 +1,4 @@ +/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ +/* DO NOT MODIFY it because it could be re-written at any time. */ + +export { GET } from '@payloadcms/next/routes/[collection]/file/[filename]/route' diff --git a/packages/dev/src/app/(payload)/api/graphql-playground/route.ts b/packages/dev/src/app/(payload)/api/graphql-playground/route.ts index b3d3f1d3f8..8181c87a43 100644 --- a/packages/dev/src/app/(payload)/api/graphql-playground/route.ts +++ b/packages/dev/src/app/(payload)/api/graphql-playground/route.ts @@ -1,4 +1,4 @@ /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ /* DO NOT MODIFY it because it could be re-written at any time. */ -export { GET } from '@payloadcms/next/routes/graphql/playground' +export { GET_GraphQLPlayground as GET } from '@payloadcms/next/routes/graphql' diff --git a/packages/dev/src/app/(payload)/api/graphql/route.ts b/packages/dev/src/app/(payload)/api/graphql/route.ts index b3e26b4242..9bf3f9d0e4 100644 --- a/packages/dev/src/app/(payload)/api/graphql/route.ts +++ b/packages/dev/src/app/(payload)/api/graphql/route.ts @@ -1,4 +1,4 @@ /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ /* DO NOT MODIFY it because it could be re-written at any time. */ -export { POST } from '@payloadcms/next/routes/graphql/handler' +export { POST_GraphQLHandler as POST } from '@payloadcms/next/routes/graphql' diff --git a/packages/dev/src/collections/Media.ts b/packages/dev/src/collections/Media.ts index ec1a8c9375..400b2435b0 100644 --- a/packages/dev/src/collections/Media.ts +++ b/packages/dev/src/collections/Media.ts @@ -3,7 +3,12 @@ import { BeforeInput } from './Pages/BeforeInput' export const Media: CollectionConfig = { slug: 'media', - upload: true, + upload: { + staticDir: 'media', + }, + access: { + read: () => true, + }, fields: [ { name: 'title', diff --git a/packages/next/bin/install.ts b/packages/next/bin/install.ts new file mode 100644 index 0000000000..a60762ec67 --- /dev/null +++ b/packages/next/bin/install.ts @@ -0,0 +1,54 @@ +import path from 'path' +import fs from 'fs' +import { copyRecursiveSync } from './utilities/copyRecursiveSync' +import { copyFile } from './utilities/copyFile' + +const install = () => { + const useSrc = fs.existsSync(path.resolve(process.cwd(), './src')) + const hasAppFolder = fs.existsSync(path.resolve(process.cwd(), `./${useSrc ? 'src/' : 'app'}`)) + + if (!hasAppFolder) { + console.error( + `You need to have a ${ + useSrc ? 'src/' : 'app/' + } folder in your project before running this command.`, + ) + process.exit(1) + } + + const basePath = useSrc ? './src' : '.' + + // Copy handlers into /api + copyRecursiveSync( + path.resolve(__dirname, './templates/pages/api'), + path.resolve(process.cwd(), `${basePath}/pages/api`), + ) + + // Copy admin into /app + copyRecursiveSync( + path.resolve(__dirname, './templates/app'), + path.resolve(process.cwd(), `${basePath}/app`), + ) + + const payloadConfigPath = path.resolve(process.cwd(), `${basePath}/payload`) + + if (!fs.existsSync(payloadConfigPath)) { + fs.mkdirSync(payloadConfigPath) + } + + // Copy payload initialization + copyFile( + path.resolve(__dirname, './templates/payloadClient.ts'), + path.resolve(process.cwd(), `${basePath}/payload/payloadClient.ts`), + ) + + // Copy base payload config + copyFile( + path.resolve(__dirname, './templates/payload.config.ts'), + path.resolve(process.cwd(), `${basePath}/payload/payload.config.ts`), + ) + + process.exit(0) +} + +export default install() diff --git a/packages/next/bin/utilities/copyFile.ts b/packages/next/bin/utilities/copyFile.ts new file mode 100644 index 0000000000..a0eca7c6f3 --- /dev/null +++ b/packages/next/bin/utilities/copyFile.ts @@ -0,0 +1,7 @@ +import fs from 'fs' + +export const copyFile = (source, target) => { + if (!fs.existsSync(target)) { + fs.writeFileSync(target, fs.readFileSync(source)) + } +} diff --git a/packages/next/bin/utilities/copyRecursiveSync.ts b/packages/next/bin/utilities/copyRecursiveSync.ts new file mode 100644 index 0000000000..01437c4018 --- /dev/null +++ b/packages/next/bin/utilities/copyRecursiveSync.ts @@ -0,0 +1,16 @@ +import fs from 'fs' +import path from 'path' + +export function copyRecursiveSync(src, dest) { + var exists = fs.existsSync(src) + var stats = exists && fs.statSync(src) + var isDirectory = exists && stats && stats.isDirectory() + if (isDirectory) { + fs.mkdirSync(dest, { recursive: true }) + fs.readdirSync(src).forEach(function (childItemName) { + copyRecursiveSync(path.join(src, childItemName), path.join(dest, childItemName)) + }) + } else { + fs.copyFileSync(src, dest) + } +} diff --git a/packages/next/package.json b/packages/next/package.json index be97dc6479..b828ab3e59 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -32,13 +32,9 @@ "import": "./src/routes/index.ts", "require": "./src/routes/index.ts" }, - "./graphql": { - "import": "./src/routes/graphql/handler.ts", - "require": "./src/routes/graphql/handler.ts" - }, - "./graphql-playground": { - "import": "./src/routes/graphql/playground.ts", - "require": "./src/routes/graphql/playground.ts" + "./routes/*": { + "import": "./src/routes/*.ts", + "require": "./src/routes/*.ts" } }, "devDependencies": { @@ -96,15 +92,10 @@ "require": "./dist/routes/index.js", "types": "./dist/routes/index.d.ts" }, - "./graphql": { - "import": "./dist/routes/graphql/handler.js", - "require": "./dist/routes/graphql/handler.js", - "types": "./dist/routes/graphql/handler.ts" - }, - "./graphql-playground": { - "import": "./dist/routes/graphql/playground.js", - "require": "./dist/routes/graphql/playground.js", - "types": "./dist/routes/graphql/playground.ts" + "./routes/*": { + "import": "./dist/routes/*.js", + "require": "./dist/routes/*.js", + "types": "./dist/routes/*.d.ts" } }, "registry": "https://registry.npmjs.org/" diff --git a/packages/next/src/app/api/[collection]/file/[filename]/route.ts b/packages/next/src/app/api/[collection]/file/[filename]/route.ts new file mode 100644 index 0000000000..755677b28e --- /dev/null +++ b/packages/next/src/app/api/[collection]/file/[filename]/route.ts @@ -0,0 +1,4 @@ +/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ +/* DO NOT MODIFY it because it could be re-written at any time. */ + +export { GET } from '@payloadcms/next/routes/[collection]/file/[filename]/route' diff --git a/packages/next/src/app/api/graphql-playground/route.ts b/packages/next/src/app/api/graphql-playground/route.ts index b3d3f1d3f8..8181c87a43 100644 --- a/packages/next/src/app/api/graphql-playground/route.ts +++ b/packages/next/src/app/api/graphql-playground/route.ts @@ -1,4 +1,4 @@ /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ /* DO NOT MODIFY it because it could be re-written at any time. */ -export { GET } from '@payloadcms/next/routes/graphql/playground' +export { GET_GraphQLPlayground as GET } from '@payloadcms/next/routes/graphql' diff --git a/packages/next/src/app/api/graphql/route.ts b/packages/next/src/app/api/graphql/route.ts index b3e26b4242..9bf3f9d0e4 100644 --- a/packages/next/src/app/api/graphql/route.ts +++ b/packages/next/src/app/api/graphql/route.ts @@ -1,4 +1,4 @@ /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ /* DO NOT MODIFY it because it could be re-written at any time. */ -export { POST } from '@payloadcms/next/routes/graphql/handler' +export { POST_GraphQLHandler as POST } from '@payloadcms/next/routes/graphql' diff --git a/packages/next/src/next-stream-file/index.ts b/packages/next/src/next-stream-file/index.ts new file mode 100644 index 0000000000..f0f15110ff --- /dev/null +++ b/packages/next/src/next-stream-file/index.ts @@ -0,0 +1,26 @@ +import fs from 'fs' + +function iteratorToStream(iterator) { + return new ReadableStream({ + async pull(controller) { + const { value, done } = await iterator.next() + if (done) { + controller.close() + } else { + controller.enqueue(value) + } + }, + }) +} + +async function* nodeStreamToIterator(stream: fs.ReadStream) { + for await (const chunk of stream) { + yield new Uint8Array(chunk) + } +} + +export function streamFile(path: string): ReadableStream { + const nodeStream = fs.createReadStream(path) + const data: ReadableStream = iteratorToStream(nodeStreamToIterator(nodeStream)) + return data +} diff --git a/packages/next/src/routes/[collection]/file/[filename]/route.ts b/packages/next/src/routes/[collection]/file/[filename]/route.ts new file mode 100644 index 0000000000..9e077e429a --- /dev/null +++ b/packages/next/src/routes/[collection]/file/[filename]/route.ts @@ -0,0 +1,119 @@ +import path from 'path' +import config from 'payload-config' +import { streamFile } from '../../../../next-stream-file' +import fsPromises from 'fs/promises' +import { Collection, PayloadRequest, Where } from 'payload/types' +import executeAccess from 'payload/dist/auth/executeAccess' +import { APIError, Forbidden } from 'payload/errors' +import { RouteError } from '../../../RouteError' +import { createPayloadRequest } from '../../../../utilities/createPayloadRequest' +import httpStatus from 'http-status' + +async function checkFileAccess({ + req, + filename, + collection, +}: { + req: PayloadRequest + filename: string + collection: Collection +}) { + const { config } = collection + const accessResult = await executeAccess({ isReadingStaticFile: true, req }, config.access.read) + + if (typeof accessResult === 'object') { + const queryToBuild: Where = { + and: [ + { + or: [ + { + filename: { + equals: filename, + }, + }, + ], + }, + accessResult, + ], + } + + if (config.upload.imageSizes) { + config.upload.imageSizes.forEach(({ name }) => { + queryToBuild.and[0].or.push({ + [`sizes.${name}.filename`]: { + equals: filename, + }, + }) + }) + } + + const doc = await req.payload.db.findOne({ + collection: config.slug, + req, + where: queryToBuild, + }) + + if (!doc) { + throw new Forbidden(req.t) + } + } +} + +export const GET = async ( + request: Request, + { params }: { params: { collection: string; filename: string } }, +) => { + const { collection: collectionSlug, filename } = params + let req: PayloadRequest + let collection: Collection + + try { + req = await createPayloadRequest({ + request, + config, + params: { collection: collectionSlug }, + }) + collection = req.payload.collections?.[collectionSlug] + + if (!collection) { + throw new APIError(`Media collection not found: ${collectionSlug}`, httpStatus.BAD_REQUEST) + } + + if (!collection.config.upload) { + throw new APIError( + `This collection is not an upload collection: ${collectionSlug}`, + httpStatus.BAD_REQUEST, + ) + } + + if (collection.config.upload.disableLocalStorage) { + throw new APIError( + `This collection has local storage disabled: ${collectionSlug}`, + httpStatus.BAD_REQUEST, + ) + } + + await checkFileAccess({ + req, + filename, + collection, + }) + + const fileDir = collection.config.upload?.staticDir || collection.config.slug + const filePath = path.resolve(`${fileDir}/${filename}`) + const stats = await fsPromises.stat(filePath) + const data = streamFile(filePath) + return new Response(data, { + status: httpStatus.OK, + headers: new Headers({ + 'content-length': stats.size + '', + }), + }) + } catch (error) { + return RouteError({ + req, + collection, + err: error, + }) + } +} diff --git a/packages/next/src/routes/fileHandler/[filename]/route.ts b/packages/next/src/routes/fileHandler/[filename]/route.ts new file mode 100644 index 0000000000..5ce8074ace --- /dev/null +++ b/packages/next/src/routes/fileHandler/[filename]/route.ts @@ -0,0 +1,125 @@ +import path from 'path' +import config from 'payload-config' +import { streamFile } from '../../../next-stream-file' +import fsPromises from 'fs/promises' +import { Collection, PayloadRequest, Where } from 'payload/types' +import executeAccess from 'payload/dist/auth/executeAccess' +import { APIError, Forbidden } from 'payload/errors' +import { RouteError } from '../../RouteError' +import { createPayloadRequest } from '../../../utilities/createPayloadRequest' +import httpStatus from 'http-status' + +async function checkFileAccess({ + req, + filename, + collection, +}: { + req: PayloadRequest + filename: string + collection: Collection +}) { + const { config } = collection + const accessResult = await executeAccess({ isReadingStaticFile: true, req }, config.access.read) + + if (typeof accessResult === 'object') { + const queryToBuild: Where = { + and: [ + { + or: [ + { + filename: { + equals: filename, + }, + }, + ], + }, + accessResult, + ], + } + + if (config.upload.imageSizes) { + config.upload.imageSizes.forEach(({ name }) => { + queryToBuild.and[0].or.push({ + [`sizes.${name}.filename`]: { + equals: filename, + }, + }) + }) + } + + const doc = await req.payload.db.findOne({ + collection: config.slug, + req, + where: queryToBuild, + }) + + if (!doc) { + throw new Forbidden(req.t) + } + } +} + +export const GET = + (collectionSlug: string) => + async (request: Request, { params }: { params: { filename: string } }) => { + let req: PayloadRequest + let collection: Collection + + try { + req = await createPayloadRequest({ + request, + config, + params: { collection: collectionSlug }, + }) + collection = req.payload.collections?.[collectionSlug] + + if (!collection) { + throw new APIError(`Media collection not found: ${collectionSlug}`, httpStatus.BAD_REQUEST) + } + + if (!collection.config.upload) { + throw new APIError( + `This collection is not an upload collection: ${collectionSlug}`, + httpStatus.BAD_REQUEST, + ) + } + + if (collection.config.upload.disableLocalStorage) { + throw new APIError( + `This collection has local storage disabled: ${collectionSlug}`, + httpStatus.BAD_REQUEST, + ) + } + + const { filename } = params + if (!filename) { + throw new APIError( + 'No filename provided, ensure this route is within a [filename] folder, i.e. staticDir/[filename]/route.ts', + httpStatus.BAD_REQUEST, + ) + } + + await checkFileAccess({ + req, + filename, + collection, + }) + + const fileDir = collection.config.upload?.staticDir || collection.config.slug + const filePath = path.resolve(`${fileDir}/${filename}`) + const stats = await fsPromises.stat(filePath) + const data = streamFile(filePath) + return new Response(data, { + status: httpStatus.OK, + headers: new Headers({ + 'content-length': stats.size + '', + }), + }) + } catch (error) { + return RouteError({ + req, + collection, + err: error, + }) + } + } diff --git a/packages/next/src/routes/graphql/index.ts b/packages/next/src/routes/graphql/index.ts new file mode 100644 index 0000000000..746a6a16a5 --- /dev/null +++ b/packages/next/src/routes/graphql/index.ts @@ -0,0 +1,2 @@ +export { POST as POST_GraphQLHandler } from './handler' +export { GET as GET_GraphQLPlayground } from './playground' diff --git a/packages/next/src/utilities/createPayloadRequest.ts b/packages/next/src/utilities/createPayloadRequest.ts index 23e24182d4..9f1d1b469c 100644 --- a/packages/next/src/utilities/createPayloadRequest.ts +++ b/packages/next/src/utilities/createPayloadRequest.ts @@ -98,7 +98,11 @@ export const createPayloadRequest = async ({ transactionID: undefined, payloadDataLoader: undefined, payloadUploadSizes: {}, - ...urlPropertiesObject, + host: urlProperties.host, + protocol: urlProperties.protocol, + pathname: urlProperties.pathname, + searchParams: urlProperties.searchParams, + origin: urlProperties.origin, } const req: PayloadRequest = Object.assign(request, customRequest) diff --git a/packages/payload/src/auth/executeAccess.ts b/packages/payload/src/auth/executeAccess.ts index 222b230008..0b26f2ff6c 100644 --- a/packages/payload/src/auth/executeAccess.ts +++ b/packages/payload/src/auth/executeAccess.ts @@ -7,16 +7,18 @@ type OperationArgs = { data?: any disableErrors?: boolean id?: number | string + isReadingStaticFile?: boolean req: PayloadRequest } const executeAccess = async ( - { id, data, disableErrors, req }: OperationArgs, + { id, data, disableErrors, isReadingStaticFile = false, req }: OperationArgs, access: Access, ): Promise => { if (access) { const result = await access({ id, data, + isReadingStaticFile, req, }) diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index 55a2c5004f..f5dc5a281e 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -208,6 +208,8 @@ export type AccessArgs = { data?: T /** ID of the resource being accessed */ id?: number | string + /** If true, the request is for a static file */ + isReadingStaticFile?: boolean /** The original request that requires an access check */ req: PayloadRequest } diff --git a/packages/payload/src/types/index.ts b/packages/payload/src/types/index.ts index f5bb1cdd41..5bc58ff060 100644 --- a/packages/payload/src/types/index.ts +++ b/packages/payload/src/types/index.ts @@ -66,7 +66,7 @@ export type CustomPayloadRequest = { transactionIDPromise?: Promise /** The signed in user */ user: (U & User) | null -} & Partial +} & Pick export type PayloadRequest = Partial & Required> & CustomPayloadRequest