Skip to content

Commit 0b97b1f

Browse files
committed
fix: copy metadata upstream
fixes #1109 Signed-off-by: ferhat elmas <elmas.ferhat@gmail.com>
1 parent e0684d9 commit 0b97b1f

13 files changed

Lines changed: 678 additions & 28 deletions

File tree

acceptance/API_COVERAGE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ These run in `smoke` / `core` profiles and are included in the default local CI
1414
| Auth/error contracts | malformed JWT rejection and missing protected-route authorization mapped to explicit auth errors |
1515
| REST buckets | create, duplicate create rejection, get, list/search, update, empty, empty already-empty bucket, delete, deletion blocked when non-empty |
1616
| REST objects | upload, update, duplicate protection, bucket file-size limit and MIME policy rejection, user metadata, authenticated read/head/info with ETag/length checks, public read/head/info, public missing-key info/head, special-character key read/list, single delete, missing-key info/head behavior |
17-
| REST object operations | empty bucket list-v1/list-v2, list-v1 search/offset, list-v2 delimiter/cursor/sort, signed URL, batch signed URLs, signed upload URL, invalid signed upload token, copy, copy without upsert when destination exists, same-bucket and cross-bucket move, move conflict, bulk delete |
17+
| REST object operations | empty bucket list-v1/list-v2, list-v1 search/offset, list-v2 delimiter/cursor/sort, signed URL, batch signed URLs, signed upload URL, invalid signed upload token, copy, copy metadata/cache-control overwrite, copy without upsert when destination exists, same-bucket and cross-bucket move, move conflict, bulk delete |
1818
| DB adapter pinning | LIKE-wildcard escaping for bucket search and list-v2 prefix, list-v2 cursor pagination with sortBy on `created_at`/`updated_at`, list-v2 delimiter common-prefix walk, literal-underscore keys |
1919
| S3 buckets | CreateBucket, duplicate CreateBucket conflict, HeadBucket, ListBuckets, GetBucketLocation, GetBucketVersioning, DeleteBucket, DeleteBucket non-empty conflict |
2020
| S3 objects | PutObject metadata/cache-control, presigned POST form upload, HeadObject, HeadObject missing-key 404, GetObject metadata, GetObjectTagging empty tag-set contract, conditional GetObject (`If-None-Match`, `If-Modified-Since`), response header overrides, Range GetObject (suffix/last-N/out-of-range), basic CopyObject, CopyObject metadata replacement, DeleteObject, DeleteObjects |

acceptance/specs/rest-extended.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ interface BucketResponse {
2424

2525
interface ObjectInfoResponse {
2626
bucket_id?: string
27+
cache_control?: string | null
28+
content_type?: string | null
2729
metadata?: Record<string, unknown> | null
2830
name?: string
2931
}
@@ -736,6 +738,75 @@ describeAcceptance(
736738
}
737739
})
738740

