feat: add route for static file GET requests (#5065)

This commit is contained in:
Jarrod Flesch
2024-02-12 16:52:20 -05:00
committed by GitHub
parent 087ee35ece
commit 35e2e1848a
20 changed files with 385 additions and 24 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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',

View File

@@ -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()

View File

@@ -0,0 +1,7 @@
import fs from 'fs'
export const copyFile = (source, target) => {
if (!fs.existsSync(target)) {
fs.writeFileSync(target, fs.readFileSync(source))
}
}

View File

@@ -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)
}
}

View File

@@ -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/"

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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
}

View File

@@ -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,
})
}
}

View File

@@ -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,
})
}
}

View File

@@ -0,0 +1,2 @@
export { POST as POST_GraphQLHandler } from './handler'
export { GET as GET_GraphQLPlayground } from './playground'

View File

@@ -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)

View File

@@ -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<AccessResult> => {
if (access) {
const result = await access({
id,
data,
isReadingStaticFile,
req,
})

View File

@@ -208,6 +208,8 @@ export type AccessArgs<T = any, U = any> = {
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<U>
}

View File

@@ -66,7 +66,7 @@ export type CustomPayloadRequest<U = any> = {
transactionIDPromise?: Promise<void>
/** The signed in user */
user: (U & User) | null
} & Partial<URL>
} & Pick<URL, 'host' | 'origin' | 'pathname' | 'protocol' | 'searchParams'>
export type PayloadRequest<U = any> = Partial<Request> &
Required<Pick<Request, 'headers'>> &
CustomPayloadRequest<U>