Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/media-signed-upload-pending-rows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": patch
---

Fixes the media table filling up with hidden, unusable `pending` records — one per upload attempt — when storage cannot create signed upload URLs, which is always the case for local storage and for R2 accessed through a Worker binding.
17 changes: 9 additions & 8 deletions packages/core/src/astro/routes/api/media/upload-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,15 @@ export const POST: APIRoute = async ({ request, locals }) => {
const ext = path.extname(body.filename) || "";
const storageKey = `${id}${ext}`;

// Get the signed upload URL before creating the pending row, so adapters
// that cannot pre-sign don't leave an orphaned `pending` record.
const signedUrl = await emdash.storage.getSignedUploadUrl({
key: storageKey,
contentType: body.contentType,
size: body.size,
expiresIn: 3600, // 1 hour
});

// Create pending media record with content hash
const mediaItem = await repo.createPending({
filename: body.filename,
Expand All @@ -113,14 +122,6 @@ export const POST: APIRoute = async ({ request, locals }) => {
authorId: user?.id,
});

// Get signed upload URL from storage
const signedUrl = await emdash.storage.getSignedUploadUrl({
key: storageKey,
contentType: body.contentType,
size: body.size,
expiresIn: 3600, // 1 hour
});

const response: UploadUrlResponse = {
uploadUrl: signedUrl.url,
method: signedUrl.method,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/** The signed-upload endpoint must not leave a pending media row behind when storage cannot pre-sign. */
import { Role } from "@emdash-cms/auth";
import type { APIContext } from "astro";
import type { Kysely } from "kysely";
import { afterEach, beforeEach, describe, expect, it } from "vitest";

import { POST as requestUploadUrl } from "../../../src/astro/routes/api/media/upload-url.js";
import type { DatabaseSchema } from "../../../src/database/types.js";
import { EmDashStorageError } from "../../../src/storage/types.js";
import type { Storage } from "../../../src/storage/types.js";
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";

/** Storage that behaves like local storage / an R2 binding: it cannot pre-sign. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] This comment still embeds PR-specific adapter names (local storage / an R2 binding). That context belongs in the PR description, not in a comment that future readers will see long after the adapter list changes. Trim it to a generic, one-sentence description of what the helper does.

Suggested change
/** Storage that behaves like local storage / an R2 binding: it cannot pre-sign. */
/** Storage adapter that does not support pre-signed upload URLs. */

function storageThatCannotPresign(): Storage {
return {
getSignedUploadUrl() {
throw new EmDashStorageError(
"Local storage does not support signed upload URLs. Upload files directly through the API.",
"NOT_SUPPORTED",
);
},
} as unknown as Storage;
}

function callRoute(db: Kysely<DatabaseSchema>, storage: Storage) {
const request = new Request("http://localhost:4321/_emdash/api/media/upload-url", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: "photo.png", contentType: "image/png", size: 1024 }),
});

return requestUploadUrl({
request,
url: new URL(request.url),
params: {},
locals: {
emdash: { db, storage, config: {} },
user: { id: "admin-1", role: Role.ADMIN },
},
// eslint-disable-next-line typescript/no-unsafe-type-assertion -- minimal stub for tests
} as unknown as APIContext);
}

async function countMedia(db: Kysely<DatabaseSchema>, status: string): Promise<number> {
const rows = await db.selectFrom("media").select("id").where("status", "=", status).execute();
return rows.length;
}

describe("POST /_emdash/api/media/upload-url with storage that cannot pre-sign", () => {
let db: Kysely<DatabaseSchema>;

beforeEach(async () => {
db = await setupTestDatabase();
});

afterEach(async () => {
await teardownTestDatabase(db);
});

it("answers 501 NOT_SUPPORTED so the client falls back to direct upload", async () => {
const response = await callRoute(db, storageThatCannotPresign());

expect(response.status).toBe(501);
const body = (await response.json()) as { error: { code: string } };
expect(body.error.code).toBe("NOT_SUPPORTED");
});

it("does not create a pending media row", async () => {
await callRoute(db, storageThatCannotPresign());

expect(await countMedia(db, "pending")).toBe(0);
});

it("does not accumulate rows across repeated attempts", async () => {
for (let i = 0; i < 5; i++) {
// oxlint-disable-next-line no-await-in-loop -- sequential on purpose: the point is that repeats don't accumulate
await callRoute(db, storageThatCannotPresign());
}

const all = await db.selectFrom("media").select("id").execute();
expect(all).toHaveLength(0);
});
});
Loading