Skip to content

Commit b8ec5ea

Browse files
fix: preserve media upload retry integrity
1 parent 37b1fa1 commit b8ec5ea

4 files changed

Lines changed: 81 additions & 19 deletions

File tree

packages/core/src/api/schemas/media.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,6 @@ export function formatFileSize(bytes: number): string {
5959
// Matches a full MIME type (type/subtype) with an optional semicolon-delimited
6060
// parameter section. Forbids CR/LF to prevent header injection.
6161
export const CONTENT_TYPE_RE = /^[a-z0-9][a-z0-9!#$&^_+\-.]*\/[a-z0-9!#$&^_+\-.]+(\s*;[^\r\n]*)?$/i;
62-
const CONTENT_HASH_RE = /^sha1:[0-9a-f]{40}$/;
6362

6463
export function mediaUploadUrlBody(maxSize: number) {
6564
if (!Number.isFinite(maxSize) || maxSize <= 0) {
@@ -77,7 +76,7 @@ export function mediaUploadUrlBody(maxSize: number) {
7776
.int()
7877
.nonnegative()
7978
.max(maxSize, `File size must not exceed ${formatFileSize(maxSize)}`),
80-
contentHash: z.string().max(80).regex(CONTENT_HASH_RE, "Invalid content hash").optional(),
79+
contentHash: z.string().optional(),
8180
fieldId: z.string().optional(),
8281
})
8382
.meta({ id: "MediaUploadUrlBody" });

packages/core/src/astro/routes/api/media/[id]/upload.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -143,11 +143,6 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
143143
}
144144
}
145145

146-
const storedSize = await getStoredSize(emdash.storage, media.storageKey);
147-
if (storedSize === expectedSize) {
148-
return apiSuccess({ uploaded: true, size: expectedSize });
149-
}
150-
151146
let receivedSize = 0;
152147
const shouldHash = expectedSize > 0 && expectedSize <= MAX_CONTENT_HASH_BYTES;
153148
let hashBytes: Uint8Array | null = null;
@@ -259,6 +254,7 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
259254
return apiError("INVALID_STATE", "Media item is no longer pending", 400);
260255
}
261256

257+
await removeUploadAttempt(emdash.storage, repo, media.storageKey);
262258
return apiSuccess({ uploaded: true, size: receivedSize });
263259
} catch (error) {
264260
return handleError(error, "Upload failed", "UPLOAD_ERROR");

packages/core/tests/integration/astro/media-stream-upload.test.ts

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,31 @@ describe("streamed media upload fallback", () => {
170170
});
171171
});
172172

173+
it("accepts existing client hash formats for deduplication", async () => {
174+
const repo = new MediaRepository(db);
175+
const contentHash =
176+
"sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
177+
const existing = await repo.create({
178+
filename: "photo.png",
179+
mimeType: "image/png",
180+
size: 3,
181+
storageKey: "photo.png",
182+
contentHash,
183+
});
184+
185+
const response = await postUploadUrl(
186+
buildContext({
187+
db,
188+
request: uploadUrlRequestWithHash(contentHash),
189+
storage: unsupportedSignedUrlStorage(),
190+
}),
191+
);
192+
193+
expect(response.status).toBe(200);
194+
const body = (await response.json()) as { data: { existing?: boolean; mediaId: string } };
195+
expect(body.data).toMatchObject({ existing: true, mediaId: existing.id });
196+
});
197+
173198
it.each([
174199
{ difference: "MIME type", contentType: "video/mp4", size: 3 },
175200
{ difference: "size", contentType: "audio/mp4", size: 4 },
@@ -533,7 +558,7 @@ describe("streamed media upload fallback", () => {
533558
expect(storage.objects.size).toBe(0);
534559
});
535560

536-
it("keeps a completed object when the upload request is retried", async () => {
561+
it("preserves a completed object when a retry upload fails", async () => {
537562
const repo = new MediaRepository(db);
538563
const pending = await repo.createPending({
539564
filename: "photo.png",
@@ -571,11 +596,53 @@ describe("streamed media upload fallback", () => {
571596
}),
572597
);
573598

574-
expect(retryResponse.status).toBe(200);
575-
expect(storage.upload).toHaveBeenCalledOnce();
599+
expect(retryResponse.status).toBe(500);
600+
expect(storage.upload).toHaveBeenCalledTimes(2);
601+
expect(await repo.findById(pending.id)).toMatchObject({ storageKey: completedKey });
576602
expect(storage.objects.get(completedKey)).toEqual(new Uint8Array([1, 2, 3]));
577603
});
578604

605+
it("processes different same-size bytes when an upload target is retried", async () => {
606+
const repo = new MediaRepository(db);
607+
const pending = await repo.createPending({
608+
filename: "photo.png",
609+
mimeType: "image/png",
610+
size: 3,
611+
storageKey: "photo.png",
612+
authorId: "user-1",
613+
});
614+
const storage = streamingStorage();
615+
616+
const firstResponse = await putUpload(
617+
buildContext({
618+
db,
619+
id: pending.id,
620+
request: uploadRequest(pending.id, new Uint8Array([1, 2, 3])),
621+
storage,
622+
}),
623+
);
624+
expect(firstResponse.status).toBe(200);
625+
const firstUpload = await repo.findById(pending.id);
626+
expect(firstUpload).not.toBeNull();
627+
628+
const secondResponse = await putUpload(
629+
buildContext({
630+
db,
631+
id: pending.id,
632+
request: uploadRequest(pending.id, new Uint8Array([4, 5, 6])),
633+
storage,
634+
}),
635+
);
636+
637+
expect(secondResponse.status).toBe(200);
638+
expect(storage.upload).toHaveBeenCalledTimes(2);
639+
const retried = await repo.findById(pending.id);
640+
expect(retried?.storageKey).not.toBe(firstUpload!.storageKey);
641+
expect(storage.objects.get(retried!.storageKey)).toEqual(new Uint8Array([4, 5, 6]));
642+
expect(storage.objects.has(firstUpload!.storageKey)).toBe(false);
643+
expect(storage.delete).toHaveBeenCalledOnce();
644+
});
645+
579646
it("publishes only one object when two uploads race", async () => {
580647
const repo = new MediaRepository(db);
581648
const pending = await repo.createPending({

packages/core/tests/unit/api/schemas.test.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -253,16 +253,16 @@ describe("mediaUploadUrlBody schema factory", () => {
253253
expect(result.size).toBe(0);
254254
});
255255

256-
it("rejects malformed client content hashes", () => {
256+
it("accepts client content hashes in existing formats", () => {
257257
const schema = mediaUploadUrlBody(1_000);
258-
expect(
259-
schema.safeParse({
260-
filename: "a.jpg",
261-
contentType: "image/jpeg",
262-
size: 500,
263-
contentHash: "not-a-content-hash",
264-
}).success,
265-
).toBe(false);
258+
const contentHash = "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
259+
const result = schema.parse({
260+
filename: "a.jpg",
261+
contentType: "image/jpeg",
262+
size: 500,
263+
contentHash,
264+
});
265+
expect(result.contentHash).toBe(contentHash);
266266
});
267267

268268
it("each call returns an independent schema with its own limit", () => {

0 commit comments

Comments
 (0)