fix: ensure body limit is respected (#5807)

Co-authored-by: James <james@trbl.design>
This commit is contained in:
Jarrod Flesch
2024-04-16 09:22:41 -04:00
committed by GitHub
parent 3db0557b07
commit 697a0f1ecf
17 changed files with 631 additions and 508 deletions

View File

@@ -77,7 +77,7 @@ export const tempFileHandler: Handler = (options, fieldname, filename) => {
}
export const memHandler: Handler = (options, fieldname, filename) => {
const buffers = []
const buffers: Buffer[] = []
const hash = crypto.createHash('md5')
let fileSize = 0
let completed = false

View File

@@ -1,4 +1,4 @@
const ACCEPTABLE_CONTENT_TYPE = /^multipart\/['"()+-_]+(?:; ?['"()+-_]*)+$/i
const ACCEPTABLE_CONTENT_TYPE = /multipart\/['"()+-_]+(?:; ?['"()+-_]*)+$/i
const UNACCEPTABLE_METHODS = new Set(['GET', 'HEAD', 'DELETE', 'OPTIONS', 'CONNECT', 'TRACE'])
const hasBody = (req: Request): boolean => {

View File

@@ -1,4 +1,5 @@
import Busboy from 'busboy'
import httpStatus from 'http-status'
import { APIError } from 'payload/errors'
import type { NextFileUploadOptions, NextFileUploadResponse } from './index.js'
@@ -17,6 +18,17 @@ type ProcessMultipart = (args: {
}) => Promise<NextFileUploadResponse>
export const processMultipart: ProcessMultipart = async ({ options, request }) => {
let parsingRequest = true
let fileCount = 0
let filesCompleted = 0
let allFilesHaveResolved: (value?: unknown) => void
let failedResolvingFiles: (err: Error) => void
const allFilesComplete = new Promise((res, rej) => {
allFilesHaveResolved = res
failedResolvingFiles = rej
})
const result: NextFileUploadResponse = {
fields: undefined,
files: undefined,
@@ -36,6 +48,7 @@ export const processMultipart: ProcessMultipart = async ({ options, request }) =
// Build req.files fields
busboy.on('file', (field, file, info) => {
fileCount += 1
// Parse file name(cutting huge names, decoding, etc..).
const { encoding, filename: name, mimeType: mime } = info
const filename = parseFileName(options, name)
@@ -73,7 +86,9 @@ export const processMultipart: ProcessMultipart = async ({ options, request }) =
debugLog(options, `Aborting upload because of size limit ${field}->${filename}.`)
cleanup()
parsingRequest = false
throw new APIError(options.responseOnLimit, 413, { size: getFileSize() })
throw new APIError(options.responseOnLimit, httpStatus.REQUEST_ENTITY_TOO_LARGE, {
size: getFileSize(),
})
}
})
@@ -95,6 +110,8 @@ export const processMultipart: ProcessMultipart = async ({ options, request }) =
return debugLog(options, `Don't add file instance if original name and size are empty`)
}
filesCompleted += 1
result.files = buildFields(
result.files,
field,
@@ -117,19 +134,25 @@ export const processMultipart: ProcessMultipart = async ({ options, request }) =
request[waitFlushProperty] = []
}
request[waitFlushProperty].push(writePromise)
if (filesCompleted === fileCount) {
allFilesHaveResolved()
}
})
file.on('error', (err) => {
uploadTimer.clear()
debugLog(options, `File Error: ${err.message}`)
cleanup()
failedResolvingFiles(err)
})
// Start upload process.
debugLog(options, `New upload started ${field}->${filename}, bytes:${getFileSize()}`)
uploadTimer.set()
})
busboy.on('finish', () => {
busboy.on('finish', async () => {
debugLog(options, `Busboy finished parsing request.`)
if (options.parseNested) {
result.fields = processNested(result.fields)
@@ -137,20 +160,27 @@ export const processMultipart: ProcessMultipart = async ({ options, request }) =
}
if (request[waitFlushProperty]) {
Promise.all(request[waitFlushProperty]).then(() => {
delete request[waitFlushProperty]
})
try {
await Promise.all(request[waitFlushProperty]).then(() => {
delete request[waitFlushProperty]
})
} catch (err) {
debugLog(options, `Error waiting for file write promises: ${err}`)
}
}
return result
})
busboy.on('error', (err) => {
debugLog(options, `Busboy error`)
parsingRequest = false
throw new APIError('Busboy error parsing multipart request', 500)
throw new APIError('Busboy error parsing multipart request', httpStatus.BAD_REQUEST)
})
const reader = request.body.getReader()
// Start parsing request
while (parsingRequest) {
const { done, value } = await reader.read()
@@ -163,5 +193,7 @@ export const processMultipart: ProcessMultipart = async ({ options, request }) =
}
}
if (fileCount !== 0) await allFilesComplete
return result
}

View File

@@ -19,7 +19,7 @@ let tempCounter = 0
export const debugLog = (options: NextFileUploadOptions, msg: string) => {
const opts = options || {}
if (!opts.debug) return false
console.log(`Express-file-upload: ${msg}`) // eslint-disable-line
console.log(`Next-file-upload: ${msg}`) // eslint-disable-line
return true
}
@@ -287,8 +287,9 @@ export const parseFileName: ParseFileName = (opts, fileName) => {
? opts.safeFileNames
: SAFE_FILE_NAME_REGEX
// Parse file name extension.
let { name, extension } = parseFileNameExtension(opts.preserveExtension, parsedName)
if (extension.length) extension = '.' + extension.replace(nameRegex, '')
const parsedFileName = parseFileNameExtension(opts.preserveExtension, parsedName)
if (parsedFileName.extension.length)
parsedFileName.extension = '.' + parsedFileName.extension.replace(nameRegex, '')
return name.replace(nameRegex, '').concat(extension)
return parsedFileName.name.replace(nameRegex, '').concat(parsedFileName.extension)
}

View File

@@ -35,8 +35,9 @@ export const preview: CollectionRouteHandlerWithID = async ({ id, collection, re
token,
})
} catch (err) {
routeError({
return routeError({
collection,
config: req.payload.config,
err,
req,
})

View File

@@ -66,6 +66,7 @@ export const getFile = async ({ collection, filename, req }: Args): Promise<Resp
} catch (error) {
return routeError({
collection,
config: req.payload.config,
err: error,
req,
})

View File

@@ -35,7 +35,8 @@ export const preview: GlobalRouteHandler = async ({ globalConfig, req }) => {
token,
})
} catch (err) {
routeError({
return routeError({
config: req.payload.config,
err,
req,
})

View File

@@ -303,6 +303,7 @@ export const GET =
} catch (error) {
return routeError({
collection,
config,
err: error,
req,
})
@@ -445,6 +446,7 @@ export const POST =
} catch (error) {
return routeError({
collection,
config,
err: error,
req,
})
@@ -514,6 +516,7 @@ export const DELETE =
} catch (error) {
return routeError({
collection,
config,
err: error,
req,
})
@@ -583,6 +586,7 @@ export const PATCH =
} catch (error) {
return routeError({
collection,
config,
err: error,
req,
})

View File

@@ -1,8 +1,10 @@
import type { Collection, PayloadRequest } from 'payload/types'
import type { Collection, PayloadRequest, SanitizedConfig } from 'payload/types'
import httpStatus from 'http-status'
import { APIError } from 'payload/errors'
import { getPayloadHMR } from '../../utilities/getPayloadHMR.js'
export type ErrorResponse = { data?: any; errors: unknown[]; stack?: string }
const formatErrors = (incoming: { [key: string]: unknown } | APIError): ErrorResponse => {
@@ -66,26 +68,33 @@ const formatErrors = (incoming: { [key: string]: unknown } | APIError): ErrorRes
}
}
export const routeError = ({
export const routeError = async ({
collection,
config: configArg,
err,
req,
}: {
collection?: Collection
config: Promise<SanitizedConfig> | SanitizedConfig
err: APIError
req: PayloadRequest
}) => {
if (!req?.payload) {
return Response.json(
{
message: err.message,
stack: err.stack,
},
{ status: httpStatus.INTERNAL_SERVER_ERROR },
)
let payload = req?.payload
if (!payload) {
try {
payload = await getPayloadHMR({ config: configArg })
} catch (e) {
return Response.json(
{
message: 'There was an error initializing Payload',
},
{ status: httpStatus.INTERNAL_SERVER_ERROR },
)
}
}
const { config, logger } = req.payload
const { config, logger } = payload
let response = formatErrors(err)

View File

@@ -1,5 +1,7 @@
import type { Collection, CustomPayloadRequest, SanitizedConfig } from 'payload/types'
import type { NextFileUploadOptions } from '../next-fileupload/index.js'
import { nextFileUpload } from '../next-fileupload/index.js'
type GetDataAndFile = (args: {
@@ -10,68 +12,53 @@ type GetDataAndFile = (args: {
data: Record<string, any>
file: CustomPayloadRequest['file']
}>
export const getDataAndFile: GetDataAndFile = async ({ collection, config, request }) => {
export const getDataAndFile: GetDataAndFile = async ({
collection,
config,
request: incomingRequest,
}) => {
let data: Record<string, any> = undefined
let file: CustomPayloadRequest['file'] = undefined
if (['PATCH', 'POST', 'PUT'].includes(request.method.toUpperCase()) && request.body) {
if (
['PATCH', 'POST', 'PUT'].includes(incomingRequest.method.toUpperCase()) &&
incomingRequest.body
) {
const request = new Request(incomingRequest)
const [contentType] = (request.headers.get('Content-Type') || '').split(';')
if (contentType === 'application/json') {
try {
data = await request.json()
} catch (error) {
data = {}
}
} else if (contentType === 'multipart/form-data') {
// possible upload request
if (collection?.config?.upload) {
// load file in memory
if (!config.upload?.useTempFiles) {
const formData = await request.formData()
const formFile = formData.get('file')
if (formFile instanceof Blob) {
const bytes = await formFile.arrayBuffer()
const buffer = Buffer.from(bytes)
file = {
name: formFile.name,
data: buffer,
mimetype: formFile.type,
size: formFile.size,
}
}
const payloadData = formData.get('_payload')
if (typeof payloadData === 'string') {
data = JSON.parse(payloadData)
}
} else {
// store temp file on disk
const { error, fields, files } = await nextFileUpload({
options: config.upload as any,
request,
})
if (error) {
throw new Error(error.message)
}
if (files?.file) file = files.file
if (fields?._payload && typeof fields._payload === 'string') {
data = JSON.parse(fields._payload)
}
const bodyByteSize = parseInt(request.headers.get('Content-Length') || '0', 10)
const upperByteLimit =
typeof config.upload?.limits?.fieldSize === 'number'
? config.upload.limits.fields
: undefined
if (bodyByteSize <= upperByteLimit || upperByteLimit === undefined) {
try {
data = await request.json()
} catch (error) {
data = {}
}
} else {
// non upload request
const formData = await request.formData()
const payloadData = formData.get('_payload')
throw new Error('Request body size exceeds the limit')
}
} else {
if (request.headers.has('Content-Length') && request.headers.get('Content-Length') !== '0') {
const { error, fields, files } = await nextFileUpload({
options: config.upload as NextFileUploadOptions,
request,
})
if (typeof payloadData === 'string') {
data = JSON.parse(payloadData)
if (error) {
throw new Error(error.message)
}
if (collection?.config?.upload && files?.file) {
file = files.file
}
if (fields?._payload && typeof fields._payload === 'string') {
data = JSON.parse(fields._payload)
}
}
}

View File

@@ -2,21 +2,20 @@ import type { SanitizedConfig } from 'payload/config'
import type { Where } from 'payload/types'
import type { ParsedQs } from 'qs'
import {
REST_DELETE as createDELETE,
REST_GET as createGET,
REST_PATCH as createPATCH,
REST_POST as createPOST,
} from '@payloadcms/next/routes'
import { GRAPHQL_POST as createGraphqlPOST } from '@payloadcms/next/routes'
import QueryString from 'qs'
import { GRAPHQL_POST as createGraphqlPOST } from '../../packages/next/src/routes/graphql/index.js'
import {
DELETE as createDELETE,
GET as createGET,
PATCH as createPATCH,
POST as createPOST,
} from '../../packages/next/src/routes/rest/index.js'
import { devUser } from '../credentials.js'
type ValidPath = `/${string}`
type RequestOptions = {
auth?: boolean
file?: boolean
query?: {
depth?: number
fallbackLocale?: string
@@ -28,6 +27,10 @@ type RequestOptions = {
}
}
type FileArg = {
file?: Omit<File, 'webkitRelativePath'>
}
function generateQueryString(query: RequestOptions['query'], params: ParsedQs): string {
return QueryString.stringify(
{
@@ -67,12 +70,16 @@ export class NextRESTClient {
this._GRAPHQL_POST = createGraphqlPOST(config)
}
private buildHeaders(options: RequestInit & RequestOptions): Headers {
private buildHeaders(options: RequestInit & RequestOptions & FileArg): Headers {
const defaultHeaders = {
'Content-Type': 'application/json',
}
const headers = new Headers({
...(options?.file ? {} : defaultHeaders),
...(options?.file
? {
'Content-Length': options.file.size.toString(),
}
: defaultHeaders),
...(options?.headers || {}),
})
@@ -141,7 +148,7 @@ export class NextRESTClient {
return this._GRAPHQL_POST(request)
}
async PATCH(path: ValidPath, options: RequestInit & RequestOptions): Promise<Response> {
async PATCH(path: ValidPath, options: RequestInit & RequestOptions & FileArg): Promise<Response> {
const { url, slug, params } = this.generateRequestParts(path)
const { query, ...rest } = options
const queryParams = generateQueryString(query, params)
@@ -156,11 +163,10 @@ export class NextRESTClient {
async POST(
path: ValidPath,
options: RequestInit & RequestOptions & { file?: boolean } = {},
options: RequestInit & RequestOptions & FileArg = {},
): Promise<Response> {
const { url, slug, params } = this.generateRequestParts(path)
const queryParams = generateQueryString({}, params)
const request = new Request(`${url}${queryParams}`, {
...options,
method: 'POST',

BIN
test/uploads/2mb.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

@@ -452,6 +452,13 @@ export default buildConfigWithDefaults({
},
},
],
upload: {
// debug: true,
abortOnLimit: true,
limits: {
fileSize: 2_000_000, // 2MB
},
},
onInit: async (payload) => {
const uploadsDir = path.resolve(dirname, './media')
removeFiles(path.normalize(uploadsDir))

View File

@@ -0,0 +1,37 @@
import { File } from 'buffer'
import NodeFormData from 'form-data'
import fs from 'fs'
import { open } from 'node:fs/promises'
import { basename } from 'node:path'
import { getMimeType } from './getMimeType.js'
export async function createStreamableFile(
path: string,
): Promise<{ file: File; handle: fs.promises.FileHandle }> {
const name = basename(path)
const handle = await open(path)
const { type } = getMimeType(path)
const file = new File([], name, { type })
file.stream = () => handle.readableWebStream()
const formDataNode = new NodeFormData()
formDataNode.append('file', fs.createReadStream(path))
const contentLength = await new Promise((resolve, reject) => {
formDataNode.getLength((err, length) => {
if (err) {
reject(err)
} else {
resolve(length)
}
})
})
// Set correct size otherwise, fetch will encounter UND_ERR_REQ_CONTENT_LENGTH_MISMATCH
Object.defineProperty(file, 'size', { get: () => contentLength })
return { file, handle }
}

View File

@@ -241,6 +241,17 @@ describe('uploads', () => {
)
})
test('should throw error when file is larger than the limit and abortOnLimit is true', async () => {
await page.goto(mediaURL.create)
await page.setInputFiles('input[type="file"]', path.resolve(dirname, './2mb.jpg'))
await wait(500) // TODO: Fix this
await page.click('#action-save', { delay: 100 })
await expect(page.locator('.Toastify .Toastify__toast--error')).toContainText(
'File size limit has been reached',
)
})
test('Should render adminThumbnail when using a function', async () => {
await page.goto(adminThumbnailFunctionURL.list)
await page.waitForURL(adminThumbnailFunctionURL.list)

View File

@@ -0,0 +1,32 @@
import path from 'path'
export const getMimeType = (
filePath: string,
): {
filename: string
type: string
} => {
const ext = path.extname(filePath).slice(1)
let type: string
switch (ext) {
case 'png':
type = 'image/png'
break
case 'jpg':
type = 'image/jpeg'
break
case 'jpeg':
type = 'image/jpeg'
break
case 'svg':
type = 'image/svg+xml'
break
default:
type = 'image/png'
}
return {
filename: path.basename(filePath),
type,
}
}

View File

@@ -1,7 +1,5 @@
import type { Payload } from 'payload'
import { File as FileBuffer } from 'buffer'
import NodeFormData from 'form-data'
import fs from 'fs'
import path from 'path'
import { getFileByPath } from 'payload/uploads'
@@ -13,6 +11,7 @@ import type { Enlarge, Media } from './payload-types.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
import configPromise from './config.js'
import { createStreamableFile } from './createStreamableFile.js'
import {
enlargeSlug,
mediaSlug,
@@ -24,54 +23,6 @@ import {
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
const getMimeType = (
filePath: string,
): {
filename: string
type: string
} => {
const ext = path.extname(filePath).slice(1)
let type: string
switch (ext) {
case 'png':
type = 'image/png'
break
case 'jpg':
type = 'image/jpeg'
break
case 'jpeg':
type = 'image/jpeg'
break
case 'svg':
type = 'image/svg+xml'
break
default:
type = 'image/png'
}
return {
filename: path.basename(filePath),
type,
}
}
const bufferToFileBlob = async (filePath: string): Promise<File> =>
new Promise((resolve, reject) => {
fs.readFile(filePath, (err, data) => {
if (err) {
console.error(`Error reading file at ${filePath}:`, err)
reject(err)
return
}
const { filename, type } = getMimeType(filePath)
// Convert type FileBuffer > unknown > File
// The File type expects webkitRelativePath, we don't have that
resolve(new FileBuffer([data], filename, { type }) as unknown as File)
})
})
const stat = promisify(fs.stat)
let restClient: NextRESTClient
@@ -90,20 +41,22 @@ describe('Collections - Uploads', () => {
}
})
describe('REST', () => {
describe('REST API', () => {
describe('create', () => {
it('creates from form data given a png', async () => {
const formData = new FormData()
const filePath = path.join(dirname, './image.png')
formData.append('file', await bufferToFileBlob(filePath))
const { file, handle } = await createStreamableFile(filePath)
formData.append('file', file)
const response = await restClient.POST(`/${mediaSlug}`, {
body: formData,
file: true,
file,
})
const { doc } = await response.json()
await handle.close()
expect(response.status).toBe(201)
const { sizes } = doc
@@ -130,16 +83,20 @@ describe('Collections - Uploads', () => {
})
it('creates from form data given an svg', async () => {
const formData = new FormData()
const filePath = path.join(dirname, './image.svg')
formData.append('file', await bufferToFileBlob(filePath))
const formData = new FormData()
const { file, handle } = await createStreamableFile(filePath)
formData.append('file', file)
const response = await restClient.POST(`/${mediaSlug}`, {
body: formData,
file: true,
file,
})
const { doc } = await response.json()
await handle.close()
expect(response.status).toBe(201)
// Check for files
@@ -151,100 +108,426 @@ describe('Collections - Uploads', () => {
expect(doc.width).toBeDefined()
expect(doc.height).toBeDefined()
})
})
it('should have valid image url', async () => {
const formData = new FormData()
const fileBlob = await bufferToFileBlob(path.join(dirname, './image.svg'))
formData.append('file', fileBlob)
it('should have valid image url', async () => {
const formData = new FormData()
const filePath = path.join(dirname, './image.svg')
const { file, handle } = await createStreamableFile(filePath)
formData.append('file', file)
const response = await restClient.POST(`/${mediaSlug}`, {
body: formData,
file: true,
const response = await restClient.POST(`/${mediaSlug}`, {
body: formData,
file,
})
const { doc } = await response.json()
await handle.close()
expect(response.status).toBe(201)
const expectedPath = path.join(dirname, './media')
expect(await fileExists(path.join(expectedPath, doc.filename))).toBe(true)
expect(doc.url).not.toContain('undefined')
})
const { doc } = await response.json()
expect(response.status).toBe(201)
const expectedPath = path.join(dirname, './media')
expect(await fileExists(path.join(expectedPath, doc.filename))).toBe(true)
it('creates images that do not require all sizes', async () => {
const formData = new FormData()
const filePath = path.join(dirname, './small.png')
const { file, handle } = await createStreamableFile(filePath)
formData.append('file', file)
expect(doc.url).not.toContain('undefined')
})
const response = await restClient.POST(`/${mediaSlug}`, {
body: formData,
file,
})
const { doc } = await response.json()
it('creates images that do not require all sizes', async () => {
const formData = new FormData()
const fileBlob = await bufferToFileBlob(path.join(dirname, './small.png'))
formData.append('file', fileBlob)
await handle.close()
const response = await restClient.POST(`/${mediaSlug}`, {
body: formData,
file: true,
expect(response.status).toBe(201)
const expectedPath = path.join(dirname, './media')
// Check for files
expect(await fileExists(path.join(expectedPath, doc.filename))).toBe(true)
expect(await fileExists(path.join(expectedPath, 'small-640x480.png'))).toBe(false)
expect(await fileExists(path.join(expectedPath, doc.sizes.icon.filename))).toBe(true)
// Check api response
expect(doc.sizes.tablet.filename).toBeNull()
expect(doc.sizes.icon.filename).toBeDefined()
})
const { doc } = await response.json()
expect(response.status).toBe(201)
it('creates images from a different format', async () => {
const formData = new FormData()
const filePath = path.join(dirname, './image.jpg')
const { file, handle } = await createStreamableFile(filePath)
formData.append('file', file)
const expectedPath = path.join(dirname, './media')
const response = await restClient.POST(`/${mediaSlug}`, {
body: formData,
file,
})
const { doc } = await response.json()
// Check for files
expect(await fileExists(path.join(expectedPath, doc.filename))).toBe(true)
expect(await fileExists(path.join(expectedPath, 'small-640x480.png'))).toBe(false)
expect(await fileExists(path.join(expectedPath, doc.sizes.icon.filename))).toBe(true)
await handle.close()
// Check api response
expect(doc.sizes.tablet.filename).toBeNull()
expect(doc.sizes.icon.filename).toBeDefined()
})
expect(response.status).toBe(201)
it('creates images from a different format', async () => {
const formData = new FormData()
const fileBlob = await bufferToFileBlob(path.join(dirname, './image.jpg'))
formData.append('file', fileBlob)
const expectedPath = path.join(dirname, './media')
const response = await restClient.POST(`/${mediaSlug}`, {
body: formData,
file: true,
// Check for files
expect(await fileExists(path.join(expectedPath, doc.filename))).toBe(true)
expect(await fileExists(path.join(expectedPath, doc.sizes.tablet.filename))).toBe(true)
// Check api response
expect(doc.filename).toContain('.png')
expect(doc.mimeType).toEqual('image/png')
expect(doc.sizes.maintainedAspectRatio.filename).toContain('.png')
expect(doc.sizes.maintainedAspectRatio.mimeType).toContain('image/png')
expect(doc.sizes.differentFormatFromMainImage.filename).toContain('.jpg')
expect(doc.sizes.differentFormatFromMainImage.mimeType).toContain('image/jpeg')
})
const { doc } = await response.json()
expect(response.status).toBe(201)
it('creates media without storing a file', async () => {
const formData = new FormData()
const filePath = path.join(dirname, './unstored.png')
const { file, handle } = await createStreamableFile(filePath)
formData.append('file', file)
const expectedPath = path.join(dirname, './media')
// unstored media
const response = await restClient.POST(`/${unstoredMediaSlug}`, {
body: formData,
file,
})
const { doc } = await response.json()
// Check for files
expect(await fileExists(path.join(expectedPath, doc.filename))).toBe(true)
expect(await fileExists(path.join(expectedPath, doc.sizes.tablet.filename))).toBe(true)
await handle.close()
// Check api response
expect(doc.filename).toContain('.png')
expect(doc.mimeType).toEqual('image/png')
expect(doc.sizes.maintainedAspectRatio.filename).toContain('.png')
expect(doc.sizes.maintainedAspectRatio.mimeType).toContain('image/png')
expect(doc.sizes.differentFormatFromMainImage.filename).toContain('.jpg')
expect(doc.sizes.differentFormatFromMainImage.mimeType).toContain('image/jpeg')
})
expect(response.status).toBe(201)
it('creates media without storing a file', async () => {
const formData = new FormData()
const fileBlob = await bufferToFileBlob(path.join(dirname, './unstored.png'))
formData.append('file', fileBlob)
// Check for files
expect(await fileExists(path.join(dirname, './media', doc.filename))).toBe(false)
// unstored media
const response = await restClient.POST(`/${unstoredMediaSlug}`, {
body: formData,
file: true,
// Check api response
expect(doc.filename).toBeDefined()
})
const { doc } = await response.json()
expect(response.status).toBe(201)
// Check for files
expect(await fileExists(path.join(dirname, './media', doc.filename))).toBe(false)
// Check api response
expect(doc.filename).toBeDefined()
})
describe('update', () => {
it('should replace image and delete old files - by ID', async () => {
const filePath = path.resolve(dirname, './image.png')
const file = await getFileByPath(filePath)
file.name = 'renamed.png'
const mediaDoc = (await payload.create({
collection: mediaSlug,
data: {},
file,
})) as unknown as Media
const formData = new FormData()
const filePath2 = path.resolve(dirname, './small.png')
const { file: file2, handle } = await createStreamableFile(filePath2)
formData.append('file', file2)
const response = await restClient.PATCH(`/${mediaSlug}/${mediaDoc.id}`, {
body: formData,
file: file2,
})
await handle.close()
expect(response.status).toBe(200)
const expectedPath = path.join(dirname, './media')
// Check that previously existing files were removed
expect(await fileExists(path.join(expectedPath, mediaDoc.filename))).toBe(false)
expect(await fileExists(path.join(expectedPath, mediaDoc.sizes.icon.filename))).toBe(false)
})
it('should replace image and delete old files - where query', async () => {
const filePath = path.resolve(dirname, './image.png')
const file = await getFileByPath(filePath)
file.name = 'renamed.png'
const mediaDoc = (await payload.create({
collection: mediaSlug,
data: {},
file,
})) as unknown as Media
const formData = new FormData()
const filePath2 = path.resolve(dirname, './small.png')
const { file: file2, handle } = await createStreamableFile(filePath2)
formData.append('file', file2)
const response = await restClient.PATCH(`/${mediaSlug}`, {
body: formData,
file: file2,
query: {
where: {
id: {
equals: mediaDoc.id,
},
},
},
})
await handle.close()
expect(response.status).toBe(200)
const expectedPath = path.join(dirname, './media')
// Check that previously existing files were removed
expect(await fileExists(path.join(expectedPath, mediaDoc.filename))).toBe(false)
expect(await fileExists(path.join(expectedPath, mediaDoc.sizes.icon.filename))).toBe(false)
})
})
describe('delete', () => {
it('should remove related files when deleting by ID', async () => {
const formData = new FormData()
const filePath = path.join(dirname, './image.png')
const { file, handle } = await createStreamableFile(filePath)
formData.append('file', file)
const { doc } = await restClient
.POST(`/${mediaSlug}`, {
body: formData,
file,
})
.then((res) => res.json())
await handle.close()
const response2 = await restClient.DELETE(`/${mediaSlug}/${doc.id}`)
expect(response2.status).toBe(200)
expect(await fileExists(path.join(dirname, doc.filename))).toBe(false)
})
it('should remove all related files when deleting with where query', async () => {
const formData = new FormData()
const filePath = path.join(dirname, './image.png')
const { file, handle } = await createStreamableFile(filePath)
formData.append('file', file)
const { doc } = await restClient
.POST(`/${mediaSlug}`, {
body: formData,
file,
})
.then((res) => res.json())
await handle.close()
const { errors } = await restClient
.DELETE(`/${mediaSlug}`, {
query: {
where: {
id: {
equals: doc.id,
},
},
},
})
.then((res) => res.json())
expect(errors).toHaveLength(0)
expect(await fileExists(path.join(dirname, doc.filename))).toBe(false)
})
})
})
describe('Local API', () => {
describe('update', () => {
it('should remove existing media on re-upload - by ID', async () => {
// Create temp file
const filePath = path.resolve(dirname, './temp.png')
const file = await getFileByPath(filePath)
file.name = 'temp.png'
const mediaDoc = (await payload.create({
collection: mediaSlug,
data: {},
file,
})) as unknown as Media
const expectedPath = path.join(dirname, './media')
// Check that the temp file was created
expect(await fileExists(path.join(expectedPath, mediaDoc.filename))).toBe(true)
// Replace the temp file with a new one
const newFilePath = path.resolve(dirname, './temp-renamed.png')
const newFile = await getFileByPath(newFilePath)
newFile.name = 'temp-renamed.png'
const updatedMediaDoc = (await payload.update({
collection: mediaSlug,
id: mediaDoc.id,
file: newFile,
data: {},
})) as unknown as Media
// Check that the replacement file was created and the old one was removed
expect(await fileExists(path.join(expectedPath, updatedMediaDoc.filename))).toBe(true)
expect(await fileExists(path.join(expectedPath, mediaDoc.filename))).toBe(false)
})
it('should remove existing media on re-upload - where query', async () => {
// Create temp file
const filePath = path.resolve(dirname, './temp.png')
const file = await getFileByPath(filePath)
file.name = 'temp.png'
const mediaDoc = (await payload.create({
collection: mediaSlug,
data: {},
file,
})) as unknown as Media
const expectedPath = path.join(dirname, './media')
// Check that the temp file was created
expect(await fileExists(path.join(expectedPath, mediaDoc.filename))).toBe(true)
// Replace the temp file with a new one
const newFilePath = path.resolve(dirname, './temp-renamed.png')
const newFile = await getFileByPath(newFilePath)
newFile.name = 'temp-renamed-second.png'
const updatedMediaDoc = (await payload.update({
collection: mediaSlug,
where: {
id: { equals: mediaDoc.id },
},
file: newFile,
data: {},
})) as unknown as { docs: Media[] }
// Check that the replacement file was created and the old one was removed
expect(updatedMediaDoc.docs[0].filename).toEqual(newFile.name)
expect(await fileExists(path.join(expectedPath, updatedMediaDoc.docs[0].filename))).toBe(
true,
)
expect(await fileExists(path.join(expectedPath, mediaDoc.filename))).toBe(false)
})
it('should remove sizes that do not pertain to the new image - by ID', async () => {
const filePath = path.resolve(dirname, './image.png')
const file = await getFileByPath(filePath)
const small = await getFileByPath(path.resolve(dirname, './small.png'))
const { id } = await payload.create({
collection: mediaSlug,
data: {},
file,
})
const doc = (await payload.update({
collection: mediaSlug,
id,
data: {},
file: small,
})) as unknown as Media
expect(doc.sizes.icon).toBeDefined()
expect(doc.sizes.tablet.width).toBeNull()
})
it('should remove sizes that do not pertain to the new image - where query', async () => {
const filePath = path.resolve(dirname, './image.png')
const file = await getFileByPath(filePath)
const small = await getFileByPath(path.resolve(dirname, './small.png'))
const { id } = await payload.create({
collection: mediaSlug,
data: {},
file,
})
const doc = (await payload.update({
collection: mediaSlug,
where: {
id: { equals: id },
},
data: {},
file: small,
})) as unknown as { docs: Media[] }
expect(doc.docs[0].sizes.icon).toBeDefined()
expect(doc.docs[0].sizes.tablet.width).toBeNull()
})
it('should allow removing file from upload relationship field - by ID', async () => {
const filePath = path.resolve(dirname, './image.png')
const file = await getFileByPath(filePath)
file.name = 'renamed.png'
const { id } = await payload.create({
collection: mediaSlug,
data: {},
file,
})
const related = await payload.create({
collection: relationSlug,
data: {
image: id,
},
})
const doc = await payload.update({
collection: relationSlug,
id: related.id,
data: {
image: null,
},
})
expect(doc.image).toBeFalsy()
})
it('should allow update removing a relationship - where query', async () => {
const filePath = path.resolve(dirname, './image.png')
const file = await getFileByPath(filePath)
file.name = 'renamed.png'
const { id } = await payload.create({
collection: mediaSlug,
data: {},
file,
})
const related = await payload.create({
collection: relationSlug,
data: {
image: id,
},
})
const doc = await payload.update({
collection: relationSlug,
where: {
id: { equals: related.id },
},
data: {
image: null,
},
})
expect(doc.docs[0].image).toBeFalsy()
})
})
})
describe('Image Manipulation', () => {
it('should enlarge images if resize options `withoutEnlargement` is set to false', async () => {
const small = await getFileByPath(path.resolve(dirname, './small.png'))
@@ -318,8 +601,6 @@ describe('Collections - Uploads', () => {
})
it('should not reduce images if resize options `withoutReduction` is set to true', async () => {
const formData = new NodeFormData()
formData.append('file', fs.createReadStream(path.join(dirname, './small.png')))
const small = await getFileByPath(path.resolve(dirname, './small.png'))
const result = await payload.create({
@@ -359,294 +640,7 @@ describe('Collections - Uploads', () => {
})
})
it('update', async () => {
// Create image
const filePath = path.resolve(dirname, './image.png')
const file = await getFileByPath(filePath)
file.name = 'renamed.png'
const mediaDoc = (await payload.create({
collection: mediaSlug,
data: {},
file,
})) as unknown as Media
const formData = new FormData()
formData.append('file', await bufferToFileBlob(path.join(dirname, './small.png')))
const response = await restClient.PATCH(`/${mediaSlug}/${mediaDoc.id}`, {
body: formData,
file: true,
})
expect(response.status).toBe(200)
const expectedPath = path.join(dirname, './media')
// Check that previously existing files were removed
expect(await fileExists(path.join(expectedPath, mediaDoc.filename))).toBe(false)
expect(await fileExists(path.join(expectedPath, mediaDoc.sizes.icon.filename))).toBe(false)
})
it('update - update many', async () => {
// Create image
const filePath = path.resolve(dirname, './image.png')
const file = await getFileByPath(filePath)
file.name = 'renamed.png'
const mediaDoc = (await payload.create({
collection: mediaSlug,
data: {},
file,
})) as unknown as Media
const formData = new FormData()
formData.append('file', await bufferToFileBlob(path.join(dirname, './small.png')))
const response = await restClient.PATCH(`/${mediaSlug}`, {
body: formData,
file: true,
query: {
where: {
id: {
equals: mediaDoc.id,
},
},
},
})
expect(response.status).toBe(200)
const expectedPath = path.join(dirname, './media')
// Check that previously existing files were removed
expect(await fileExists(path.join(expectedPath, mediaDoc.filename))).toBe(false)
expect(await fileExists(path.join(expectedPath, mediaDoc.sizes.icon.filename))).toBe(false)
})
it('should remove existing media on re-upload', async () => {
// Create temp file
const filePath = path.resolve(dirname, './temp.png')
const file = await getFileByPath(filePath)
file.name = 'temp.png'
const mediaDoc = (await payload.create({
collection: mediaSlug,
data: {},
file,
})) as unknown as Media
const expectedPath = path.join(dirname, './media')
// Check that the temp file was created
expect(await fileExists(path.join(expectedPath, mediaDoc.filename))).toBe(true)
// Replace the temp file with a new one
const newFilePath = path.resolve(dirname, './temp-renamed.png')
const newFile = await getFileByPath(newFilePath)
newFile.name = 'temp-renamed.png'
const updatedMediaDoc = (await payload.update({
collection: mediaSlug,
id: mediaDoc.id,
file: newFile,
data: {},
})) as unknown as Media
// Check that the replacement file was created and the old one was removed
expect(await fileExists(path.join(expectedPath, updatedMediaDoc.filename))).toBe(true)
expect(await fileExists(path.join(expectedPath, mediaDoc.filename))).toBe(false)
})
it('should remove existing media on re-upload - update many', async () => {
// Create temp file
const filePath = path.resolve(dirname, './temp.png')
const file = await getFileByPath(filePath)
file.name = 'temp.png'
const mediaDoc = (await payload.create({
collection: mediaSlug,
data: {},
file,
})) as unknown as Media
const expectedPath = path.join(dirname, './media')
// Check that the temp file was created
expect(await fileExists(path.join(expectedPath, mediaDoc.filename))).toBe(true)
// Replace the temp file with a new one
const newFilePath = path.resolve(dirname, './temp-renamed.png')
const newFile = await getFileByPath(newFilePath)
newFile.name = 'temp-renamed-second.png'
const updatedMediaDoc = (await payload.update({
collection: mediaSlug,
where: {
id: { equals: mediaDoc.id },
},
file: newFile,
data: {},
})) as unknown as { docs: Media[] }
// Check that the replacement file was created and the old one was removed
expect(updatedMediaDoc.docs[0].filename).toEqual(newFile.name)
expect(await fileExists(path.join(expectedPath, updatedMediaDoc.docs[0].filename))).toBe(true)
expect(await fileExists(path.join(expectedPath, mediaDoc.filename))).toBe(false)
})
it('should remove extra sizes on update', async () => {
const filePath = path.resolve(dirname, './image.png')
const file = await getFileByPath(filePath)
const small = await getFileByPath(path.resolve(dirname, './small.png'))
const { id } = await payload.create({
collection: mediaSlug,
data: {},
file,
})
const doc = (await payload.update({
collection: mediaSlug,
id,
data: {},
file: small,
})) as unknown as Media
expect(doc.sizes.icon).toBeDefined()
expect(doc.sizes.tablet.width).toBeNull()
})
it('should remove extra sizes on update - update many', async () => {
const filePath = path.resolve(dirname, './image.png')
const file = await getFileByPath(filePath)
const small = await getFileByPath(path.resolve(dirname, './small.png'))
const { id } = await payload.create({
collection: mediaSlug,
data: {},
file,
})
const doc = (await payload.update({
collection: mediaSlug,
where: {
id: { equals: id },
},
data: {},
file: small,
})) as unknown as { docs: Media[] }
expect(doc.docs[0].sizes.icon).toBeDefined()
expect(doc.docs[0].sizes.tablet.width).toBeNull()
})
it('should allow update removing a relationship', async () => {
const filePath = path.resolve(dirname, './image.png')
const file = await getFileByPath(filePath)
file.name = 'renamed.png'
const { id } = await payload.create({
collection: mediaSlug,
data: {},
file,
})
const related = await payload.create({
collection: relationSlug,
data: {
image: id,
},
})
const doc = await payload.update({
collection: relationSlug,
id: related.id,
data: {
image: null,
},
})
expect(doc.image).toBeFalsy()
})
it('should allow update removing a relationship - update many', async () => {
const filePath = path.resolve(dirname, './image.png')
const file = await getFileByPath(filePath)
file.name = 'renamed.png'
const { id } = await payload.create({
collection: mediaSlug,
data: {},
file,
})
const related = await payload.create({
collection: relationSlug,
data: {
image: id,
},
})
const doc = await payload.update({
collection: relationSlug,
where: {
id: { equals: related.id },
},
data: {
image: null,
},
})
expect(doc.docs[0].image).toBeFalsy()
})
it('delete', async () => {
const formData = new FormData()
formData.append('file', await bufferToFileBlob(path.join(dirname, './image.png')))
const { doc } = await restClient
.POST(`/${mediaSlug}`, {
body: formData,
file: true,
})
.then((res) => res.json())
const response2 = await restClient.DELETE(`/${mediaSlug}/${doc.id}`)
expect(response2.status).toBe(200)
expect(await fileExists(path.join(dirname, doc.filename))).toBe(false)
})
it('delete - update many', async () => {
const formData = new FormData()
formData.append('file', await bufferToFileBlob(path.join(dirname, './image.png')))
const { doc } = await restClient
.POST(`/${mediaSlug}`, {
body: formData,
file: true,
})
.then((res) => res.json())
const { errors } = await restClient
.DELETE(`/${mediaSlug}`, {
query: {
where: {
id: {
equals: doc.id,
},
},
},
})
.then((res) => res.json())
expect(errors).toHaveLength(0)
expect(await fileExists(path.join(dirname, doc.filename))).toBe(false)
})
describe('filesRequiredOnCreate', () => {
describe('Required Files', () => {
// eslint-disable-next-line @typescript-eslint/require-await
it('should allow file to be optional if filesRequiredOnCreate is false', async () => {
const successfulCreate = await payload.create({