Fixes #7101 Fixes #7006 Drawers were sending duplicate query params. This new approach modeled after the fix in V2, ensures that each drawer has its own action url created per document and the query params will be created when that is generated. Also fixes the following: - incorrect focal point cropping - generated filenames for animated image names used incorrect heights
345 lines
9.8 KiB
TypeScript
345 lines
9.8 KiB
TypeScript
import type { OutputInfo, Sharp, SharpOptions } from 'sharp'
|
|
|
|
import { fileTypeFromBuffer } from 'file-type'
|
|
import fs from 'fs'
|
|
import { mkdirSync } from 'node:fs'
|
|
import sanitize from 'sanitize-filename'
|
|
|
|
import type { Collection } from '../collections/config/types.js'
|
|
import type { SanitizedConfig } from '../config/types.js'
|
|
import type { PayloadRequest } from '../types/index.js'
|
|
import type { FileData, FileToSave, ProbedImageSize, UploadEdits } from './types.js'
|
|
|
|
import { FileRetrievalError, FileUploadError, MissingFile } from '../errors/index.js'
|
|
import { canResizeImage } from './canResizeImage.js'
|
|
import { cropImage } from './cropImage.js'
|
|
import { getExternalFile } from './getExternalFile.js'
|
|
import { getFileByPath } from './getFileByPath.js'
|
|
import { getImageSize } from './getImageSize.js'
|
|
import { getSafeFileName } from './getSafeFilename.js'
|
|
import { resizeAndTransformImageSizes } from './imageResizer.js'
|
|
import { isImage } from './isImage.js'
|
|
|
|
type Args<T> = {
|
|
collection: Collection
|
|
config: SanitizedConfig
|
|
data: T
|
|
operation: 'create' | 'update'
|
|
originalDoc?: T
|
|
overwriteExistingFiles?: boolean
|
|
req: PayloadRequest
|
|
throwOnMissingFile?: boolean
|
|
}
|
|
|
|
type Result<T> = Promise<{
|
|
data: T
|
|
files: FileToSave[]
|
|
}>
|
|
|
|
export const generateFileData = async <T>({
|
|
collection: { config: collectionConfig },
|
|
data,
|
|
operation,
|
|
originalDoc,
|
|
overwriteExistingFiles,
|
|
req,
|
|
throwOnMissingFile,
|
|
}: Args<T>): Result<T> => {
|
|
if (!collectionConfig.upload) {
|
|
return {
|
|
data,
|
|
files: [],
|
|
}
|
|
}
|
|
|
|
const { sharp } = req.payload.config
|
|
|
|
let file = req.file
|
|
|
|
const uploadEdits = parseUploadEditsFromReqOrIncomingData({
|
|
data,
|
|
operation,
|
|
originalDoc,
|
|
req,
|
|
})
|
|
|
|
const {
|
|
disableLocalStorage,
|
|
focalPoint: focalPointEnabled = true,
|
|
formatOptions,
|
|
imageSizes,
|
|
resizeOptions,
|
|
staticDir,
|
|
trimOptions,
|
|
} = collectionConfig.upload
|
|
|
|
const staticPath = staticDir
|
|
|
|
if (!file && uploadEdits && data) {
|
|
const { filename, url } = data as FileData
|
|
|
|
try {
|
|
if (url && url.startsWith('/') && !disableLocalStorage) {
|
|
const filePath = `${staticPath}/${filename}`
|
|
const response = await getFileByPath(filePath)
|
|
file = response
|
|
overwriteExistingFiles = true
|
|
} else if (filename && url) {
|
|
file = await getExternalFile({
|
|
data: data as FileData,
|
|
req,
|
|
uploadConfig: collectionConfig.upload,
|
|
})
|
|
overwriteExistingFiles = true
|
|
}
|
|
} catch (err: unknown) {
|
|
throw new FileRetrievalError(req.t, err instanceof Error ? err.message : undefined)
|
|
}
|
|
}
|
|
|
|
if (!file) {
|
|
if (throwOnMissingFile) throw new MissingFile(req.t)
|
|
|
|
return {
|
|
data,
|
|
files: [],
|
|
}
|
|
}
|
|
|
|
if (!disableLocalStorage) {
|
|
mkdirSync(staticPath, { recursive: true })
|
|
}
|
|
|
|
let newData = data
|
|
const filesToSave: FileToSave[] = []
|
|
const fileData: Partial<FileData> = {}
|
|
const fileIsAnimatedType = ['image/avif', 'image/gif', 'image/webp'].includes(file.mimetype)
|
|
const cropData =
|
|
typeof uploadEdits === 'object' && 'crop' in uploadEdits ? uploadEdits.crop : undefined
|
|
|
|
try {
|
|
const fileSupportsResize = canResizeImage(file.mimetype)
|
|
let fsSafeName: string
|
|
let sharpFile: Sharp | undefined
|
|
let dimensions: ProbedImageSize | undefined
|
|
let fileBuffer: { data: Buffer; info: OutputInfo }
|
|
let ext
|
|
let mime: string
|
|
const fileHasAdjustments =
|
|
fileSupportsResize &&
|
|
Boolean(resizeOptions || formatOptions || imageSizes || trimOptions || file.tempFilePath)
|
|
|
|
const sharpOptions: SharpOptions = {}
|
|
|
|
if (fileIsAnimatedType) sharpOptions.animated = true
|
|
|
|
if (sharp && (fileIsAnimatedType || fileHasAdjustments)) {
|
|
if (file.tempFilePath) {
|
|
sharpFile = sharp(file.tempFilePath, sharpOptions).rotate() // pass rotate() to auto-rotate based on EXIF data. https://github.com/payloadcms/payload/pull/3081
|
|
} else {
|
|
sharpFile = sharp(file.data, sharpOptions).rotate() // pass rotate() to auto-rotate based on EXIF data. https://github.com/payloadcms/payload/pull/3081
|
|
}
|
|
|
|
if (fileHasAdjustments) {
|
|
if (resizeOptions) {
|
|
sharpFile = sharpFile.resize(resizeOptions)
|
|
}
|
|
if (formatOptions) {
|
|
sharpFile = sharpFile.toFormat(formatOptions.format, formatOptions.options)
|
|
}
|
|
if (trimOptions) {
|
|
sharpFile = sharpFile.trim(trimOptions)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (fileSupportsResize || isImage(file.mimetype)) {
|
|
dimensions = await getImageSize(file)
|
|
fileData.width = dimensions.width
|
|
fileData.height = dimensions.height
|
|
}
|
|
|
|
if (sharpFile) {
|
|
const metadata = await sharpFile.metadata()
|
|
fileBuffer = await sharpFile.toBuffer({ resolveWithObject: true })
|
|
;({ ext, mime } = await fileTypeFromBuffer(fileBuffer.data)) // This is getting an incorrect gif height back.
|
|
fileData.width = fileBuffer.info.width
|
|
fileData.height = fileBuffer.info.height
|
|
fileData.filesize = fileBuffer.info.size
|
|
|
|
// Animated GIFs + WebP aggregate the height from every frame, so we need to use divide by number of pages
|
|
if (metadata.pages) {
|
|
fileData.height = fileBuffer.info.height / metadata.pages
|
|
fileData.filesize = fileBuffer.data.length
|
|
}
|
|
} else {
|
|
mime = file.mimetype
|
|
fileData.filesize = file.size
|
|
|
|
if (file.name.includes('.')) {
|
|
ext = file.name.split('.').pop().split('?')[0]
|
|
} else {
|
|
ext = ''
|
|
}
|
|
}
|
|
|
|
// Adjust SVG mime type. fromBuffer modifies it.
|
|
if (mime === 'application/xml' && ext === 'svg') mime = 'image/svg+xml'
|
|
fileData.mimeType = mime
|
|
|
|
const baseFilename = sanitize(file.name.substring(0, file.name.lastIndexOf('.')) || file.name)
|
|
fsSafeName = `${baseFilename}${ext ? `.${ext}` : ''}`
|
|
|
|
if (!overwriteExistingFiles) {
|
|
fsSafeName = await getSafeFileName({
|
|
collectionSlug: collectionConfig.slug,
|
|
desiredFilename: fsSafeName,
|
|
req,
|
|
staticPath,
|
|
})
|
|
}
|
|
|
|
fileData.filename = fsSafeName
|
|
let fileForResize = file
|
|
|
|
if (cropData && sharp) {
|
|
const { data: croppedImage, info } = await cropImage({
|
|
cropData,
|
|
dimensions,
|
|
file,
|
|
heightInPixels: uploadEdits.heightInPixels,
|
|
sharp,
|
|
widthInPixels: uploadEdits.widthInPixels,
|
|
})
|
|
|
|
filesToSave.push({
|
|
buffer: croppedImage,
|
|
path: `${staticPath}/${fsSafeName}`,
|
|
})
|
|
|
|
fileForResize = {
|
|
...file,
|
|
data: croppedImage,
|
|
size: info.size,
|
|
}
|
|
fileData.width = info.width
|
|
fileData.height = info.height
|
|
if (fileIsAnimatedType) {
|
|
const metadata = await sharpFile.metadata()
|
|
fileData.height = metadata.pages ? info.height / metadata.pages : info.height
|
|
}
|
|
fileData.filesize = info.size
|
|
|
|
if (file.tempFilePath) {
|
|
await fs.promises.writeFile(file.tempFilePath, croppedImage) // write fileBuffer to the temp path
|
|
} else {
|
|
req.file = fileForResize
|
|
}
|
|
} else {
|
|
filesToSave.push({
|
|
buffer: fileBuffer?.data || file.data,
|
|
path: `${staticPath}/${fsSafeName}`,
|
|
})
|
|
|
|
// If using temp files and the image is being resized, write the file to the temp path
|
|
if (fileBuffer?.data || file.data.length > 0) {
|
|
if (file.tempFilePath) {
|
|
await fs.promises.writeFile(file.tempFilePath, fileBuffer?.data || file.data) // write fileBuffer to the temp path
|
|
} else {
|
|
// Assign the _possibly modified_ file to the request object
|
|
req.file = {
|
|
...file,
|
|
data: fileBuffer?.data || file.data,
|
|
size: fileBuffer?.info.size,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (fileSupportsResize && (Array.isArray(imageSizes) || focalPointEnabled !== false)) {
|
|
req.payloadUploadSizes = {}
|
|
const { focalPoint, sizeData, sizesToSave } = await resizeAndTransformImageSizes({
|
|
config: collectionConfig,
|
|
dimensions: !cropData
|
|
? dimensions
|
|
: {
|
|
...dimensions,
|
|
height: fileData.height,
|
|
width: fileData.width,
|
|
},
|
|
file: fileForResize,
|
|
mimeType: fileData.mimeType,
|
|
req,
|
|
savedFilename: fsSafeName || file.name,
|
|
sharp,
|
|
staticPath,
|
|
uploadEdits,
|
|
})
|
|
|
|
fileData.sizes = sizeData
|
|
fileData.focalX = focalPoint?.x
|
|
fileData.focalY = focalPoint?.y
|
|
filesToSave.push(...sizesToSave)
|
|
}
|
|
} catch (err) {
|
|
req.payload.logger.error(err)
|
|
throw new FileUploadError(req.t)
|
|
}
|
|
|
|
newData = {
|
|
...newData,
|
|
...fileData,
|
|
}
|
|
|
|
return {
|
|
data: newData,
|
|
files: filesToSave,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse upload edits from req or incoming data
|
|
*/
|
|
function parseUploadEditsFromReqOrIncomingData(args: {
|
|
data: unknown
|
|
operation: 'create' | 'update'
|
|
originalDoc: unknown
|
|
req: PayloadRequest
|
|
}): UploadEdits {
|
|
const { data, operation, originalDoc, req } = args
|
|
|
|
// Get intended focal point change from query string or incoming data
|
|
const uploadEdits =
|
|
req.query?.uploadEdits && typeof req.query.uploadEdits === 'object'
|
|
? (req.query.uploadEdits as UploadEdits)
|
|
: {}
|
|
|
|
if (uploadEdits.focalPoint) return uploadEdits
|
|
|
|
const incomingData = data as FileData
|
|
const origDoc = originalDoc as FileData
|
|
|
|
// If no change in focal point, return undefined.
|
|
// This prevents a refocal operation triggered from admin, because it always sends the focal point.
|
|
if (origDoc && incomingData.focalX === origDoc.focalX && incomingData.focalY === origDoc.focalY) {
|
|
return undefined
|
|
}
|
|
|
|
if (incomingData?.focalX && incomingData?.focalY) {
|
|
uploadEdits.focalPoint = {
|
|
x: incomingData.focalX,
|
|
y: incomingData.focalY,
|
|
}
|
|
return uploadEdits
|
|
}
|
|
|
|
// If no focal point is set, default to center
|
|
if (operation === 'create') {
|
|
uploadEdits.focalPoint = {
|
|
x: 50,
|
|
y: 50,
|
|
}
|
|
}
|
|
return uploadEdits
|
|
}
|