Skip to content
Merged
7 changes: 7 additions & 0 deletions .changeset/stream-media-uploads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"emdash": patch
"@emdash-cms/admin": patch
---

Fixes media uploads with native R2 storage, keeps client-side hashing optional, and prevents deduplication from returning media with a different type or size.
Images larger than 8 MiB skip server-generated placeholders in signed and streamed upload flows.
4 changes: 2 additions & 2 deletions docs/src/content/docs/contributing/architecture.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -245,12 +245,12 @@ Portable Text fields edit in TipTap (ProseMirror). Content is converted at the l

### Signed uploads

Media uploads bypass Worker body-size limits with direct-to-storage signed URLs:
Media uploads use direct-to-storage signed URLs when the adapter supports them and a same-origin streaming endpoint otherwise:

<Steps>

1. The client requests an upload URL (`POST /api/media/upload-url`).
2. The client uploads directly to the signed URL (R2 or S3).
2. The client uploads to the returned target. S3-compatible adapters can return a signed URL that bypasses Worker body-size limits; native R2 bindings and local storage return an EmDash streaming endpoint.
3. The client confirms (`POST /api/media/:id/confirm`).
4. The server extracts metadata (dimensions, MIME type).

Expand Down
39 changes: 33 additions & 6 deletions packages/admin/src/lib/api/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,6 @@ import {
type FindManyResult,
} from "./client.js";

/**
* Maximum length of the media filename search term. Mirrors the server-side
* zod schema (`q: z.string().trim().min(1).max(200)`); keep in sync.
*/
export const MEDIA_SEARCH_MAX_LENGTH = 200;

/** Trim and clamp a search term to the server-accepted range. */
Expand Down Expand Up @@ -103,22 +99,47 @@ interface UploadUrlResponse {
expiresAt: string;
}

interface ExistingMediaResponse {
existing: true;
mediaId: string;
storageKey: string;
url: string;
}

const MAX_CLIENT_HASH_BYTES = 8 * 1024 * 1024;

async function computeContentHash(file: File): Promise<string | undefined> {
const subtle = globalThis.crypto?.subtle;
if (!subtle || file.size === 0 || file.size > MAX_CLIENT_HASH_BYTES) return undefined;
try {
const hash = await subtle.digest("SHA-1", await file.arrayBuffer());
const hex = Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, "0")).join(
"",
);
return `sha1:${hex}`;
} catch {
return undefined;
}
}

/**
* Try to get a signed upload URL
* Returns null if signed URLs are not supported (e.g., local storage)
*/
async function getUploadUrl(
file: File,
opts?: { fieldId?: string },
): Promise<UploadUrlResponse | null> {
): Promise<UploadUrlResponse | ExistingMediaResponse | null> {
try {
const contentHash = await computeContentHash(file);
const response = await apiFetch(`${API_BASE}/media/upload-url`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filename: file.name,
contentType: file.type,
size: file.size,
...(contentHash ? { contentHash } : {}),
...(opts?.fieldId ? { fieldId: opts.fieldId } : {}),
}),
});
Expand All @@ -128,7 +149,10 @@ async function getUploadUrl(
return null;
}

return parseApiResponse<UploadUrlResponse>(response, i18n._(msg`Failed to get upload URL`));
return parseApiResponse<UploadUrlResponse | ExistingMediaResponse>(
response,
i18n._(msg`Failed to get upload URL`),
);
} catch (error) {
// If the endpoint doesn't exist, fall back to direct upload
if (error instanceof TypeError && error.message.includes("fetch")) {
Expand Down Expand Up @@ -234,6 +258,9 @@ export async function uploadMedia(file: File, opts?: { fieldId?: string }): Prom
// Signed URLs not supported, use direct upload
return uploadMediaDirect(file, opts);
}
if ("existing" in uploadInfo) {
return fetchMediaItem(uploadInfo.mediaId);
}

// Upload directly to storage via signed URL
await uploadToSignedUrl(file, uploadInfo);
Expand Down
4 changes: 3 additions & 1 deletion packages/admin/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1341,7 +1341,9 @@ function MediaPage() {
isLoading={isLoading || isFetchingNextPage}
hasMore={!!hasNextPage}
onLoadMore={() => void fetchNextPage()}
onUpload={(file) => uploadMutation.mutate(file)}
onUpload={async (file) => {
await uploadMutation.mutateAsync(file);
}}
onLocalSearchChange={setSearch}
onLocalMimeFilterChange={setMimeFilter}
/>
Expand Down
198 changes: 198 additions & 0 deletions packages/admin/tests/lib/media-upload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { afterEach, expect, it, vi } from "vitest";

import { uploadMedia } from "../../src/lib/api/media.js";

afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});

