Files
payloadcms/packages/payload/src/uploads/generateFileData.ts
Patrik 00771b1f2a fix(ui): uploading from drawer & focal point positioning (#7117)
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
2024-07-18 13:43:53 -04:00

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
}