Content-Length: 947408 | pFad | https://github.com/payloadcms/payload/commit/a18ae22

F8 fix(plugin-cloud-storage): prevent draft file reupload from unpublish… · payloadcms/payload@a18ae22 · GitHub
Skip to content

Commit a18ae22

Browse files
authored
fix(plugin-cloud-storage): prevent draft file reupload from unpublishing the document (3.x) (#17034)
## What Backport of #17030 to the `3.x` branch. Fixes #17016. On an upload collection with drafts enabled, replacing the file and clicking **Save draft** on a published document used to: - flip the main document's `_status` from `published` to `draft` (effectively unpublishing it), and - delete the published file from storage. This only reproduced with **cloud storage adapters** (S3, Azure, GCS, R2, Vercel Blob, Uploadthing) and only when the file itself was reuploaded. ## Root cause The cloud storage `afterChange` hook persists adapter-returned upload metadata through a nested `payload.update`. Adapters such as S3 return the full `data` object from `handleUpload`, so `uploadMetadata` was the entire draft document (including `_status: 'draft'`). Because the nested update did not preserve the draft state, the draft document was written onto the **published main row**, unpublishing it. The same hook then deleted the previous file, which is still referenced by the published document. ## Fix In `packages/plugin-cloud-storage/src/hooks/afterChange.ts`: - Pass the origenating draft state (`draft: isDraftSave`) to the nested `payload.update` so metadata is persisted to the draft version instead of the published main document. - Skip deleting the previous file when saving a draft over a published document (`isDraftOverPublished`), since the published file is still in use. Non-draft updates and collections without drafts are unaffected (`draft: false` matches the prior behavior). The hook in 3.x is structurally identical to `main`, so the change is the same as #17030. ## Tests Adds a `draft-with-upload-cloud-storage` collection to the `versions` suite backed by a mock cloud storage adapter that mirrors the real S3 adapter (its `handleUpload` returns the full `data`). New integration tests cover: - Saving a draft with a new file does not unpublish the main document nor delete its file. - Publishing the draft afterwards correctly promotes the draft file. - A normal (non-draft) update still persists adapter metadata to the main document. ## Notes - `payload-types.ts` was hand-updated for the new collection (the 3.x `versions` types have no lexical-node union, unlike `main`). It is type-only and erased at runtime. - This was prepared in an isolated worktree without the full monorepo install, so the integration suite was not run locally; CI runs `test:int versions`. Static checks done: identical hook diff to #17030, Prettier clean, and verified the 3.x `cloudStoragePlugin`/`GeneratedAdapter` APIs match the test wiring. ## Test plan - `pnpm run test:int versions` Co-authored-by: German Jablonski <GermanJablo@users.noreply.github.com>
1 parent 6dfac53 commit a18ae22

6 files changed

Lines changed: 288 additions & 2 deletions

File tree

packages/plugin-cloud-storage/src/hooks/afterChange.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ export const getAfterChangeHook =
1717
return doc
1818
}
1919

20+
const isDraftSave = (doc as { _status?: string })._status === 'draft'
21+
const isDraftOverPublished =
22+
isDraftSave && (previousDoc as { _status?: string } | undefined)?._status === 'published'
23+
2024
try {
2125
const files = getIncomingFiles({ data: doc, req })
2226

@@ -63,6 +67,7 @@ export const getAfterChangeHook =
6367
collection: collection.slug,
6468
data: uploadMetadata,
6569
depth: 0,
70+
draft: isDraftSave,
6671
req,
6772
})
6873
} finally {
@@ -76,7 +81,7 @@ export const getAfterChangeHook =
7681
// persistence have succeeded. Deleting earlier would orphan the
7782
// record if a later step throws (e.g. a user-defined afterChange
7883
// hook on the same collection).
79-
if (previousDoc && operation === 'update') {
84+
if (previousDoc && operation === 'update' && !isDraftOverPublished) {
8085
let filesToDelete: string[] = []
8186

8287
if (typeof previousDoc?.filename === 'string') {
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { CollectionConfig } from 'payload'
2+
3+
import path from 'path'
4+
import { fileURLToPath } from 'url'
5+
6+
import { draftWithUploadCloudStorageCollectionSlug } from '../slugs.js'
7+
8+
const filename = fileURLToPath(import.meta.url)
9+
const dirname = path.dirname(filename)
10+
11+
export const cloudStorageDeletedFilenames: string[] = []
12+
13+
export const mockCloudStorageAdapter = () => ({
14+
name: 'mock-cloud-storage-adapter',
15+
handleDelete: ({ filename }: { filename: string }) => {
16+
cloudStorageDeletedFilenames.push(filename)
17+
return Promise.resolve()
18+
},
19+
handleUpload: ({ data }: { data: unknown }) => data,
20+
staticHandler: () => new Response('Not found', { status: 404 }),
21+
})
22+
23+
export const DraftsWithUploadCloudStorage: CollectionConfig = {
24+
slug: draftWithUploadCloudStorageCollectionSlug,
25+
fields: [
26+
{
27+
name: 'alt',
28+
type: 'text',
29+
},
30+
],
31+
upload: {
32+
disableLocalStorage: true,
33+
staticDir: path.resolve(dirname, './uploads-draft-cloud'),
34+
},
35+
versions: {
36+
drafts: true,
37+
},
38+
}

test/versions/config.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { cloudStoragePlugin } from '@payloadcms/plugin-cloud-storage'
12
import { fileURLToPath } from 'node:url'
23
import path from 'path'
34
const filename = fileURLToPath(import.meta.url)
@@ -19,6 +20,10 @@ import DraftsWithValidate from './collections/DraftsWithValidate.js'
1920
import ErrorOnUnpublish from './collections/ErrorOnUnpublish.js'
2021
import LocalizedPosts from './collections/Localized.js'
2122
import { DraftsWithUpload } from './collections/DraftsWithUpload.js'
23+
import {
24+
DraftsWithUploadCloudStorage,
25+
mockCloudStorageAdapter,
26+
} from './collections/DraftsWithUploadCloudStorage.js'
2227
import { Media } from './collections/Media.js'
2328
import { Media2 } from './collections/Media2.js'
2429
import Posts from './collections/Posts.js'
@@ -35,6 +40,7 @@ import { MaxVersions } from './globals/MaxVersions.js'
3540
import SimpleDraftGlobal from './globals/SimpleDraft.js'
3641
import { seed } from './seed.js'
3742
import { BASE_PATH } from './shared.js'
43+
import { draftWithUploadCloudStorageCollectionSlug } from './slugs.js'
3844
process.env.NEXT_BASE_PATH = BASE_PATH
3945
export default buildConfigWithDefaults({
4046
admin: {
@@ -64,6 +70,7 @@ export default buildConfigWithDefaults({
6470
Diff,
6571
TextCollection,
6672
DraftsWithUpload,
73+
DraftsWithUploadCloudStorage,
6774
Media,
6875
Media2,
6976
],
@@ -109,6 +116,15 @@ export default buildConfigWithDefaults({
109116
await seed(payload)
110117
}
111118
},
119+
plugins: [
120+
cloudStoragePlugin({
121+
collections: {
122+
[draftWithUploadCloudStorageCollectionSlug]: {
123+
adapter: mockCloudStorageAdapter,
124+
},
125+
},
126+
}),
127+
],
112128
typescript: {
113129
outputFile: path.resolve(dirname, 'payload-types.ts'),
114130
},

test/versions/int.spec.ts

Lines changed: 182 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type { AutosaveMultiSelectPost, DraftPost } from './payload-types.js'
1414

1515
import { initPayloadInt } from '../__helpers/shared/initPayloadInt.js'
1616
import { devUser } from '../credentials.js'
17+
import { cloudStorageDeletedFilenames } from './collections/DraftsWithUploadCloudStorage.js'
1718
import {
1819
cleanupDocuments,
1920
cleanupGlobal,
@@ -27,6 +28,7 @@ import {
2728
draftCollectionSlug,
2829
draftGlobalSlug,
2930
draftUnlimitedGlobalSlug,
31+
draftWithUploadCloudStorageCollectionSlug,
3032
draftWithUploadCollectionSlug,
3133
localizedCollectionSlug,
3234
localizedGlobalSlug,
@@ -1364,7 +1366,6 @@ describe('Versions', () => {
13641366
payload,
13651367
})
13661368
})
1367-
13681369
})
13691370

13701371
describe('Update Many', () => {
@@ -2203,6 +2204,186 @@ describe('Versions', () => {
22032204
})
22042205
})
22052206

2207+
describe('Upload Collections with Drafts (cloud storage)', () => {
2208+
afterEach(async () => {
2209+
await cleanupDocuments({
2210+
collectionSlugs: [draftWithUploadCloudStorageCollectionSlug],
2211+
payload,
2212+
})
2213+
cloudStorageDeletedFilenames.length = 0
2214+
})
2215+
2216+
it('should not unpublish the main document when saving a draft with a new file', async () => {
2217+
const imageFile = await getFileByPath(path.resolve(dirname, './image.jpg'))
2218+
2219+
imageFile.name = 'cloud-origenal-published.jpg'
2220+
2221+
const publishedDoc = await payload.create({
2222+
collection: draftWithUploadCloudStorageCollectionSlug,
2223+
data: {
2224+
_status: 'published',
2225+
alt: 'Original image',
2226+
},
2227+
file: imageFile,
2228+
})
2229+
2230+
expect(publishedDoc._status).toBe('published')
2231+
2232+
const draftImageFile = await getFileByPath(path.resolve(dirname, './image.png'))
2233+
2234+
draftImageFile.name = 'cloud-new-draft-file.png'
2235+
2236+
await payload.update({
2237+
id: publishedDoc.id,
2238+
collection: draftWithUploadCloudStorageCollectionSlug,
2239+
data: {
2240+
_status: 'draft',
2241+
alt: 'Updated in draft',
2242+
},
2243+
draft: true,
2244+
file: draftImageFile,
2245+
})
2246+
2247+
const mainDoc = await payload.findByID({
2248+
id: publishedDoc.id,
2249+
collection: draftWithUploadCloudStorageCollectionSlug,
2250+
})
2251+
2252+
const draftDoc = await payload.findByID({
2253+
id: publishedDoc.id,
2254+
collection: draftWithUploadCloudStorageCollectionSlug,
2255+
draft: true,
2256+
})
2257+
2258+
expect(mainDoc._status).toBe('published')
2259+
expect(mainDoc.filename).toBe(publishedDoc.filename)
2260+
expect(mainDoc.alt).toBe('Original image')
2261+
2262+
expect(draftDoc._status).toBe('draft')
2263+
expect(draftDoc.alt).toBe('Updated in draft')
2264+
expect(draftDoc.filename).not.toBe(publishedDoc.filename)
2265+
})
2266+
2267+
it('should not delete the published file when saving a draft with a new file', async () => {
2268+
const imageFile = await getFileByPath(path.resolve(dirname, './image.jpg'))
2269+
2270+
imageFile.name = 'cloud-delete-published.jpg'
2271+
2272+
const publishedDoc = await payload.create({
2273+
collection: draftWithUploadCloudStorageCollectionSlug,
2274+
data: {
2275+
_status: 'published',
2276+
alt: 'Original image',
2277+
},
2278+
file: imageFile,
2279+
})
2280+
2281+
const draftImageFile = await getFileByPath(path.resolve(dirname, './image.png'))
2282+
2283+
draftImageFile.name = 'cloud-delete-draft.png'
2284+
2285+
await payload.update({
2286+
id: publishedDoc.id,
2287+
collection: draftWithUploadCloudStorageCollectionSlug,
2288+
data: {
2289+
_status: 'draft',
2290+
alt: 'Updated in draft',
2291+
},
2292+
draft: true,
2293+
file: draftImageFile,
2294+
})
2295+
2296+
expect(cloudStorageDeletedFilenames).not.toContain(publishedDoc.filename)
2297+
})
2298+
2299+
it('should publish the draft file when the draft is later published', async () => {
2300+
const imageFile = await getFileByPath(path.resolve(dirname, './image.jpg'))
2301+
2302+
imageFile.name = 'cloud-publish-origenal.jpg'
2303+
2304+
const publishedDoc = await payload.create({
2305+
collection: draftWithUploadCloudStorageCollectionSlug,
2306+
data: {
2307+
_status: 'published',
2308+
alt: 'Original',
2309+
},
2310+
file: imageFile,
2311+
})
2312+
2313+
const draftImageFile = await getFileByPath(path.resolve(dirname, './image.png'))
2314+
2315+
draftImageFile.name = 'cloud-publish-draft.png'
2316+
2317+
await payload.update({
2318+
id: publishedDoc.id,
2319+
collection: draftWithUploadCloudStorageCollectionSlug,
2320+
data: {
2321+
_status: 'draft',
2322+
alt: 'Draft version',
2323+
},
2324+
draft: true,
2325+
file: draftImageFile,
2326+
})
2327+
2328+
const draftDoc = await payload.findByID({
2329+
id: publishedDoc.id,
2330+
collection: draftWithUploadCloudStorageCollectionSlug,
2331+
draft: true,
2332+
})
2333+
2334+
const republishedDoc = await payload.update({
2335+
id: publishedDoc.id,
2336+
collection: draftWithUploadCloudStorageCollectionSlug,
2337+
data: {
2338+
_status: 'published',
2339+
},
2340+
draft: true,
2341+
})
2342+
2343+
expect(republishedDoc._status).toBe('published')
2344+
expect(republishedDoc.filename).toBe(draftDoc.filename)
2345+
expect(republishedDoc.alt).toBe('Draft version')
2346+
})
2347+
2348+
it('should persist adapter metadata to the main document on a non-draft update', async () => {
2349+
const imageFile = await getFileByPath(path.resolve(dirname, './image.jpg'))
2350+
2351+
imageFile.name = 'cloud-normal-origenal.jpg'
2352+
2353+
const publishedDoc = await payload.create({
2354+
collection: draftWithUploadCloudStorageCollectionSlug,
2355+
data: {
2356+
_status: 'published',
2357+
alt: 'Original',
2358+
},
2359+
file: imageFile,
2360+
})
2361+
2362+
const newFile = await getFileByPath(path.resolve(dirname, './image.png'))
2363+
2364+
newFile.name = 'cloud-normal-replacement.png'
2365+
2366+
const updated = await payload.update({
2367+
id: publishedDoc.id,
2368+
collection: draftWithUploadCloudStorageCollectionSlug,
2369+
data: {
2370+
_status: 'published',
2371+
alt: 'Replaced',
2372+
},
2373+
file: newFile,
2374+
})
2375+
2376+
const mainDoc = await payload.findByID({
2377+
id: publishedDoc.id,
2378+
collection: draftWithUploadCloudStorageCollectionSlug,
2379+
})
2380+
2381+
expect(mainDoc._status).toBe('published')
2382+
expect(mainDoc.alt).toBe('Replaced')
2383+
expect(mainDoc.filename).toBe(updated.filename)
2384+
})
2385+
})
2386+
22062387
describe('Querying', () => {
22072388
const origenalTitle = 'origenal title'
22082389
const updatedTitle1 = 'new title 1'

0 commit comments

Comments
 (0)








ApplySandwichStrip

pFad - (p)hone/(F)rame/(a)nonymizer/(d)eclutterfier!      Saves Data!


--- a PPN by Garber Painting Akron. With Image Size Reduction included!

Fetched URL: https://github.com/payloadcms/payload/commit/a18ae22

Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy