Add public image optimization endpoint
This commit is contained in:
213
src/routes.ts
213
src/routes.ts
@@ -1,4 +1,5 @@
|
|||||||
import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
|
import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
|
||||||
|
import type { Context } from "hono";
|
||||||
import { resolveCurrentUser, type CurrentUser } from "./auth-session";
|
import { resolveCurrentUser, type CurrentUser } from "./auth-session";
|
||||||
import type { Env } from "./env";
|
import type { Env } from "./env";
|
||||||
import { problemResponse, validationProblemHook } from "./problem";
|
import { problemResponse, validationProblemHook } from "./problem";
|
||||||
@@ -45,6 +46,14 @@ import {
|
|||||||
|
|
||||||
type AppEnv = { Bindings: Env; Variables: { user: CurrentUser } };
|
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) {
|
function errRes(description: string) {
|
||||||
return { description, content: { "application/json": { schema: ProblemSchema } } };
|
return { description, content: { "application/json": { schema: ProblemSchema } } };
|
||||||
}
|
}
|
||||||
@@ -60,6 +69,46 @@ const SearchQuery = z.object({
|
|||||||
status: z.string().optional(),
|
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<string>(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<AppEnv>, error: string) {
|
||||||
|
return c.json({ error }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
function pagination(page: number | undefined, pageSize: number | undefined) {
|
function pagination(page: number | undefined, pageSize: number | undefined) {
|
||||||
const p = Math.max(1, page ?? 1);
|
const p = Math.max(1, page ?? 1);
|
||||||
const ps = Math.min(100, Math.max(1, pageSize ?? 20));
|
const ps = Math.min(100, Math.max(1, pageSize ?? 20));
|
||||||
@@ -71,7 +120,7 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
|
|||||||
|
|
||||||
app.onError((error) => problemResponse(error));
|
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) => {
|
app.use("/api/*", async (c, next) => {
|
||||||
const user = await resolveCurrentUser(c.req.raw, c.env);
|
const user = await resolveCurrentUser(c.req.raw, c.env);
|
||||||
c.set("user", user);
|
c.set("user", user);
|
||||||
@@ -80,10 +129,114 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
|
|||||||
|
|
||||||
app.get("/healthz", (c) => c.json({ ok: true, service: "cfw-attachment" }));
|
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(
|
app.openapi(
|
||||||
createRoute({
|
createRoute({
|
||||||
method: "get",
|
method: "get",
|
||||||
path: "/files/{id}",
|
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 },
|
request: { params: IdParams },
|
||||||
responses: {
|
responses: {
|
||||||
200: { description: "File bytes streamed with the stored content-type" },
|
200: { description: "File bytes streamed with the stored content-type" },
|
||||||
@@ -100,6 +253,9 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
|
|||||||
createRoute({
|
createRoute({
|
||||||
method: "get",
|
method: "get",
|
||||||
path: "/api/attachments/search",
|
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 },
|
request: { query: SearchQuery },
|
||||||
responses: { 200: { description: "Paginated attachments", content: { "application/json": { schema: PaginatedAttachmentsSchema } } }, 401: errRes("Authentication required") },
|
responses: { 200: { description: "Paginated attachments", content: { "application/json": { schema: PaginatedAttachmentsSchema } } }, 401: errRes("Authentication required") },
|
||||||
}),
|
}),
|
||||||
@@ -125,6 +281,9 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
|
|||||||
createRoute({
|
createRoute({
|
||||||
method: "post",
|
method: "post",
|
||||||
path: "/api/attachments",
|
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 } },
|
request: { body: { content: { "application/json": { schema: CreateAttachmentSchema } }, required: true } },
|
||||||
responses: {
|
responses: {
|
||||||
201: { description: "Attachment created", content: { "application/json": { schema: AttachmentResponseSchema } } },
|
201: { description: "Attachment created", content: { "application/json": { schema: AttachmentResponseSchema } } },
|
||||||
@@ -143,6 +302,9 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
|
|||||||
createRoute({
|
createRoute({
|
||||||
method: "get",
|
method: "get",
|
||||||
path: "/api/attachments/{id}",
|
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 },
|
request: { params: IdParams },
|
||||||
responses: {
|
responses: {
|
||||||
200: { description: "Attachment detail", content: { "application/json": { schema: AttachmentResponseSchema } } },
|
200: { description: "Attachment detail", content: { "application/json": { schema: AttachmentResponseSchema } } },
|
||||||
@@ -161,6 +323,9 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
|
|||||||
createRoute({
|
createRoute({
|
||||||
method: "patch",
|
method: "patch",
|
||||||
path: "/api/attachments/{id}",
|
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: {
|
request: {
|
||||||
params: IdParams,
|
params: IdParams,
|
||||||
body: { content: { "application/json": { schema: UpdateAttachmentSchema } }, required: true },
|
body: { content: { "application/json": { schema: UpdateAttachmentSchema } }, required: true },
|
||||||
@@ -183,6 +348,9 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
|
|||||||
createRoute({
|
createRoute({
|
||||||
method: "put",
|
method: "put",
|
||||||
path: "/api/attachments/{id}/content",
|
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 },
|
request: { params: IdParams },
|
||||||
responses: {
|
responses: {
|
||||||
200: { description: "Content uploaded", content: { "application/json": { schema: AttachmentResponseSchema } } },
|
200: { description: "Content uploaded", content: { "application/json": { schema: AttachmentResponseSchema } } },
|
||||||
@@ -203,6 +371,9 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
|
|||||||
createRoute({
|
createRoute({
|
||||||
method: "post",
|
method: "post",
|
||||||
path: "/api/attachments/{id}/multipart",
|
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 },
|
request: { params: IdParams },
|
||||||
responses: {
|
responses: {
|
||||||
200: { description: "Multipart upload initialized", content: { "application/json": { schema: MultipartInitResponseSchema } } },
|
200: { description: "Multipart upload initialized", content: { "application/json": { schema: MultipartInitResponseSchema } } },
|
||||||
@@ -222,6 +393,9 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
|
|||||||
createRoute({
|
createRoute({
|
||||||
method: "put",
|
method: "put",
|
||||||
path: "/api/attachments/{id}/multipart/parts/{partNumber}",
|
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 },
|
request: { params: PartParams },
|
||||||
responses: {
|
responses: {
|
||||||
200: { description: "Part uploaded", content: { "application/json": { schema: UploadPartResponseSchema } } },
|
200: { description: "Part uploaded", content: { "application/json": { schema: UploadPartResponseSchema } } },
|
||||||
@@ -242,6 +416,9 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
|
|||||||
createRoute({
|
createRoute({
|
||||||
method: "delete",
|
method: "delete",
|
||||||
path: "/api/attachments/{id}/multipart",
|
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 },
|
request: { params: IdParams },
|
||||||
responses: {
|
responses: {
|
||||||
204: { description: "Multipart upload aborted" },
|
204: { description: "Multipart upload aborted" },
|
||||||
@@ -260,6 +437,9 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
|
|||||||
createRoute({
|
createRoute({
|
||||||
method: "post",
|
method: "post",
|
||||||
path: "/api/attachments/{id}/finalize",
|
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 },
|
request: { params: IdParams },
|
||||||
responses: {
|
responses: {
|
||||||
200: { description: "Attachment finalized", content: { "application/json": { schema: AttachmentResponseSchema } } },
|
200: { description: "Attachment finalized", content: { "application/json": { schema: AttachmentResponseSchema } } },
|
||||||
@@ -280,6 +460,9 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
|
|||||||
createRoute({
|
createRoute({
|
||||||
method: "get",
|
method: "get",
|
||||||
path: "/api/attachments/{id}/download",
|
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 },
|
request: { params: IdParams },
|
||||||
responses: {
|
responses: {
|
||||||
200: { description: "File bytes" },
|
200: { description: "File bytes" },
|
||||||
@@ -299,6 +482,9 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
|
|||||||
createRoute({
|
createRoute({
|
||||||
method: "delete",
|
method: "delete",
|
||||||
path: "/api/attachments/{id}",
|
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 },
|
request: { params: IdParams },
|
||||||
responses: {
|
responses: {
|
||||||
204: { description: "Attachment deleted" },
|
204: { description: "Attachment deleted" },
|
||||||
@@ -318,6 +504,9 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
|
|||||||
createRoute({
|
createRoute({
|
||||||
method: "post",
|
method: "post",
|
||||||
path: "/api/attachment-categories",
|
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 } },
|
request: { body: { content: { "application/json": { schema: CategoryCreateSchema } }, required: true } },
|
||||||
responses: {
|
responses: {
|
||||||
201: { description: "Category created", content: { "application/json": { schema: CategoryResponseSchema } } },
|
201: { description: "Category created", content: { "application/json": { schema: CategoryResponseSchema } } },
|
||||||
@@ -336,6 +525,9 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
|
|||||||
createRoute({
|
createRoute({
|
||||||
method: "get",
|
method: "get",
|
||||||
path: "/api/attachment-categories",
|
path: "/api/attachment-categories",
|
||||||
|
tags: ["Categories"],
|
||||||
|
summary: "List attachment categories",
|
||||||
|
description: "Lists all categories owned by the current user.",
|
||||||
responses: {
|
responses: {
|
||||||
200: { description: "Categories", content: { "application/json": { schema: CategoriesResponseSchema } } },
|
200: { description: "Categories", content: { "application/json": { schema: CategoriesResponseSchema } } },
|
||||||
401: errRes("Authentication required"),
|
401: errRes("Authentication required"),
|
||||||
@@ -351,6 +543,9 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
|
|||||||
createRoute({
|
createRoute({
|
||||||
method: "patch",
|
method: "patch",
|
||||||
path: "/api/attachment-categories/{id}",
|
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 } },
|
request: { params: IdParams, body: { content: { "application/json": { schema: CategoryUpdateSchema } }, required: true } },
|
||||||
responses: {
|
responses: {
|
||||||
200: { description: "Category updated", content: { "application/json": { schema: CategoryResponseSchema } } },
|
200: { description: "Category updated", content: { "application/json": { schema: CategoryResponseSchema } } },
|
||||||
@@ -370,6 +565,9 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
|
|||||||
createRoute({
|
createRoute({
|
||||||
method: "delete",
|
method: "delete",
|
||||||
path: "/api/attachment-categories/{id}",
|
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 },
|
request: { params: IdParams },
|
||||||
responses: { 204: { description: "Category deleted" }, 401: errRes("Authentication required"), 404: errRes("Category not found") },
|
responses: { 204: { description: "Category deleted" }, 401: errRes("Authentication required"), 404: errRes("Category not found") },
|
||||||
}),
|
}),
|
||||||
@@ -385,6 +583,9 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
|
|||||||
createRoute({
|
createRoute({
|
||||||
method: "post",
|
method: "post",
|
||||||
path: "/api/attachment-tags",
|
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 } },
|
request: { body: { content: { "application/json": { schema: TagCreateSchema } }, required: true } },
|
||||||
responses: {
|
responses: {
|
||||||
201: { description: "Tag created", content: { "application/json": { schema: TagResponseSchema } } },
|
201: { description: "Tag created", content: { "application/json": { schema: TagResponseSchema } } },
|
||||||
@@ -403,6 +604,9 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
|
|||||||
createRoute({
|
createRoute({
|
||||||
method: "get",
|
method: "get",
|
||||||
path: "/api/attachment-tags",
|
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") },
|
responses: { 200: { description: "Tags", content: { "application/json": { schema: TagsResponseSchema } } }, 401: errRes("Authentication required") },
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
@@ -415,6 +619,9 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
|
|||||||
createRoute({
|
createRoute({
|
||||||
method: "patch",
|
method: "patch",
|
||||||
path: "/api/attachment-tags/{id}",
|
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 } },
|
request: { params: IdParams, body: { content: { "application/json": { schema: TagUpdateSchema } }, required: true } },
|
||||||
responses: {
|
responses: {
|
||||||
200: { description: "Tag updated", content: { "application/json": { schema: TagResponseSchema } } },
|
200: { description: "Tag updated", content: { "application/json": { schema: TagResponseSchema } } },
|
||||||
@@ -434,6 +641,9 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
|
|||||||
createRoute({
|
createRoute({
|
||||||
method: "delete",
|
method: "delete",
|
||||||
path: "/api/attachment-tags/{id}",
|
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 },
|
request: { params: IdParams },
|
||||||
responses: { 204: { description: "Tag deleted" }, 401: errRes("Authentication required"), 404: errRes("Tag not found") },
|
responses: { 204: { description: "Tag deleted" }, 401: errRes("Authentication required"), 404: errRes("Tag not found") },
|
||||||
}),
|
}),
|
||||||
@@ -451,6 +661,7 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
|
|||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
description: "Attachment management service (metadata, single-shot & multipart upload, public files).",
|
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" }],
|
servers: [{ url: c.env.PUBLIC_BASE_URL ?? "https://cfw-attachment.bowong.cc", description: "Production" }],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import app from "../src/index";
|
import app from "../src/index";
|
||||||
import { makeEnv, sharedStores } from "./fakes";
|
import { makeEnv, sharedStores } from "./fakes";
|
||||||
|
|
||||||
@@ -35,6 +35,69 @@ async function createAttachment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("cfw-attachment worker (core flow)", () => {
|
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 () => {
|
it("creates an attachment for the current user and hides it from another user", async () => {
|
||||||
const stores = sharedStores();
|
const stores = sharedStores();
|
||||||
const userA = makeEnv(stores, "user_a");
|
const userA = makeEnv(stores, "user_a");
|
||||||
@@ -153,14 +216,84 @@ describe("cfw-attachment worker (core flow)", () => {
|
|||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
const document = (await response.json()) as {
|
const document = (await response.json()) as {
|
||||||
openapi: string;
|
openapi: string;
|
||||||
paths: Record<string, unknown>;
|
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<string, unknown> };
|
components?: { schemas?: Record<string, unknown> };
|
||||||
};
|
};
|
||||||
expect(document.openapi).toBe("3.1.0");
|
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"]).toBeTruthy();
|
||||||
expect(document.paths["/api/attachments/{id}/multipart"]).toBeTruthy();
|
expect(document.paths["/api/attachments/{id}/multipart"]).toBeTruthy();
|
||||||
expect(document.paths["/api/attachments/{id}/multipart/parts/{partNumber}"]).toBeTruthy();
|
expect(document.paths["/api/attachments/{id}/multipart/parts/{partNumber}"]).toBeTruthy();
|
||||||
expect(document.paths["/files/{id}"]).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();
|
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");
|
expect(((await second.json()) as { code: string }).code).toBe("multipart-active");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user