diff --git a/packages/next/src/next-fileupload/handlers.ts b/packages/next/src/next-fileupload/handlers.ts index 0904d784fa..7a4860d9b1 100644 --- a/packages/next/src/next-fileupload/handlers.ts +++ b/packages/next/src/next-fileupload/handlers.ts @@ -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 diff --git a/packages/next/src/next-fileupload/isEligibleRequest.ts b/packages/next/src/next-fileupload/isEligibleRequest.ts index b7722366c5..316b347e50 100644 --- a/packages/next/src/next-fileupload/isEligibleRequest.ts +++ b/packages/next/src/next-fileupload/isEligibleRequest.ts @@ -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 => { diff --git a/packages/next/src/next-fileupload/processMultipart.ts b/packages/next/src/next-fileupload/processMultipart.ts index 1722b4e13c..c7fe71fee5 100644 --- a/packages/next/src/next-fileupload/processMultipart.ts +++ b/packages/next/src/next-fileupload/processMultipart.ts @@ -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 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 } diff --git a/packages/next/src/next-fileupload/utilities.ts b/packages/next/src/next-fileupload/utilities.ts index 52627dd6a8..7d2e652860 100644 --- a/packages/next/src/next-fileupload/utilities.ts +++ b/packages/next/src/next-fileupload/utilities.ts @@ -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) } diff --git a/packages/next/src/routes/rest/collections/preview.ts b/packages/next/src/routes/rest/collections/preview.ts index d2d3a9190d..5c99258d68 100644 --- a/packages/next/src/routes/rest/collections/preview.ts +++ b/packages/next/src/routes/rest/collections/preview.ts @@ -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, }) diff --git a/packages/next/src/routes/rest/files/getFile.ts b/packages/next/src/routes/rest/files/getFile.ts index 6d564a02b9..d99ac5477c 100644 --- a/packages/next/src/routes/rest/files/getFile.ts +++ b/packages/next/src/routes/rest/files/getFile.ts @@ -66,6 +66,7 @@ export const getFile = async ({ collection, filename, req }: Args): Promise { token, }) } catch (err) { - routeError({ + return routeError({ + config: req.payload.config, err, req, }) diff --git a/packages/next/src/routes/rest/index.ts b/packages/next/src/routes/rest/index.ts index 323812ee2b..e9962c5def 100644 --- a/packages/next/src/routes/rest/index.ts +++ b/packages/next/src/routes/rest/index.ts @@ -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, }) diff --git a/packages/next/src/routes/rest/routeError.ts b/packages/next/src/routes/rest/routeError.ts index acaf94f6da..d362fc4e84 100644 --- a/packages/next/src/routes/rest/routeError.ts +++ b/packages/next/src/routes/rest/routeError.ts @@ -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 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) diff --git a/packages/next/src/utilities/getDataAndFile.ts b/packages/next/src/utilities/getDataAndFile.ts index de4699c4eb..e81a1daadb 100644 --- a/packages/next/src/utilities/getDataAndFile.ts +++ b/packages/next/src/utilities/getDataAndFile.ts @@ -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 file: CustomPayloadRequest['file'] }> -export const getDataAndFile: GetDataAndFile = async ({ collection, config, request }) => { +export const getDataAndFile: GetDataAndFile = async ({ + collection, + config, + request: incomingRequest, +}) => { let data: Record = 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) } } } diff --git a/test/helpers/NextRESTClient.ts b/test/helpers/NextRESTClient.ts index 6b0886c482..fa262ec957 100644 --- a/test/helpers/NextRESTClient.ts +++ b/test/helpers/NextRESTClient.ts @@ -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 +} + 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 { + async PATCH(path: ValidPath, options: RequestInit & RequestOptions & FileArg): Promise { 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 { const { url, slug, params } = this.generateRequestParts(path) const queryParams = generateQueryString({}, params) - const request = new Request(`${url}${queryParams}`, { ...options, method: 'POST', diff --git a/test/uploads/2mb.jpg b/test/uploads/2mb.jpg new file mode 100644 index 0000000000..3803d240dd Binary files /dev/null and b/test/uploads/2mb.jpg differ diff --git a/test/uploads/config.ts b/test/uploads/config.ts index 20321c1d22..17e2aeb578 100644 --- a/test/uploads/config.ts +++ b/test/uploads/config.ts @@ -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)) diff --git a/test/uploads/createStreamableFile.ts b/test/uploads/createStreamableFile.ts new file mode 100644 index 0000000000..496eb25fd9 --- /dev/null +++ b/test/uploads/createStreamableFile.ts @@ -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 } +} diff --git a/test/uploads/e2e.spec.ts b/test/uploads/e2e.spec.ts index c392d8f97e..3a451d6daf 100644 --- a/test/uploads/e2e.spec.ts +++ b/test/uploads/e2e.spec.ts @@ -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) diff --git a/test/uploads/getMimeType.ts b/test/uploads/getMimeType.ts new file mode 100644 index 0000000000..908418d13e --- /dev/null +++ b/test/uploads/getMimeType.ts @@ -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, + } +} diff --git a/test/uploads/int.spec.ts b/test/uploads/int.spec.ts index 4d61f6a288..189de748b3 100644 --- a/test/uploads/int.spec.ts +++ b/test/uploads/int.spec.ts @@ -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 => - 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({