Skip to content

feat: disambiguate media files by prefix via query parameter#15844

Merged
DanRibbens merged 16 commits intopayloadcms:mainfrom
tschwartz:media-endpoint
Apr 7, 2026
Merged

feat: disambiguate media files by prefix via query parameter#15844
DanRibbens merged 16 commits intopayloadcms:mainfrom
tschwartz:media-endpoint

Conversation

@tschwartz
Copy link
Copy Markdown
Contributor

Add optional ?prefix= query string support to the existing /api/:collection/file/:filename endpoint to disambiguate files that share the same filename but have different storage prefixes.

When a storage prefix (e.g., UUID or folder path) is used to make S3 keys unique, multiple documents can share the same filename. The current endpoint resolves by filename alone, which can match the wrong document in both access control and file retrieval.

  • checkFileAccess accepts optional prefix and adds it to the where clause alongside existing access filters
  • getFile handler reads prefix from req.searchParams and threads it to checkFileAccess and handler params
  • getFilePrefix accepts explicitPrefix to skip the DB query when the prefix is already known from the URL
  • S3 staticHandler forwards prefix from params to getFilePrefix
  • afterRead hook appends ?prefix= to Payload-proxied URLs
  • Added unit tests for checkFileAccess and getFilePrefix, and integration tests for the endpoint'

Add optional `?prefix=` query string support to the existing
`/api/:collection/file/:filename` endpoint to disambiguate files
that share the same filename but have different storage prefixes.

When a storage prefix (e.g., UUID or folder path) is used to make
S3 keys unique, multiple documents can share the same `filename`.
The current endpoint resolves by filename alone, which can match
the wrong document in both access control and file retrieval.

- `checkFileAccess` accepts optional `prefix` and adds it to the
  `where` clause alongside existing access filters
- `getFile` handler reads `prefix` from `req.searchParams` and
  threads it to `checkFileAccess` and handler `params`
- `getFilePrefix` accepts `explicitPrefix` to skip the DB query
  when the prefix is already known from the URL
- S3 `staticHandler` forwards `prefix` from params to `getFilePrefix`
- `afterRead` hook appends `?prefix=` to Payload-proxied URLs
- Added unit tests for `checkFileAccess` and `getFilePrefix`,
  and integration tests for the endpoint'
@tschwartz tschwartz requested a review from denolfe as a code owner March 4, 2026 17:28
Comment thread packages/storage-s3/src/staticHandler.ts
Comment thread test/uploads/config.ts
When a media URL already contains query parameters, appending the
imageCacheTag with '?' produces a malformed URL with double '?'.
Check for existing query parameters and use '&' as the separator
when needed, matching the pattern already used in the Thumbnail
component.
GermanJablo
GermanJablo previously approved these changes Mar 10, 2026
Copy link
Copy Markdown
Contributor

@GermanJablo GermanJablo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One minor note (non-blocking)

Only the S3 storage adapter is updated. Other storage adapters (Azure, GCS, Vercel Blob, etc.) aren't changed. This is fine since the core changes (checkFileAccess, getFile) work independently. Other adapters can be updated separately.

@JarrodMFlesch JarrodMFlesch self-assigned this Mar 31, 2026
Update storage-vercel-blob, storage-azure, storage-gcs, and storage-r2
static handlers to destructure `prefix` from params and pass it to
`getFilePrefix` as `explicitPrefix`, matching the pattern established
in the S3 adapter. This avoids an unnecessary DB query when the prefix
is already known from the `?prefix=` URL query parameter.

- storage-vercel-blob: add `prefix: explicitPrefix` to params and
  `getFilePrefix` call
- storage-azure: same
- storage-gcs: same
- storage-r2: import `getFilePrefix`, replace static prefix arg with
  dynamic lookup, fall back to constructor default
- Update URL assertions in storage-azure, storage-s3, and
  plugin-cloud-storage integration tests to expect `?prefix=` query
  parameter on prefix-enabled collection URLs
Add optional `?prefix=` query string support to the existing
`/api/:collection/file/:filename` endpoint to disambiguate files
that share the same filename but have different storage prefixes.

When a storage prefix (e.g., UUID or folder path) is used to make
S3 keys unique, multiple documents can share the same `filename`.
The current endpoint resolves by filename alone, which can match
the wrong document in both access control and file retrieval.

