From 518a5380d9644b2e71d7e6c95b2f2953fcefed92 Mon Sep 17 00:00:00 2001 From: imeepos Date: Thu, 2 Jul 2026 19:36:47 -0700 Subject: [PATCH] Add public image optimization endpoint --- src/routes.ts | 213 +++++++++++++++++++++++++++++++- tests/attachment-worker.test.ts | 138 ++++++++++++++++++++- 2 files changed, 347 insertions(+), 4 deletions(-) diff --git a/src/routes.ts b/src/routes.ts index 443c203..0ea455b 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -1,4 +1,5 @@ import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi"; +import type { Context } from "hono"; import { resolveCurrentUser, type CurrentUser } from "./auth-session"; import type { Env } from "./env"; import { problemResponse, validationProblemHook } from "./problem"; @@ -45,6 +46,14 @@ import { type AppEnv = { Bindings: Env; Variables: { user: CurrentUser } }; +const OPENAPI_TAGS = [ + { name: "Attachments", description: "Authenticated attachment metadata, content upload, lifecycle, search, and download APIs." }, + { name: "Categories", description: "Authenticated user-scoped attachment category management APIs." }, + { name: "Tags", description: "Authenticated user-scoped attachment tag management APIs." }, + { name: "Public files", description: "Public file-serving APIs for attachments marked public or shared." }, + { name: "Images", description: "Public remote image optimization proxy APIs." }, +]; + function errRes(description: string) { return { description, content: { "application/json": { schema: ProblemSchema } } }; } @@ -60,6 +69,46 @@ const SearchQuery = z.object({ status: z.string().optional(), }); +const IMAGE_PATH_EXT = /\.(jpe?g|png|gif|webp)$/i; +const IMAGE_FIT_OPTIONS = ["scale-down", "scale-up", "contain", "cover", "crop", "pad", "squeeze"] as const; +const IMAGE_FORMAT_OPTIONS = ["avif", "webp", "jpeg", "jpg", "png", "gif", "auto"] as const; +const IMAGE_FIT_VALUES = new Set(IMAGE_FIT_OPTIONS); +const ImageQuery = z + .object({ + url: z.string().optional().openapi({ + description: "Remote image URL. Only HTTP(S) JPEG, PNG, GIF, and WebP URLs are accepted.", + param: { required: true }, + }), + width: z.string().optional().openapi({ type: "integer", minimum: 1, description: "Target image width in pixels." }), + height: z.string().optional().openapi({ type: "integer", minimum: 1, description: "Target image height in pixels." }), + quality: z.string().optional().openapi({ type: "integer", minimum: 1, maximum: 100, description: "Output quality from 1 to 100." }), + format: z.string().optional().openapi({ + enum: [...IMAGE_FORMAT_OPTIONS], + description: "Requested output format. Use auto to negotiate AVIF/WebP from the request Accept header.", + }), + fit: z.string().optional().openapi({ + enum: [...IMAGE_FIT_OPTIONS], + description: "Resize mode used by Cloudflare image transformations.", + }), + bg: z.string().optional().openapi({ description: "Background color used for transparent images, for example white or #ffffff." }), + }) + .openapi("ImageQuery"); + +function parsePositiveIntParam(value: string | undefined, label: string): { value?: number; error?: string } { + if (value === undefined) { + return {}; + } + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + return { error: `Invalid ${label}. Must be a positive integer` }; + } + return { value: parsed }; +} + +function imageError(c: Context, error: string) { + return c.json({ error }, 400); +} + function pagination(page: number | undefined, pageSize: number | undefined) { const p = Math.max(1, page ?? 1); const ps = Math.min(100, Math.max(1, pageSize ?? 20)); @@ -71,7 +120,7 @@ export function createRoutes(): OpenAPIHono { app.onError((error) => problemResponse(error)); - // /api/* 先解析登录用户(401 先于 zod 422);/healthz、/openapi.json、/files 保持公开 + // /api/* 先解析登录用户(401 先于 zod 422);/healthz、/openapi.json、/files、/image 保持公开 app.use("/api/*", async (c, next) => { const user = await resolveCurrentUser(c.req.raw, c.env); c.set("user", user); @@ -80,10 +129,114 @@ export function createRoutes(): OpenAPIHono { app.get("/healthz", (c) => c.json({ ok: true, service: "cfw-attachment" })); + app.openapi( + createRoute({ + method: "get", + path: "/image", + tags: ["Images"], + summary: "Optimize a remote image", + description: "Public proxy that applies Cloudflare image transformation options to a remote JPEG, PNG, GIF, or WebP URL.", + request: { query: ImageQuery }, + responses: { + 200: { description: "Optimized image bytes" }, + 400: { description: "Invalid image proxy request" }, + 500: { description: "Image processing failed" }, + }, + }), + async (c) => { + try { + const { url, width, height, quality, format, fit, bg } = c.req.query(); + + if (!url) { + return imageError(c, "Missing required parameter: url"); + } + + let imageUrl: URL; + try { + imageUrl = new URL(url); + } catch { + return imageError(c, "Invalid image URL"); + } + + if (imageUrl.protocol !== "http:" && imageUrl.protocol !== "https:") { + return imageError(c, "Invalid image URL protocol. Only HTTP and HTTPS are supported"); + } + + if (!IMAGE_PATH_EXT.test(imageUrl.pathname)) { + return imageError(c, "Unsupported image format. Only JPEG, PNG, GIF, WebP are supported"); + } + + const cfImage: RequestInitCfPropertiesImage = {}; + const numericParams = [ + ["width", width], + ["height", height], + ["quality", quality], + ] as const; + + for (const [label, value] of numericParams) { + const parsed = parsePositiveIntParam(value, label); + if (parsed.error) { + return imageError(c, parsed.error); + } + if (parsed.value !== undefined) { + cfImage[label] = parsed.value; + } + } + + if (fit) { + if (!IMAGE_FIT_VALUES.has(fit)) { + return imageError(c, "Invalid fit. Supported values: scale-down, scale-up, contain, cover, crop, pad, squeeze"); + } + cfImage.fit = fit as RequestInitCfPropertiesImage["fit"]; + } + if (bg) cfImage.background = bg; + + if (format === "avif") { + cfImage.format = "avif"; + } else if (format === "webp") { + cfImage.format = "webp"; + } else if (format === "jpeg" || format === "jpg") { + cfImage.format = "jpeg"; + } else if (format === "png") { + cfImage.format = "png"; + } else if (format === "auto") { + const accept = c.req.header("Accept"); + if (accept) { + if (/image\/avif/.test(accept)) { + cfImage.format = "avif"; + } else if (/image\/webp/.test(accept)) { + cfImage.format = "webp"; + } + } + } else if (format && format !== "gif") { + return imageError(c, "Unsupported output format. Supported values: avif, webp, jpeg, jpg, png, gif, auto"); + } + + const options: RequestInit = { + headers: { + Accept: "image/avif, image/webp, image/*", + }, + }; + + if (Object.keys(cfImage).length > 0) { + options.cf = { image: cfImage }; + } + + return await fetch(imageUrl.toString(), options); + } catch (error) { + console.error("Image processing error:", error); + return c.json({ error: error instanceof Error ? error.message : "Image processing failed" }, 500); + } + }, + ); + app.openapi( createRoute({ method: "get", path: "/files/{id}", + tags: ["Public files"], + summary: "Serve a public attachment file", + description: "Streams bytes for an available attachment whose visibility allows public file access.", request: { params: IdParams }, responses: { 200: { description: "File bytes streamed with the stored content-type" }, @@ -100,6 +253,9 @@ export function createRoutes(): OpenAPIHono { createRoute({ method: "get", path: "/api/attachments/search", + tags: ["Attachments"], + summary: "Search attachments", + description: "Returns a paginated list of the current user's attachments, optionally filtered by keyword, category, tag, or status.", request: { query: SearchQuery }, responses: { 200: { description: "Paginated attachments", content: { "application/json": { schema: PaginatedAttachmentsSchema } } }, 401: errRes("Authentication required") }, }), @@ -125,6 +281,9 @@ export function createRoutes(): OpenAPIHono { createRoute({ method: "post", path: "/api/attachments", + tags: ["Attachments"], + summary: "Create attachment metadata", + description: "Creates an upload-pending attachment record for the current user before content is uploaded.", request: { body: { content: { "application/json": { schema: CreateAttachmentSchema } }, required: true } }, responses: { 201: { description: "Attachment created", content: { "application/json": { schema: AttachmentResponseSchema } } }, @@ -143,6 +302,9 @@ export function createRoutes(): OpenAPIHono { createRoute({ method: "get", path: "/api/attachments/{id}", + tags: ["Attachments"], + summary: "Get attachment detail", + description: "Reads one attachment belonging to the current user, including metadata, category, tags, and computed public URL when available.", request: { params: IdParams }, responses: { 200: { description: "Attachment detail", content: { "application/json": { schema: AttachmentResponseSchema } } }, @@ -161,6 +323,9 @@ export function createRoutes(): OpenAPIHono { createRoute({ method: "patch", path: "/api/attachments/{id}", + tags: ["Attachments"], + summary: "Update attachment metadata", + description: "Updates mutable metadata for one attachment, including filename, visibility, description, category, and tag assignments.", request: { params: IdParams, body: { content: { "application/json": { schema: UpdateAttachmentSchema } }, required: true }, @@ -183,6 +348,9 @@ export function createRoutes(): OpenAPIHono { createRoute({ method: "put", path: "/api/attachments/{id}/content", + tags: ["Attachments"], + summary: "Upload attachment content", + description: "Uploads the full file body for an upload-pending attachment through the Worker into R2.", request: { params: IdParams }, responses: { 200: { description: "Content uploaded", content: { "application/json": { schema: AttachmentResponseSchema } } }, @@ -203,6 +371,9 @@ export function createRoutes(): OpenAPIHono { createRoute({ method: "post", path: "/api/attachments/{id}/multipart", + tags: ["Attachments"], + summary: "Initialize multipart upload", + description: "Starts an R2 multipart upload session for an upload-pending attachment owned by the current user.", request: { params: IdParams }, responses: { 200: { description: "Multipart upload initialized", content: { "application/json": { schema: MultipartInitResponseSchema } } }, @@ -222,6 +393,9 @@ export function createRoutes(): OpenAPIHono { createRoute({ method: "put", path: "/api/attachments/{id}/multipart/parts/{partNumber}", + tags: ["Attachments"], + summary: "Upload multipart part", + description: "Uploads or replaces one numbered part in the active multipart upload session for an attachment.", request: { params: PartParams }, responses: { 200: { description: "Part uploaded", content: { "application/json": { schema: UploadPartResponseSchema } } }, @@ -242,6 +416,9 @@ export function createRoutes(): OpenAPIHono { createRoute({ method: "delete", path: "/api/attachments/{id}/multipart", + tags: ["Attachments"], + summary: "Abort multipart upload", + description: "Aborts the active multipart upload session for an attachment and returns it to single-shot upload eligibility.", request: { params: IdParams }, responses: { 204: { description: "Multipart upload aborted" }, @@ -260,6 +437,9 @@ export function createRoutes(): OpenAPIHono { createRoute({ method: "post", path: "/api/attachments/{id}/finalize", + tags: ["Attachments"], + summary: "Finalize attachment upload", + description: "Validates uploaded content or multipart state and marks the attachment available for download.", request: { params: IdParams }, responses: { 200: { description: "Attachment finalized", content: { "application/json": { schema: AttachmentResponseSchema } } }, @@ -280,6 +460,9 @@ export function createRoutes(): OpenAPIHono { createRoute({ method: "get", path: "/api/attachments/{id}/download", + tags: ["Attachments"], + summary: "Download attachment file", + description: "Streams file bytes for an available attachment owned by the current user.", request: { params: IdParams }, responses: { 200: { description: "File bytes" }, @@ -299,6 +482,9 @@ export function createRoutes(): OpenAPIHono { createRoute({ method: "delete", path: "/api/attachments/{id}", + tags: ["Attachments"], + summary: "Delete attachment", + description: "Soft-deletes one attachment for the current user and attempts to delete its R2 object.", request: { params: IdParams }, responses: { 204: { description: "Attachment deleted" }, @@ -318,6 +504,9 @@ export function createRoutes(): OpenAPIHono { createRoute({ method: "post", path: "/api/attachment-categories", + tags: ["Categories"], + summary: "Create attachment category", + description: "Creates a user-scoped category that can be assigned to attachments.", request: { body: { content: { "application/json": { schema: CategoryCreateSchema } }, required: true } }, responses: { 201: { description: "Category created", content: { "application/json": { schema: CategoryResponseSchema } } }, @@ -336,6 +525,9 @@ export function createRoutes(): OpenAPIHono { createRoute({ method: "get", path: "/api/attachment-categories", + tags: ["Categories"], + summary: "List attachment categories", + description: "Lists all categories owned by the current user.", responses: { 200: { description: "Categories", content: { "application/json": { schema: CategoriesResponseSchema } } }, 401: errRes("Authentication required"), @@ -351,6 +543,9 @@ export function createRoutes(): OpenAPIHono { createRoute({ method: "patch", path: "/api/attachment-categories/{id}", + tags: ["Categories"], + summary: "Update attachment category", + description: "Updates the name or slug for one category owned by the current user.", request: { params: IdParams, body: { content: { "application/json": { schema: CategoryUpdateSchema } }, required: true } }, responses: { 200: { description: "Category updated", content: { "application/json": { schema: CategoryResponseSchema } } }, @@ -370,6 +565,9 @@ export function createRoutes(): OpenAPIHono { createRoute({ method: "delete", path: "/api/attachment-categories/{id}", + tags: ["Categories"], + summary: "Delete attachment category", + description: "Deletes one category owned by the current user and clears it from attachments that reference it.", request: { params: IdParams }, responses: { 204: { description: "Category deleted" }, 401: errRes("Authentication required"), 404: errRes("Category not found") }, }), @@ -385,6 +583,9 @@ export function createRoutes(): OpenAPIHono { createRoute({ method: "post", path: "/api/attachment-tags", + tags: ["Tags"], + summary: "Create attachment tag", + description: "Creates a user-scoped tag that can be assigned to attachments.", request: { body: { content: { "application/json": { schema: TagCreateSchema } }, required: true } }, responses: { 201: { description: "Tag created", content: { "application/json": { schema: TagResponseSchema } } }, @@ -403,6 +604,9 @@ export function createRoutes(): OpenAPIHono { createRoute({ method: "get", path: "/api/attachment-tags", + tags: ["Tags"], + summary: "List attachment tags", + description: "Lists all tags owned by the current user.", responses: { 200: { description: "Tags", content: { "application/json": { schema: TagsResponseSchema } } }, 401: errRes("Authentication required") }, }), async (c) => { @@ -415,6 +619,9 @@ export function createRoutes(): OpenAPIHono { createRoute({ method: "patch", path: "/api/attachment-tags/{id}", + tags: ["Tags"], + summary: "Update attachment tag", + description: "Updates the name or slug for one tag owned by the current user.", request: { params: IdParams, body: { content: { "application/json": { schema: TagUpdateSchema } }, required: true } }, responses: { 200: { description: "Tag updated", content: { "application/json": { schema: TagResponseSchema } } }, @@ -434,6 +641,9 @@ export function createRoutes(): OpenAPIHono { createRoute({ method: "delete", path: "/api/attachment-tags/{id}", + tags: ["Tags"], + summary: "Delete attachment tag", + description: "Deletes one tag owned by the current user and removes it from attachments that reference it.", request: { params: IdParams }, responses: { 204: { description: "Tag deleted" }, 401: errRes("Authentication required"), 404: errRes("Tag not found") }, }), @@ -451,6 +661,7 @@ export function createRoutes(): OpenAPIHono { version: "0.1.0", description: "Attachment management service (metadata, single-shot & multipart upload, public files).", }, + tags: OPENAPI_TAGS, servers: [{ url: c.env.PUBLIC_BASE_URL ?? "https://cfw-attachment.bowong.cc", description: "Production" }], })); diff --git a/tests/attachment-worker.test.ts b/tests/attachment-worker.test.ts index 20aac4c..7220d30 100644 --- a/tests/attachment-worker.test.ts +++ b/tests/attachment-worker.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import app from "../src/index"; import { makeEnv, sharedStores } from "./fakes"; @@ -35,6 +35,69 @@ async function createAttachment( } describe("cfw-attachment worker (core flow)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("proxies /image without a session and forwards Cloudflare image options", async () => { + const stores = sharedStores(); + const env = makeEnv(stores, null); + const origin = "https://cdn.example.com/photo.jpg"; + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("image-bytes", { status: 200, headers: { "content-type": "image/webp" } }), + ); + + const response = await call( + "GET", + `/image?url=${encodeURIComponent(origin)}&width=320&height=200&quality=70&fit=cover&format=auto&bg=white`, + env, + { headers: { Accept: "image/webp" } }, + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("image/webp"); + expect(await response.text()).toBe("image-bytes"); + expect(env.AUTH.fetch).not.toHaveBeenCalled(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy.mock.calls[0][0]).toBe(origin); + expect(fetchSpy.mock.calls[0][1]).toEqual({ + headers: { Accept: "image/avif, image/webp, image/*" }, + cf: { + image: { + width: 320, + height: 200, + quality: 70, + fit: "cover", + format: "webp", + background: "white", + }, + }, + }); + }); + + it("returns 400 for invalid /image requests before fetching upstream", async () => { + const stores = sharedStores(); + const env = makeEnv(stores, null); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + const missingUrl = await call("GET", "/image", env); + expect(missingUrl.status).toBe(400); + expect(await missingUrl.json()).toEqual({ error: "Missing required parameter: url" }); + + const unsupported = await call("GET", `/image?url=${encodeURIComponent("https://cdn.example.com/vector.svg")}`, env); + expect(unsupported.status).toBe(400); + expect(await unsupported.json()).toEqual({ + error: "Unsupported image format. Only JPEG, PNG, GIF, WebP are supported", + }); + + const invalidSize = await call("GET", `/image?url=${encodeURIComponent("https://cdn.example.com/photo.png")}&width=0`, env); + expect(invalidSize.status).toBe(400); + expect(await invalidSize.json()).toEqual({ error: "Invalid width. Must be a positive integer" }); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(env.AUTH.fetch).not.toHaveBeenCalled(); + }); + it("creates an attachment for the current user and hides it from another user", async () => { const stores = sharedStores(); const userA = makeEnv(stores, "user_a"); @@ -153,14 +216,84 @@ describe("cfw-attachment worker (core flow)", () => { expect(response.status).toBe(200); const document = (await response.json()) as { openapi: string; - paths: Record; + tags?: Array<{ name: string; description?: string }>; + paths: Record< + string, + Record< + string, + { + tags?: string[]; + summary?: string; + description?: string; + parameters?: Array<{ + name: string; + in: string; + required?: boolean; + schema?: { type?: string; enum?: string[] }; + }>; + } + > + >; components?: { schemas?: Record }; }; expect(document.openapi).toBe("3.1.0"); + expect(document.tags).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "Attachments", description: expect.any(String) }), + expect.objectContaining({ name: "Categories", description: expect.any(String) }), + expect.objectContaining({ name: "Tags", description: expect.any(String) }), + expect.objectContaining({ name: "Public files", description: expect.any(String) }), + ]), + ); expect(document.paths["/api/attachments"]).toBeTruthy(); expect(document.paths["/api/attachments/{id}/multipart"]).toBeTruthy(); expect(document.paths["/api/attachments/{id}/multipart/parts/{partNumber}"]).toBeTruthy(); expect(document.paths["/files/{id}"]).toBeTruthy(); + expect(document.paths["/image"]).toBeTruthy(); + expect(document.paths["/api/attachments"].post).toMatchObject({ + tags: ["Attachments"], + summary: expect.any(String), + description: expect.any(String), + }); + expect(document.paths["/api/attachment-categories"].get).toMatchObject({ + tags: ["Categories"], + summary: expect.any(String), + description: expect.any(String), + }); + expect(document.paths["/api/attachment-tags"].get).toMatchObject({ + tags: ["Tags"], + summary: expect.any(String), + description: expect.any(String), + }); + expect(document.paths["/files/{id}"].get).toMatchObject({ + tags: ["Public files"], + summary: expect.any(String), + description: expect.any(String), + }); + expect(document.paths["/image"].get).toMatchObject({ + tags: ["Images"], + summary: expect.any(String), + description: expect.any(String), + }); + expect(document.paths["/image"].get.parameters).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "url", in: "query", required: true, schema: expect.objectContaining({ type: "string" }) }), + expect.objectContaining({ name: "width", in: "query", schema: expect.objectContaining({ type: "integer" }) }), + expect.objectContaining({ name: "height", in: "query", schema: expect.objectContaining({ type: "integer" }) }), + expect.objectContaining({ name: "quality", in: "query", schema: expect.objectContaining({ type: "integer" }) }), + expect.objectContaining({ + name: "format", + in: "query", + schema: expect.objectContaining({ enum: expect.arrayContaining(["avif", "webp", "jpeg", "jpg", "png", "gif", "auto"]) }), + }), + expect.objectContaining({ + name: "fit", + in: "query", + schema: expect.objectContaining({ enum: expect.arrayContaining(["scale-down", "scale-up", "contain", "cover", "crop", "pad", "squeeze"]) }), + }), + expect.objectContaining({ name: "bg", in: "query", schema: expect.objectContaining({ type: "string" }) }), + ]), + ); expect(document.components?.schemas?.Attachment).toBeTruthy(); }); }); @@ -475,4 +608,3 @@ describe("cfw-attachment worker (multipart & public files)", () => { expect(((await second.json()) as { code: string }).code).toBe("multipart-active"); }); }); -