feat: support server-side image compression for MCP uploads
This commit is contained in:
1 parent
0430ce67fb
commit
85687deef6
5 files changed
+297
-48
No files matched your search
@@ -28,7 +28,7 @@ EdgeEver 是一个开源、自托管、Cloudflare-native 的现代笔记工作
|
||||
- 三栏布局:笔记本树、笔记列表、主编辑区。
|
||||
- 无限级嵌套笔记本。
|
||||
- 支持富文本编辑。
|
||||
- 笔记内图片可自动压缩,典型场景可节省约 80% 图片体积。
|
||||
- 笔记内图片可在服务端自动压缩,Web、REST API 与 MCP 上传入口保持一致,典型场景可节省约 80% 图片体积。
|
||||
- 多选合并笔记。
|
||||
- 多选移动笔记,笔记本支持拖拽排序和调整层级。
|
||||
- 已有笔记支持离线编辑草稿和本地同步队列。
|
||||
@@ -42,7 +42,7 @@ EdgeEver 是一个开源、自托管、Cloudflare-native 的现代笔记工作
|
||||
## 技术栈
|
||||
|
||||
- 前端:Vite、React、Shadcn UI、Tailwind CSS、TipTap、TanStack Query、Dexie。
|
||||
- 后端:Cloudflare Workers、Hono。
|
||||
- 后端:Cloudflare Workers、Hono、Cloudflare Images。
|
||||
- 存储:Cloudflare D1、Cloudflare R2。
|
||||
- 工具链:Bun、Wrangler、TypeScript。
|
||||
|
||||
|
||||
+288
-46
@@ -38,6 +38,7 @@ import openApiSpec from "../../../docs/openapi.json";
|
||||
type Bindings = {
|
||||
DB: D1Database;
|
||||
RESOURCES: R2Bucket;
|
||||
IMAGES?: ImagesBinding;
|
||||
EDGE_EVER_AUTH_USERNAME?: string;
|
||||
EDGE_EVER_AUTH_PASSWORD_HASH?: string;
|
||||
EDGE_EVER_SESSION_TTL_DAYS?: string;
|
||||
@@ -55,6 +56,11 @@ type AuthContext = {
|
||||
tokenId?: string;
|
||||
};
|
||||
|
||||
type AuditActor = {
|
||||
actorType: "user" | "agent";
|
||||
actorId: string | null;
|
||||
};
|
||||
|
||||
type NotebookRow = {
|
||||
id: string;
|
||||
parent_id: string | null;
|
||||
@@ -188,6 +194,10 @@ const SESSION_TOKEN_BYTES = 32;
|
||||
const DEFAULT_SESSION_TTL_DAYS = 30;
|
||||
const DEFAULT_R2_BUCKET_NAME = "edgeever-resources";
|
||||
const MAX_IMAGE_UPLOAD_BYTES = 50 * 1024 * 1024;
|
||||
const MAX_COMPRESSED_IMAGE_EDGE = 2560;
|
||||
const IMAGE_COMPRESSION_QUALITY = 82;
|
||||
const COMPRESSED_IMAGE_MIME_TYPE = "image/webp";
|
||||
const COMPRESSED_IMAGE_EXTENSION = ".webp";
|
||||
const REVISION_SNAPSHOT_INTERVAL_MS = 5 * 60 * 1000;
|
||||
const API_TOKEN_BYTES = 32;
|
||||
const API_TOKEN_PREFIX = "eev";
|
||||
@@ -209,6 +219,7 @@ const SUPPORTED_IMAGE_MIME_TYPES = new Set([
|
||||
"image/webp",
|
||||
"image/avif",
|
||||
]);
|
||||
const COMPRESSIBLE_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/avif"]);
|
||||
|
||||
const app = new Hono<{ Bindings: Bindings; Variables: { auth: AuthContext } }>();
|
||||
|
||||
@@ -923,48 +934,63 @@ app.post("/api/v1/memos/:id/resources", async (c) => {
|
||||
return badRequest(c, "Expected multipart form field named file.");
|
||||
}
|
||||
|
||||
const mimeType = file.type || "application/octet-stream";
|
||||
|
||||
if (!SUPPORTED_IMAGE_MIME_TYPES.has(mimeType)) {
|
||||
return c.json(
|
||||
{
|
||||
error: {
|
||||
code: "unsupported_media_type",
|
||||
message: "Only PNG, JPEG, GIF, WebP and AVIF images are supported.",
|
||||
},
|
||||
},
|
||||
415
|
||||
);
|
||||
}
|
||||
|
||||
if (file.size <= 0 || file.size > MAX_IMAGE_UPLOAD_BYTES) {
|
||||
return c.json(
|
||||
{
|
||||
error: {
|
||||
code: "upload_too_large",
|
||||
message: "Image must be between 1 byte and 50 MB.",
|
||||
},
|
||||
},
|
||||
413
|
||||
);
|
||||
}
|
||||
|
||||
const actor = getAuditActor(c);
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
let resource: Resource;
|
||||
|
||||
try {
|
||||
resource = await createImageResource(c, {
|
||||
memoId,
|
||||
filename: file.name,
|
||||
mimeType: file.type || "application/octet-stream",
|
||||
bytes,
|
||||
actor,
|
||||
source: "upload",
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
return apiError(c, error.code, error.message, error.status);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
return c.json({ resource }, 201);
|
||||
});
|
||||
|
||||
const createImageResource = async (
|
||||
c: AppContext,
|
||||
input: {
|
||||
memoId: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
bytes: Uint8Array;
|
||||
actor: AuditActor;
|
||||
source: "upload" | "mcp";
|
||||
}
|
||||
) => {
|
||||
validateImageUpload(input.mimeType, input.bytes.byteLength);
|
||||
|
||||
const resourceId = createId("res");
|
||||
const now = isoNow();
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
const objectKey = `memos/${memoId}/${resourceId}${inferImageExtension(file.name, mimeType)}`;
|
||||
const processed = await prepareImageForStorage(c.env.IMAGES, {
|
||||
bytes: input.bytes,
|
||||
filename: input.filename,
|
||||
mimeType: input.mimeType,
|
||||
source: input.source,
|
||||
});
|
||||
const objectKey = `memos/${input.memoId}/${resourceId}${inferImageExtension(processed.filename, processed.mimeType)}`;
|
||||
const bucketName = c.env.EDGE_EVER_R2_BUCKET_NAME?.trim() || DEFAULT_R2_BUCKET_NAME;
|
||||
const filename = normalizeFilename(file.name) || `${resourceId}${inferImageExtension(file.name, mimeType)}`;
|
||||
const checksum = await sha256Bytes(bytes);
|
||||
const filename = normalizeFilename(processed.filename) || `${resourceId}${inferImageExtension(processed.filename, processed.mimeType)}`;
|
||||
const checksum = await sha256Bytes(processed.bytes);
|
||||
|
||||
await c.env.RESOURCES.put(objectKey, bytes, {
|
||||
await c.env.RESOURCES.put(objectKey, processed.bytes, {
|
||||
httpMetadata: {
|
||||
contentType: mimeType,
|
||||
contentType: processed.mimeType,
|
||||
cacheControl: "private, max-age=3600",
|
||||
},
|
||||
customMetadata: {
|
||||
memoId,
|
||||
memoId: input.memoId,
|
||||
resourceId,
|
||||
filename,
|
||||
},
|
||||
@@ -975,25 +1001,28 @@ app.post("/api/v1/memos/:id/resources", async (c) => {
|
||||
c.env.DB.prepare(
|
||||
`INSERT INTO resources (
|
||||
id, memo_id, bucket_name, object_key, kind, mime_type, filename,
|
||||
byte_size, sha256, metadata_json, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, 'image', ?, ?, ?, ?, ?, ?, ?)`
|
||||
byte_size, sha256, width, height, metadata_json, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, 'image', ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).bind(
|
||||
resourceId,
|
||||
memoId,
|
||||
input.memoId,
|
||||
bucketName,
|
||||
objectKey,
|
||||
mimeType,
|
||||
processed.mimeType,
|
||||
filename,
|
||||
file.size,
|
||||
processed.bytes.byteLength,
|
||||
checksum,
|
||||
JSON.stringify({ source: "paste" }),
|
||||
processed.width,
|
||||
processed.height,
|
||||
JSON.stringify(processed.metadata),
|
||||
now,
|
||||
now
|
||||
),
|
||||
auditStatement(c.env.DB, actor.actorType, actor.actorId, "resource.create", "resource", resourceId, {
|
||||
memoId,
|
||||
mimeType,
|
||||
byteSize: file.size,
|
||||
auditStatement(c.env.DB, input.actor.actorType, input.actor.actorId, "resource.create", "resource", resourceId, {
|
||||
memoId: input.memoId,
|
||||
mimeType: processed.mimeType,
|
||||
byteSize: processed.bytes.byteLength,
|
||||
compressed: processed.compressed,
|
||||
}),
|
||||
]);
|
||||
} catch (error) {
|
||||
@@ -1004,11 +1033,156 @@ app.post("/api/v1/memos/:id/resources", async (c) => {
|
||||
const resource = await getResourceRow(c.env.DB, resourceId);
|
||||
|
||||
if (!resource) {
|
||||
return notFound(c, "Resource not found");
|
||||
throw new AppError("not_found", "Resource not found", 404);
|
||||
}
|
||||
|
||||
return c.json({ resource: mapResource(resource) }, 201);
|
||||
});
|
||||
return mapResource(resource);
|
||||
};
|
||||
|
||||
const validateImageUpload = (mimeType: string, size: number) => {
|
||||
if (!SUPPORTED_IMAGE_MIME_TYPES.has(mimeType)) {
|
||||
throw new AppError("unsupported_media_type", "Only PNG, JPEG, GIF, WebP and AVIF images are supported.", 415);
|
||||
}
|
||||
|
||||
if (size <= 0 || size > MAX_IMAGE_UPLOAD_BYTES) {
|
||||
throw new AppError("upload_too_large", "Image must be between 1 byte and 50 MB.", 413);
|
||||
}
|
||||
};
|
||||
|
||||
type PreparedImage = {
|
||||
bytes: Uint8Array;
|
||||
mimeType: string;
|
||||
filename: string;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
compressed: boolean;
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const prepareImageForStorage = async (
|
||||
images: ImagesBinding | undefined,
|
||||
input: { bytes: Uint8Array; filename: string; mimeType: string; source: "upload" | "mcp" }
|
||||
): Promise<PreparedImage> => {
|
||||
const baseMetadata: Record<string, unknown> = {
|
||||
source: input.source,
|
||||
originalFilename: normalizeFilename(input.filename) || null,
|
||||
originalMimeType: input.mimeType,
|
||||
originalByteSize: input.bytes.byteLength,
|
||||
};
|
||||
|
||||
if (!images) {
|
||||
return {
|
||||
bytes: input.bytes,
|
||||
mimeType: input.mimeType,
|
||||
filename: input.filename,
|
||||
width: null,
|
||||
height: null,
|
||||
compressed: false,
|
||||
metadata: {
|
||||
...baseMetadata,
|
||||
compression: "unavailable",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let info: ImageInfoResponse | null = null;
|
||||
|
||||
try {
|
||||
info = await images.info(bytesToStream(input.bytes));
|
||||
} catch {
|
||||
info = null;
|
||||
}
|
||||
|
||||
const originalWidth = getPositiveInteger(info && "width" in info ? info.width : null);
|
||||
const originalHeight = getPositiveInteger(info && "height" in info ? info.height : null);
|
||||
const baseResult = {
|
||||
bytes: input.bytes,
|
||||
mimeType: input.mimeType,
|
||||
filename: input.filename,
|
||||
width: originalWidth,
|
||||
height: originalHeight,
|
||||
compressed: false,
|
||||
metadata: {
|
||||
...baseMetadata,
|
||||
width: originalWidth,
|
||||
height: originalHeight,
|
||||
compressed: false,
|
||||
},
|
||||
};
|
||||
|
||||
if (!COMPRESSIBLE_IMAGE_MIME_TYPES.has(input.mimeType)) {
|
||||
return baseResult;
|
||||
}
|
||||
|
||||
try {
|
||||
const transform: ImageTransform = {};
|
||||
const maxEdge = originalWidth && originalHeight ? Math.max(originalWidth, originalHeight) : null;
|
||||
|
||||
if (maxEdge && maxEdge > MAX_COMPRESSED_IMAGE_EDGE) {
|
||||
const scale = MAX_COMPRESSED_IMAGE_EDGE / maxEdge;
|
||||
transform.width = Math.max(1, Math.round((originalWidth ?? maxEdge) * scale));
|
||||
transform.height = Math.max(1, Math.round((originalHeight ?? maxEdge) * scale));
|
||||
transform.fit = "scale-down";
|
||||
}
|
||||
|
||||
const result = await images
|
||||
.input(bytesToStream(input.bytes))
|
||||
.transform(transform)
|
||||
.output({ format: COMPRESSED_IMAGE_MIME_TYPE, quality: IMAGE_COMPRESSION_QUALITY });
|
||||
const response = result.response();
|
||||
const contentType = result.contentType() || response.headers.get("Content-Type") || COMPRESSED_IMAGE_MIME_TYPE;
|
||||
const compressedBytes = new Uint8Array(await response.arrayBuffer());
|
||||
|
||||
if (contentType !== COMPRESSED_IMAGE_MIME_TYPE || compressedBytes.byteLength >= input.bytes.byteLength) {
|
||||
return baseResult;
|
||||
}
|
||||
|
||||
return {
|
||||
bytes: compressedBytes,
|
||||
mimeType: COMPRESSED_IMAGE_MIME_TYPE,
|
||||
filename: toCompressedFilename(input.filename),
|
||||
width: transform.width ?? originalWidth,
|
||||
height: transform.height ?? originalHeight,
|
||||
compressed: true,
|
||||
metadata: {
|
||||
...baseMetadata,
|
||||
width: originalWidth,
|
||||
height: originalHeight,
|
||||
compressed: true,
|
||||
compression: {
|
||||
format: COMPRESSED_IMAGE_MIME_TYPE,
|
||||
quality: IMAGE_COMPRESSION_QUALITY,
|
||||
maxEdge: MAX_COMPRESSED_IMAGE_EDGE,
|
||||
originalByteSize: input.bytes.byteLength,
|
||||
byteSize: compressedBytes.byteLength,
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return baseResult;
|
||||
}
|
||||
};
|
||||
|
||||
const bytesToStream = (bytes: Uint8Array) =>
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(bytes);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
const getPositiveInteger = (value: unknown) =>
|
||||
typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null;
|
||||
|
||||
const toCompressedFilename = (filename: string) => {
|
||||
const trimmed = filename.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return `image${COMPRESSED_IMAGE_EXTENSION}`;
|
||||
}
|
||||
|
||||
return trimmed.replace(/\.[^.]+$/, "") + COMPRESSED_IMAGE_EXTENSION;
|
||||
};
|
||||
|
||||
app.get("/api/v1/resources/:id/blob", async (c) => {
|
||||
const denied = requireScopes(c, "read:resources");
|
||||
@@ -1520,6 +1694,23 @@ const MCP_TOOLS = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "upload_memo_image",
|
||||
description:
|
||||
"Upload a base64-encoded image resource to a memo. The server compresses supported images before storing them and returns Markdown that can be inserted into memo content.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["memoId", "mimeType", "dataBase64"],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
memoId: { type: "string" },
|
||||
filename: { type: "string" },
|
||||
mimeType: { type: "string", enum: ["image/png", "image/jpeg", "image/gif", "image/webp", "image/avif"] },
|
||||
dataBase64: { type: "string" },
|
||||
alt: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "move_memos",
|
||||
description: "Move one or more active memos to another notebook.",
|
||||
@@ -1652,6 +1843,33 @@ const callMcpTool = async (
|
||||
|
||||
return { memo: result.memo };
|
||||
}
|
||||
case "upload_memo_image": {
|
||||
assertScope(auth, "write:resources");
|
||||
const memoId = getRequiredString(args.memoId, "memoId");
|
||||
const memo = await getMemoDetail(c.env.DB, memoId);
|
||||
|
||||
if (!memo) {
|
||||
throw new AppError("not_found", "Memo not found", 404);
|
||||
}
|
||||
|
||||
const mimeType = getRequiredString(args.mimeType, "mimeType");
|
||||
const filename = getOptionalString(args.filename) ?? `image${inferImageExtension("", mimeType)}`;
|
||||
const bytes = decodeBase64ImageData(getRequiredString(args.dataBase64, "dataBase64"));
|
||||
const resource = await createImageResource(c, {
|
||||
memoId,
|
||||
filename,
|
||||
mimeType,
|
||||
bytes,
|
||||
actor: getAuditActor(c),
|
||||
source: "mcp",
|
||||
});
|
||||
const alt = getOptionalString(args.alt) ?? normalizeFilename(filename) ?? "image";
|
||||
|
||||
return {
|
||||
resource,
|
||||
markdownImage: ``,
|
||||
};
|
||||
}
|
||||
case "move_memos": {
|
||||
assertScope(auth, "write:memos");
|
||||
const notebookId = getRequiredString(args.notebookId, "notebookId");
|
||||
@@ -1766,6 +1984,30 @@ const getRequiredStringArray = (value: unknown, name: string) => {
|
||||
return items;
|
||||
};
|
||||
|
||||
const decodeBase64ImageData = (value: string) => {
|
||||
const [, dataUrlPayload] = value.match(/^data:[^;]+;base64,(.+)$/i) ?? [];
|
||||
const base64 = (dataUrlPayload ?? value).replace(/\s/g, "");
|
||||
|
||||
if (!base64) {
|
||||
throw new AppError("invalid_params", "dataBase64 is required", 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
} catch {
|
||||
throw new AppError("invalid_params", "dataBase64 must be valid base64 image data.", 400);
|
||||
}
|
||||
};
|
||||
|
||||
const escapeMarkdownImageAlt = (value: string) => value.replace(/[\\[\]]/g, "\\$&");
|
||||
|
||||
const isAuthRequired = async (env: Bindings) => {
|
||||
if (env.EDGE_EVER_AUTH_PASSWORD_HASH?.trim()) {
|
||||
return true;
|
||||
|
||||
@@ -698,6 +698,7 @@
|
||||
],
|
||||
"post": {
|
||||
"summary": "Upload image resource for memo",
|
||||
"description": "Uploads an image resource for a memo. The API attempts server-side compression for PNG, JPEG, WebP, and AVIF images before storing the resource.",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
|
||||
@@ -36,6 +36,9 @@
|
||||
},
|
||||
"RESOURCES": {
|
||||
"description": "EdgeEver 的 R2 对象存储桶。用于存储笔记内图片、附件和未来导入导出文件;D1 只保存资源元数据和 memo 关联。"
|
||||
},
|
||||
"IMAGES": {
|
||||
"description": "Cloudflare Images binding。用于在 Worker 中对 Web、REST API 与 MCP 上传的笔记图片进行服务端压缩。"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -20,5 +20,8 @@ binding = "RESOURCES"
|
||||
bucket_name = "edgeever-resources"
|
||||
preview_bucket_name = "edgeever-resources-preview"
|
||||
|
||||
[images]
|
||||
binding = "IMAGES"
|
||||
|
||||
[observability]
|
||||
enabled = true
|
||||
Reference in new issue
Block a user