diff --git a/.changeset/images-binding-direct-upload.md b/.changeset/images-binding-direct-upload.md new file mode 100644 index 00000000000..46d4281e110 --- /dev/null +++ b/.changeset/images-binding-direct-upload.md @@ -0,0 +1,5 @@ +--- +"miniflare": minor +--- + +Support `env.IMAGES.hosted.createDirectUpload()` in local development. Creates a draft image and returns an `uploadURL` served by a new local endpoint that accepts the completed upload as `multipart/form-data` (field name `file`). Matches production's validation (`expiresIn` bounds of 120–21600 seconds, rejecting UUID custom IDs) and single-use/expiry semantics: completing an unknown or already-used upload link returns 404/409, and an expired link returns 410. diff --git a/.changeset/images-binding-metadata-filter.md b/.changeset/images-binding-metadata-filter.md new file mode 100644 index 00000000000..2d78f9c6f87 --- /dev/null +++ b/.changeset/images-binding-metadata-filter.md @@ -0,0 +1,5 @@ +--- +"miniflare": minor +--- + +Support the `filter.metadata` option on `env.IMAGES.hosted.list()` in local development, matching the metadata filtering behaviour of the production Images binding. Filters support the `eq` (implicit for bare values), `in`, `gt`, `gte`, `lt`, and `lte` operators, dot-notation nested field paths, and AND logic across multiple fields. diff --git a/.changeset/images-binding-signed-url.md b/.changeset/images-binding-signed-url.md new file mode 100644 index 00000000000..c49ea407fdb --- /dev/null +++ b/.changeset/images-binding-signed-url.md @@ -0,0 +1,5 @@ +--- +"miniflare": minor +--- + +Support `env.IMAGES.hosted.image(id).signedUrl()` in local development. A fixed local-dev signing secret is used to generate and verify signed delivery URLs, so images uploaded with `requireSignedURLs: true` can only be fetched from the local image delivery endpoint with a valid, unexpired signature — matching the production Images binding's signed URL behaviour end-to-end. diff --git a/packages/miniflare/src/workers/core/constants.ts b/packages/miniflare/src/workers/core/constants.ts index b19aec075e8..7187f7a34de 100644 --- a/packages/miniflare/src/workers/core/constants.ts +++ b/packages/miniflare/src/workers/core/constants.ts @@ -18,6 +18,8 @@ export const CorePaths = { STREAM_VIDEO: "/__cf_local/stream", /** Local image delivery endpoint (outside /cdn-cgi/ for tunnel access) */ IMAGE_DELIVERY: "/__cf_local/imagedelivery", + /** Local Direct Creator Upload completion endpoint (outside /cdn-cgi/ for tunnel access) */ + IMAGE_UPLOAD: "/__cf_local/imageupload", /** Public R2 bucket object serving endpoint */ R2_PUBLIC: "/cdn-cgi/local/r2/public", /** S3-compatible API endpoint for local R2 buckets */ diff --git a/packages/miniflare/src/workers/core/entry.worker.ts b/packages/miniflare/src/workers/core/entry.worker.ts index 3ae53589f9e..e207834df56 100644 --- a/packages/miniflare/src/workers/core/entry.worker.ts +++ b/packages/miniflare/src/workers/core/entry.worker.ts @@ -545,7 +545,9 @@ export default >{ const imagesDelivery = env[CoreBindings.SERVICE_IMAGES_DELIVERY]; if ( (url.pathname === CorePaths.IMAGE_DELIVERY || - url.pathname.startsWith(`${CorePaths.IMAGE_DELIVERY}/`)) && + url.pathname.startsWith(`${CorePaths.IMAGE_DELIVERY}/`) || + url.pathname === CorePaths.IMAGE_UPLOAD || + url.pathname.startsWith(`${CorePaths.IMAGE_UPLOAD}/`)) && imagesDelivery ) { return await imagesDelivery.fetch(request); diff --git a/packages/miniflare/src/workers/images/images.worker.ts b/packages/miniflare/src/workers/images/images.worker.ts index 5715b2af34e..5ea96de084c 100644 --- a/packages/miniflare/src/workers/images/images.worker.ts +++ b/packages/miniflare/src/workers/images/images.worker.ts @@ -22,6 +22,14 @@ function buildVariantUrl( ).toString(); } +function buildUploadUrl(publicUrl: URL, imageId: string): string { + return new URL(`${CorePaths.IMAGE_UPLOAD}/${imageId}`, publicUrl).toString(); +} + +function draftExpiryKey(imageId: string): string { + return `${imageId}:direct-upload-expiry`; +} + // Rewrites stored variant names (e.g. `["public"]`) to absolute URLs. async function withResolvedVariants( metadata: ImageMetadata, @@ -55,6 +63,160 @@ async function base64DecodeStream( return base64DecodeArrayBuffer(buffer); } +function resolveMetaPath(obj: unknown, path: string): unknown { + return path + .split(".") + .reduce( + (acc, key) => + acc && typeof acc === "object" + ? (acc as Record)[key] + : undefined, + obj + ); +} + +function matchesCondition( + actual: unknown, + condition: ImageMetadataFilterValue +): boolean { + if ( + condition === null || + typeof condition !== "object" || + Array.isArray(condition) + ) { + return actual === condition; + } + + return Object.entries(condition).every(([op, expected]) => { + switch (op) { + case "eq": + return actual === expected; + case "in": + return ( + Array.isArray(expected) && + expected.some((candidate) => candidate === actual) + ); + case "gt": + return typeof actual === "number" && actual > (expected as number); + case "gte": + return typeof actual === "number" && actual >= (expected as number); + case "lt": + return typeof actual === "number" && actual < (expected as number); + case "lte": + return typeof actual === "number" && actual <= (expected as number); + default: + return false; + } + }); +} + +// AND logic across fields, matching images-core/images-edge-api behaviour. +function matchesMetadataFilters( + image: ImageMetadata, + filters: Record | undefined +): boolean { + if (!filters) { + return true; + } + + return Object.entries(filters).every(([field, condition]) => + matchesCondition(resolveMetaPath(image.meta ?? {}, field), condition) + ); +} + +// No real account signing key store exists in local dev, so this single +// secret stands in for every account and `keyName`; it has no security value. +const LOCAL_SIGNING_SECRET = "miniflare-local-dev-images-signing-key"; + +async function hmacSha256Hex(secret: string, value: string): Promise { + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + const signature = await crypto.subtle.sign( + "HMAC", + key, + encoder.encode(value) + ); + return Array.from(new Uint8Array(signature)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +function assertVariantName(variant: string): void { + if (variant === "") { + throw new Error("variant is required"); + } + if (/[/?#%]/.test(variant)) { + throw new Error("variant contains invalid URL path characters"); + } +} + +function resolveExpiresAt(expiresIn: number | undefined): number | undefined { + if (expiresIn === undefined) { + return undefined; + } + if (!Number.isInteger(expiresIn) || expiresIn <= 0) { + throw new Error("expiresIn must be a positive integer"); + } + return Math.floor(Date.now() / 1000) + expiresIn; +} + +// Returns `null` when valid, or an error message otherwise. +async function verifySignedRequest(url: URL): Promise { + const sig = url.searchParams.get("sig"); + if (!sig) { + return "Missing signature"; + } + + const exp = url.searchParams.get("exp"); + if (exp !== null) { + const expiresAt = Number.parseInt(exp, 10); + if (Number.isNaN(expiresAt) || expiresAt < Date.now() / 1000) { + return "Signature expired"; + } + } + + const unsignedUrl = new URL(url); + unsignedUrl.searchParams.delete("sig"); + const expectedSig = await hmacSha256Hex( + LOCAL_SIGNING_SECRET, + `${unsignedUrl.pathname}${unsignedUrl.search}` + ); + if (sig !== expectedSig) { + return "Invalid signature"; + } + + return null; +} + +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const MIN_DIRECT_UPLOAD_EXPIRES_IN = 120; +const MAX_DIRECT_UPLOAD_EXPIRES_IN = 21600; +const DEFAULT_DIRECT_UPLOAD_EXPIRES_IN = 1800; + +function assertCustomIdNotUuid(id: string): void { + if (UUID_PATTERN.test(id)) { + throw new Error("CustomID must not be UUID"); + } +} + +function resolveDirectUploadExpiresAt(expiresIn: number | undefined): number { + const duration = expiresIn ?? DEFAULT_DIRECT_UPLOAD_EXPIRES_IN; + if ( + duration <= MIN_DIRECT_UPLOAD_EXPIRES_IN || + duration >= MAX_DIRECT_UPLOAD_EXPIRES_IN + ) { + throw new Error("expiry is out of accepted bound."); + } + return Math.floor(Date.now() / 1000) + duration; +} + class ImageHandleImpl extends RpcTarget { readonly #imageId: string; readonly #env: Env; @@ -84,6 +246,28 @@ class ImageHandleImpl extends RpcTarget { return new Blob([data]).stream(); } + async signedUrl(options: ImageSignedUrlOptions): Promise { + assertVariantName(options.variant); + + const publicUrl = await getPublicUrl( + this.#env[CoreBindings.SERVICE_LOOPBACK] + ); + const expiresAt = resolveExpiresAt(options.expiresIn); + const url = new URL( + buildVariantUrl(publicUrl, this.#imageId, options.variant) + ); + if (expiresAt !== undefined) { + url.searchParams.set("exp", String(expiresAt)); + } + + const signature = await hmacSha256Hex( + LOCAL_SIGNING_SECRET, + `${url.pathname}${url.search}` + ); + url.searchParams.set("sig", signature); + return url.toString(); + } + async update(options: ImageUpdateOptions): Promise { const existing = await this.#env.IMAGES_STORE.getWithMetadata( @@ -160,6 +344,38 @@ export default class ImagesService extends WorkerEntrypoint { return withResolvedVariants(metadata, this.env); } + async createDirectUpload( + options?: ImageDirectUploadOptions + ): Promise { + if (options?.id !== undefined) { + assertCustomIdNotUuid(options.id); + if (options.requireSignedURLs) { + throw new Error("Private custom ID is not supported"); + } + } + + const expiresAt = resolveDirectUploadExpiresAt(options?.expiresIn); + const id = options?.id ?? crypto.randomUUID(); + + const metadata: ImageMetadata = { + id, + uploaded: new Date().toISOString(), + requireSignedURLs: options?.requireSignedURLs ?? false, + meta: options?.metadata ?? {}, + variants: ["public"], + draft: true, + creator: options?.creator, + }; + + await this.env.IMAGES_STORE.put(id, new ArrayBuffer(0), { metadata }); + await this.env.IMAGES_STORE.put(draftExpiryKey(id), String(expiresAt)); + + const publicUrl = await getPublicUrl( + this.env[CoreBindings.SERVICE_LOOPBACK] + ); + return { id, uploadURL: buildUploadUrl(publicUrl, id) }; + } + async list(options?: ImageListOptions): Promise { const limit = options?.limit ?? 50; @@ -186,6 +402,15 @@ export default class ImagesService extends WorkerEntrypoint { ); } + if (options?.filter?.metadata) { + const metadataFilter = options.filter.metadata; + allImages.splice( + 0, + allImages.length, + ...allImages.filter((i) => matchesMetadataFilters(i, metadataFilter)) + ); + } + allImages.sort((a, b) => { const dateA = a.uploaded ?? ""; const dateB = b.uploaded ?? ""; @@ -247,6 +472,53 @@ export default class ImagesService extends WorkerEntrypoint { return "application/octet-stream"; } + async #completeDirectUpload(request: Request, url: URL): Promise { + if (request.method !== "POST") { + return new Response("Method not allowed", { status: 405 }); + } + + const imageId = url.pathname.slice(CorePaths.IMAGE_UPLOAD.length + 1); + if (!imageId) { + return new Response("Missing image ID", { status: 400 }); + } + + const existing = await this.env.IMAGES_STORE.getWithMetadata( + imageId, + "arrayBuffer" + ); + if (existing.metadata === null) { + return new Response("Upload link not found", { status: 404 }); + } + if (!existing.metadata.draft) { + return new Response("Upload link already used", { status: 409 }); + } + + const expiresAt = await this.env.IMAGES_STORE.get(draftExpiryKey(imageId)); + if (expiresAt === null || Number(expiresAt) < Date.now() / 1000) { + return new Response("Upload link expired", { status: 410 }); + } + + const formData = await request.formData(); + const file = formData.get("file"); + if (!(file instanceof Blob)) { + return new Response("Missing file", { status: 400 }); + } + const buffer = await file.arrayBuffer(); + + const completedMetadata: ImageMetadata = { + ...existing.metadata, + filename: file instanceof File ? file.name : existing.metadata.filename, + draft: false, + }; + + await this.env.IMAGES_STORE.put(imageId, buffer, { + metadata: completedMetadata, + }); + await this.env.IMAGES_STORE.delete(draftExpiryKey(imageId)); + + return Response.json({ id: imageId, success: true }); + } + // Handle HTTP requests for image delivery and transform operations async fetch(request: Request): Promise { const url = new URL(request.url); @@ -261,17 +533,32 @@ export default class ImagesService extends WorkerEntrypoint { return new Response("Missing image ID", { status: 400 }); } - const data = await this.env.IMAGES_STORE.get(imageId, "arrayBuffer"); - if (data === null) { + const { value: data, metadata } = + await this.env.IMAGES_STORE.getWithMetadata( + imageId, + "arrayBuffer" + ); + if (data === null || metadata === null) { return new Response("Image not found", { status: 404 }); } + if (metadata.requireSignedURLs) { + const verifyError = await verifySignedRequest(url); + if (verifyError !== null) { + return new Response(verifyError, { status: 401 }); + } + } + const contentType = await this.#detectContentType(data); return new Response(data, { headers: { "Content-Type": contentType }, }); } + if (url.pathname.startsWith(`${CorePaths.IMAGE_UPLOAD}/`)) { + return this.#completeDirectUpload(request, url); + } + // Forward transform/info operations to Node.js via loopback where Sharp runs const forwardRequest = new Request(request); forwardRequest.headers.set( diff --git a/packages/miniflare/test/plugins/images/index.spec.ts b/packages/miniflare/test/plugins/images/index.spec.ts index 415f836451b..9e4d7a18a3d 100644 --- a/packages/miniflare/test/plugins/images/index.spec.ts +++ b/packages/miniflare/test/plugins/images/index.spec.ts @@ -42,6 +42,10 @@ async function handleCommand(images, op, args) { return hosted.image(args.id).update(args.options); case "delete": return hosted.image(args.id).delete(); + case "signedUrl": + return hosted.image(args.id).signedUrl(args.options); + case "createDirectUpload": + return hosted.createDirectUpload(args.options); case "list": return hosted.list(args.options); default: @@ -269,6 +273,123 @@ describe("Images hosted CRUD", () => { expect(list.images[0].id).toBe("img2"); }); + test("list images filtered by metadata", async ({ expect }) => { + const mf = createMiniflare(); + useDispose(mf); + + await upload(mf, TEST_IMAGE_BYTES, { + id: "meta-1", + metadata: { status: "active", priority: 1, config: { region: "eu" } }, + }); + await upload(mf, TEST_IMAGE_BYTES, { + id: "meta-2", + metadata: { status: "archived", priority: 5, config: { region: "us" } }, + }); + + const list = await sendCmd(mf, "list", { + options: { filter: { metadata: { status: "active" } } }, + }); + expect(list.images).toHaveLength(1); + expect(list.images[0].id).toBe("meta-1"); + }); + + test("list images filtered by metadata with range operators", async ({ + expect, + }) => { + const mf = createMiniflare(); + useDispose(mf); + + await upload(mf, TEST_IMAGE_BYTES, { + id: "range-1", + metadata: { priority: 1 }, + }); + await upload(mf, TEST_IMAGE_BYTES, { + id: "range-2", + metadata: { priority: 5 }, + }); + await upload(mf, TEST_IMAGE_BYTES, { + id: "range-3", + metadata: { priority: 9 }, + }); + + const list = await sendCmd(mf, "list", { + options: { filter: { metadata: { priority: { gte: 2, lte: 8 } } } }, + }); + expect(list.images).toHaveLength(1); + expect(list.images[0].id).toBe("range-2"); + }); + + test("list images filtered by metadata with in operator", async ({ + expect, + }) => { + const mf = createMiniflare(); + useDispose(mf); + + await upload(mf, TEST_IMAGE_BYTES, { + id: "in-1", + metadata: { region: "us-east" }, + }); + await upload(mf, TEST_IMAGE_BYTES, { + id: "in-2", + metadata: { region: "eu-west" }, + }); + await upload(mf, TEST_IMAGE_BYTES, { + id: "in-3", + metadata: { region: "ap-south" }, + }); + + const list = await sendCmd(mf, "list", { + options: { + filter: { metadata: { region: { in: ["us-east", "eu-west"] } } }, + }, + }); + expect(list.images.map((i) => i.id).sort()).toEqual(["in-1", "in-2"]); + }); + + test("list images filtered by nested metadata field", async ({ expect }) => { + const mf = createMiniflare(); + useDispose(mf); + + await upload(mf, TEST_IMAGE_BYTES, { + id: "nested-1", + metadata: { config: { region: "eu-west" } }, + }); + await upload(mf, TEST_IMAGE_BYTES, { + id: "nested-2", + metadata: { config: { region: "us-east" } }, + }); + + const list = await sendCmd(mf, "list", { + options: { filter: { metadata: { "config.region": "eu-west" } } }, + }); + expect(list.images).toHaveLength(1); + expect(list.images[0].id).toBe("nested-1"); + }); + + test("list images filtered by multiple metadata fields (AND logic)", async ({ + expect, + }) => { + const mf = createMiniflare(); + useDispose(mf); + + await upload(mf, TEST_IMAGE_BYTES, { + id: "and-1", + metadata: { status: "active", priority: 5 }, + }); + await upload(mf, TEST_IMAGE_BYTES, { + id: "and-2", + metadata: { status: "active", priority: 1 }, + }); + + const list = await sendCmd(mf, "list", { + options: { + filter: { metadata: { status: "active", priority: { gte: 3 } } }, + }, + }); + expect(list.images).toHaveLength(1); + expect(list.images[0].id).toBe("and-1"); + }); + test("list images with cursor pagination", async ({ expect }) => { const mf = createMiniflare(); useDispose(mf); @@ -305,3 +426,346 @@ describe("Images hosted CRUD", () => { expect(new Set(allIds).size).toBe(5); }); }); + +describe("Images signed URLs", () => { + test("signed URL includes a signature and requested variant", async ({ + expect, + }) => { + const mf = createMiniflare(); + useDispose(mf); + + await upload(mf, TEST_IMAGE_BYTES, { + id: "signed-1", + requireSignedURLs: true, + }); + + const signedUrl = await sendCmd(mf, "signedUrl", { + id: "signed-1", + options: { variant: "public" }, + }); + + const url = new URL(signedUrl); + expect(url.pathname).toBe("/__cf_local/imagedelivery/signed-1/public"); + expect(url.searchParams.get("sig")).toMatch(/^[0-9a-f]{64}$/); + expect(url.searchParams.get("exp")).toBeNull(); + }); + + test("signed URL includes an exp param when expiresIn is provided", async ({ + expect, + }) => { + const mf = createMiniflare(); + useDispose(mf); + + await upload(mf, TEST_IMAGE_BYTES, { + id: "signed-2", + requireSignedURLs: true, + }); + + const before = Math.floor(Date.now() / 1000); + const signedUrl = await sendCmd(mf, "signedUrl", { + id: "signed-2", + options: { variant: "public", expiresIn: 60 }, + }); + const url = new URL(signedUrl); + const exp = Number(url.searchParams.get("exp")); + expect(exp).toBeGreaterThanOrEqual(before + 60); + expect(exp).toBeLessThanOrEqual(before + 61); + }); + + test("rejects a variant containing invalid URL path characters", async ({ + expect, + }) => { + const mf = createMiniflare(); + useDispose(mf); + + await upload(mf, TEST_IMAGE_BYTES, { + id: "signed-3", + requireSignedURLs: true, + }); + + await expect( + sendCmd(mf, "signedUrl", { + id: "signed-3", + options: { variant: "public?evil=1" }, + }) + ).rejects.toThrow(); + }); + + test("rejects a non-positive-integer expiresIn", async ({ expect }) => { + const mf = createMiniflare(); + useDispose(mf); + + await upload(mf, TEST_IMAGE_BYTES, { + id: "signed-4", + requireSignedURLs: true, + }); + + await expect( + sendCmd(mf, "signedUrl", { + id: "signed-4", + options: { variant: "public", expiresIn: 0 }, + }) + ).rejects.toThrow(); + }); + + test("a signed URL can be used to fetch a private image", async ({ + expect, + }) => { + const mf = createMiniflare(); + useDispose(mf); + + await upload(mf, TEST_IMAGE_BYTES, { + id: "signed-fetch-1", + requireSignedURLs: true, + }); + + const signedUrl = await sendCmd(mf, "signedUrl", { + id: "signed-fetch-1", + options: { variant: "public" }, + }); + + const response = await mf.dispatchFetch(signedUrl); + expect(response.status).toBe(200); + const data = new Uint8Array(await response.arrayBuffer()); + expect(data).toEqual(TEST_IMAGE_BYTES); + }); + + test("fetching a private image without a signature is rejected", async ({ + expect, + }) => { + const mf = createMiniflare(); + useDispose(mf); + const url = await mf.ready; + + await upload(mf, TEST_IMAGE_BYTES, { + id: "signed-fetch-2", + requireSignedURLs: true, + }); + + const response = await mf.dispatchFetch( + `${url.origin}/__cf_local/imagedelivery/signed-fetch-2/public` + ); + expect(response.status).toBe(401); + await response.arrayBuffer(); + }); + + test("fetching a private image with a tampered signature is rejected", async ({ + expect, + }) => { + const mf = createMiniflare(); + useDispose(mf); + + await upload(mf, TEST_IMAGE_BYTES, { + id: "signed-fetch-3", + requireSignedURLs: true, + }); + + const signedUrl = await sendCmd(mf, "signedUrl", { + id: "signed-fetch-3", + options: { variant: "public" }, + }); + const url = new URL(signedUrl); + url.searchParams.set("sig", "0".repeat(64)); + + const response = await mf.dispatchFetch(url.toString()); + expect(response.status).toBe(401); + await response.arrayBuffer(); + }); + + test("fetching a private image with an expired signature is rejected", async ({ + expect, + }) => { + const mf = createMiniflare(); + useDispose(mf); + + await upload(mf, TEST_IMAGE_BYTES, { + id: "signed-fetch-4", + requireSignedURLs: true, + }); + + const signedUrl = await sendCmd(mf, "signedUrl", { + id: "signed-fetch-4", + options: { variant: "public", expiresIn: 60 }, + }); + // Manually forge an expired timestamp; the sig will no longer match + // but the expiry check should reject the request before that anyway. + const url = new URL(signedUrl); + url.searchParams.set("exp", String(Math.floor(Date.now() / 1000) - 60)); + + const response = await mf.dispatchFetch(url.toString()); + expect(response.status).toBe(401); + await response.arrayBuffer(); + }); + + test("fetching a public image never requires a signature", async ({ + expect, + }) => { + const mf = createMiniflare(); + useDispose(mf); + const url = await mf.ready; + + await upload(mf, TEST_IMAGE_BYTES, { id: "public-fetch-1" }); + + const response = await mf.dispatchFetch( + `${url.origin}/__cf_local/imagedelivery/public-fetch-1/public` + ); + expect(response.status).toBe(200); + await response.arrayBuffer(); + }); +}); + +// `dispatchFetch()` doesn't preserve the auto-generated multipart boundary +// when given a `FormData` body directly, so the body is built manually here +// with an explicit `Content-Type` header instead. +function completeDirectUpload( + mf: Miniflare, + uploadURL: string, + bytes: Uint8Array, + filename = "upload.jpg" +): Promise { + const boundary = "----MiniflareDirectUploadTestBoundary"; + const encoder = new TextEncoder(); + const head = encoder.encode( + `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: application/octet-stream\r\n\r\n` + ); + const tail = encoder.encode(`\r\n--${boundary}--\r\n`); + const body = new Uint8Array(head.length + bytes.length + tail.length); + body.set(head, 0); + body.set(bytes, head.length); + body.set(tail, head.length + bytes.length); + + return mf.dispatchFetch(uploadURL, { + method: "POST", + headers: { "Content-Type": `multipart/form-data; boundary=${boundary}` }, + body, + }); +} + +describe("Images direct upload", () => { + test("createDirectUpload returns an id and upload URL", async ({ + expect, + }) => { + const mf = createMiniflare(); + useDispose(mf); + const url = await mf.ready; + + const result = await sendCmd<{ id: string; uploadURL: string }>( + mf, + "createDirectUpload" + ); + expect(result.id).toBeTruthy(); + expect(result.uploadURL).toBe( + `${url.origin}/__cf_local/imageupload/${result.id}` + ); + }); + + test("createDirectUpload rejects a custom id that is a UUID", async ({ + expect, + }) => { + const mf = createMiniflare(); + useDispose(mf); + + await expect( + sendCmd(mf, "createDirectUpload", { + options: { id: "3ce3b103-2ac0-4836-954f-937a2f04ccbe" }, + }) + ).rejects.toThrow(); + }); + + test("createDirectUpload rejects requireSignedURLs with a custom id", async ({ + expect, + }) => { + const mf = createMiniflare(); + useDispose(mf); + + await expect( + sendCmd(mf, "createDirectUpload", { + options: { id: "custom-id", requireSignedURLs: true }, + }) + ).rejects.toThrow(); + }); + + test("createDirectUpload rejects an expiresIn outside the accepted bounds", async ({ + expect, + }) => { + const mf = createMiniflare(); + useDispose(mf); + + await expect( + sendCmd(mf, "createDirectUpload", { options: { expiresIn: 60 } }) + ).rejects.toThrow(); + await expect( + sendCmd(mf, "createDirectUpload", { options: { expiresIn: 21601 } }) + ).rejects.toThrow(); + }); + + test("a completed direct upload is retrievable and no longer a draft", async ({ + expect, + }) => { + const mf = createMiniflare(); + useDispose(mf); + + const { id, uploadURL } = await sendCmd<{ + id: string; + uploadURL: string; + }>(mf, "createDirectUpload", { + options: { metadata: { source: "direct-upload" } }, + }); + + const beforeUpload = await sendCmd(mf, "details", { + id, + }); + expect(beforeUpload?.draft).toBe(true); + + const response = await completeDirectUpload( + mf, + uploadURL, + TEST_IMAGE_BYTES + ); + expect(response.status).toBe(200); + await response.arrayBuffer(); + + const afterUpload = await sendCmd(mf, "details", { + id, + }); + expect(afterUpload?.draft).toBe(false); + expect(afterUpload?.meta).toEqual({ source: "direct-upload" }); + + const data = await sendCmd(mf, "bytes", { id }); + expect(new Uint8Array(data)).toEqual(TEST_IMAGE_BYTES); + }); + + test("completing an unknown upload link returns 404", async ({ expect }) => { + const mf = createMiniflare(); + useDispose(mf); + const url = await mf.ready; + + const response = await completeDirectUpload( + mf, + `${url.origin}/__cf_local/imageupload/does-not-exist`, + TEST_IMAGE_BYTES + ); + expect(response.status).toBe(404); + await response.arrayBuffer(); + }); + + test("completing an already-used upload link returns 409", async ({ + expect, + }) => { + const mf = createMiniflare(); + useDispose(mf); + + const { uploadURL } = await sendCmd<{ id: string; uploadURL: string }>( + mf, + "createDirectUpload" + ); + + const first = await completeDirectUpload(mf, uploadURL, TEST_IMAGE_BYTES); + expect(first.status).toBe(200); + await first.arrayBuffer(); + + const second = await completeDirectUpload(mf, uploadURL, TEST_IMAGE_BYTES); + expect(second.status).toBe(409); + await second.arrayBuffer(); + }); +});