From 8fe38453d41107a42da244b8d589bd8a36c7ea6e Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Mon, 20 Jul 2026 00:31:43 +0400 Subject: [PATCH] fix: properly handle x-amz-meta-updated-at --- donut-sync/src/sync/sync.service.ts | 11 +++++- donut-sync/test/sync.e2e-spec.ts | 61 +++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/donut-sync/src/sync/sync.service.ts b/donut-sync/src/sync/sync.service.ts index bb2ec1e..c9eef1c 100644 --- a/donut-sync/src/sync/sync.service.ts +++ b/donut-sync/src/sync/sync.service.ts @@ -329,7 +329,16 @@ export class SyncService implements OnModuleInit { Metadata: metadata, }); - const url = await getSignedUrl(this.s3Client, command, { expiresIn }); + const metadataHeaders = new Set( + Object.keys(metadata ?? {}).map((name) => `x-amz-meta-${name}`), + ); + const url = await getSignedUrl(this.s3Client, command, { + expiresIn, + // The AWS presigner otherwise hoists user metadata into the query string. + // The client echoes the response metadata as headers, so those headers + // must remain in the request and be covered by SignedHeaders. + unhoistableHeaders: metadataHeaders, + }); // Report profile usage after upload presign if key is under profiles/ if (ctx.mode === "cloud" && dto.key.startsWith("profiles/")) { diff --git a/donut-sync/test/sync.e2e-spec.ts b/donut-sync/test/sync.e2e-spec.ts index 9f19247..9fc4ffe 100644 --- a/donut-sync/test/sync.e2e-spec.ts +++ b/donut-sync/test/sync.e2e-spec.ts @@ -17,6 +17,7 @@ import { interface PresignResponse { url: string; expiresAt: string; + metadata?: Record; } interface ListResponse { @@ -34,6 +35,7 @@ interface StatResponse { exists: boolean; size?: number; lastModified?: string; + metadata?: Record; } describe("SyncController (e2e)", () => { @@ -112,6 +114,65 @@ describe("SyncController (e2e)", () => { expect(body.url).toContain("test/upload-key.txt"); expect(body.expiresAt).toBeDefined(); }); + + it("should sign and persist echoed object metadata", async () => { + const testKey = `vpns/metadata-${Date.now()}.json`; + const updatedAt = Math.floor(Date.now() / 1000).toString(); + + try { + const response = await request(app.getHttpServer()) + .post("/v1/objects/presign-upload") + .set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`) + .send({ + key: testKey, + contentType: "application/json", + metadata: { + "updated-at": updatedAt, + ignored: "not-allowed", + }, + }) + .expect(200); + + const body = response.body as PresignResponse; + expect(body.metadata).toEqual({ "updated-at": updatedAt }); + + const uploadUrl = new URL(body.url); + const signedHeaders = + uploadUrl.searchParams.get("X-Amz-SignedHeaders")?.split(";") ?? []; + expect(signedHeaders).toContain("x-amz-meta-updated-at"); + expect(uploadUrl.searchParams.has("x-amz-meta-updated-at")).toBe(false); + + const uploadResult = await fetch(body.url, { + method: "PUT", + body: "{}", + headers: { + "Content-Type": "application/json", + "x-amz-meta-updated-at": updatedAt, + }, + }); + if (!uploadResult.ok) { + throw new Error( + `Metadata upload failed with status ${uploadResult.status}: ${await uploadResult.text()}`, + ); + } + + const statResponse = await request(app.getHttpServer()) + .post("/v1/objects/stat") + .set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`) + .send({ key: testKey }) + .expect(200); + + const statBody = statResponse.body as StatResponse; + expect(statBody.exists).toBe(true); + expect(statBody.metadata?.["updated-at"]).toBe(updatedAt); + } finally { + await request(app.getHttpServer()) + .post("/v1/objects/delete") + .set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`) + .send({ key: testKey }) + .expect(200); + } + }); }); describe("POST /v1/objects/presign-download", () => {