it("deduplicates uploads using the file content hash", async () => {
let uploadUrlBody: Record<string, unknown> | undefined;
const fetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url === "/_emdash/api/media/upload-url") {
if (typeof init?.body !== "string") throw new TypeError("Expected a JSON request body");
uploadUrlBody = JSON.parse(init.body) as Record<string, unknown>;
return Response.json({
success: true,
data: {
existing: true,
mediaId: "existing-media",
storageKey: "existing.pdf",
url: "/_emdash/api/media/file/existing.pdf",
},
});
}
if (url === "/_emdash/api/media/existing-media") {
return Response.json({
success: true,
data: {
item: {
id: "existing-media",
filename: "existing.pdf",
mimeType: "application/pdf",
url: "/_emdash/api/media/file/existing.pdf",
storageKey: "existing.pdf",
size: 3,
createdAt: "2026-01-01T00:00:00.000Z",
},
},
});
}
return new Response(null, { status: 500 });
});
const file = new File([new Uint8Array([97, 98, 99])], "document.pdf", {
type: "application/pdf",
});

const item = await uploadMedia(file);

expect(uploadUrlBody?.contentHash).toBe("sha1:a9993e364706816aba3e25717850c26c9cd0d89d");
expect(item.id).toBe("existing-media");
expect(fetch).toHaveBeenCalledTimes(2);
});

it("uploads without deduplication when Web Crypto is unavailable", async () => {
vi.stubGlobal("crypto", {});
let uploadUrlBody: Record<string, unknown> | undefined;
vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url === "/_emdash/api/media/upload-url") {
if (typeof init?.body !== "string") throw new TypeError("Expected a JSON request body");
uploadUrlBody = JSON.parse(init.body) as Record<string, unknown>;
return Response.json({
success: true,
data: {
uploadUrl: "/_emdash/api/media/new-media/upload",
method: "PUT",
headers: { "Content-Type": "application/pdf" },
mediaId: "new-media",
storageKey: "new.pdf",
expiresAt: "2026-01-01T01:00:00.000Z",
},
});
}
if (url === "/_emdash/api/media/new-media/upload") {
return Response.json({ success: true, data: { uploaded: true, size: 3 } });
}
if (url === "/_emdash/api/media/new-media/confirm") {
return Response.json({
success: true,
data: {
item: {
id: "new-media",
filename: "new.pdf",
mimeType: "application/pdf",
url: "/_emdash/api/media/file/new.pdf",
storageKey: "new.pdf",
size: 3,
createdAt: "2026-01-01T00:00:00.000Z",
},
},
});
}
return new Response(null, { status: 500 });
});
const file = new File([new Uint8Array([1, 2, 3])], "new.pdf", {
type: "application/pdf",
});

const item = await uploadMedia(file);

expect(uploadUrlBody).not.toHaveProperty("contentHash");
expect(item.id).toBe("new-media");
});

it("uploads without deduplication when content hashing fails", async () => {
vi.stubGlobal("crypto", {
subtle: {
digest: vi.fn().mockRejectedValue(new Error("SHA-1 unavailable")),
},
});
let uploadUrlBody: Record<string, unknown> | undefined;
const fetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url === "/_emdash/api/media/upload-url") {
if (typeof init?.body !== "string") throw new TypeError("Expected a JSON request body");
uploadUrlBody = JSON.parse(init.body) as Record<string, unknown>;
return new Response(null, { status: 501 });
}
if (url === "/_emdash/api/media") {
return Response.json({
success: true,
data: {
item: {
id: "new-media",
filename: "new.pdf",
mimeType: "application/pdf",
url: "/_emdash/api/media/file/new.pdf",
storageKey: "new.pdf",
size: 3,
createdAt: "2026-01-01T00:00:00.000Z",
},
},
});
}
return new Response(null, { status: 500 });
});
const file = new File([new Uint8Array([1, 2, 3])], "new.pdf", {
type: "application/pdf",
});

const item = await uploadMedia(file);

expect(uploadUrlBody).not.toHaveProperty("contentHash");
expect(item.id).toBe("new-media");
expect(fetch).toHaveBeenCalledTimes(2);
});

it("does not deduplicate empty files by their shared hash", async () => {
let uploadUrlBody: Record<string, unknown> | undefined;
vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url === "/_emdash/api/media/upload-url") {
if (typeof init?.body !== "string") throw new TypeError("Expected a JSON request body");
uploadUrlBody = JSON.parse(init.body) as Record<string, unknown>;
return Response.json({
success: true,
data: {
uploadUrl: "/_emdash/api/media/empty-media/upload",
method: "PUT",
headers: { "Content-Type": "application/pdf" },
mediaId: "empty-media",
storageKey: "empty.pdf",
expiresAt: "2026-01-01T01:00:00.000Z",
},
});
}
if (url === "/_emdash/api/media/empty-media/upload") {
return Response.json({ success: true, data: { uploaded: true, size: 0 } });
}
if (url === "/_emdash/api/media/empty-media/confirm") {
return Response.json({
success: true,
data: {
item: {
id: "empty-media",
filename: "empty.pdf",
mimeType: "application/pdf",
url: "/_emdash/api/media/file/empty.pdf",
storageKey: "empty.pdf",
size: 0,
createdAt: "2026-01-01T00:00:00.000Z",
},
},
});
}
return new Response(null, { status: 500 });
});

await uploadMedia(new File([], "empty.pdf", { type: "application/pdf" }));

expect(uploadUrlBody).not.toHaveProperty("contentHash");
});
Loading
Loading