- `checkFileAccess` accepts optional `prefix` and adds it to the
  `where` clause alongside existing access filters
- `getFile` handler reads `prefix` from `req.searchParams` and
  threads it to `checkFileAccess` and handler `params`
- `getFilePrefix` accepts `explicitPrefix` to skip the DB query
  when the prefix is already known from the URL
- S3 `staticHandler` forwards `prefix` from params to `getFilePrefix`
- `afterRead` hook appends `?prefix=` to Payload-proxied URLs
- Added unit tests for `checkFileAccess` and `getFilePrefix`,
  and integration tests for the endpoint'
When a media URL already contains query parameters, appending the
imageCacheTag with '?' produces a malformed URL with double '?'.
Check for existing query parameters and use '&' as the separator
when needed, matching the pattern already used in the Thumbnail
component.
Update storage-vercel-blob, storage-azure, storage-gcs, and storage-r2
static handlers to destructure `prefix` from params and pass it to
`getFilePrefix` as `explicitPrefix`, matching the pattern established
in the S3 adapter. This avoids an unnecessary DB query when the prefix
is already known from the `?prefix=` URL query parameter.

- storage-vercel-blob: add `prefix: explicitPrefix` to params and
  `getFilePrefix` call
- storage-azure: same
- storage-gcs: same
- storage-r2: import `getFilePrefix`, replace static prefix arg with
  dynamic lookup, fall back to constructor default
- Update URL assertions in storage-azure, storage-s3, and
  plugin-cloud-storage integration tests to expect `?prefix=` query
  parameter on prefix-enabled collection URLs
Comment thread packages/storage-r2/src/staticHandler.ts Outdated
@DanRibbens DanRibbens merged commit 05ddec1 into payloadcms:main Apr 7, 2026
312 of 314 checks passed
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 8, 2026

🚀 This is included in version v3.82.0

JarrodMFlesch added a commit that referenced this pull request Apr 10, 2026
## Summary

- Replace factory function pattern (`getHandleUpload`,
`getHandleDelete`, etc.) with simple helper functions
- Create `adapter.ts` in each storage package that maps the plugin
interface to the helpers
- Fix R2 prefix bug: use OR logic (`filePrefix || prefix`) to match
`handleUpload` behavior
- Remove redundant `async/await` on pass-through handlers

I think this approach makes it easier to trace backwards to the
cloud-storage plugin the arguments that are actually passed into the
adapter methods.

## Changes

**Pattern change (all 6 storage adapters):**

Before (factory functions):
```typescript
export const getHandleDelete = ({ bucket }: Args): HandleDelete => {
  return async ({ doc: { prefix = '' }, filename }) => {
    await bucket.delete(path.posix.join(prefix, filename))
  }
}
```

After (simple functions + adapter mapping):
```typescript
// handleDelete.ts
export async function deleteFile({ bucket, prefix, filename }: DeleteArgs): Promise<void> {
  await bucket.delete(path.posix.join(prefix, filename))
}

// adapter.ts
export function createR2Adapter({ bucket, clientUploads }: CreateR2AdapterArgs): Adapter {
  return ({ collection, prefix = '' }): GeneratedAdapter => ({
    name: 'r2',
    clientUploads,

    handleDelete: ({ doc: { prefix: docPrefix = '' }, filename }) =>
      deleteFile({ bucket, filename, prefix: docPrefix }),

    handleUpload: ({ data, file }) =>
      uploadFile({
        bucket,
        buffer: file.buffer,
        filename: file.filename,
        mimeType: file.mimeType,
        prefix: data.prefix || prefix,
      }),

    staticHandler: (
      req,
      { headers, params: { clientUploadContext, filename, prefix: prefixQueryParam } },
    ) =>
      getFile({
        bucket,
        clientUploadContext,
        collection,
        filename,
        incomingHeaders: headers,
        prefix,
        prefixQueryParam,
        req,
      }),
  })
}
```

**Bug fix (storage-r2):**