741+
it('serves updated cache-control after copy overwrites object metadata', async () => {
742+
const client = createRestClient()
743+
const token = requireServiceKey()
744+
const bucketName = uniqueBucketName('copycache')
745+
const objectKey = uniqueObjectKey('copy-cache')
746+
const initialCacheControl = 'max-age=60'
747+
const updatedCacheControl = 'max-age=604800'
748+
const payload = 'cache-control-copy-body'
749+
750+
try {
751+
await createRestBucket(bucketName, { isPublic: false })
752+
753+
await client.request('POST', `/object/${bucketName}/${encodePathSegments(objectKey)}`, {
754+
body: payload,
755+
expectedStatus: 200,
756+
headers: {
757+
'cache-control': initialCacheControl,
758+
'content-type': 'text/plain',
759+
'x-upsert': 'true',
760+
},
761+
token,
762+
})
763+
764+
const beforeCopy = await client.request(
765+
'GET',
766+
`/object/authenticated/${bucketName}/${encodePathSegments(objectKey)}`,
767+
{ expectedStatus: 200, token }
768+
)
769+
expect(beforeCopy.body).toBe(payload)
770+
expect(beforeCopy.headers.get('cache-control')).toBe(initialCacheControl)
771+
772+
await client.request('POST', '/object/copy', {
773+
body: {
774+
bucketId: bucketName,
775+
copyMetadata: false,
776+
destinationKey: objectKey,
777+
metadata: {
778+
cacheControl: updatedCacheControl,
779+
},
780+
sourceKey: objectKey,
781+
},
782+
expectedStatus: 200,
783+
headers: {
784+
'x-upsert': 'true',
785+
},
786+
token,
787+
})
788+
789+
const afterCopy = await client.request(
790+
'GET',
791+
`/object/authenticated/${bucketName}/${encodePathSegments(objectKey)}`,
792+
{ expectedStatus: 200, token }
793+
)
794+
expect(afterCopy.body).toBe(payload)
795+
expect(afterCopy.headers.get('cache-control')).toBe(updatedCacheControl)
796+
expect(afterCopy.headers.get('content-type')).toBe('text/plain')
797+
798+
const info = await client.request<ObjectInfoResponse>(
799+
'GET',
800+
`/object/info/authenticated/${bucketName}/${encodePathSegments(objectKey)}`,
801+
{ expectedStatus: 200, token }
802+
)
803+
expect(info.json?.cache_control).toBe(updatedCacheControl)
804+
expect(info.json?.content_type).toBe('text/plain')
805+
} finally {
806+
await cleanupRestResources(bucketName, [objectKey], client)
807+
}
808+
})
809+
739810
it('enforces bucket file-size limits on REST uploads', async () => {
740811
const config = getAcceptanceConfig()
741812
const client = createRestClient()

acceptance/specs/s3.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,9 @@ describeAcceptance(
589589
ContentType: 'application/json',
590590
CopySource: `${bucketName}/${keyA}`,
591591
Key: metadataReplacedKey,
592+
Metadata: {
593+
copied: 'yes',
594+
},
592595
MetadataDirective: 'REPLACE',
593596
})
594597
)
@@ -597,6 +600,7 @@ describeAcceptance(
597600
)
598601
expect(metadataReplaced.CacheControl).toBe('max-age=91')
599602
expect(metadataReplaced.ContentType).toBe('application/json')
603+
expect(metadataReplaced.Metadata).toMatchObject({ copied: 'yes' })
600604

601605
const multipartCopy = await client.send(
602606
new CreateMultipartUploadCommand({

src/http/routes/object/copyObject.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export default async function routes(fastify: FastifyInstance) {
7373
typeof userMetadata === 'string' ? parseUserMetadata(userMetadata) : undefined,
7474
metadata,
7575
copyMetadata: request.body.copyMetadata ?? true,
76+
preserveUnspecifiedFileMetadata: true,
7677
upsert: request.headers['x-upsert'] === 'true',
7778
uploadType: 'standard',
7879
})

src/http/routes/s3/commands/copy-object.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,15 @@ export default function CopyObject(s3Router: S3Router) {
3737
{ schema: CopyObjectInput, operation: ROUTE_OPERATIONS.S3_COPY_OBJECT },
3838
(req, ctx) => {
3939
const s3Protocol = new S3ProtocolHandler(ctx.storage, ctx.tenantId, ctx.owner)
40+
const metadata = s3Protocol.parseMetadataHeaders(req.Headers)
4041

4142
return s3Protocol.copyObject({
4243
Bucket: req.Params.Bucket,
4344
Key: req.Params['*'],
4445
CopySource: req.Headers['x-amz-copy-source'],
4546
ContentType: req.Headers['content-type'],
4647
CacheControl: req.Headers['cache-control'],
48+
Metadata: metadata,
4749
MetadataDirective: req.Headers['x-amz-metadata-directive'] as MetadataDirective | undefined,
4850
Expires: req.Headers.expires ? new Date(req.Headers.expires) : undefined,
4951
ContentEncoding: req.Headers['content-encoding'],

src/storage/backend/adapter.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ export type UploadPart = {
4444
ChecksumSHA256?: string
4545
}
4646

47+
export type CopyObjectOptions = {
48+
copyMetadata?: boolean
49+
}
50+
4751
/**
4852
* A generic storage Adapter to interact with files
4953
*/
@@ -135,7 +139,8 @@ export abstract class StorageBackendAdapter {
135139
ifNoneMatch?: string
136140
ifModifiedSince?: Date
137141
ifUnmodifiedSince?: Date
138-
}
142+
},
143+
options?: CopyObjectOptions
139144
): Promise<Pick<ObjectMetadata, 'httpStatusCode' | 'eTag' | 'lastModified'>> {
140145
throw new Error('copyObject not implemented')
141146
}

0 commit comments

Comments
 (0)