Describe the Bug
When clientUploads: true is enabled on a cloud-storage adapter, the original file is uploaded to the bucket under a key computed by a different sanitizer than the one Payload uses for filename on the document. The two paths diverge whenever File.name contains characters that sanitize-filename strips but payload/shared's sanitizeFilename does not — trailing dots and trailing spaces are the easy cases.
Result, for File.name = "My Photo...png":
|
Value |
DB doc.filename |
My Photo.png |
DB doc.sizes.*.filename |
My Photo-768x1024.png, … |
| S3 key (original) |
My Photo...png ← divergent |
| S3 keys (variants) |
My Photo-768x1024.png, … ← match DB |
Variants work because they are generated server-side from the buffer and uploaded via uploadFile.ts keyed on data.filename (the already-sanitized name). Only the original — uploaded directly from the client via the presigned URL — ends up at the wrong key. Any URL the CMS hands out for the original (e.g. /api/.../file/<filename>, or a custom generateFileURL) 404s.
Browsers and OS dialogs routinely produce filenames containing literal ellipses or trailing spaces when the source name was truncated, so this is reachable from normal usage.
Root cause
Two different sanitizers are applied on the two upload paths.
Path A — DB filename (and server-side variant uploads). packages/payload/src/uploads/generateFileData.ts splits off the extension, sanitizes the base with the sanitize-filename npm package (which applies windowsTrailingRe = /[\. ]+$/), then rejoins:
const baseFilename = sanitize(
file.name.substring(0, file.name.lastIndexOf('.')) || file.name,
)
fsSafeName = `${baseFilename}${ext ? `.${ext}` : ''}`
fileData.filename = fsSafeName
For My Photo...png: base My Photo.. → My Photo → final My Photo.png.
Path B — S3 key for clientUploads-presigned PUT. packages/storage-s3/src/generateSignedURL.ts builds the Key via getFileKey, which uses sanitizeFilename from payload/shared:
import { sanitizeFilename } from 'payload/shared'
// ...
const safeFilename = sanitizeFilename(filename)
That helper (packages/payload/src/utilities/sanitizeFilename.ts) only strips path components and ASCII control characters — it does not touch trailing dots or spaces. Applied to My Photo...png, the result is unchanged, and that is the Key the client PUTs to.
The non-clientUploads path is fine because the original is uploaded server-side via uploadFile.ts keyed on the already-sanitized data.filename, so DB record, original, and variants all share the same key.
Suggested fix
Centralize the sanitization so both paths produce the same name. Preferred: extract the sanitize-filename-based logic in generateFileData.ts into a shared helper (e.g. sanitizeUploadFilename) and call it from both generateFileData.ts and getFileKey.ts. Alternative: have generateSignedURL.ts compute the final filename and return it alongside the presigned URL, then have S3ClientUploadHandler.ts send that name to the Payload upload endpoint via updateFilename.
Link to the code that reproduces this issue
https://github.com/jhb-dev/payload-client-uploads-key-mismatch
Reproduction Steps
- Clone the reproduction repository and run the development server (
pnpm install && docker compose up -d && pnpm dev).
- In a second terminal, run
pnpm reproduce. The script:
- Shows the pure-function divergence between
getFileKey({ filename: 'My Photo...png' }) and the sanitize-filename-based name Payload writes to the DB.
- Hits
POST /api/storage-s3-generate-signed-url, PUTs the file to the returned URL, then POST /api/media with _payload.clientUploadContext set so the cloud-storage hook treats the original as already uploaded.
- Lists the bucket and reads the created document.
- Expected: the S3 key for the original equals
doc.filename.
- Actual: S3 stores the original under
My Photo...png; the document records filename = "My Photo.png".
Observed output:
--- 1) Pure-function divergence ---
input filename : "My Photo...png"
getFileKey -> S3 key : "My Photo...png"
Payload core -> DB filename: "My Photo.png"
=> DIVERGENT (S3 key !== DB filename)
--- 2) End-to-end via HTTP ---
Signed-URL S3 key : "My Photo...png"
PUT to S3 status : 200
DB doc.filename : "My Photo.png"
DB doc.sizes[*].filename : {"sm":"My Photo-768x1024.png","md":"My Photo-1024x1365.png"}
S3 keys after upload :
- My Photo-1024x1365.png
- My Photo-768x1024.png
- My Photo...png
- My Photo.png
Original key on S3 : "My Photo...png"
Original key in DB : "My Photo.png"
=> BUG REPRODUCED
Any filename ending in . / ... before the extension, or with trailing spaces (e.g. example .png), is enough to trigger the divergence.
Which area(s) are affected?
plugin: cloud-storage
Environment Info
Relevant Packages:
payload: 3.84.1
next: 16.2.6
@payloadcms/db-mongodb: 3.84.1
@payloadcms/plugin-cloud-storage: 3.84.1
@payloadcms/storage-s3: 3.84.1
Binaries:
Node: 24.3.0
pnpm: 10.33.0
Operating System:
Platform: darwin
Arch: arm64
Describe the Bug
When
clientUploads: trueis enabled on a cloud-storage adapter, the original file is uploaded to the bucket under a key computed by a different sanitizer than the one Payload uses forfilenameon the document. The two paths diverge wheneverFile.namecontains characters thatsanitize-filenamestrips butpayload/shared'ssanitizeFilenamedoes not — trailing dots and trailing spaces are the easy cases.Result, for
File.name = "My Photo...png":doc.filenameMy Photo.pngdoc.sizes.*.filenameMy Photo-768x1024.png, …My Photo...png← divergentMy Photo-768x1024.png, … ← match DBVariants work because they are generated server-side from the buffer and uploaded via
uploadFile.tskeyed ondata.filename(the already-sanitized name). Only the original — uploaded directly from the client via the presigned URL — ends up at the wrong key. Any URL the CMS hands out for the original (e.g./api/.../file/<filename>, or a customgenerateFileURL) 404s.Browsers and OS dialogs routinely produce filenames containing literal ellipses or trailing spaces when the source name was truncated, so this is reachable from normal usage.
Root cause
Two different sanitizers are applied on the two upload paths.
Path A — DB filename (and server-side variant uploads).
packages/payload/src/uploads/generateFileData.tssplits off the extension, sanitizes the base with thesanitize-filenamenpm package (which applieswindowsTrailingRe = /[\. ]+$/), then rejoins:For
My Photo...png: baseMy Photo..→My Photo→ finalMy Photo.png.Path B — S3 key for clientUploads-presigned PUT.
packages/storage-s3/src/generateSignedURL.tsbuilds theKeyviagetFileKey, which usessanitizeFilenamefrompayload/shared:That helper (
packages/payload/src/utilities/sanitizeFilename.ts) only strips path components and ASCII control characters — it does not touch trailing dots or spaces. Applied toMy Photo...png, the result is unchanged, and that is theKeythe clientPUTs to.The non-clientUploads path is fine because the original is uploaded server-side via
uploadFile.tskeyed on the already-sanitizeddata.filename, so DB record, original, and variants all share the same key.Suggested fix
Centralize the sanitization so both paths produce the same name. Preferred: extract the
sanitize-filename-based logic ingenerateFileData.tsinto a shared helper (e.g.sanitizeUploadFilename) and call it from bothgenerateFileData.tsandgetFileKey.ts. Alternative: havegenerateSignedURL.tscompute the final filename and return it alongside the presigned URL, then haveS3ClientUploadHandler.tssend that name to the Payload upload endpoint viaupdateFilename.Link to the code that reproduces this issue
https://github.com/jhb-dev/payload-client-uploads-key-mismatch
Reproduction Steps
pnpm install && docker compose up -d && pnpm dev).pnpm reproduce. The script:getFileKey({ filename: 'My Photo...png' })and thesanitize-filename-based name Payload writes to the DB.POST /api/storage-s3-generate-signed-url, PUTs the file to the returned URL, thenPOST /api/mediawith_payload.clientUploadContextset so the cloud-storage hook treats the original as already uploaded.doc.filename.My Photo...png; the document recordsfilename = "My Photo.png".Observed output:
Any filename ending in
./...before the extension, or with trailing spaces (e.g.example .png), is enough to trigger the divergence.Which area(s) are affected?
plugin: cloud-storage
Environment Info