The prefix query param feature (#15844) introduced inconsistent logic in
R2:
- `handleUpload`: uses `data.prefix || prefix` (OR logic)
- `staticHandler`: was using `prefix + filePrefix` (AND logic)

Fixed to use consistent OR logic: `filePrefix || prefix`

## Packages affected

- `@payloadcms/storage-r2`
- `@payloadcms/storage-s3`
- `@payloadcms/storage-gcs`
- `@payloadcms/storage-azure`
- `@payloadcms/storage-vercel-blob`
- `@payloadcms/storage-uploadthing`
@jasperkennis
Copy link
Copy Markdown

This diff caused me some trouble. Our app transforms the returned media path without checking for query params, to point to dirs where our m3u8 files live. Due to there suddenly being a query param in the url it breaks.

I've had to revert a deploy and am now checking how to unset the query param without disabling the rest of the prefix logic.

milamer pushed a commit to milamer/payload that referenced this pull request Apr 20, 2026
…cms#15844)

Add optional `?prefix=` query string support to the existing
`/api/:collection/file/:filename` endpoint to disambiguate files that
share the same filename but have different storage prefixes.

When a storage prefix (e.g., UUID or folder path) is used to make S3
keys unique, multiple documents can share the same `filename`. The
current endpoint resolves by filename alone, which can match the wrong
document in both access control and file retrieval.

- `checkFileAccess` accepts optional `prefix` and adds it to the `where`
clause alongside existing access filters
- `getFile` handler reads `prefix` from `req.searchParams` and threads
it to `checkFileAccess` and handler `params`
- `getFilePrefix` accepts `explicitPrefix` to skip the DB query when the
prefix is already known from the URL
- S3 `staticHandler` forwards `prefix` from params to `getFilePrefix`
- `afterRead` hook appends `?prefix=` to Payload-proxied URLs
- Added unit tests for `checkFileAccess` and `getFilePrefix`, and
integration tests for the endpoint'

---------

Co-authored-by: Jarrod Flesch <jarrodmflesch@gmail.com>
milamer pushed a commit to milamer/payload that referenced this pull request Apr 20, 2026
## Summary

- Replace factory function pattern (`getHandleUpload`,
`getHandleDelete`, etc.) with simple helper functions
- Create `adapter.ts` in each storage package that maps the plugin
interface to the helpers
- Fix R2 prefix bug: use OR logic (`filePrefix || prefix`) to match
`handleUpload` behavior
- Remove redundant `async/await` on pass-through handlers

I think this approach makes it easier to trace backwards to the
cloud-storage plugin the arguments that are actually passed into the
adapter methods.

## Changes

**Pattern change (all 6 storage adapters):**

Before (factory functions):
```typescript
export const getHandleDelete = ({ bucket }: Args): HandleDelete => {
  return async ({ doc: { prefix = '' }, filename }) => {
    await bucket.delete(path.posix.join(prefix, filename))
  }
}
```

After (simple functions + adapter mapping):
```typescript
// handleDelete.ts
export async function deleteFile({ bucket, prefix, filename }: DeleteArgs): Promise<void> {
  await bucket.delete(path.posix.join(prefix, filename))
}

// adapter.ts
export function createR2Adapter({ bucket, clientUploads }: CreateR2AdapterArgs): Adapter {
  return ({ collection, prefix = '' }): GeneratedAdapter => ({
    name: 'r2',
    clientUploads,

    handleDelete: ({ doc: { prefix: docPrefix = '' }, filename }) =>
      deleteFile({ bucket, filename, prefix: docPrefix }),

    handleUpload: ({ data, file }) =>
      uploadFile({
        bucket,
        buffer: file.buffer,
        filename: file.filename,
        mimeType: file.mimeType,
        prefix: data.prefix || prefix,
      }),

    staticHandler: (
      req,
      { headers, params: { clientUploadContext, filename, prefix: prefixQueryParam } },
    ) =>
      getFile({
        bucket,
        clientUploadContext,
        collection,
        filename,
        incomingHeaders: headers,
        prefix,
        prefixQueryParam,
        req,
      }),
  })
}
```

**Bug fix (storage-r2):**

The prefix query param feature (payloadcms#15844) introduced inconsistent logic in
R2:
- `handleUpload`: uses `data.prefix || prefix` (OR logic)
- `staticHandler`: was using `prefix + filePrefix` (AND logic)

Fixed to use consistent OR logic: `filePrefix || prefix`

## Packages affected

- `@payloadcms/storage-r2`
- `@payloadcms/storage-s3`
- `@payloadcms/storage-gcs`
- `@payloadcms/storage-azure`
- `@payloadcms/storage-vercel-blob`
- `@payloadcms/storage-uploadthing`
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants