Skip to content

Commit ecdf0b2

Browse files
fix: reject stale media confirmations
1 parent b8ec5ea commit ecdf0b2

6 files changed

Lines changed: 221 additions & 36 deletions

File tree

packages/core/src/api/openapi/document.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -828,7 +828,7 @@ function buildMediaPaths(maxUploadSize: number) {
828828
},
829829
},
830830
...authErrors,
831-
...standardErrors(400, 404, 500),
831+
...standardErrors(400, 404, 409, 500),
832832
},
833833
},
834834
},

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

Lines changed: 30 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,21 @@ async function forgetUploadAttempt(repo: MediaRepository, storageKey: string): P
5656
}
5757
}
5858

59+
async function confirmationConflict(repo: MediaRepository, id: string): Promise<Response> {
60+
const current = await repo.findById(id);
61+
if (!current) {
62+
return apiError("NOT_FOUND", `Media item not found: ${id}`, 404);
63+
}
64+
if (current.status === "ready") {
65+
await forgetUploadAttempt(repo, current.storageKey);
66+
return apiSuccess({ item: addUrlToMedia(current) });
67+
}
68+
if (current.status === "pending") {
69+
return apiError("INVALID_STATE", "Media item changed during confirmation", 409);
70+
}
71+
return apiError("INVALID_STATE", `Media item is not pending: ${current.status}`, 400);
72+
}
73+
5974
async function consumeDownload(download: DownloadResult): Promise<Uint8Array> {
6075
const reader = download.body.getReader();
6176
try {
@@ -148,8 +163,8 @@ export const POST: APIRoute = async ({ params, request, locals }) => {
148163
if (emdash.storage) {
149164
const exists = await emdash.storage.exists(existing.storageKey);
150165
if (!exists) {
151-
// Mark as failed
152-
await repo.markFailed(id);
166+
const failed = await repo.markFailed(id, existing.storageKey);
167+
if (!failed) return await confirmationConflict(repo, id);
153168
return apiError("FILE_NOT_FOUND", "File was not uploaded to storage", 400);
154169
}
155170

@@ -207,28 +222,21 @@ export const POST: APIRoute = async ({ params, request, locals }) => {
207222
}
208223

209224
// Confirm the upload
210-
const item = await repo.confirmUpload(id, {
211-
size: confirmedSize,
212-
width,
213-
height,
214-
blurhash,
215-
dominantColor,
216-
contentHash,
217-
});
225+
const item = await repo.confirmUpload(
226+
id,
227+
{
228+
size: confirmedSize,
229+
width,
230+
height,
231+
blurhash,
232+
dominantColor,
233+
contentHash,
234+
},
235+
existing.storageKey,
236+
);
218237

219238
if (!item) {
220-
const current = await repo.findById(id);
221-
if (!current) {
222-
return apiError("NOT_FOUND", `Media item not found: ${id}`, 404);
223-
}
224-
if (current.status === "ready") {
225-
await forgetUploadAttempt(repo, current.storageKey);
226-
return apiSuccess({ item: addUrlToMedia(current) });
227-
}
228-
if (current.status !== "pending") {
229-
return apiError("INVALID_STATE", `Media item is not pending: ${current.status}`, 400);
230-
}
231-
return apiError("CONFIRM_FAILED", "Failed to confirm upload", 500);
239+
return await confirmationConflict(repo, id);
232240
}
233241

234242
await forgetUploadAttempt(repo, item.storageKey);

packages/core/src/database/repositories/media.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,7 @@ export class MediaRepository {
277277
dominantColor?: string;
278278
contentHash?: string | null;
279279
},
280+
expectedStorageKey?: string,
280281
): Promise<MediaItem | null> {
281282
const updates: Partial<MediaRow> = {
282283
status: "ready",
@@ -288,29 +289,31 @@ export class MediaRepository {
288289
if (metadata?.dominantColor !== undefined) updates.dominant_color = metadata.dominantColor;
289290
if (metadata?.contentHash !== undefined) updates.content_hash = metadata.contentHash;
290291

291-
const row = await this.db
292+
let query = this.db
292293
.updateTable("media")
293294
.set(updates)
294295
.where("id", "=", id)
295-
.where("status", "=", "pending")
296-
.returningAll()
297-
.executeTakeFirst();
296+
.where("status", "=", "pending");
297+
if (expectedStorageKey !== undefined) {
298+
query = query.where("storage_key", "=", expectedStorageKey);
299+
}
300+
301+
const row = await query.returningAll().executeTakeFirst();
298302

299303
return row ? this.rowToItem(row) : null;
300304
}
301305

302306
/**
303307
* Mark upload as failed
304308
*/
305-
async markFailed(id: string): Promise<MediaItem | null> {
306-
const existing = await this.findById(id);
307-
if (!existing) {
308-
return null;
309+
async markFailed(id: string, expectedStorageKey?: string): Promise<MediaItem | null> {
310+
let query = this.db.updateTable("media").set({ status: "failed" }).where("id", "=", id);
311+
if (expectedStorageKey !== undefined) {
312+
query = query.where("status", "=", "pending").where("storage_key", "=", expectedStorageKey);
309313
}
310314

311-
await this.db.updateTable("media").set({ status: "failed" }).where("id", "=", id).execute();
312-
313-
return this.findById(id);
315+
const row = await query.returningAll().executeTakeFirst();
316+
return row ? this.rowToItem(row) : null;
314317
}
315318

316319
/**

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

Lines changed: 149 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,8 +172,7 @@ describe("streamed media upload fallback", () => {
172172

173173
it("accepts existing client hash formats for deduplication", async () => {
174174
const repo = new MediaRepository(db);
175-
const contentHash =
176-
"sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
175+
const contentHash = "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
177176
const existing = await repo.create({
178177
filename: "photo.png",
179178
mimeType: "image/png",
@@ -643,6 +642,154 @@ describe("streamed media upload fallback", () => {
643642
expect(storage.delete).toHaveBeenCalledOnce();
644643
});
645644

645+
it("does not confirm stale metadata after a replacement upload", async () => {
646+
const repo = new MediaRepository(db);
647+
const pending = await repo.createPending({
648+
filename: "document.pdf",
649+
mimeType: "application/pdf",
650+
size: 3,
651+
storageKey: "document.pdf",
652+
authorId: "user-1",
653+
});
654+
const storage = streamingStorage();
655+
const firstBytes = new Uint8Array([1, 2, 3]);
656+
const replacementBytes = new Uint8Array([4, 5, 6]);
657+
658+
await putUpload(
659+
buildContext({
660+
db,
661+
id: pending.id,
662+
request: uploadRequest(pending.id, firstBytes, "application/pdf"),
663+
storage,
664+
}),
665+
);
666+
667+
let markConfirmationReading: () => void = () => undefined;
668+
const confirmationReading = new Promise<void>((resolve) => {
669+
markConfirmationReading = resolve;
670+
});
671+
let finishConfirmation: () => void = () => undefined;
672+
const confirmationCanFinish = new Promise<void>((resolve) => {
673+
finishConfirmation = resolve;
674+
});
675+
storage.download.mockImplementationOnce(async (key: string) => {
676+
const bytes = storage.objects.get(key);
677+
if (!bytes) throw new EmDashStorageError("File not found", "NOT_FOUND");
678+
return {
679+
body: new ReadableStream<Uint8Array>({
680+
cancel() {
681+
markConfirmationReading();
682+
return confirmationCanFinish;
683+
},
684+
}),
685+
contentType: "application/pdf",
686+
size: bytes.byteLength,
687+
};
688+
});
689+
690+
const confirm = () =>
691+
postConfirm(
692+
buildContext({
693+
db,
694+
id: pending.id,
695+
request: new Request(`http://localhost/_emdash/api/media/${pending.id}/confirm`, {
696+
method: "POST",
697+
headers: { "Content-Type": "application/json", "X-EmDash-Request": "1" },
698+
body: "{}",
699+
}),
700+
storage,
701+
}),
702+
);
703+
const staleConfirmation = confirm();
704+
await confirmationReading;
705+
706+
await putUpload(
707+
buildContext({
708+
db,
709+
id: pending.id,
710+
request: uploadRequest(pending.id, replacementBytes, "application/pdf"),
711+
storage,
712+
}),
713+
);
714+
finishConfirmation();
715+
716+
expect((await staleConfirmation).status).toBe(409);
717+
const replacement = await repo.findById(pending.id);
718+
expect(replacement).toMatchObject({
719+
status: "pending",
720+
contentHash: await computeContentHash(replacementBytes),
721+
});
722+
expect(storage.objects.get(replacement!.storageKey)).toEqual(replacementBytes);
723+
724+
expect((await confirm()).status).toBe(200);
725+
expect(await repo.findById(pending.id)).toMatchObject({
726+
status: "ready",
727+
contentHash: await computeContentHash(replacementBytes),
728+
});
729+
});
730+
731+
it("does not mark a replacement upload as failed from a stale storage check", async () => {
732+
const repo = new MediaRepository(db);
733+
const pending = await repo.createPending({
734+
filename: "document.pdf",
735+
mimeType: "application/pdf",
736+
size: 3,
737+
storageKey: "document.pdf",
738+
authorId: "user-1",
739+
});
740+
const storage = streamingStorage();
741+
const replacementBytes = new Uint8Array([4, 5, 6]);
742+
743+
let markStorageChecked: () => void = () => undefined;
744+
const storageChecked = new Promise<void>((resolve) => {
745+
markStorageChecked = resolve;
746+
});
747+
let finishStorageCheck: () => void = () => undefined;
748+
const storageCheckCanFinish = new Promise<void>((resolve) => {
749+
finishStorageCheck = resolve;
750+
});
751+
storage.exists.mockImplementationOnce(async () => {
752+
markStorageChecked();
753+
await storageCheckCanFinish;
754+
return false;
755+
});
756+
757+
const confirm = () =>
758+
postConfirm(
759+
buildContext({
760+
db,
761+
id: pending.id,
762+
request: new Request(`http://localhost/_emdash/api/media/${pending.id}/confirm`, {
763+
method: "POST",
764+
headers: { "Content-Type": "application/json", "X-EmDash-Request": "1" },
765+
body: "{}",
766+
}),
767+
storage,
768+
}),
769+
);
770+
const staleConfirmation = confirm();
771+
await storageChecked;
772+
773+
await putUpload(
774+
buildContext({
775+
db,
776+
id: pending.id,
777+
request: uploadRequest(pending.id, replacementBytes, "application/pdf"),
778+
storage,
779+
}),
780+
);
781+
finishStorageCheck();
782+
783+
expect((await staleConfirmation).status).toBe(409);
784+
expect(await repo.findById(pending.id)).toMatchObject({
785+
status: "pending",
786+
contentHash: await computeContentHash(replacementBytes),
787+
});
788+
789+
expect((await confirm()).status).toBe(200);
790+
expect(await repo.findById(pending.id)).toMatchObject({ status: "ready" });
791+
});
792+
646793
it("publishes only one object when two uploads race", async () => {
647794
const repo = new MediaRepository(db);
648795
const pending = await repo.createPending({

packages/core/tests/integration/database/media-upload-publish.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,32 @@ describeEachDialect("pending media upload publication", (dialect) => {
5555
expect((await repo.findById(pending.id))?.storageKey).toMatch(/^attempt-[ab]\.png$/);
5656
});
5757

58+
it("does not apply stale confirmation state after the storage key changes", async () => {
59+
const pending = await repo.createPending({
60+
filename: "photo.png",
61+
mimeType: "image/png",
62+
size: 3,
63+
storageKey: "pending.png",
64+
});
65+
await repo.createUploadAttempt(pending.id, "replacement.png");
66+
await repo.publishPendingStorageKey(
67+
pending.id,
68+
pending.storageKey,
69+
"replacement.png",
70+
"sha1:589c22335a381f122d129225f5c0ba3056ed5811",
71+
);
72+
73+
await expect(
74+
repo.confirmUpload(pending.id, { width: 100 }, pending.storageKey),
75+
).resolves.toBeNull();
76+
await expect(repo.markFailed(pending.id, pending.storageKey)).resolves.toBeNull();
77+
expect(await repo.findById(pending.id)).toMatchObject({
78+
status: "pending",
79+
storageKey: "replacement.png",
80+
width: null,
81+
});
82+
});
83+
5884
it("does not claim a fresh active attempt for cleanup", async () => {
5985
const pending = await repo.createPending({
6086
filename: "photo.png",

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ describe("OpenAPI document generation", () => {
3737
expect(paths).toContain("/_emdash/api/media/{id}/confirm");
3838
expect(paths).toContain("/_emdash/api/media/{id}/upload");
3939
expect(paths).toContain("/_emdash/api/admin/media-usage/repair");
40+
expect(doc.paths?.["/_emdash/api/media/{id}/confirm"]?.post?.responses).toHaveProperty("409");
4041
});
4142

4243
it("documents media usage summary opt-in parameters and read responses", () => {

0 commit comments

Comments
 (0)