Skip to content

bug: clientUploads stores file under a different S3 key than the DB filename #16694

@jhb-dev

Description

@jhb-dev

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

  1. Clone the reproduction repository and run the development server (pnpm install && docker compose up -d && pnpm dev).
  2. 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.
  3. Expected: the S3 key for the original equals doc.filename.
  4. 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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions