Skip to content

Commit 74aa825

Browse files
feat(storage-*): add useCompositePrefixes option and fix client upload prefix handling (#16230)
## Summary - Adds `useCompositePrefixes` option to all storage adapters (S3, Azure, GCS, R2, Vercel Blob) - Fixes client uploads ignoring document prefix (previously only server uploads respected `data.prefix`) - Adds centralized `getFileKey` utility for consistent path computation across adapters - Documents prefix behavior and composition ## Bug Fix Client uploads previously ignored document prefix entirely: ```typescript // Old generateSignedURL - only used collection prefix const prefix = collectionS3Config.prefix || '' const fileKey = path.posix.join(prefix, filename) ``` Now client and server uploads both use `getFileKey` with document prefix support. ## New Feature With `useCompositePrefixes: true`, prefixes combine instead of override: | Mode | Collection Prefix | Doc Prefix | Result | |------|------------------|------------|--------| | `false` (default) | `media-folder` | `user-123` | `user-123/file.jpg` | | `true` | `media-folder` | `user-123` | `media-folder/user-123/file.jpg` | ## Test plan - [x] Unit tests for `getFileKey` utility (12 tests) - [x] Integration tests verify composite prefix paths - [x] Client uploads now respect document prefix
1 parent cb0ce1c commit 74aa825

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

54 files changed

+1706
-748
lines changed

docs/upload/storage-adapters.mdx

Lines changed: 76 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,15 @@ export default buildConfig({
6060

6161
### Configuration Options#vercel-blob-configuration
6262

63-
| Option | Description | Default |
64-
| -------------------- | -------------------------------------------------------------------- | ----------------------------- |
65-
| `enabled` | Whether or not to enable the plugin | `true` |
66-
| `collections` | Collections to apply the Vercel Blob adapter to | |
67-
| `addRandomSuffix` | Add a random suffix to the uploaded file name in Vercel Blob storage | `false` |
68-
| `cacheControlMaxAge` | Cache-Control max-age in seconds | `365 * 24 * 60 * 60` (1 Year) |
69-
| `token` | Vercel Blob storage read/write token | `''` |
70-
| `clientUploads` | Do uploads directly on the client to bypass limits on Vercel. | |
63+
| Option | Description | Default |
64+
| ---------------------- | ---------------------------------------------------------------------------------------- | ----------------------------- |
65+
| `enabled` | Whether or not to enable the plugin | `true` |
66+
| `collections` | Collections to apply the Vercel Blob adapter to | |
67+
| `addRandomSuffix` | Add a random suffix to the uploaded file name in Vercel Blob storage | `false` |
68+
| `cacheControlMaxAge` | Cache-Control max-age in seconds | `365 * 24 * 60 * 60` (1 Year) |
69+
| `token` | Vercel Blob storage read/write token | `''` |
70+
| `clientUploads` | Do uploads directly on the client to bypass limits on Vercel. | |
71+
| `useCompositePrefixes` | Combine collection prefix with document prefix instead of document prefix overriding it. | `false` |
7172

7273
## S3 Storage
7374

@@ -127,15 +128,16 @@ export default buildConfig({
127128

128129
### Configuration Options#s3-configuration
129130

130-
| Option | Description | Default |
131-
| ----------------- | ----------------------------------------------------------------------- | ----------- |
132-
| `enabled` | Whether or not to enable the plugin | `true` |
133-
| `collections` | Collections to apply the S3 adapter to | |
134-
| `bucket` | The name of the S3 bucket | |
135-
| `config` | `S3ClientConfig` object passed to the AWS SDK client | |
136-
| `acl` | Access control list for uploaded files (e.g. `'public-read'`) | `undefined` |
137-
| `clientUploads` | Do uploads directly on the client to bypass Vercel's 4.5MB server limit | |
138-
| `signedDownloads` | Use presigned URLs for file downloads. Can be overridden per collection | |
131+
| Option | Description | Default |
132+
| ---------------------- | ---------------------------------------------------------------------------------------- | ----------- |
133+
| `enabled` | Whether or not to enable the plugin | `true` |
134+
| `collections` | Collections to apply the S3 adapter to | |
135+
| `bucket` | The name of the S3 bucket | |
136+
| `config` | `S3ClientConfig` object passed to the AWS SDK client | |
137+
| `acl` | Access control list for uploaded files (e.g. `'public-read'`) | `undefined` |
138+
| `clientUploads` | Do uploads directly on the client to bypass Vercel's 4.5MB server limit | |
139+
| `signedDownloads` | Use presigned URLs for file downloads. Can be overridden per collection | |
140+
| `useCompositePrefixes` | Combine collection prefix with document prefix instead of document prefix overriding it. | `false` |
139141

140142
For full `S3ClientConfig` options, see the [AWS SDK Package](https://github.com/aws/aws-sdk-js-v3) and [`S3ClientConfig`](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/s3) docs.
141143

@@ -243,15 +245,16 @@ export default buildConfig({
243245

244246
### Configuration Options#azure-configuration
245247

246-
| Option | Description | Default |
247-
| ---------------------- | ------------------------------------------------------------------------ | ------- |
248-
| `enabled` | Whether or not to enable the plugin | `true` |
249-
| `collections` | Collections to apply the Azure Blob adapter to | |
250-
| `allowContainerCreate` | Whether or not to allow the container to be created if it does not exist | `false` |
251-
| `baseURL` | Base URL for the Azure Blob storage account | |
252-
| `connectionString` | Azure Blob storage connection string | |
253-
| `containerName` | Azure Blob storage container name | |
254-
| `clientUploads` | Do uploads directly on the client to bypass limits on Vercel. | |
248+
| Option | Description | Default |
249+
| ---------------------- | ---------------------------------------------------------------------------------------- | ------- |
250+
| `enabled` | Whether or not to enable the plugin | `true` |
251+
| `collections` | Collections to apply the Azure Blob adapter to | |
252+
| `allowContainerCreate` | Whether or not to allow the container to be created if it does not exist | `false` |
253+
| `baseURL` | Base URL for the Azure Blob storage account | |
254+
| `connectionString` | Azure Blob storage connection string | |
255+
| `containerName` | Azure Blob storage container name | |
256+
| `clientUploads` | Do uploads directly on the client to bypass limits on Vercel. | |
257+
| `useCompositePrefixes` | Combine collection prefix with document prefix instead of document prefix overriding it. | `false` |
255258

256259
## Google Cloud Storage
257260

@@ -296,14 +299,15 @@ export default buildConfig({
296299

297300
### Configuration Options#gcs-configuration
298301

299-
| Option | Description | Default |
300-
| --------------- | --------------------------------------------------------------------------------------------------- | --------- |
301-
| `enabled` | Whether or not to enable the plugin | `true` |
302-
| `collections` | Collections to apply the storage to | |
303-
| `bucket` | The name of the bucket to use | |
304-
| `options` | Google Cloud Storage client configuration. See [Docs](https://github.com/googleapis/nodejs-storage) | |
305-
| `acl` | Access control list for files that are uploaded | `Private` |
306-
| `clientUploads` | Do uploads directly on the client to bypass limits on Vercel. | |
302+
| Option | Description | Default |
303+
| ---------------------- | --------------------------------------------------------------------------------------------------- | --------- |
304+
| `enabled` | Whether or not to enable the plugin | `true` |
305+
| `collections` | Collections to apply the storage to | |
306+
| `bucket` | The name of the bucket to use | |
307+
| `options` | Google Cloud Storage client configuration. See [Docs](https://github.com/googleapis/nodejs-storage) | |
308+
| `acl` | Access control list for files that are uploaded | `Private` |
309+
| `clientUploads` | Do uploads directly on the client to bypass limits on Vercel. | |
310+
| `useCompositePrefixes` | Combine collection prefix with document prefix instead of document prefix overriding it. | `false` |
307311

308312
## Uploadthing Storage
309313

@@ -456,6 +460,44 @@ This plugin is configurable to work across many different Payload collections. A
456460
| `generateFileURL` | [GenerateFileURL](https://github.com/payloadcms/payload/blob/main/packages/plugin-cloud-storage/src/types.ts#L67) | Override the generated file URL with one that you create. |
457461
| `prefix` | `string` | Set to `media/images` to upload files inside `media/images` folder in the bucket. |
458462

463+
## Prefix Composition
464+
465+
Storage adapters support two types of prefixes:
466+
467+
- **Collection prefix**: Set at the adapter configuration level (e.g., `prefix: 'media-folder'`)
468+
- **Document prefix**: Set per-document via the `prefix` field on the upload collection
469+
470+
By default, if a document has a prefix, it **overrides** the collection prefix entirely.
471+
472+
With `useCompositePrefixes: true`, the prefixes are **combined**:
473+
474+
```
475+
# Without useCompositePrefixes (default)
476+
Collection prefix: media-folder
477+
Document prefix: user-123
478+
Result: user-123/image.jpg
479+
480+
# With useCompositePrefixes: true
481+
Collection prefix: media-folder
482+
Document prefix: user-123
483+
Result: media-folder/user-123/image.jpg
484+
```
485+
486+
This is useful when you want a base folder structure (collection prefix) while still allowing per-document organization (document prefix).
487+
488+
```ts
489+
s3Storage({
490+
collections: {
491+
media: {
492+
prefix: 'uploads', // All files go under uploads/
493+
},
494+
},
495+
useCompositePrefixes: true, // Document prefixes append to collection prefix
496+
bucket: process.env.S3_BUCKET,
497+
// ...
498+
})
499+
```
500+
459501
## Payload Access Control
460502

461503
Payload ships with [Access Control](../access-control/overview) that runs _even on statically served files_. The same `read` Access Control property on your `upload`-enabled collections is used, and it allows you to restrict who can request your uploaded files.

packages/plugin-cloud-storage/src/admin/fields/getFields.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,19 @@ interface Args {
99
alwaysInsertFields?: boolean
1010
collection: CollectionConfig
1111
prefix?: string
12+
/**
13+
* When true, do not default the `prefix` field to the collection prefix; the
14+
* document field holds only the document-level segment.
15+
*/
16+
useCompositePrefixes?: boolean
1217
}
1318

14-
export const getFields = ({ alwaysInsertFields, collection, prefix }: Args): Field[] => {
19+
export const getFields = ({
20+
alwaysInsertFields,
21+
collection,
22+
prefix,
23+
useCompositePrefixes = false,
24+
}: Args): Field[] => {
1525
const baseURLField: TextField = {
1626
name: 'url',
1727
type: 'text',
@@ -122,7 +132,7 @@ export const getFields = ({ alwaysInsertFields, collection, prefix }: Args): Fie
122132
fields.push({
123133
...basePrefixField,
124134
...(existingPrefixField || {}),
125-
defaultValue: prefix ? path.posix.join(prefix) : '',
135+
defaultValue: useCompositePrefixes ? '' : prefix ? path.posix.join(prefix) : '',
126136
} as TextField)
127137
}
128138

packages/plugin-cloud-storage/src/client/createClientUploadHandler.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export const createClientUploadHandler = <T extends Record<string, unknown>>({
2020
handler: (args: {
2121
apiRoute: string
2222
collectionSlug: UploadCollectionSlug
23+
docPrefix?: string
2324
extra: T
2425
file: File
2526
prefix?: string
@@ -48,10 +49,11 @@ export const createClientUploadHandler = <T extends Record<string, unknown>>({
4849
if (enabled) {
4950
setUploadHandler({
5051
collectionSlug,
51-
handler: ({ file, updateFilename }) => {
52+
handler: ({ docPrefix, file, updateFilename }) => {
5253
return handler({
5354
apiRoute,
5455
collectionSlug,
56+
docPrefix,
5557
extra,
5658
file,
5759
prefix,
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
1+
export { getFileKey } from '../utilities/getFileKey.js'
12
export { getFilePrefix } from '../utilities/getFilePrefix.js'
23
export { initClientUploads } from '../utilities/initClientUploads.js'
4+
export { sanitizePrefix } from '../utilities/sanitizePrefix.js'

packages/plugin-cloud-storage/src/fields/getFields.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ interface Args {
1717
disablePayloadAccessControl?: true
1818
generateFileURL?: GenerateFileURL
1919
prefix?: string
20+
/**
21+
* When true, do not default the `prefix` field to the collection prefix; the
22+
* document field holds only the document-level segment.
23+
*/
24+
useCompositePrefixes?: boolean
2025
}
2126

2227
export const getFields = ({
@@ -26,6 +31,7 @@ export const getFields = ({
2631
disablePayloadAccessControl,
2732
generateFileURL,
2833
prefix,
34+
useCompositePrefixes = false,
2935
}: Args): Field[] => {
3036
const baseURLField: TextField = {
3137
name: 'url',
@@ -194,7 +200,7 @@ export const getFields = ({
194200
fields.push({
195201
...basePrefixField,
196202
...(existingPrefixField || {}),
197-
defaultValue: prefix ? path.posix.join(prefix) : '',
203+
defaultValue: useCompositePrefixes ? '' : prefix ? path.posix.join(prefix) : '',
198204
} as TextField)
199205
}
200206

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@ import { getPreserveFileDataHook } from './hooks/preserveFileData.js'
1919
export const cloudStoragePlugin =
2020
(pluginOptions: PluginOptions) =>
2121
(incomingConfig: Config): Config => {
22-
const { alwaysInsertFields, collections: allCollectionOptions, enabled } = pluginOptions
22+
const {
23+
alwaysInsertFields,
24+
collections: allCollectionOptions,
25+
enabled,
26+
useCompositePrefixes,
27+
} = pluginOptions
2328
const config = { ...incomingConfig }
2429

2530
// If disabled but alwaysInsertFields is true, only insert fields without full plugin functionality
@@ -46,6 +51,7 @@ export const cloudStoragePlugin =
4651
disablePayloadAccessControl: options.disablePayloadAccessControl,
4752
generateFileURL: options.generateFileURL,
4853
prefix: options.prefix,
54+
useCompositePrefixes,
4955
})
5056

5157
return {
@@ -85,6 +91,7 @@ export const cloudStoragePlugin =
8591
disablePayloadAccessControl: options.disablePayloadAccessControl,
8692
generateFileURL: options.generateFileURL,
8793
prefix: options.prefix,
94+
useCompositePrefixes,
8895
})
8996

9097
const handlers = [

packages/plugin-cloud-storage/src/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,4 +127,14 @@ export interface PluginOptions {
127127
* Default: true
128128
*/
129129
enabled?: boolean
130+
/**
131+
* When true (compositional prefixes), the stored `prefix` field is only the
132+
* document-level segment; the collection prefix comes from plugin options and
133+
* must not be pre-filled as the field default.
134+
*
135+
* Set by storage adapters that support compositional prefixes (e.g. S3, Azure, R2, Vercel Blob, GCS).
136+
*
137+
* @default false
138+
*/
139+
useCompositePrefixes?: boolean
130140
}

0 commit comments

Comments
 (0)