Skip to content

Commit 64b2860

Browse files
authored
fix(plugin-cloud-storage): dedupe filename in clientUploads signed URL (#16510)
Backports #16495
1 parent 931a349 commit 64b2860

14 files changed

Lines changed: 214 additions & 34 deletions

File tree

packages/payload/src/uploads/getSafeFilename.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,18 @@ type Args = {
3434
desiredFilename: string
3535
prefix?: string
3636
req: PayloadRequest
37-
staticPath: string
37+
/**
38+
* Filesystem path where uploads are stored. When omitted, only the database
39+
* is consulted for filename conflicts - useful for cloud-storage adapters
40+
* that have no local filesystem.
41+
*/
42+
staticPath?: string
3843
}
3944

4045
/**
41-
* Generates a safe, unique filename by checking for conflicts in both the database
42-
* and filesystem. If a conflict exists, it increments a numeric suffix until a
43-
* unique name is found.
46+
* Generates a safe, unique filename by checking for conflicts in the database
47+
* and (when a `staticPath` is provided) the local filesystem. If a conflict
48+
* exists, it increments a numeric suffix until a unique name is found.
4449
*
4550
* @param args.collectionSlug - The slug of the upload collection
4651
* @param args.desiredFilename - The original filename to make safe
@@ -71,13 +76,14 @@ export async function getSafeFileName({
7176
(await docWithFilenameExists({
7277
collectionSlug,
7378
filename: modifiedFilename,
74-
path: staticPath,
79+
path: staticPath ?? '',
7580
prefix,
7681
req,
7782
})) ||
78-
(await fileExists(`${staticPath}/${modifiedFilename}`))
83+
(staticPath ? await fileExists(`${staticPath}/${modifiedFilename}`) : false)
7984
) {
8085
modifiedFilename = incrementName(modifiedFilename)
8186
}
87+
8288
return modifiedFilename
8389
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { getFileKey } from '../utilities/getFileKey.js'
22
export { getFilePrefix } from '../utilities/getFilePrefix.js'
33
export { initClientUploads } from '../utilities/initClientUploads.js'
4+
export { resolveSignedURLKey } from '../utilities/resolveSignedURLKey.js'
45
export { sanitizePrefix } from '../utilities/sanitizePrefix.js'
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import type { PayloadRequest } from 'payload'
2+
3+
import { getSafeFileName } from 'payload/internal'
4+
5+
import { getFileKey } from './getFileKey.js'
6+
7+
type Args = {
8+
collectionPrefix?: string
9+
collectionSlug: string
10+
docPrefix?: string
11+
filename: string
12+
req: PayloadRequest
13+
useCompositePrefixes?: boolean
14+
}
15+
16+
/**
17+
* Resolves the storage key for a clientUploads signed-URL request, deduping
18+
* the filename via {@link getSafeFileName} so a duplicate upload does not
19+
* overwrite an existing blob.
20+
*
21+
* The resolved `sanitizedFilename` is returned so the browser-side handler
22+
* can update the form via `updateFilename`.
23+
*/
24+
export async function resolveSignedURLKey({
25+
collectionPrefix = '',
26+
collectionSlug,
27+
docPrefix,
28+
filename,
29+
req,
30+
useCompositePrefixes = false,
31+
}: Args) {
32+
const sanitizedFilename = await getSafeFileName({
33+
collectionSlug,
34+
desiredFilename: filename,
35+
prefix: docPrefix,
36+
req,
37+
})
38+
39+
const { fileKey, sanitizedDocPrefix } = getFileKey({
40+
collectionPrefix,
41+
docPrefix,
42+
filename: sanitizedFilename,
43+
useCompositePrefixes,
44+
})
45+
46+
return { fileKey, sanitizedDocPrefix, sanitizedFilename }
47+
}

packages/storage-azure/src/client/AzureClientUploadHandler.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,15 @@ import { createClientUploadHandler } from '@payloadcms/plugin-cloud-storage/clie
33
import { formatAdminURL } from 'payload/shared'
44

55
export const AzureClientUploadHandler = createClientUploadHandler({
6-
handler: async ({ apiRoute, collectionSlug, docPrefix, file, serverHandlerPath, serverURL }) => {
6+
handler: async ({
7+
apiRoute,
8+
collectionSlug,
9+
docPrefix,
10+
file,
11+
serverHandlerPath,
12+
serverURL,
13+
updateFilename,
14+
}) => {
715
const endpointRoute = formatAdminURL({
816
apiRoute,
917
path: serverHandlerPath,
@@ -20,11 +28,20 @@ export const AzureClientUploadHandler = createClientUploadHandler({
2028
method: 'POST',
2129
})
2230

23-
const { docPrefix: sanitizedDocPrefix, url } = (await response.json()) as {
31+
const {
32+
docPrefix: sanitizedDocPrefix,
33+
filename: sanitizedFilename,
34+
url,
35+
} = (await response.json()) as {
2436
docPrefix: string
37+
filename?: string
2538
url: string
2639
}
2740

41+
if (sanitizedFilename && sanitizedFilename !== file.name) {
42+
updateFilename(sanitizedFilename)
43+
}
44+
2845
await fetch(url, {
2946
body: file,
3047
headers: {

packages/storage-azure/src/generateSignedURL.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { ClientUploadsAccess } from '@payloadcms/plugin-cloud-storage/types
33
import type { PayloadHandler } from 'payload'
44

55
import { BlobSASPermissions, generateBlobSASQueryParameters } from '@azure/storage-blob'
6-
import { getFileKey } from '@payloadcms/plugin-cloud-storage/utilities'
6+
import { resolveSignedURLKey } from '@payloadcms/plugin-cloud-storage/utilities'
77
import { APIError, Forbidden } from 'payload'
88

99
import type { AzureStorageOptions } from './index.js'
@@ -49,10 +49,12 @@ export const getGenerateSignedURLHandler = ({
4949
throw new Forbidden()
5050
}
5151

52-
const { fileKey, sanitizedDocPrefix } = getFileKey({
52+
const { fileKey, sanitizedDocPrefix, sanitizedFilename } = await resolveSignedURLKey({
5353
collectionPrefix,
54-
docPrefix: docPrefix || '',
54+
collectionSlug,
55+
docPrefix,
5556
filename,
57+
req,
5658
useCompositePrefixes,
5759
})
5860

@@ -72,6 +74,7 @@ export const getGenerateSignedURLHandler = ({
7274

7375
return Response.json({
7476
docPrefix: sanitizedDocPrefix,
77+
filename: sanitizedFilename,
7578
url: `${blobClient.url}?${sasToken.toString()}`,
7679
})
7780
}

packages/storage-gcs/src/client/GcsClientUploadHandler.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,15 @@ import { createClientUploadHandler } from '@payloadcms/plugin-cloud-storage/clie
33
import { formatAdminURL } from 'payload/shared'
44

55
export const GcsClientUploadHandler = createClientUploadHandler({
6-
handler: async ({ apiRoute, collectionSlug, docPrefix, file, serverHandlerPath, serverURL }) => {
6+
handler: async ({
7+
apiRoute,
8+
collectionSlug,
9+
docPrefix,
10+
file,
11+
serverHandlerPath,
12+
serverURL,
13+
updateFilename,
14+
}) => {
715
const endpointRoute = formatAdminURL({
816
apiRoute,
917
path: serverHandlerPath,
@@ -20,11 +28,20 @@ export const GcsClientUploadHandler = createClientUploadHandler({
2028
method: 'POST',
2129
})
2230

23-
const { docPrefix: sanitizedDocPrefix, url } = (await response.json()) as {
31+
const {
32+
docPrefix: sanitizedDocPrefix,
33+
filename: sanitizedFilename,
34+
url,
35+
} = (await response.json()) as {
2436
docPrefix: string
37+
filename?: string
2538
url: string
2639
}
2740

41+
if (sanitizedFilename && sanitizedFilename !== file.name) {
42+
updateFilename(sanitizedFilename)
43+
}
44+
2845
await fetch(url, {
2946
body: file,
3047
headers: { 'Content-Length': file.size.toString(), 'Content-Type': file.type },

packages/storage-gcs/src/generateSignedURL.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { Storage } from '@google-cloud/storage'
22
import type { ClientUploadsAccess } from '@payloadcms/plugin-cloud-storage/types'
33
import type { PayloadHandler } from 'payload'
44

5-
import { getFileKey } from '@payloadcms/plugin-cloud-storage/utilities'
5+
import { resolveSignedURLKey } from '@payloadcms/plugin-cloud-storage/utilities'
66
import { APIError, Forbidden } from 'payload'
77

88
import type { GcsStorageOptions } from './index.js'
@@ -49,10 +49,12 @@ export const getGenerateSignedURLHandler = ({
4949
throw new Forbidden()
5050
}
5151

52-
const { fileKey, sanitizedDocPrefix } = getFileKey({
52+
const { fileKey, sanitizedDocPrefix, sanitizedFilename } = await resolveSignedURLKey({
5353
collectionPrefix,
54+
collectionSlug,
5455
docPrefix,
5556
filename,
57+
req,
5658
useCompositePrefixes,
5759
})
5860

@@ -68,6 +70,7 @@ export const getGenerateSignedURLHandler = ({
6870

6971
return Response.json({
7072
docPrefix: sanitizedDocPrefix,
73+
filename: sanitizedFilename,
7174
url,
7275
})
7376
}

packages/storage-r2/src/client/R2ClientUploadHandler.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export const R2ClientUploadHandler = createClientUploadHandler<R2StorageClientUp
2222
prefix,
2323
serverHandlerPath,
2424
serverURL,
25+
updateFilename,
2526
}): Promise<R2StorageClientUploadContext | undefined> => {
2627
const { sanitizedDocPrefix } = getFileKey({
2728
collectionPrefix: prefix,
@@ -49,7 +50,14 @@ export const R2ClientUploadHandler = createClientUploadHandler<R2StorageClientUp
4950
throw new Error('Failed to initialize multipart upload')
5051
}
5152

52-
const multipartUpload = (await multipart.json()) as Pick<R2MultipartUpload, 'key' | 'uploadId'>
53+
const { filename: sanitizedFilename, ...multipartUpload } = (await multipart.json()) as {
54+
filename?: string
55+
} & Pick<R2MultipartUpload, 'key' | 'uploadId'>
56+
57+
if (sanitizedFilename && sanitizedFilename !== file.name) {
58+
updateFilename(sanitizedFilename)
59+
}
60+
5361
const multipartUploadedParts: R2UploadedPart[] = []
5462

5563
params.multipartId = multipartUpload.uploadId

packages/storage-r2/src/handleMultiPartUpload.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { ClientUploadsAccess } from '@payloadcms/plugin-cloud-storage/types'
22
import type { PayloadHandler } from 'payload'
33

4-
import { getFileKey } from '@payloadcms/plugin-cloud-storage/utilities'
4+
import { resolveSignedURLKey } from '@payloadcms/plugin-cloud-storage/utilities'
55
import { APIError, Forbidden } from 'payload'
66

77
import type { R2StorageOptions } from './index.js'
@@ -52,10 +52,12 @@ export const getHandleMultiPartUpload =
5252
}
5353

5454
const collectionPrefix = (typeof collectionConfig === 'object' && collectionConfig.prefix) || ''
55-
const { fileKey } = getFileKey({
55+
const { fileKey, sanitizedFilename } = await resolveSignedURLKey({
5656
collectionPrefix,
57+
collectionSlug,
5758
docPrefix: params.docPrefix ?? undefined,
5859
filename: params.fileName,
60+
req,
5961
useCompositePrefixes,
6062
})
6163

@@ -88,6 +90,7 @@ export const getHandleMultiPartUpload =
8890
})
8991

9092
return Response.json({
93+
filename: sanitizedFilename,
9194
key: multipartUpload.key,
9295
uploadId: multipartUpload.uploadId,
9396
})

packages/storage-s3/src/client/S3ClientUploadHandler.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,15 @@ import { createClientUploadHandler } from '@payloadcms/plugin-cloud-storage/clie
33
import { formatAdminURL } from 'payload/shared'
44

55
export const S3ClientUploadHandler = createClientUploadHandler({
6-
handler: async ({ apiRoute, collectionSlug, docPrefix, file, serverHandlerPath, serverURL }) => {
6+
handler: async ({
7+
apiRoute,
8+
collectionSlug,
9+
docPrefix,
10+
file,
11+
serverHandlerPath,
12+
serverURL,
13+
updateFilename,
14+
}) => {
715
const endpointRoute = formatAdminURL({
816
apiRoute,
917
path: serverHandlerPath,
@@ -31,11 +39,20 @@ export const S3ClientUploadHandler = createClientUploadHandler({
3139
throw new Error(errors.reduce((acc, err) => `${acc ? `${acc}, ` : ''}${err.message}`, ''))
3240
}
3341

34-
const { docPrefix: sanitizedDocPrefix, url } = (await response.json()) as {
42+
const {
43+
docPrefix: sanitizedDocPrefix,
44+
filename: sanitizedFilename,
45+
url,
46+
} = (await response.json()) as {
3547
docPrefix: string
48+
filename?: string
3649
url: string
3750
}
3851

52+
if (sanitizedFilename && sanitizedFilename !== file.name) {
53+
updateFilename(sanitizedFilename)
54+
}
55+
3956
// upload the file directly to S3 using the signed URL
4057
await fetch(url, {
4158
body: file,

0 commit comments

Comments
 (0)