Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/plugin-cloud-storage/src/hooks/afterChange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ export const getAfterChangeHook =
}
req.context.skipCloudStorage = true

// Clear to prevent re-processing
req.file = undefined
req.payloadUploadSizes = undefined

await req.payload.update({
id: doc.id,
collection: collection.slug,
Expand Down
16 changes: 16 additions & 0 deletions packages/plugin-cloud-storage/src/hooks/preserveFileData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { CollectionBeforeChangeHook, FileData, TypeWithID } from 'payload'

/**
* Preserves req.file in req.context and ensures nested calls don't overwrite the original file data.
*/
export const getPreserveFileDataHook =
(): CollectionBeforeChangeHook<FileData & TypeWithID> =>
({ req }) => {
if (req.file && !req.context?._payloadCloudStorage) {
req.context = req.context || {}
req.context._payloadCloudStorage = {
file: req.file,
uploadSizes: req.payloadUploadSizes,
}
}
}
5 changes: 5 additions & 0 deletions packages/plugin-cloud-storage/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { AllowList, PluginOptions } from './types.js'
import { getFields } from './fields/getFields.js'
import { getAfterChangeHook } from './hooks/afterChange.js'
import { getAfterDeleteHook } from './hooks/afterDelete.js'
import { getPreserveFileDataHook } from './hooks/preserveFileData.js'

// This plugin extends all targeted collections by offloading uploaded files
// to cloud storage instead of solely storing files locally.
Expand Down Expand Up @@ -159,6 +160,10 @@ export const cloudStoragePlugin =
...(existingCollection.hooks?.afterDelete || []),
getAfterDeleteHook({ adapter, collection: existingCollection }),
],
beforeChange: [
...(existingCollection.hooks?.beforeChange || []),
getPreserveFileDataHook(),
],
},
upload: {
...(typeof existingCollection.upload === 'object' ? existingCollection.upload : {}),
Expand Down
16 changes: 12 additions & 4 deletions packages/plugin-cloud-storage/src/utilities/getIncomingFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,22 @@ import type { FileData, PayloadRequest } from 'payload'

import type { File } from '../types.js'

interface CloudStorageContext {
file: PayloadRequest['file']
uploadSizes: PayloadRequest['payloadUploadSizes']
}

export function getIncomingFiles({
data,
req,
}: {
data: Partial<FileData>
req: PayloadRequest
}): File[] {
const file = req.file
// Fall back to context if req.file was cleared
const ctx = req.context?._payloadCloudStorage as CloudStorageContext | undefined
const file = req.file ?? ctx?.file
const payloadUploadSizes = req.payloadUploadSizes ?? ctx?.uploadSizes

let files: File[] = []

Expand All @@ -27,12 +35,12 @@ export function getIncomingFiles({

if (data?.sizes) {
Object.entries(data.sizes).forEach(([key, resizedFileData]) => {
if (req.payloadUploadSizes?.[key] && resizedFileData.mimeType) {
if (payloadUploadSizes?.[key] && resizedFileData.mimeType) {
files = files.concat([
{
buffer: req.payloadUploadSizes[key],
buffer: payloadUploadSizes[key],
filename: `${resizedFileData.filename}`,
filesize: req.payloadUploadSizes[key].length,
filesize: payloadUploadSizes[key].length,
mimeType: resizedFileData.mimeType,
},
])
Expand Down
65 changes: 65 additions & 0 deletions test/storage-s3/searchBeforeS3.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { searchPlugin } from '@payloadcms/plugin-search'
import { s3Storage } from '@payloadcms/storage-s3'
import dotenv from 'dotenv'
import { fileURLToPath } from 'node:url'
import path from 'path'

import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { devUser } from '../credentials.js'
import { Media } from './collections/Media.js'
import { Users } from './collections/Users.js'
import { mediaSlug } from './shared.js'

const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)

dotenv.config({
path: path.resolve(dirname, '../plugin-cloud-storage/.env.emulated'),
})

export default buildConfigWithDefaults({
admin: {
importMap: {
baseDir: path.resolve(dirname),
},
},
collections: [Media, Users],
onInit: async (payload) => {
await payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
})
},
plugins: [
searchPlugin({
collections: [mediaSlug],
beforeSync: ({ originalDoc, searchDoc }) => {
return {
...searchDoc,
title: originalDoc?.filename || 'Untitled',
}
},
}),
s3Storage({
collections: {
[mediaSlug]: true,
},
bucket: process.env.S3_BUCKET!,
config: {
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID!,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
},
endpoint: process.env.S3_ENDPOINT,
forcePathStyle: process.env.S3_FORCE_PATH_STYLE === 'true',
region: process.env.S3_REGION,
},
}),
],
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
})
81 changes: 81 additions & 0 deletions test/storage-s3/searchBeforeS3.int.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { Payload } from 'payload'

import path from 'path'
import { fileURLToPath } from 'url'
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'

import { initPayloadInt } from '../__helpers/shared/initPayloadInt.js'
import { mediaSlug } from './shared.js'
import { clearTestBucket, createTestBucket, verifyUploads } from './test-utils.js'

const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)

let payload: Payload

describe('Search plugin before S3 - Issue #15431', () => {
beforeAll(async () => {
;({ payload } = await initPayloadInt(
dirname,
'storage-s3-search-before',
true,
'searchBeforeS3.config.ts',
))
await createTestBucket()
await clearTestBucket()
})

afterAll(async () => {
await payload.destroy()
})

afterEach(async () => {
await payload.delete({
collection: mediaSlug,
where: { id: { exists: true } },
})
// Only delete from search if the collection exists
if (payload.collections['search']) {
await payload.delete({
collection: 'search',
where: { id: { exists: true } },
})
}
await clearTestBucket()
})

it('should upload all image sizes to S3 when search plugin is listed before S3 plugin', async () => {
const upload = await payload.create({
collection: mediaSlug,
data: {},
filePath: path.resolve(dirname, '../uploads/image.png'),
})

expect(upload.id).toBeTruthy()
expect(upload.filename).toBeTruthy()

await verifyUploads({
collectionSlug: mediaSlug,
uploadId: upload.id,
payload,
})
})

it('should create search document when uploading media', async () => {
const upload = await payload.create({
collection: mediaSlug,
data: {},
filePath: path.resolve(dirname, '../uploads/image.png'),
})

const { docs: searchDocs } = await payload.find({
collection: 'search',
where: {
'doc.value': { equals: upload.id },
'doc.relationTo': { equals: mediaSlug },
},
})

expect(searchDocs.length).toBe(1)
})
})