4083 lines
122 KiB
TypeScript
4083 lines
122 KiB
TypeScript
import {
|
||
createExcerpt,
|
||
DEFAULT_MEMO_TITLE,
|
||
docToMarkdown,
|
||
docToText,
|
||
emptyDoc,
|
||
ApiTokenCreateSchema,
|
||
DeleteMemosSchema,
|
||
LoginSchema,
|
||
markdownToDoc,
|
||
MemoCreateSchema,
|
||
MemoUpdateSchema,
|
||
MergeMemosSchema,
|
||
MoveMemosSchema,
|
||
normalizeTags,
|
||
TagRenameSchema,
|
||
NotebookCreateSchema,
|
||
NotebookUpdateSchema,
|
||
type ApiToken,
|
||
type CreatedApiToken,
|
||
type MemoDetail,
|
||
type MemoRevision,
|
||
type MemoSummary,
|
||
type Notebook,
|
||
type NotebookCreateInput,
|
||
type Resource,
|
||
type ResourceListItem,
|
||
type ResourceStorageSummary,
|
||
type TagSummary,
|
||
type TiptapDoc,
|
||
} from "@edgeever/shared";
|
||
import { zValidator } from "@hono/zod-validator";
|
||
import type { Context } from "hono";
|
||
import { Hono } from "hono";
|
||
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
||
import { cors } from "hono/cors";
|
||
import openApiSpec from "../../../docs/openapi.json";
|
||
|
||
type Bindings = {
|
||
DB: D1Database;
|
||
RESOURCES: R2Bucket;
|
||
EDGE_EVER_AUTH_USERNAME?: string;
|
||
EDGE_EVER_AUTH_PASSWORD_HASH?: string;
|
||
EDGE_EVER_SESSION_TTL_DAYS?: string;
|
||
EDGE_EVER_R2_BUCKET_NAME?: string;
|
||
EDGE_EVER_DEMO_MODE?: string;
|
||
};
|
||
|
||
type AuthContext = {
|
||
kind: "user" | "agent";
|
||
actorType: "user" | "agent";
|
||
actorId: string | null;
|
||
username: string;
|
||
displayName: string | null;
|
||
scopes: string[];
|
||
sessionId?: string;
|
||
tokenId?: string;
|
||
};
|
||
|
||
type AuditActor = {
|
||
actorType: "user" | "agent";
|
||
actorId: string | null;
|
||
};
|
||
|
||
type NotebookRow = {
|
||
id: string;
|
||
parent_id: string | null;
|
||
name: string;
|
||
slug: string | null;
|
||
icon: string | null;
|
||
color: string | null;
|
||
sort_order: number;
|
||
memo_count: number | null;
|
||
last_memo_updated_at: string | null;
|
||
created_at: string;
|
||
updated_at: string;
|
||
};
|
||
|
||
type MemoSummaryRow = {
|
||
id: string;
|
||
notebook_id: string;
|
||
title: string | null;
|
||
excerpt: string;
|
||
tags_json: string;
|
||
is_pinned: number;
|
||
is_archived: number;
|
||
is_deleted: number;
|
||
created_at: string;
|
||
updated_at: string;
|
||
deleted_at: string | null;
|
||
revision: number;
|
||
};
|
||
|
||
type MemoDetailRow = MemoSummaryRow & {
|
||
content_json: string;
|
||
content_markdown: string;
|
||
content_text: string;
|
||
source_memo_ids: string;
|
||
merge_source_count: number;
|
||
merged_into_memo_id: string | null;
|
||
content_hash: string;
|
||
};
|
||
|
||
type MemoRevisionRow = {
|
||
id: string;
|
||
memo_id: string;
|
||
revision: number;
|
||
title: string | null;
|
||
tags_json: string;
|
||
content_json: string;
|
||
content_markdown: string;
|
||
content_text: string;
|
||
content_hash: string;
|
||
created_by: string;
|
||
created_at: string;
|
||
};
|
||
|
||
type UserRow = {
|
||
id: string;
|
||
username: string;
|
||
password_hash: string;
|
||
display_name: string | null;
|
||
is_disabled: number;
|
||
};
|
||
|
||
type SessionRow = {
|
||
id: string;
|
||
user_id: string;
|
||
username: string;
|
||
display_name: string | null;
|
||
expires_at: string;
|
||
};
|
||
|
||
type ApiTokenRow = {
|
||
id: string;
|
||
name: string;
|
||
token_value: string | null;
|
||
scopes_json: string;
|
||
last_used_at: string | null;
|
||
expires_at: string | null;
|
||
is_revoked: number;
|
||
created_at: string;
|
||
};
|
||
|
||
type TagSummaryRow = {
|
||
name: string;
|
||
memo_count: number;
|
||
updated_at: string | null;
|
||
};
|
||
|
||
type MemoTagUpdateRow = {
|
||
id: string;
|
||
title: string | null;
|
||
tags_json: string;
|
||
content_text: string;
|
||
};
|
||
|
||
type ResourceRow = {
|
||
id: string;
|
||
memo_id: string;
|
||
original_memo_id: string | null;
|
||
bucket_name: string;
|
||
object_key: string;
|
||
kind: "image" | "attachment";
|
||
mime_type: string | null;
|
||
filename: string | null;
|
||
byte_size: number;
|
||
sha256: string | null;
|
||
width: number | null;
|
||
height: number | null;
|
||
created_at: string;
|
||
updated_at: string;
|
||
};
|
||
|
||
type ResourceListRow = ResourceRow & {
|
||
memo_title: string | null;
|
||
memo_excerpt: string | null;
|
||
memo_is_deleted: number | null;
|
||
};
|
||
|
||
type ResourceStatsRow = {
|
||
total_count: number;
|
||
total_bytes: number;
|
||
image_count: number;
|
||
attachment_count: number;
|
||
};
|
||
|
||
type AppContext = Context<{ Bindings: Bindings; Variables: { auth: AuthContext } }>;
|
||
|
||
const SESSION_COOKIE = "edgeever_session";
|
||
const PASSWORD_HASH_ALGORITHM = "pbkdf2-sha256";
|
||
const PASSWORD_HASH_ITERATIONS = 100_000;
|
||
const PASSWORD_HASH_BYTES = 32;
|
||
const PASSWORD_SALT_BYTES = 16;
|
||
const SESSION_TOKEN_BYTES = 32;
|
||
const DEFAULT_SESSION_TTL_DAYS = 5 * 365;
|
||
const MAX_SESSION_TTL_DAYS = 5 * 365;
|
||
const DEFAULT_R2_BUCKET_NAME = "edgeever-resources";
|
||
const DEMO_SEED_NOTEBOOKS = [
|
||
{ id: "nb_inbox", parentId: null, name: "等待分类", slug: "inbox", icon: "notebook", color: "#0f766e", sortOrder: 10 },
|
||
{ id: "nb_projects", parentId: null, name: "工作项目", slug: "work-projects", icon: "notebook", color: "#2563eb", sortOrder: 20 },
|
||
{ id: "nb_learning", parentId: null, name: "学习资料", slug: "learning-resources", icon: "notebook", color: "#7c3aed", sortOrder: 30 },
|
||
{ id: "nb_creative", parentId: null, name: "灵感创作", slug: "creative-ideas", icon: "notebook", color: "#db2777", sortOrder: 40 },
|
||
{ id: "nb_personal", parentId: null, name: "生活个人", slug: "personal-life", icon: "notebook", color: "#ea580c", sortOrder: 50 },
|
||
{ id: "nb_demo_features", parentId: "nb_projects", name: "功能演示", slug: "demo-features", icon: "notebook", color: "#0891b2", sortOrder: 21 },
|
||
];
|
||
const DEMO_SEED_MEMOS = [
|
||
{
|
||
id: "memo_welcome",
|
||
notebookId: "nb_inbox",
|
||
title: "欢迎来到 EdgeEver",
|
||
tags: ["edgeever", "welcome"],
|
||
isPinned: true,
|
||
markdown:
|
||
"## 欢迎来到 EdgeEver\n\n这是公开演示环境,可以随便创建、编辑、搜索和合并笔记。\n\n演示环境会每天自动重置,请不要保存私密内容。",
|
||
},
|
||
{
|
||
id: "memo_demo_editor",
|
||
notebookId: "nb_demo_features",
|
||
title: "富文本与 Markdown 编辑",
|
||
tags: ["editor", "markdown"],
|
||
isPinned: true,
|
||
markdown:
|
||
"## 富文本与 Markdown 编辑\n\nEdgeEver 使用 TipTap 保存结构化正文,同时保留 Markdown 和纯文本索引。\n\n- 支持标题、列表、引用和代码块\n- API 和 MCP 可以直接读写 Markdown\n- 搜索使用纯文本索引,不依赖前端编辑器\n\n",
|
||
},
|
||
{
|
||
id: "memo_demo_search_tags",
|
||
notebookId: "nb_learning",
|
||
title: "标签、搜索与归档",
|
||
tags: ["search", "tags", "workflow"],
|
||
isPinned: false,
|
||
markdown:
|
||
"## 标签、搜索与归档\n\n这条笔记用于演示标签和全文搜索。你可以搜索 `workflow`、`全文搜索` 或 `EdgeEver`。\n\n建议把临时资料先放入等待分类,再通过标签和笔记本整理。",
|
||
},
|
||
{
|
||
id: "memo_demo_merge",
|
||
notebookId: "nb_demo_features",
|
||
title: "多选合并笔记示例",
|
||
tags: ["merge", "long-term-note"],
|
||
isPinned: false,
|
||
markdown:
|
||
"## 多选合并笔记示例\n\n在笔记列表中多选几条笔记后,可以合并为一条长期笔记。原笔记会进入回收站,资源关联会移动到新笔记。\n\n这个能力适合把零散摘录整理成项目总结。",
|
||
},
|
||
{
|
||
id: "memo_demo_agent",
|
||
notebookId: "nb_projects",
|
||
title: "Agent-ready:REST API 与 MCP",
|
||
tags: ["api", "mcp", "agent"],
|
||
isPinned: false,
|
||
markdown:
|
||
"## Agent-ready\n\nEdgeEver 提供 REST API、OpenAPI schema 和 MCP endpoint。AI Agent 可以读取笔记本、创建笔记、整理标签,并把导入资料迁移到你的自托管实例。\n\nOpenAPI 路径:`/api/openapi.json`",
|
||
},
|
||
{
|
||
id: "memo_demo_mobile",
|
||
notebookId: "nb_personal",
|
||
title: "移动端与 PWA",
|
||
tags: ["pwa", "mobile"],
|
||
isPinned: false,
|
||
markdown:
|
||
"## 移动端与 PWA\n\nEdgeEver 支持桌面三栏工作流,也适配移动端。你可以把站点安装为 PWA,用手机快速记录,再回到桌面整理。",
|
||
},
|
||
{
|
||
id: "memo_demo_images",
|
||
notebookId: "nb_creative",
|
||
title: "图片笔记示例",
|
||
tags: ["image", "attachment", "demo"],
|
||
isPinned: false,
|
||
markdown:
|
||
"## 图片笔记示例\n\n笔记正文可以直接插入图片。上传后的图片会进入 R2,正文里保存的是资源 URL,API、MCP 和前端编辑器都能读取。\n\n\n\n这类笔记适合保存截图、设计稿、读书摘图和临时资料。",
|
||
},
|
||
];
|
||
const DEMO_SEED_RESOURCES = [
|
||
{
|
||
id: "res_demo_editor_image",
|
||
memoId: "memo_demo_editor",
|
||
filename: "edgeever-editor-demo.svg",
|
||
mimeType: "image/svg+xml",
|
||
width: 960,
|
||
height: 520,
|
||
svg:
|
||
'<svg xmlns="http://www.w3.org/2000/svg" width="960" height="520" viewBox="0 0 960 520" role="img" aria-label="EdgeEver editor demo"><rect width="960" height="520" rx="32" fill="#f8fafc"/><rect x="42" y="44" width="876" height="432" rx="24" fill="#ffffff" stroke="#dbe7e2" stroke-width="2"/><rect x="42" y="44" width="250" height="432" rx="24" fill="#ecfdf5"/><rect x="322" y="88" width="524" height="24" rx="12" fill="#0f766e"/><rect x="322" y="138" width="408" height="14" rx="7" fill="#94a3b8"/><rect x="322" y="166" width="470" height="14" rx="7" fill="#cbd5e1"/><rect x="322" y="214" width="220" height="118" rx="18" fill="#d1fae5"/><circle cx="384" cy="272" r="34" fill="#10b981"/><path d="M356 314 402 264l35 38 24-28 51 40H356Z" fill="#047857"/><rect x="570" y="224" width="270" height="16" rx="8" fill="#64748b"/><rect x="570" y="258" width="230" height="14" rx="7" fill="#94a3b8"/><rect x="570" y="288" width="250" height="14" rx="7" fill="#cbd5e1"/><rect x="570" y="348" width="180" height="38" rx="19" fill="#10b981"/><text x="660" y="373" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" font-weight="700" fill="#ffffff">支持图片笔记</text><rect x="78" y="90" width="170" height="18" rx="9" fill="#0f766e"/><rect x="78" y="132" width="124" height="14" rx="7" fill="#5eead4"/><rect x="78" y="166" width="148" height="14" rx="7" fill="#99f6e4"/><rect x="78" y="200" width="110" height="14" rx="7" fill="#99f6e4"/></svg>',
|
||
},
|
||
{
|
||
id: "res_demo_gallery_image",
|
||
memoId: "memo_demo_images",
|
||
filename: "edgeever-gallery-demo.svg",
|
||
mimeType: "image/svg+xml",
|
||
width: 960,
|
||
height: 540,
|
||
svg:
|
||
'<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540" role="img" aria-label="EdgeEver image note demo"><defs><linearGradient id="sky" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#e0f2fe"/><stop offset="1" stop-color="#dcfce7"/></linearGradient></defs><rect width="960" height="540" rx="36" fill="url(#sky)"/><rect x="78" y="70" width="804" height="400" rx="30" fill="#ffffff" stroke="#bae6fd" stroke-width="2"/><rect x="118" y="112" width="390" height="276" rx="24" fill="#eff6ff"/><circle cx="220" cy="194" r="54" fill="#facc15"/><path d="M118 340 246 236l94 78 66-58 102 84v48H118Z" fill="#22c55e"/><path d="M118 360 292 276l112 84 104-64v92H118Z" fill="#15803d" opacity=".75"/><rect x="550" y="120" width="260" height="28" rx="14" fill="#0f172a"/><rect x="550" y="178" width="214" height="16" rx="8" fill="#64748b"/><rect x="550" y="212" width="250" height="16" rx="8" fill="#94a3b8"/><rect x="550" y="246" width="188" height="16" rx="8" fill="#cbd5e1"/><rect x="550" y="318" width="118" height="42" rx="21" fill="#0ea5e9"/><rect x="686" y="318" width="118" height="42" rx="21" fill="#10b981"/><text x="480" y="438" text-anchor="middle" font-family="Arial, sans-serif" font-size="18" font-weight="700" fill="#0f766e">图片会作为资源保存,并在正文中直接展示</text></svg>',
|
||
},
|
||
];
|
||
const DEMO_SEED_NOTEBOOK_IDS = DEMO_SEED_NOTEBOOKS.map((notebook) => notebook.id);
|
||
const DEMO_SEED_MEMO_IDS = DEMO_SEED_MEMOS.map((memo) => memo.id);
|
||
const MAX_IMAGE_UPLOAD_BYTES = 100 * 1024 * 1024;
|
||
const MAX_ATTACHMENT_UPLOAD_BYTES = 100 * 1024 * 1024;
|
||
const REVISION_SNAPSHOT_INTERVAL_MS = 5 * 60 * 1000;
|
||
const API_TOKEN_BYTES = 32;
|
||
const API_TOKEN_PREFIX = "eev";
|
||
const ALL_TOKEN_SCOPES = [
|
||
"read:notebooks",
|
||
"write:notebooks",
|
||
"read:memos",
|
||
"write:memos",
|
||
"read:resources",
|
||
"write:resources",
|
||
"read:tags",
|
||
"write:tags",
|
||
] as const;
|
||
type TokenScope = (typeof ALL_TOKEN_SCOPES)[number];
|
||
const SUPPORTED_IMAGE_MIME_TYPES = new Set([
|
||
"image/png",
|
||
"image/jpeg",
|
||
"image/gif",
|
||
"image/webp",
|
||
"image/avif",
|
||
]);
|
||
|
||
const app = new Hono<{ Bindings: Bindings; Variables: { auth: AuthContext } }>();
|
||
|
||
app.use(
|
||
"/api/*",
|
||
cors({
|
||
origin: ["http://127.0.0.1:5173", "http://localhost:5173"],
|
||
allowHeaders: ["Content-Type", "Authorization"],
|
||
allowMethods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
|
||
credentials: true,
|
||
})
|
||
);
|
||
|
||
app.use(
|
||
"/mcp",
|
||
cors({
|
||
origin: ["http://127.0.0.1:5173", "http://localhost:5173"],
|
||
allowHeaders: ["Content-Type", "Authorization"],
|
||
allowMethods: ["GET", "POST", "OPTIONS"],
|
||
credentials: true,
|
||
})
|
||
);
|
||
|
||
app.get("/api/health", (c) =>
|
||
c.json({
|
||
ok: true,
|
||
name: "edgeever",
|
||
runtime: "cloudflare-workers",
|
||
})
|
||
);
|
||
|
||
app.get("/api/openapi.json", (c) => c.json(openApiSpec));
|
||
|
||
app.get("/api/v1/auth/session", async (c) => {
|
||
const authRequired = await isAuthRequired(c.env);
|
||
|
||
if (!authRequired) {
|
||
return c.json({
|
||
authRequired: false,
|
||
authenticated: true,
|
||
user: {
|
||
id: "local",
|
||
username: "owner",
|
||
displayName: "Owner",
|
||
},
|
||
});
|
||
}
|
||
|
||
const auth = await authenticateRequest(c, false);
|
||
|
||
return c.json({
|
||
authRequired: true,
|
||
authenticated: Boolean(auth && auth.kind === "user"),
|
||
user:
|
||
auth && auth.kind === "user"
|
||
? {
|
||
id: auth.actorId,
|
||
username: auth.username,
|
||
displayName: auth.displayName,
|
||
}
|
||
: null,
|
||
});
|
||
});
|
||
|
||
app.post("/api/v1/auth/login", zValidator("json", LoginSchema), async (c) => {
|
||
const input = c.req.valid("json");
|
||
const user = await verifyLogin(c.env, input.username, input.password);
|
||
|
||
if (!user) {
|
||
return unauthorized(c, "Username or password is incorrect.");
|
||
}
|
||
|
||
const session = await createSession(c, user);
|
||
setSessionCookie(c, session.token, session.maxAge);
|
||
|
||
await c.env.DB.batch([
|
||
c.env.DB.prepare(`UPDATE users SET last_login_at = ?, updated_at = ? WHERE id = ?`).bind(
|
||
isoNow(),
|
||
isoNow(),
|
||
user.id
|
||
),
|
||
auditStatement(c.env.DB, "user", user.id, "auth.login", "session", session.id, {
|
||
username: user.username,
|
||
}),
|
||
]);
|
||
|
||
return c.json({
|
||
authRequired: true,
|
||
authenticated: true,
|
||
user: {
|
||
id: user.id,
|
||
username: user.username,
|
||
displayName: user.display_name,
|
||
},
|
||
});
|
||
});
|
||
|
||
app.post("/api/v1/auth/logout", async (c) => {
|
||
const token = getCookie(c, SESSION_COOKIE);
|
||
|
||
if (token) {
|
||
await revokeSession(c.env.DB, token);
|
||
}
|
||
|
||
deleteCookie(c, SESSION_COOKIE, { path: "/" });
|
||
return c.json({ ok: true });
|
||
});
|
||
|
||
app.use("/api/v1/*", async (c, next) => {
|
||
if (c.req.path.startsWith("/api/v1/auth/")) {
|
||
await next();
|
||
return;
|
||
}
|
||
|
||
const authRequired = await isAuthRequired(c.env);
|
||
|
||
if (!authRequired) {
|
||
c.set("auth", {
|
||
kind: "user",
|
||
actorType: "user",
|
||
actorId: null,
|
||
username: "owner",
|
||
displayName: "Owner",
|
||
scopes: [],
|
||
});
|
||
await next();
|
||
return;
|
||
}
|
||
|
||
const auth = await authenticateRequest(c, true);
|
||
|
||
if (!auth) {
|
||
return unauthorized(c, "Authentication required.");
|
||
}
|
||
|
||
c.set("auth", auth);
|
||
await next();
|
||
});
|
||
|
||
app.get("/api/v1/api-tokens", async (c) => {
|
||
const userOnly = requireUser(c);
|
||
|
||
if (userOnly) {
|
||
return userOnly;
|
||
}
|
||
|
||
const rows = await c.env.DB.prepare(
|
||
`SELECT id, name, token_value, scopes_json, last_used_at, expires_at, is_revoked, created_at
|
||
FROM api_tokens
|
||
ORDER BY is_revoked ASC, created_at DESC
|
||
LIMIT 200`
|
||
).all<ApiTokenRow>();
|
||
|
||
return c.json({
|
||
apiTokens: rows.results.map(mapApiToken),
|
||
availableScopes: ALL_TOKEN_SCOPES,
|
||
});
|
||
});
|
||
|
||
app.post("/api/v1/api-tokens", zValidator("json", ApiTokenCreateSchema), async (c) => {
|
||
const userOnly = requireUser(c);
|
||
|
||
if (userOnly) {
|
||
return userOnly;
|
||
}
|
||
|
||
const input = c.req.valid("json");
|
||
const scopes = normalizeTokenScopes(input.scopes);
|
||
|
||
if (!scopes) {
|
||
return badRequest(c, "Token scope is not supported.");
|
||
}
|
||
|
||
const id = createId("tok");
|
||
const token = `${API_TOKEN_PREFIX}_${randomToken(API_TOKEN_BYTES)}`;
|
||
const now = isoNow();
|
||
const actor = getAuditActor(c);
|
||
|
||
await c.env.DB.batch([
|
||
c.env.DB.prepare(
|
||
`INSERT INTO api_tokens (id, name, token_hash, token_value, scopes_json, expires_at, created_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||
).bind(id, input.name, await sha256(token), token, JSON.stringify(scopes), input.expiresAt ?? null, now),
|
||
auditStatement(c.env.DB, actor.actorType, actor.actorId, "api_token.create", "api_token", id, {
|
||
name: input.name,
|
||
scopes,
|
||
expiresAt: input.expiresAt ?? null,
|
||
}),
|
||
]);
|
||
|
||
const row = await getApiTokenRow(c.env.DB, id);
|
||
|
||
if (!row) {
|
||
return notFound(c, "API token not found");
|
||
}
|
||
|
||
return c.json({ token, apiToken: mapApiToken(row) } satisfies CreatedApiToken, 201);
|
||
});
|
||
|
||
app.delete("/api/v1/api-tokens/:id", async (c) => {
|
||
const userOnly = requireUser(c);
|
||
|
||
if (userOnly) {
|
||
return userOnly;
|
||
}
|
||
|
||
const id = c.req.param("id");
|
||
const actor = getAuditActor(c);
|
||
|
||
await c.env.DB.batch([
|
||
c.env.DB.prepare(`UPDATE api_tokens SET is_revoked = 1 WHERE id = ?`).bind(id),
|
||
auditStatement(c.env.DB, actor.actorType, actor.actorId, "api_token.revoke", "api_token", id, {}),
|
||
]);
|
||
|
||
return c.json({ ok: true });
|
||
});
|
||
|
||
app.get("/api/v1/notebooks", async (c) => {
|
||
const denied = requireScopes(c, "read:notebooks");
|
||
|
||
if (denied) {
|
||
return denied;
|
||
}
|
||
|
||
if (isDemoMode(c.env)) {
|
||
await ensureDemoSeed(c.env);
|
||
}
|
||
|
||
const rows = await c.env.DB.prepare(
|
||
notebookSelectSql(
|
||
`WHERE n.is_deleted = 0
|
||
GROUP BY n.id, n.parent_id, n.name, n.slug, n.icon, n.color, n.sort_order, n.created_at, n.updated_at
|
||
ORDER BY n.parent_id IS NOT NULL, n.sort_order ASC, n.name ASC`
|
||
)
|
||
).all<NotebookRow>();
|
||
|
||
return c.json({ notebooks: rows.results.map(mapNotebook) });
|
||
});
|
||
|
||
app.post("/api/v1/notebooks", zValidator("json", NotebookCreateSchema), async (c) => {
|
||
const denied = requireScopes(c, "write:notebooks");
|
||
|
||
if (denied) {
|
||
return denied;
|
||
}
|
||
|
||
const input = c.req.valid("json");
|
||
const actor = getAuditActor(c);
|
||
|
||
try {
|
||
const notebook = await createNotebookRecord(c.env.DB, input, actor);
|
||
return c.json({ notebook }, 201);
|
||
} catch (error) {
|
||
if (error instanceof AppError) {
|
||
return apiError(c, error.code, error.message, error.status);
|
||
}
|
||
|
||
throw error;
|
||
}
|
||
});
|
||
|
||
app.patch("/api/v1/notebooks/:id", zValidator("json", NotebookUpdateSchema), async (c) => {
|
||
const denied = requireScopes(c, "write:notebooks");
|
||
|
||
if (denied) {
|
||
return denied;
|
||
}
|
||
|
||
const id = c.req.param("id");
|
||
const input = c.req.valid("json");
|
||
const actor = getAuditActor(c);
|
||
|
||
try {
|
||
const notebook = await updateNotebookRecord(c.env.DB, id, input, actor);
|
||
return c.json({ notebook });
|
||
} catch (error) {
|
||
if (error instanceof AppError) {
|
||
return apiError(c, error.code, error.message, error.status);
|
||
}
|
||
|
||
throw error;
|
||
}
|
||
});
|
||
|
||
app.delete("/api/v1/notebooks/:id", async (c) => {
|
||
const denied = requireScopes(c, "write:notebooks");
|
||
|
||
if (denied) {
|
||
return denied;
|
||
}
|
||
|
||
const id = c.req.param("id");
|
||
const actor = getAuditActor(c);
|
||
const now = isoNow();
|
||
const current = await getNotebook(c.env.DB, id);
|
||
|
||
if (!current) {
|
||
return notFound(c, "Notebook not found");
|
||
}
|
||
|
||
if (id === "nb_inbox" || current.slug === "inbox") {
|
||
return badRequest(c, "等待分类不能删除。");
|
||
}
|
||
|
||
const [childCount, memoCount] = await Promise.all([
|
||
c.env.DB.prepare(`SELECT COUNT(*) AS count FROM notebooks WHERE parent_id = ? AND is_deleted = 0`)
|
||
.bind(id)
|
||
.first<{ count: number }>(),
|
||
c.env.DB.prepare(`SELECT COUNT(*) AS count FROM memos WHERE notebook_id = ? AND is_deleted = 0`)
|
||
.bind(id)
|
||
.first<{ count: number }>(),
|
||
]);
|
||
|
||
if ((childCount?.count ?? 0) > 0 || (memoCount?.count ?? 0) > 0) {
|
||
return conflict(c, "notebook_not_empty", "Move or delete child notebooks and memos before deleting this notebook.");
|
||
}
|
||
|
||
await c.env.DB.prepare(
|
||
`UPDATE notebooks
|
||
SET is_deleted = 1, deleted_at = ?, updated_at = ?
|
||
WHERE id = ? AND id <> 'nb_inbox'`
|
||
)
|
||
.bind(now, now, id)
|
||
.run();
|
||
|
||
await audit(c.env.DB, actor.actorType, actor.actorId, "notebook.delete", "notebook", id, {});
|
||
return c.json({ ok: true });
|
||
});
|
||
|
||
app.get("/api/v1/tags", async (c) => {
|
||
const denied = requireScopes(c, "read:tags");
|
||
|
||
if (denied) {
|
||
return denied;
|
||
}
|
||
|
||
return c.json({ tags: await listTagSummaries(c.env.DB) });
|
||
});
|
||
|
||
app.patch("/api/v1/tags/:tag", zValidator("json", TagRenameSchema), async (c) => {
|
||
const denied = requireScopes(c, "write:tags");
|
||
|
||
if (denied) {
|
||
return denied;
|
||
}
|
||
|
||
const oldTag = decodeTagParam(c.req.param("tag"));
|
||
const input = c.req.valid("json");
|
||
const actor = getAuditActor(c);
|
||
const actorLabel = getActorLabel(c);
|
||
const updated = await updateTagAcrossMemos(c.env.DB, oldTag, input.name, actor, actorLabel);
|
||
|
||
return c.json({ ok: true, updated });
|
||
});
|
||
|
||
app.delete("/api/v1/tags/:tag", async (c) => {
|
||
const denied = requireScopes(c, "write:tags");
|
||
|
||
if (denied) {
|
||
return denied;
|
||
}
|
||
|
||
const tag = decodeTagParam(c.req.param("tag"));
|
||
const actor = getAuditActor(c);
|
||
const actorLabel = getActorLabel(c);
|
||
const updated = await updateTagAcrossMemos(c.env.DB, tag, null, actor, actorLabel);
|
||
|
||
return c.json({ ok: true, updated });
|
||
});
|
||
|
||
app.get("/api/v1/memos", async (c) => {
|
||
const denied = requireScopes(c, "read:memos");
|
||
|
||
if (denied) {
|
||
return denied;
|
||
}
|
||
|
||
const notebookId = c.req.query("notebookId");
|
||
const q = c.req.query("q")?.trim();
|
||
const includeTrash = c.req.query("trash") === "1";
|
||
const limit = clampNumber(Number(c.req.query("limit") ?? 5000), 1, 5000);
|
||
const deletedClause = includeTrash ? "m.is_deleted = 1" : "m.is_deleted = 0";
|
||
|
||
if (q) {
|
||
const ftsQuery = toFtsQuery(q);
|
||
const likeQuery = `%${escapeLike(q)}%`;
|
||
|
||
if (ftsQuery) {
|
||
const [rows, totalRow] = await Promise.all([
|
||
c.env.DB.prepare(
|
||
`WITH raw_matches(memo_id, rank) AS (
|
||
SELECT memo_id, bm25(memos_fts)
|
||
FROM memos_fts
|
||
WHERE memos_fts MATCH ?
|
||
|
||
UNION ALL
|
||
|
||
SELECT m.id, 100.0
|
||
FROM memos m
|
||
INNER JOIN memo_contents c ON c.memo_id = m.id
|
||
WHERE m.title LIKE ? ESCAPE '\\'
|
||
OR c.content_text LIKE ? ESCAPE '\\'
|
||
OR m.tags_json LIKE ? ESCAPE '\\'
|
||
),
|
||
search_matches AS (
|
||
SELECT memo_id, MIN(rank) AS rank
|
||
FROM raw_matches
|
||
GROUP BY memo_id
|
||
)
|
||
SELECT m.id, m.notebook_id, m.title, m.excerpt, m.tags_json, m.is_pinned,
|
||
m.is_archived, m.is_deleted, m.created_at, m.updated_at, m.deleted_at, c.revision
|
||
FROM search_matches s
|
||
INNER JOIN memos m ON m.id = s.memo_id
|
||
INNER JOIN memo_contents c ON c.memo_id = m.id
|
||
WHERE ${deletedClause}
|
||
AND (? IS NULL OR m.notebook_id = ?)
|
||
ORDER BY s.rank ASC, m.is_pinned DESC, m.updated_at DESC
|
||
LIMIT ?`
|
||
)
|
||
.bind(ftsQuery, likeQuery, likeQuery, likeQuery, notebookId ?? null, notebookId ?? null, limit)
|
||
.all<MemoSummaryRow>(),
|
||
c.env.DB.prepare(
|
||
`WITH raw_matches(memo_id) AS (
|
||
SELECT memo_id
|
||
FROM memos_fts
|
||
WHERE memos_fts MATCH ?
|
||
|
||
UNION ALL
|
||
|
||
SELECT m.id
|
||
FROM memos m
|
||
INNER JOIN memo_contents c ON c.memo_id = m.id
|
||
WHERE m.title LIKE ? ESCAPE '\\'
|
||
OR c.content_text LIKE ? ESCAPE '\\'
|
||
OR m.tags_json LIKE ? ESCAPE '\\'
|
||
),
|
||
search_matches AS (
|
||
SELECT memo_id
|
||
FROM raw_matches
|
||
GROUP BY memo_id
|
||
)
|
||
SELECT COUNT(*) AS count
|
||
FROM search_matches s
|
||
INNER JOIN memos m ON m.id = s.memo_id
|
||
WHERE ${deletedClause}
|
||
AND (? IS NULL OR m.notebook_id = ?)`
|
||
)
|
||
.bind(ftsQuery, likeQuery, likeQuery, likeQuery, notebookId ?? null, notebookId ?? null)
|
||
.first<{ count: number }>(),
|
||
]);
|
||
|
||
return c.json({ memos: rows.results.map(mapMemoSummary), totalCount: totalRow?.count ?? rows.results.length });
|
||
}
|
||
}
|
||
|
||
const [rows, totalRow] = await Promise.all([
|
||
c.env.DB.prepare(
|
||
`SELECT m.id, m.notebook_id, m.title, m.excerpt, m.tags_json, m.is_pinned,
|
||
m.is_archived, m.is_deleted, m.created_at, m.updated_at, m.deleted_at, c.revision
|
||
FROM memos m
|
||
INNER JOIN memo_contents c ON c.memo_id = m.id
|
||
WHERE ${deletedClause}
|
||
AND (? IS NULL OR m.notebook_id = ?)
|
||
ORDER BY ${includeTrash ? "m.deleted_at DESC," : "m.is_pinned DESC,"} m.updated_at DESC
|
||
LIMIT ?`
|
||
)
|
||
.bind(notebookId ?? null, notebookId ?? null, limit)
|
||
.all<MemoSummaryRow>(),
|
||
c.env.DB.prepare(
|
||
`SELECT COUNT(*) AS count
|
||
FROM memos m
|
||
WHERE ${deletedClause}
|
||
AND (? IS NULL OR m.notebook_id = ?)`
|
||
)
|
||
.bind(notebookId ?? null, notebookId ?? null)
|
||
.first<{ count: number }>(),
|
||
]);
|
||
|
||
return c.json({ memos: rows.results.map(mapMemoSummary), totalCount: totalRow?.count ?? rows.results.length });
|
||
});
|
||
|
||
app.post("/api/v1/memos", zValidator("json", MemoCreateSchema), async (c) => {
|
||
const denied = requireScopes(c, "write:memos");
|
||
|
||
if (denied) {
|
||
return denied;
|
||
}
|
||
|
||
const input = c.req.valid("json");
|
||
const actor = getAuditActor(c);
|
||
const actorLabel = getActorLabel(c);
|
||
const tags = normalizeTags(input.tags);
|
||
const contentMarkdown = input.contentMarkdown ?? "";
|
||
const contentJson = markdownToDoc(contentMarkdown);
|
||
const contentText = docToText(contentJson);
|
||
const title = normalizeMemoTitle(input.title);
|
||
const excerpt = createExcerpt(contentText);
|
||
const contentHash = await sha256(contentMarkdown + JSON.stringify(contentJson));
|
||
const id = createId("memo");
|
||
const now = isoNow();
|
||
const createdAt = input.createdAt ?? now;
|
||
const updatedAt = input.updatedAt ?? now;
|
||
|
||
await c.env.DB.batch([
|
||
c.env.DB.prepare(
|
||
`INSERT INTO memos (
|
||
id, notebook_id, title, excerpt, tags_json, created_by, updated_by, created_at, updated_at
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||
).bind(id, input.notebookId, title, excerpt, JSON.stringify(tags), actorLabel, actorLabel, createdAt, updatedAt),
|
||
c.env.DB.prepare(
|
||
`INSERT INTO memo_contents (
|
||
memo_id, content_json, content_markdown, content_text, content_hash, revision, created_at, updated_at
|
||
) VALUES (?, ?, ?, ?, ?, 0, ?, ?)`
|
||
).bind(id, JSON.stringify(contentJson), contentMarkdown, contentText, contentHash, createdAt, updatedAt),
|
||
c.env.DB.prepare(
|
||
`INSERT INTO memos_fts (memo_id, title, content_text, tags)
|
||
VALUES (?, ?, ?, ?)`
|
||
).bind(id, title, contentText, tags.join(" ")),
|
||
auditStatement(c.env.DB, actor.actorType, actor.actorId, "memo.create", "memo", id, {
|
||
notebookId: input.notebookId,
|
||
}),
|
||
]);
|
||
|
||
return c.json({ memo: await getMemoDetail(c.env.DB, id) }, 201);
|
||
});
|
||
|
||
app.post("/api/v1/memos/batch/move", zValidator("json", MoveMemosSchema), async (c) => {
|
||
const denied = requireScopes(c, "write:memos");
|
||
|
||
if (denied) {
|
||
return denied;
|
||
}
|
||
|
||
const input = c.req.valid("json");
|
||
const target = await getNotebook(c.env.DB, input.notebookId);
|
||
|
||
if (!target) {
|
||
return notFound(c, "Target notebook not found");
|
||
}
|
||
|
||
const actor = getAuditActor(c);
|
||
const actorLabel = getActorLabel(c);
|
||
|
||
try {
|
||
const moved = await moveMemosToNotebook(c.env.DB, input.memoIds, input.notebookId, actor, actorLabel);
|
||
|
||
return c.json({ ok: true, moved });
|
||
} catch (error) {
|
||
if (error instanceof AppError) {
|
||
return apiError(c, error.code, error.message, error.status);
|
||
}
|
||
|
||
throw error;
|
||
}
|
||
});
|
||
|
||
app.post("/api/v1/memos/batch/delete", zValidator("json", DeleteMemosSchema), async (c) => {
|
||
const denied = requireScopes(c, "write:memos");
|
||
|
||
if (denied) {
|
||
return denied;
|
||
}
|
||
|
||
const input = c.req.valid("json");
|
||
const actor = getAuditActor(c);
|
||
|
||
try {
|
||
const deleted = await deleteMemosRecord(c.env.DB, c.env.RESOURCES, input.memoIds, Boolean(input.permanent), actor);
|
||
return c.json({ ok: true, deleted });
|
||
} catch (error) {
|
||
if (error instanceof AppError) {
|
||
return apiError(c, error.code, error.message, error.status);
|
||
}
|
||
|
||
throw error;
|
||
}
|
||
});
|
||
|
||
app.delete("/api/v1/memos/trash/empty", async (c) => {
|
||
const denied = requireScopes(c, "write:memos");
|
||
|
||
if (denied) {
|
||
return denied;
|
||
}
|
||
|
||
const actor = getAuditActor(c);
|
||
const deleted = await emptyTrashMemosRecord(c.env.DB, c.env.RESOURCES, actor);
|
||
|
||
return c.json({ ok: true, deleted });
|
||
});
|
||
|
||
app.get("/api/v1/memos/:id", async (c) => {
|
||
const denied = requireScopes(c, "read:memos");
|
||
|
||
if (denied) {
|
||
return denied;
|
||
}
|
||
|
||
const includeDeleted = c.req.query("includeDeleted") === "1";
|
||
const memo = await getMemoDetail(c.env.DB, c.req.param("id"), includeDeleted);
|
||
|
||
if (!memo) {
|
||
return notFound(c, "Memo not found");
|
||
}
|
||
|
||
return c.json({ memo });
|
||
});
|
||
|
||
app.get("/api/v1/memos/:id/revisions", async (c) => {
|
||
const denied = requireScopes(c, "read:memos");
|
||
|
||
if (denied) {
|
||
return denied;
|
||
}
|
||
|
||
const memoId = c.req.param("id");
|
||
const memo = await getMemoDetail(c.env.DB, memoId);
|
||
|
||
if (!memo) {
|
||
return notFound(c, "Memo not found");
|
||
}
|
||
|
||
const limit = clampNumber(Number(c.req.query("limit") ?? 50), 1, 100);
|
||
const rows = await c.env.DB.prepare(
|
||
`SELECT id, memo_id, revision, title, tags_json, content_json, content_markdown,
|
||
content_text, content_hash, created_by, created_at
|
||
FROM memo_revisions
|
||
WHERE memo_id = ?
|
||
ORDER BY revision DESC, created_at DESC
|
||
LIMIT ?`
|
||
)
|
||
.bind(memoId, limit)
|
||
.all<MemoRevisionRow>();
|
||
|
||
return c.json({ revisions: rows.results.map(mapMemoRevision) });
|
||
});
|
||
|
||
app.post("/api/v1/memos/:id/revisions/:revisionId/restore", async (c) => {
|
||
const denied = requireScopes(c, "write:memos");
|
||
|
||
if (denied) {
|
||
return denied;
|
||
}
|
||
|
||
const memoId = c.req.param("id");
|
||
const revisionId = c.req.param("revisionId");
|
||
const actor = getAuditActor(c);
|
||
const actorLabel = getActorLabel(c);
|
||
const current = await getMemoDetailRow(c.env.DB, memoId);
|
||
|
||
if (!current) {
|
||
return notFound(c, "Memo not found");
|
||
}
|
||
|
||
const revision = await getMemoRevisionRow(c.env.DB, memoId, revisionId);
|
||
|
||
if (!revision) {
|
||
return notFound(c, "Memo revision not found");
|
||
}
|
||
|
||
const tags = parseJsonArray(revision.tags_json);
|
||
const contentJson = parseDoc(revision.content_json);
|
||
const contentMarkdown = revision.content_markdown || docToMarkdown(contentJson);
|
||
const contentText = revision.content_text || docToText(contentJson);
|
||
const title = normalizeMemoTitle(revision.title);
|
||
const excerpt = createExcerpt(contentText);
|
||
const contentHash = await sha256(contentMarkdown + JSON.stringify(contentJson));
|
||
const nextRevision = current.revision + 1;
|
||
const now = isoNow();
|
||
|
||
await c.env.DB.batch([
|
||
createMemoRevisionStatement(c.env.DB, current, actorLabel, now),
|
||
c.env.DB.prepare(
|
||
`UPDATE memos
|
||
SET title = ?, excerpt = ?, tags_json = ?, updated_by = ?, updated_at = ?
|
||
WHERE id = ? AND is_deleted = 0`
|
||
).bind(title, excerpt, JSON.stringify(tags), actorLabel, now, memoId),
|
||
c.env.DB.prepare(
|
||
`UPDATE memo_contents
|
||
SET content_json = ?, content_markdown = ?, content_text = ?, content_hash = ?,
|
||
revision = ?, updated_at = ?
|
||
WHERE memo_id = ?`
|
||
).bind(JSON.stringify(contentJson), contentMarkdown, contentText, contentHash, nextRevision, now, memoId),
|
||
c.env.DB.prepare(`DELETE FROM memos_fts WHERE memo_id = ?`).bind(memoId),
|
||
c.env.DB.prepare(
|
||
`INSERT INTO memos_fts (memo_id, title, content_text, tags)
|
||
VALUES (?, ?, ?, ?)`
|
||
).bind(memoId, title, contentText, tags.join(" ")),
|
||
auditStatement(c.env.DB, actor.actorType, actor.actorId, "memo.revision_restore", "memo", memoId, {
|
||
revisionId,
|
||
restoredRevision: revision.revision,
|
||
revision: nextRevision,
|
||
}),
|
||
]);
|
||
|
||
return c.json({ memo: await getMemoDetail(c.env.DB, memoId) });
|
||
});
|
||
|
||
app.get("/api/v1/resources", async (c) => {
|
||
const denied = requireScopes(c, "read:resources");
|
||
|
||
if (denied) {
|
||
return denied;
|
||
}
|
||
|
||
const limit = clampNumber(Number(c.req.query("limit") ?? 500), 1, 500);
|
||
const [rows, stats] = await Promise.all([
|
||
c.env.DB.prepare(
|
||
`SELECT r.id, r.memo_id, r.original_memo_id, r.bucket_name, r.object_key, r.kind,
|
||
r.mime_type, r.filename, r.byte_size, r.sha256, r.width, r.height,
|
||
r.created_at, r.updated_at, m.title AS memo_title, m.excerpt AS memo_excerpt,
|
||
m.is_deleted AS memo_is_deleted
|
||
FROM resources r
|
||
LEFT JOIN memos m ON m.id = r.memo_id
|
||
WHERE r.is_deleted = 0
|
||
ORDER BY r.created_at DESC
|
||
LIMIT ?`
|
||
)
|
||
.bind(limit)
|
||
.all<ResourceListRow>(),
|
||
c.env.DB.prepare(
|
||
`SELECT COUNT(*) AS total_count,
|
||
COALESCE(SUM(byte_size), 0) AS total_bytes,
|
||
COALESCE(SUM(CASE WHEN kind = 'image' THEN 1 ELSE 0 END), 0) AS image_count,
|
||
COALESCE(SUM(CASE WHEN kind = 'attachment' THEN 1 ELSE 0 END), 0) AS attachment_count
|
||
FROM resources
|
||
WHERE is_deleted = 0`
|
||
).first<ResourceStatsRow>(),
|
||
]);
|
||
|
||
return c.json({
|
||
resources: rows.results.map(mapResourceListItem),
|
||
summary: mapResourceStorageSummary(stats),
|
||
});
|
||
});
|
||
|
||
app.post("/api/v1/memos/:id/resources", async (c) => {
|
||
const denied = requireScopes(c, "write:resources");
|
||
|
||
if (denied) {
|
||
return denied;
|
||
}
|
||
|
||
const memoId = c.req.param("id");
|
||
const memo = await getMemoDetail(c.env.DB, memoId);
|
||
|
||
if (!memo) {
|
||
return notFound(c, "Memo not found");
|
||
}
|
||
|
||
const form = await c.req.raw.formData();
|
||
const file = form.get("file");
|
||
|
||
if (!(file instanceof File)) {
|
||
return badRequest(c, "Expected multipart form field named file.");
|
||
}
|
||
|
||
const actor = getAuditActor(c);
|
||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||
const mimeType = file.type || "application/octet-stream";
|
||
let resource: Resource;
|
||
|
||
try {
|
||
resource = SUPPORTED_IMAGE_MIME_TYPES.has(mimeType)
|
||
? await createImageResource(c, {
|
||
memoId,
|
||
filename: file.name,
|
||
mimeType,
|
||
bytes,
|
||
actor,
|
||
source: "upload",
|
||
})
|
||
: await createAttachmentResource(c, {
|
||
memoId,
|
||
filename: file.name,
|
||
mimeType,
|
||
bytes,
|
||
actor,
|
||
});
|
||
} 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 processed = prepareImageForStorage({
|
||
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(processed.filename) || `${resourceId}${inferImageExtension(processed.filename, processed.mimeType)}`;
|
||
const checksum = await sha256Bytes(processed.bytes);
|
||
|
||
await c.env.RESOURCES.put(objectKey, processed.bytes, {
|
||
httpMetadata: {
|
||
contentType: processed.mimeType,
|
||
cacheControl: "private, max-age=3600",
|
||
},
|
||
customMetadata: {
|
||
memoId: input.memoId,
|
||
resourceId,
|
||
filename,
|
||
},
|
||
});
|
||
|
||
try {
|
||
await c.env.DB.batch([
|
||
c.env.DB.prepare(
|
||
`INSERT INTO resources (
|
||
id, memo_id, bucket_name, object_key, kind, mime_type, filename,
|
||
byte_size, sha256, width, height, metadata_json, created_at, updated_at
|
||
) VALUES (?, ?, ?, ?, 'image', ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||
).bind(
|
||
resourceId,
|
||
input.memoId,
|
||
bucketName,
|
||
objectKey,
|
||
processed.mimeType,
|
||
filename,
|
||
processed.bytes.byteLength,
|
||
checksum,
|
||
processed.width,
|
||
processed.height,
|
||
JSON.stringify(processed.metadata),
|
||
now,
|
||
now
|
||
),
|
||
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) {
|
||
await c.env.RESOURCES.delete(objectKey);
|
||
throw error;
|
||
}
|
||
|
||
const resource = await getResourceRow(c.env.DB, resourceId);
|
||
|
||
if (!resource) {
|
||
throw new AppError("not_found", "Resource not found", 404);
|
||
}
|
||
|
||
return mapResource(resource);
|
||
};
|
||
|
||
const createAttachmentResource = async (
|
||
c: AppContext,
|
||
input: {
|
||
memoId: string;
|
||
filename: string;
|
||
mimeType: string;
|
||
bytes: Uint8Array;
|
||
actor: AuditActor;
|
||
}
|
||
) => {
|
||
validateAttachmentUpload(input.bytes.byteLength);
|
||
|
||
const resourceId = createId("res");
|
||
const now = isoNow();
|
||
const filename = normalizeFilename(input.filename) || resourceId;
|
||
const objectKey = `memos/${input.memoId}/${resourceId}`;
|
||
const bucketName = c.env.EDGE_EVER_R2_BUCKET_NAME?.trim() || DEFAULT_R2_BUCKET_NAME;
|
||
const checksum = await sha256Bytes(input.bytes);
|
||
|
||
await c.env.RESOURCES.put(objectKey, input.bytes, {
|
||
httpMetadata: {
|
||
contentType: input.mimeType,
|
||
cacheControl: "private, max-age=3600",
|
||
},
|
||
customMetadata: {
|
||
memoId: input.memoId,
|
||
resourceId,
|
||
filename,
|
||
},
|
||
});
|
||
|
||
try {
|
||
await c.env.DB.batch([
|
||
c.env.DB.prepare(
|
||
`INSERT INTO resources (
|
||
id, memo_id, bucket_name, object_key, kind, mime_type, filename,
|
||
byte_size, sha256, width, height, metadata_json, created_at, updated_at
|
||
) VALUES (?, ?, ?, ?, 'attachment', ?, ?, ?, ?, NULL, NULL, ?, ?, ?)`
|
||
).bind(
|
||
resourceId,
|
||
input.memoId,
|
||
bucketName,
|
||
objectKey,
|
||
input.mimeType,
|
||
filename,
|
||
input.bytes.byteLength,
|
||
checksum,
|
||
JSON.stringify({ originalFilename: filename }),
|
||
now,
|
||
now
|
||
),
|
||
auditStatement(c.env.DB, input.actor.actorType, input.actor.actorId, "resource.create", "resource", resourceId, {
|
||
memoId: input.memoId,
|
||
mimeType: input.mimeType,
|
||
byteSize: input.bytes.byteLength,
|
||
}),
|
||
]);
|
||
} catch (error) {
|
||
await c.env.RESOURCES.delete(objectKey);
|
||
throw error;
|
||
}
|
||
|
||
const resource = await getResourceRow(c.env.DB, resourceId);
|
||
|
||
if (!resource) {
|
||
throw new AppError("not_found", "Resource not found", 404);
|
||
}
|
||
|
||
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);
|
||
}
|
||
};
|
||
|
||
const validateAttachmentUpload = (size: number) => {
|
||
if (size <= 0 || size > MAX_ATTACHMENT_UPLOAD_BYTES) {
|
||
throw new AppError("upload_too_large", "Attachment 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 = (input: {
|
||
bytes: Uint8Array;
|
||
filename: string;
|
||
mimeType: string;
|
||
source: "upload" | "mcp";
|
||
}): PreparedImage => ({
|
||
bytes: input.bytes,
|
||
mimeType: input.mimeType,
|
||
filename: input.filename,
|
||
width: null,
|
||
height: null,
|
||
compressed: false,
|
||
metadata: {
|
||
source: input.source,
|
||
originalFilename: normalizeFilename(input.filename) || null,
|
||
originalMimeType: input.mimeType,
|
||
originalByteSize: input.bytes.byteLength,
|
||
compression: "disabled",
|
||
},
|
||
});
|
||
|
||
app.get("/api/v1/resources/:id/blob", async (c) => {
|
||
const denied = requireScopes(c, "read:resources");
|
||
|
||
if (denied) {
|
||
return denied;
|
||
}
|
||
|
||
const resource = await getResourceRow(c.env.DB, c.req.param("id"));
|
||
|
||
if (!resource) {
|
||
return notFound(c, "Resource not found");
|
||
}
|
||
|
||
const object = await c.env.RESOURCES.get(resource.object_key);
|
||
|
||
if (!object) {
|
||
return notFound(c, "Resource object not found");
|
||
}
|
||
|
||
const headers = new Headers();
|
||
object.writeHttpMetadata(headers);
|
||
headers.set("Content-Type", resource.mime_type ?? headers.get("Content-Type") ?? "application/octet-stream");
|
||
headers.set("Cache-Control", headers.get("Cache-Control") ?? "private, max-age=3600");
|
||
headers.set("Content-Length", String(object.size));
|
||
headers.set("Content-Disposition", contentDispositionInline(resource.filename));
|
||
headers.set("X-Content-Type-Options", "nosniff");
|
||
|
||
return new Response(object.body, { headers });
|
||
});
|
||
|
||
app.patch("/api/v1/memos/:id", zValidator("json", MemoUpdateSchema), async (c) => {
|
||
const denied = requireScopes(c, "write:memos");
|
||
|
||
if (denied) {
|
||
return denied;
|
||
}
|
||
|
||
const id = c.req.param("id");
|
||
const input = c.req.valid("json");
|
||
const actor = getAuditActor(c);
|
||
const actorLabel = getActorLabel(c);
|
||
const current = await getMemoDetailRow(c.env.DB, id);
|
||
|
||
if (!current) {
|
||
return notFound(c, "Memo not found");
|
||
}
|
||
|
||
if (input.expectedRevision !== undefined && input.expectedRevision !== current.revision) {
|
||
return c.json(
|
||
{
|
||
error: {
|
||
code: "revision_conflict",
|
||
message: "Memo was updated elsewhere. Reload before saving.",
|
||
details: {
|
||
expectedRevision: input.expectedRevision,
|
||
currentRevision: current.revision,
|
||
},
|
||
},
|
||
},
|
||
409
|
||
);
|
||
}
|
||
|
||
const isPinned = input.isPinned ?? Boolean(current.is_pinned);
|
||
const hasContentUpdate =
|
||
input.notebookId !== undefined ||
|
||
input.title !== undefined ||
|
||
input.contentJson !== undefined ||
|
||
input.contentMarkdown !== undefined ||
|
||
input.tags !== undefined ||
|
||
input.createdAt !== undefined ||
|
||
input.updatedAt !== undefined;
|
||
const now = isoNow();
|
||
const updatedAt = input.updatedAt ?? now;
|
||
|
||
if (!hasContentUpdate) {
|
||
if (input.isPinned === undefined || isPinned === Boolean(current.is_pinned)) {
|
||
return c.json({ memo: await getMemoDetail(c.env.DB, id) });
|
||
}
|
||
|
||
await c.env.DB.batch([
|
||
c.env.DB.prepare(
|
||
`UPDATE memos
|
||
SET is_pinned = ?, updated_by = ?, updated_at = ?, created_at = COALESCE(?, created_at)
|
||
WHERE id = ? AND is_deleted = 0`
|
||
).bind(isPinned ? 1 : 0, actorLabel, updatedAt, input.createdAt ?? null, id),
|
||
auditStatement(c.env.DB, actor.actorType, actor.actorId, isPinned ? "memo.pin" : "memo.unpin", "memo", id, {}),
|
||
]);
|
||
|
||
return c.json({ memo: await getMemoDetail(c.env.DB, id) });
|
||
}
|
||
|
||
const currentContentJson = JSON.parse(current.content_json) as TiptapDoc;
|
||
const contentJson = input.contentJson
|
||
? (input.contentJson as TiptapDoc)
|
||
: input.contentMarkdown !== undefined
|
||
? markdownToDoc(input.contentMarkdown)
|
||
: currentContentJson;
|
||
const contentMarkdown =
|
||
input.contentMarkdown !== undefined ? input.contentMarkdown : docToMarkdown(contentJson);
|
||
const contentText = docToText(contentJson);
|
||
const title =
|
||
input.title !== undefined ? normalizeMemoTitle(input.title) : normalizeMemoTitle(current.title);
|
||
const tags = input.tags === undefined ? parseJsonArray(current.tags_json) : normalizeTags(input.tags);
|
||
const excerpt = createExcerpt(contentText);
|
||
const notebookId = input.notebookId ?? current.notebook_id;
|
||
const nextRevision = current.revision + 1;
|
||
const contentHash = await sha256(contentMarkdown + JSON.stringify(contentJson));
|
||
const revisionStatements = (await shouldSnapshotMemoRevision(c.env.DB, current, title, JSON.stringify(tags), contentHash, updatedAt))
|
||
? [createMemoRevisionStatement(c.env.DB, current, actorLabel, updatedAt)]
|
||
: [];
|
||
|
||
await c.env.DB.batch([
|
||
...revisionStatements,
|
||
c.env.DB.prepare(
|
||
`UPDATE memos
|
||
SET notebook_id = ?, title = ?, excerpt = ?, tags_json = ?, is_pinned = ?, updated_by = ?, updated_at = ?, created_at = COALESCE(?, created_at)
|
||
WHERE id = ? AND is_deleted = 0`
|
||
).bind(notebookId, title, excerpt, JSON.stringify(tags), isPinned ? 1 : 0, actorLabel, updatedAt, input.createdAt ?? null, id),
|
||
c.env.DB.prepare(
|
||
`UPDATE memo_contents
|
||
SET content_json = ?, content_markdown = ?, content_text = ?, content_hash = ?,
|
||
revision = ?, updated_at = ?, created_at = COALESCE(?, created_at)
|
||
WHERE memo_id = ?`
|
||
).bind(JSON.stringify(contentJson), contentMarkdown, contentText, contentHash, nextRevision, updatedAt, input.createdAt ?? null, id),
|
||
c.env.DB.prepare(`DELETE FROM memos_fts WHERE memo_id = ?`).bind(id),
|
||
c.env.DB.prepare(
|
||
`INSERT INTO memos_fts (memo_id, title, content_text, tags)
|
||
VALUES (?, ?, ?, ?)`
|
||
).bind(id, title, contentText, tags.join(" ")),
|
||
auditStatement(c.env.DB, actor.actorType, actor.actorId, "memo.update", "memo", id, {
|
||
revision: nextRevision,
|
||
}),
|
||
]);
|
||
|
||
return c.json({ memo: await getMemoDetail(c.env.DB, id) });
|
||
});
|
||
|
||
app.delete("/api/v1/memos/:id", async (c) => {
|
||
const denied = requireScopes(c, "write:memos");
|
||
|
||
if (denied) {
|
||
return denied;
|
||
}
|
||
|
||
const id = c.req.param("id");
|
||
const actor = getAuditActor(c);
|
||
const permanent = c.req.query("permanent") === "1";
|
||
const now = isoNow();
|
||
|
||
if (permanent) {
|
||
const current = await getMemoDetailRow(c.env.DB, id, true);
|
||
|
||
if (!current || current.is_deleted === 0) {
|
||
return notFound(c, "Memo not found in trash");
|
||
}
|
||
|
||
const resources = await getResourceRowsForMemo(c.env.DB, id);
|
||
|
||
if (resources.length > 0) {
|
||
await c.env.RESOURCES.delete(resources.map((resource) => resource.object_key));
|
||
}
|
||
|
||
await c.env.DB.batch([
|
||
c.env.DB.prepare(`DELETE FROM memos_fts WHERE memo_id = ?`).bind(id),
|
||
c.env.DB.prepare(`DELETE FROM resources WHERE memo_id = ?`).bind(id),
|
||
c.env.DB.prepare(`DELETE FROM memo_revisions WHERE memo_id = ?`).bind(id),
|
||
c.env.DB.prepare(`DELETE FROM memo_contents WHERE memo_id = ?`).bind(id),
|
||
c.env.DB.prepare(`DELETE FROM memos WHERE id = ? AND is_deleted = 1`).bind(id),
|
||
auditStatement(c.env.DB, actor.actorType, actor.actorId, "memo.delete_permanent", "memo", id, {}),
|
||
]);
|
||
|
||
return c.json({ ok: true });
|
||
}
|
||
|
||
await c.env.DB.batch([
|
||
c.env.DB.prepare(
|
||
`UPDATE memos
|
||
SET is_deleted = 1, deleted_at = ?, updated_at = ?
|
||
WHERE id = ? AND is_deleted = 0`
|
||
).bind(now, now, id),
|
||
c.env.DB.prepare(`DELETE FROM memos_fts WHERE memo_id = ?`).bind(id),
|
||
auditStatement(c.env.DB, actor.actorType, actor.actorId, "memo.delete", "memo", id, {}),
|
||
]);
|
||
|
||
return c.json({ ok: true });
|
||
});
|
||
|
||
app.post("/api/v1/memos/:id/restore", async (c) => {
|
||
const denied = requireScopes(c, "write:memos");
|
||
|
||
if (denied) {
|
||
return denied;
|
||
}
|
||
|
||
const id = c.req.param("id");
|
||
const actor = getAuditActor(c);
|
||
const current = await getMemoDetailRow(c.env.DB, id, true);
|
||
|
||
if (!current || current.is_deleted === 0) {
|
||
return notFound(c, "Memo not found in trash");
|
||
}
|
||
|
||
const tags = parseJsonArray(current.tags_json);
|
||
const now = isoNow();
|
||
|
||
await c.env.DB.batch([
|
||
c.env.DB.prepare(
|
||
`UPDATE memos
|
||
SET is_deleted = 0, deleted_at = NULL, updated_at = ?
|
||
WHERE id = ? AND is_deleted = 1`
|
||
).bind(now, id),
|
||
c.env.DB.prepare(`DELETE FROM memos_fts WHERE memo_id = ?`).bind(id),
|
||
c.env.DB.prepare(
|
||
`INSERT INTO memos_fts (memo_id, title, content_text, tags)
|
||
VALUES (?, ?, ?, ?)`
|
||
).bind(id, current.title, current.content_text, tags.join(" ")),
|
||
auditStatement(c.env.DB, actor.actorType, actor.actorId, "memo.restore", "memo", id, {}),
|
||
]);
|
||
|
||
return c.json({ memo: await getMemoDetail(c.env.DB, id) });
|
||
});
|
||
|
||
app.post("/api/v1/memos/merge", zValidator("json", MergeMemosSchema), async (c) => {
|
||
const denied = requireScopes(c, "write:memos");
|
||
|
||
if (denied) {
|
||
return denied;
|
||
}
|
||
|
||
const input = c.req.valid("json");
|
||
const actor = getAuditActor(c);
|
||
const actorLabel = getActorLabel(c);
|
||
|
||
try {
|
||
const memo = await mergeMemosRecord(c.env.DB, input, actor, actorLabel);
|
||
return c.json({ memo }, 201);
|
||
} catch (error) {
|
||
if (error instanceof AppError) {
|
||
return apiError(c, error.code, error.message, error.status);
|
||
}
|
||
|
||
throw error;
|
||
}
|
||
});
|
||
|
||
app.get("/mcp", (c) =>
|
||
c.json({
|
||
name: "EdgeEver MCP endpoint",
|
||
status: "ready",
|
||
transport: "streamable-http-jsonrpc",
|
||
auth: "Authorization: Bearer <api-token>",
|
||
restBasePath: "/api/v1",
|
||
})
|
||
);
|
||
|
||
app.post("/mcp", async (c) => {
|
||
let payload: unknown;
|
||
|
||
try {
|
||
payload = await c.req.json();
|
||
} catch {
|
||
return c.json(jsonRpcError(null, -32700, "Parse error"), 400);
|
||
}
|
||
|
||
if (Array.isArray(payload)) {
|
||
if (payload.length === 0) {
|
||
return c.json(jsonRpcError(null, -32600, "Invalid Request"), 400);
|
||
}
|
||
|
||
const results = await Promise.all(payload.map((request) => handleMcpMessage(c, request)));
|
||
const responses = results.filter((result): result is JsonRpcHandlerResult => Boolean(result));
|
||
const bodies = responses.map((response) => response.body);
|
||
|
||
if (bodies.length === 0) {
|
||
return new Response(null, { status: 204 });
|
||
}
|
||
|
||
return c.json(bodies, Math.max(...responses.map((response) => response.status)) as 200);
|
||
}
|
||
|
||
const result = await handleMcpMessage(c, payload);
|
||
|
||
if (!result) {
|
||
return new Response(null, { status: 204 });
|
||
}
|
||
|
||
return c.json(result.body, result.status as 200);
|
||
});
|
||
|
||
const worker = {
|
||
fetch(request: Request, env: Bindings, ctx: ExecutionContext) {
|
||
return app.fetch(request, env, ctx);
|
||
},
|
||
async scheduled(controller: ScheduledController, env: Bindings, ctx: ExecutionContext) {
|
||
if (!isDemoMode(env)) {
|
||
return;
|
||
}
|
||
|
||
ctx.waitUntil(resetDemoData(env, controller.scheduledTime));
|
||
},
|
||
};
|
||
|
||
app.notFound((c) =>
|
||
c.json(
|
||
{
|
||
error: {
|
||
code: "not_found",
|
||
message: "Route not found",
|
||
},
|
||
},
|
||
404
|
||
)
|
||
);
|
||
|
||
export default worker;
|
||
|
||
type JsonRpcRequest = {
|
||
jsonrpc?: string;
|
||
id?: string | number | null;
|
||
method?: string;
|
||
params?: unknown;
|
||
};
|
||
|
||
type JsonRpcId = string | number | null;
|
||
type JsonRpcHandlerResult = {
|
||
body: unknown;
|
||
status: number;
|
||
};
|
||
|
||
class AppError extends Error {
|
||
code: string;
|
||
status: number;
|
||
|
||
constructor(code: string, message: string, status = 400) {
|
||
super(message);
|
||
this.name = "AppError";
|
||
this.code = code;
|
||
this.status = status;
|
||
}
|
||
}
|
||
|
||
const MCP_PROTOCOL_VERSION = "2024-11-05";
|
||
|
||
const handleMcpMessage = async (c: AppContext, payload: unknown): Promise<JsonRpcHandlerResult | null> => {
|
||
const request = payload as JsonRpcRequest;
|
||
const id = getJsonRpcId(payload);
|
||
const isNotification =
|
||
payload &&
|
||
typeof payload === "object" &&
|
||
!("id" in payload) &&
|
||
typeof (payload as JsonRpcRequest).method === "string";
|
||
|
||
if (!request || request.jsonrpc !== "2.0" || typeof request.method !== "string") {
|
||
return { body: jsonRpcError(id, -32600, "Invalid Request"), status: 400 };
|
||
}
|
||
|
||
if (request.method === "notifications/initialized" && isNotification) {
|
||
return null;
|
||
}
|
||
|
||
if (request.method === "initialize") {
|
||
return {
|
||
body: jsonRpcResult(request.id ?? null, {
|
||
protocolVersion: MCP_PROTOCOL_VERSION,
|
||
capabilities: {
|
||
tools: {
|
||
listChanged: false,
|
||
},
|
||
},
|
||
serverInfo: {
|
||
name: "edgeever",
|
||
version: "0.1.0",
|
||
},
|
||
instructions:
|
||
"Use scoped EdgeEver API tokens. Prefer read-only scopes for search/list/get tools and grant write scopes only to agents that modify notes.",
|
||
}),
|
||
status: 200,
|
||
};
|
||
}
|
||
|
||
const auth = await authenticateRequest(c, true);
|
||
|
||
if (!auth) {
|
||
return { body: jsonRpcError(request.id ?? null, -32001, "Authentication required"), status: 401 };
|
||
}
|
||
|
||
c.set("auth", auth);
|
||
|
||
if (request.method === "tools/list") {
|
||
return {
|
||
body: jsonRpcResult(request.id ?? null, {
|
||
tools: MCP_TOOLS,
|
||
}),
|
||
status: 200,
|
||
};
|
||
}
|
||
|
||
if (request.method === "tools/call") {
|
||
const params = asRecord(request.params);
|
||
const name = getOptionalString(params.name);
|
||
|
||
if (!name) {
|
||
return { body: jsonRpcError(request.id ?? null, -32602, "Tool name is required"), status: 400 };
|
||
}
|
||
|
||
try {
|
||
const result = await callMcpTool(c, auth, name, asRecord(params.arguments));
|
||
return {
|
||
body: jsonRpcResult(request.id ?? null, {
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: JSON.stringify(result, null, 2),
|
||
},
|
||
],
|
||
isError: false,
|
||
}),
|
||
status: 200,
|
||
};
|
||
} catch (error) {
|
||
const mapped = mapMcpToolError(error);
|
||
return {
|
||
body: jsonRpcError(request.id ?? null, mapped.rpcCode, mapped.message, mapped.data),
|
||
status: mapped.status,
|
||
};
|
||
}
|
||
}
|
||
|
||
if (isNotification) {
|
||
return null;
|
||
}
|
||
|
||
return { body: jsonRpcError(request.id ?? null, -32601, "Method not found"), status: 404 };
|
||
};
|
||
|
||
const mapMcpToolError = (error: unknown) => {
|
||
if (error instanceof AppError) {
|
||
const rpcCode =
|
||
error.status === 401
|
||
? -32001
|
||
: error.status === 403
|
||
? -32003
|
||
: error.status === 404
|
||
? -32004
|
||
: error.status === 409
|
||
? -32009
|
||
: -32602;
|
||
|
||
return {
|
||
rpcCode,
|
||
status: error.status,
|
||
message: error.message,
|
||
data: {
|
||
code: error.code,
|
||
},
|
||
};
|
||
}
|
||
|
||
return {
|
||
rpcCode: -32000,
|
||
status: 400,
|
||
message: error instanceof Error ? error.message : "Tool call failed",
|
||
data: undefined,
|
||
};
|
||
};
|
||
|
||
const MCP_TOOLS = [
|
||
{
|
||
name: "search_memos",
|
||
description: "Search active EdgeEver memos by text, tag, or notebook.",
|
||
inputSchema: {
|
||
type: "object",
|
||
additionalProperties: false,
|
||
properties: {
|
||
query: { type: "string" },
|
||
notebookId: { type: "string" },
|
||
limit: { type: "integer", minimum: 1, maximum: 50 },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "list_memos",
|
||
description: "List active EdgeEver memos with pagination. Use includeContent when full Markdown is needed.",
|
||
inputSchema: {
|
||
type: "object",
|
||
additionalProperties: false,
|
||
properties: {
|
||
notebookId: { type: "string" },
|
||
limit: { type: "integer", minimum: 1, maximum: 100 },
|
||
offset: { type: "integer", minimum: 0 },
|
||
includeContent: { type: "boolean" },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "get_memo",
|
||
description: "Read a memo with Markdown content.",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["memoId"],
|
||
additionalProperties: false,
|
||
properties: {
|
||
memoId: { type: "string" },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "create_memo",
|
||
description: "Create a memo in a notebook.",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["notebookId"],
|
||
additionalProperties: false,
|
||
properties: {
|
||
notebookId: { type: "string" },
|
||
title: { type: "string" },
|
||
contentMarkdown: { type: "string" },
|
||
tags: { type: "array", items: { type: "string" } },
|
||
createdAt: { type: "string", format: "date-time" },
|
||
updatedAt: { type: "string", format: "date-time" },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "update_memo",
|
||
description: "Update memo title, Markdown, tags, notebook, or pinned state.",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["memoId"],
|
||
additionalProperties: false,
|
||
properties: {
|
||
memoId: { type: "string" },
|
||
title: { type: "string" },
|
||
isPinned: { type: "boolean" },
|
||
contentMarkdown: { type: "string" },
|
||
tags: { type: "array", items: { type: "string" } },
|
||
notebookId: { type: "string" },
|
||
expectedRevision: { type: "integer", minimum: 0 },
|
||
createdAt: { type: "string", format: "date-time" },
|
||
updatedAt: { type: "string", format: "date-time" },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "upload_memo_image",
|
||
description:
|
||
"Upload a base64-encoded image resource to a memo and return Markdown that can be inserted into memo content. Images are stored as provided; server-side compression is disabled to avoid Cloudflare Images quota usage.",
|
||
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.",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["memoIds", "notebookId"],
|
||
additionalProperties: false,
|
||
properties: {
|
||
memoIds: { type: "array", minItems: 1, maxItems: 100, items: { type: "string" } },
|
||
notebookId: { type: "string" },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "merge_memos",
|
||
description: "Merge multiple active memos into a new memo and soft-delete the sources.",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["memoIds"],
|
||
additionalProperties: false,
|
||
properties: {
|
||
memoIds: { type: "array", minItems: 2, maxItems: 50, items: { type: "string" } },
|
||
notebookId: { type: "string" },
|
||
title: { type: "string" },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "move_notebook",
|
||
description: "Move a notebook under another notebook or root and update its sort order.",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["notebookId"],
|
||
additionalProperties: false,
|
||
properties: {
|
||
notebookId: { type: "string" },
|
||
parentId: { type: ["string", "null"] },
|
||
sortOrder: { type: "integer" },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "create_notebook",
|
||
description: "Create a notebook at the root or under another notebook.",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["name"],
|
||
additionalProperties: false,
|
||
properties: {
|
||
name: { type: "string", minLength: 1, maxLength: 80 },
|
||
parentId: { type: ["string", "null"] },
|
||
sortOrder: { type: "integer" },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "list_notebooks",
|
||
description: "List active notebooks.",
|
||
inputSchema: {
|
||
type: "object",
|
||
additionalProperties: false,
|
||
properties: {},
|
||
},
|
||
},
|
||
{
|
||
name: "list_tags",
|
||
description: "List tags and memo counts.",
|
||
inputSchema: {
|
||
type: "object",
|
||
additionalProperties: false,
|
||
properties: {},
|
||
},
|
||
},
|
||
];
|
||
|
||
const callMcpTool = async (
|
||
c: AppContext,
|
||
auth: AuthContext,
|
||
name: string,
|
||
args: Record<string, unknown>
|
||
) => {
|
||
switch (name) {
|
||
case "search_memos": {
|
||
assertScope(auth, "read:memos");
|
||
return {
|
||
memos: await searchMemoSummaries(c.env.DB, {
|
||
query: getOptionalString(args.query),
|
||
notebookId: getOptionalString(args.notebookId),
|
||
limit: clampNumber(Number(args.limit ?? 20), 1, 50),
|
||
}),
|
||
};
|
||
}
|
||
case "list_memos": {
|
||
assertScope(auth, "read:memos");
|
||
return await listMemosForMcp(c.env.DB, {
|
||
notebookId: getOptionalString(args.notebookId),
|
||
limit: clampNumber(Number(args.limit ?? 50), 1, 100),
|
||
offset: clampNumber(Number(args.offset ?? 0), 0, 100_000),
|
||
includeContent: args.includeContent === true,
|
||
});
|
||
}
|
||
case "get_memo": {
|
||
assertScope(auth, "read:memos");
|
||
const memoId = getRequiredString(args.memoId, "memoId");
|
||
const memo = await getMemoDetail(c.env.DB, memoId);
|
||
|
||
if (!memo) {
|
||
throw new Error("Memo not found");
|
||
}
|
||
|
||
return { memo };
|
||
}
|
||
case "create_memo": {
|
||
assertScope(auth, "write:memos");
|
||
const notebookId = getRequiredString(args.notebookId, "notebookId");
|
||
const actor = getAuditActor(c);
|
||
const actorLabel = getActorLabel(c);
|
||
const memo = await createMemoRecord(c.env.DB, {
|
||
notebookId,
|
||
title: getOptionalString(args.title) ?? undefined,
|
||
contentMarkdown: getOptionalString(args.contentMarkdown) ?? "",
|
||
tags: getOptionalStringArray(args.tags),
|
||
createdAt: getOptionalString(args.createdAt) ?? undefined,
|
||
updatedAt: getOptionalString(args.updatedAt) ?? undefined,
|
||
}, actor, actorLabel);
|
||
|
||
return { memo };
|
||
}
|
||
case "update_memo": {
|
||
assertScope(auth, "write:memos");
|
||
const memoId = getRequiredString(args.memoId, "memoId");
|
||
const actor = getAuditActor(c);
|
||
const actorLabel = getActorLabel(c);
|
||
const result = await updateMemoRecord(
|
||
c.env.DB,
|
||
memoId,
|
||
{
|
||
expectedRevision:
|
||
typeof args.expectedRevision === "number" && Number.isInteger(args.expectedRevision)
|
||
? args.expectedRevision
|
||
: undefined,
|
||
notebookId: getOptionalString(args.notebookId) ?? undefined,
|
||
title: getOptionalString(args.title) ?? undefined,
|
||
isPinned: typeof args.isPinned === "boolean" ? args.isPinned : undefined,
|
||
contentMarkdown: getOptionalString(args.contentMarkdown) ?? undefined,
|
||
tags: Array.isArray(args.tags) ? getOptionalStringArray(args.tags) : undefined,
|
||
createdAt: getOptionalString(args.createdAt) ?? undefined,
|
||
updatedAt: getOptionalString(args.updatedAt) ?? undefined,
|
||
},
|
||
actor,
|
||
actorLabel
|
||
);
|
||
|
||
if ("error" in result) {
|
||
throw new Error(result.message);
|
||
}
|
||
|
||
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 = await 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");
|
||
const memoIds = getRequiredStringArray(args.memoIds, "memoIds");
|
||
const target = await getNotebook(c.env.DB, notebookId);
|
||
|
||
if (!target) {
|
||
throw new AppError("not_found", "Target notebook not found", 404);
|
||
}
|
||
|
||
const actor = getAuditActor(c);
|
||
const actorLabel = getActorLabel(c);
|
||
const moved = await moveMemosToNotebook(c.env.DB, memoIds, notebookId, actor, actorLabel);
|
||
|
||
return { ok: true, moved };
|
||
}
|
||
case "merge_memos": {
|
||
assertScope(auth, "write:memos");
|
||
const actor = getAuditActor(c);
|
||
const actorLabel = getActorLabel(c);
|
||
const memo = await mergeMemosRecord(
|
||
c.env.DB,
|
||
{
|
||
memoIds: getRequiredStringArray(args.memoIds, "memoIds"),
|
||
notebookId: getOptionalString(args.notebookId) ?? undefined,
|
||
title: getOptionalString(args.title) ?? undefined,
|
||
},
|
||
actor,
|
||
actorLabel
|
||
);
|
||
|
||
return { memo };
|
||
}
|
||
case "move_notebook": {
|
||
assertScope(auth, "write:notebooks");
|
||
const actor = getAuditActor(c);
|
||
const notebook = await updateNotebookRecord(
|
||
c.env.DB,
|
||
getRequiredString(args.notebookId, "notebookId"),
|
||
{
|
||
parentId: args.parentId === null ? null : getOptionalString(args.parentId) ?? undefined,
|
||
sortOrder: typeof args.sortOrder === "number" && Number.isInteger(args.sortOrder) ? args.sortOrder : undefined,
|
||
},
|
||
actor
|
||
);
|
||
|
||
return { notebook };
|
||
}
|
||
case "create_notebook": {
|
||
assertScope(auth, "write:notebooks");
|
||
const actor = getAuditActor(c);
|
||
const name = getRequiredString(args.name, "name");
|
||
|
||
if (name.length > 80) {
|
||
throw new AppError("invalid_params", "name must be at most 80 characters", 400);
|
||
}
|
||
|
||
const notebook = await createNotebookRecord(
|
||
c.env.DB,
|
||
{
|
||
name,
|
||
parentId: args.parentId === null ? null : getOptionalString(args.parentId) ?? undefined,
|
||
sortOrder: typeof args.sortOrder === "number" && Number.isInteger(args.sortOrder) ? args.sortOrder : undefined,
|
||
},
|
||
actor
|
||
);
|
||
|
||
return { notebook };
|
||
}
|
||
case "list_notebooks": {
|
||
assertScope(auth, "read:notebooks");
|
||
return { notebooks: await listNotebooks(c.env.DB) };
|
||
}
|
||
case "list_tags": {
|
||
assertScope(auth, "read:tags");
|
||
return { tags: await listTagSummaries(c.env.DB) };
|
||
}
|
||
default:
|
||
throw new Error(`Unknown tool: ${name}`);
|
||
}
|
||
};
|
||
|
||
const jsonRpcResult = (id: JsonRpcId, result: unknown) => ({
|
||
jsonrpc: "2.0",
|
||
id,
|
||
result,
|
||
});
|
||
|
||
const jsonRpcError = (id: JsonRpcId, code: number, message: string, data?: unknown) => ({
|
||
jsonrpc: "2.0",
|
||
id,
|
||
error: {
|
||
code,
|
||
message,
|
||
...(data === undefined ? {} : { data }),
|
||
},
|
||
});
|
||
|
||
const getJsonRpcId = (request: unknown): JsonRpcId => {
|
||
if (!request || typeof request !== "object" || !("id" in request)) {
|
||
return null;
|
||
}
|
||
|
||
const id = (request as { id?: unknown }).id;
|
||
return typeof id === "string" || typeof id === "number" || id === null ? id : null;
|
||
};
|
||
|
||
const asRecord = (value: unknown): Record<string, unknown> =>
|
||
value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||
|
||
const getOptionalString = (value: unknown) => (typeof value === "string" && value.trim() ? value.trim() : null);
|
||
|
||
const getRequiredString = (value: unknown, name: string) => {
|
||
const parsed = getOptionalString(value);
|
||
|
||
if (!parsed) {
|
||
throw new AppError("invalid_params", `${name} is required`, 400);
|
||
}
|
||
|
||
return parsed;
|
||
};
|
||
|
||
const getOptionalStringArray = (value: unknown) =>
|
||
Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
||
|
||
const getRequiredStringArray = (value: unknown, name: string) => {
|
||
const items = getOptionalStringArray(value);
|
||
|
||
if (items.length === 0) {
|
||
throw new AppError("invalid_params", `${name} must include at least one item`, 400);
|
||
}
|
||
|
||
return items;
|
||
};
|
||
|
||
const decodeBase64ImageData = async (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 response = await fetch("data:application/octet-stream;base64," + base64);
|
||
if (!response.ok) {
|
||
throw new Error("failed to decode base64");
|
||
}
|
||
return new Uint8Array(await response.arrayBuffer());
|
||
} catch (error) {
|
||
throw new AppError("invalid_params", "dataBase64 must be valid base64 image data: " + (error as Error).message, 400);
|
||
}
|
||
};
|
||
|
||
const escapeMarkdownImageAlt = (value: string) => value.replace(/[\\[\]]/g, "\\$&");
|
||
|
||
const isAuthRequired = async (env: Bindings) => {
|
||
if (env.EDGE_EVER_AUTH_PASSWORD_HASH?.trim()) {
|
||
return true;
|
||
}
|
||
|
||
const user = await env.DB.prepare(`SELECT id FROM users WHERE is_disabled = 0 LIMIT 1`).first<{ id: string }>();
|
||
return Boolean(user);
|
||
};
|
||
|
||
const verifyLogin = async (env: Bindings, username: string, password: string): Promise<UserRow | null> => {
|
||
const normalizedUsername = username.trim();
|
||
const existingUser = await getUserByUsername(env.DB, normalizedUsername);
|
||
|
||
if (existingUser) {
|
||
return (await verifyPassword(password, existingUser.password_hash)) ? existingUser : null;
|
||
}
|
||
|
||
const configuredHash = env.EDGE_EVER_AUTH_PASSWORD_HASH?.trim();
|
||
|
||
if (!configuredHash) {
|
||
return null;
|
||
}
|
||
|
||
const configuredUsername = env.EDGE_EVER_AUTH_USERNAME?.trim() || "admin";
|
||
|
||
if (normalizedUsername !== configuredUsername || !(await verifyPassword(password, configuredHash))) {
|
||
return null;
|
||
}
|
||
|
||
const now = isoNow();
|
||
const userId = createId("usr");
|
||
const passwordHash = await hashPassword(password);
|
||
|
||
await env.DB.prepare(
|
||
`INSERT OR IGNORE INTO users (id, username, password_hash, display_name, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?)`
|
||
)
|
||
.bind(userId, normalizedUsername, passwordHash, normalizedUsername, now, now)
|
||
.run();
|
||
|
||
return getUserByUsername(env.DB, normalizedUsername);
|
||
};
|
||
|
||
const getUserByUsername = async (db: D1Database, username: string) =>
|
||
db
|
||
.prepare(
|
||
`SELECT id, username, password_hash, display_name, is_disabled
|
||
FROM users
|
||
WHERE username = ? AND is_disabled = 0`
|
||
)
|
||
.bind(username)
|
||
.first<UserRow>();
|
||
|
||
const createSession = async (c: AppContext, user: UserRow) => {
|
||
const token = randomToken(SESSION_TOKEN_BYTES);
|
||
const id = createId("sess");
|
||
const now = isoNow();
|
||
const maxAge = getSessionMaxAge(c.env);
|
||
const expiresAt = new Date(Date.now() + maxAge * 1000).toISOString();
|
||
const ip = c.req.header("CF-Connecting-IP");
|
||
const ipHash = ip ? await sha256(ip) : null;
|
||
|
||
await c.env.DB.prepare(
|
||
`INSERT INTO sessions (
|
||
id, user_id, token_hash, user_agent, ip_hash, expires_at, created_at, last_seen_at
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||
)
|
||
.bind(
|
||
id,
|
||
user.id,
|
||
await sha256(token),
|
||
c.req.header("User-Agent") ?? null,
|
||
ipHash,
|
||
expiresAt,
|
||
now,
|
||
now
|
||
)
|
||
.run();
|
||
|
||
return { id, token, maxAge };
|
||
};
|
||
|
||
const setSessionCookie = (c: AppContext, token: string, maxAge: number) => {
|
||
setCookie(c, SESSION_COOKIE, token, {
|
||
httpOnly: true,
|
||
secure: new URL(c.req.url).protocol === "https:",
|
||
sameSite: "Lax",
|
||
path: "/",
|
||
maxAge,
|
||
});
|
||
};
|
||
|
||
const revokeSession = async (db: D1Database, token: string) => {
|
||
await db
|
||
.prepare(`UPDATE sessions SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL`)
|
||
.bind(isoNow(), await sha256(token))
|
||
.run();
|
||
};
|
||
|
||
const authenticateRequest = async (c: AppContext, touch: boolean): Promise<AuthContext | null> => {
|
||
const bearerAuth = await authenticateBearerToken(c, touch);
|
||
|
||
if (bearerAuth) {
|
||
return bearerAuth;
|
||
}
|
||
|
||
return authenticateSession(c, touch);
|
||
};
|
||
|
||
const authenticateBearerToken = async (c: AppContext, touch: boolean): Promise<AuthContext | null> => {
|
||
const token = getBearerToken(c);
|
||
|
||
if (!token) {
|
||
return null;
|
||
}
|
||
|
||
const row = await c.env.DB.prepare(
|
||
`SELECT id, name, token_value, scopes_json, last_used_at, expires_at, is_revoked, created_at
|
||
FROM api_tokens
|
||
WHERE token_hash = ?
|
||
AND is_revoked = 0
|
||
AND (expires_at IS NULL OR expires_at > ?)`
|
||
)
|
||
.bind(await sha256(token), isoNow())
|
||
.first<ApiTokenRow>();
|
||
|
||
if (!row) {
|
||
return null;
|
||
}
|
||
|
||
if (touch) {
|
||
await c.env.DB.prepare(`UPDATE api_tokens SET last_used_at = ? WHERE id = ?`).bind(isoNow(), row.id).run();
|
||
}
|
||
|
||
return {
|
||
kind: "agent",
|
||
actorType: "agent",
|
||
actorId: row.id,
|
||
username: row.name,
|
||
displayName: row.name,
|
||
scopes: parseJsonArray(row.scopes_json),
|
||
tokenId: row.id,
|
||
};
|
||
};
|
||
|
||
const authenticateSession = async (c: AppContext, touch: boolean): Promise<AuthContext | null> => {
|
||
const token = getCookie(c, SESSION_COOKIE);
|
||
|
||
if (!token) {
|
||
return null;
|
||
}
|
||
|
||
const row = await c.env.DB.prepare(
|
||
`SELECT s.id, s.user_id, u.username, u.display_name, s.expires_at
|
||
FROM sessions s
|
||
INNER JOIN users u ON u.id = s.user_id
|
||
WHERE s.token_hash = ?
|
||
AND s.revoked_at IS NULL
|
||
AND s.expires_at > ?
|
||
AND u.is_disabled = 0`
|
||
)
|
||
.bind(await sha256(token), isoNow())
|
||
.first<SessionRow>();
|
||
|
||
if (!row) {
|
||
return null;
|
||
}
|
||
|
||
if (touch) {
|
||
await c.env.DB.prepare(`UPDATE sessions SET last_seen_at = ? WHERE id = ?`).bind(isoNow(), row.id).run();
|
||
}
|
||
|
||
return {
|
||
kind: "user",
|
||
actorType: "user",
|
||
actorId: row.user_id,
|
||
username: row.username,
|
||
displayName: row.display_name,
|
||
scopes: [],
|
||
sessionId: row.id,
|
||
};
|
||
};
|
||
|
||
const getBearerToken = (c: AppContext) => {
|
||
const authorization = c.req.header("Authorization");
|
||
|
||
if (!authorization) {
|
||
return null;
|
||
}
|
||
|
||
const [scheme, token] = authorization.split(/\s+/, 2);
|
||
return scheme.toLowerCase() === "bearer" && token ? token : null;
|
||
};
|
||
|
||
const getAuditActor = (c: AppContext) => {
|
||
const auth = c.get("auth");
|
||
|
||
return {
|
||
actorType: auth?.actorType ?? "user",
|
||
actorId: auth?.actorId ?? null,
|
||
};
|
||
};
|
||
|
||
const getActorLabel = (c: AppContext) => {
|
||
const auth = c.get("auth");
|
||
return auth?.actorId ? `${auth.actorType}:${auth.actorId}` : auth?.username ?? "user";
|
||
};
|
||
|
||
const requireUser = (c: AppContext) => {
|
||
const auth = c.get("auth");
|
||
|
||
if (auth?.kind === "user") {
|
||
return null;
|
||
}
|
||
|
||
return forbidden(c, "Only an interactive user session can manage this resource.");
|
||
};
|
||
|
||
const requireScopes = (c: AppContext, ...scopes: TokenScope[]) => {
|
||
const auth = c.get("auth");
|
||
|
||
if (!auth) {
|
||
return unauthorized(c, "Authentication required.");
|
||
}
|
||
|
||
if (hasScopes(auth, scopes)) {
|
||
return null;
|
||
}
|
||
|
||
return forbidden(c, `Missing required scope: ${scopes.join(", ")}`);
|
||
};
|
||
|
||
const assertScope = (auth: AuthContext, scope: TokenScope) => {
|
||
if (!hasScopes(auth, [scope])) {
|
||
throw new AppError("forbidden", `Missing required scope: ${scope}`, 403);
|
||
}
|
||
};
|
||
|
||
const hasScopes = (auth: AuthContext, scopes: TokenScope[]) => {
|
||
if (auth.kind === "user") {
|
||
return true;
|
||
}
|
||
|
||
return scopes.every((scope) => auth.scopes.includes(scope));
|
||
};
|
||
|
||
const normalizeTokenScopes = (scopes: string[]) => {
|
||
const normalized = Array.from(new Set(scopes.map((scope) => scope.trim()).filter(Boolean)));
|
||
|
||
if (normalized.some((scope) => !isTokenScope(scope))) {
|
||
return null;
|
||
}
|
||
|
||
return normalized as TokenScope[];
|
||
};
|
||
|
||
const isTokenScope = (scope: string): scope is TokenScope =>
|
||
(ALL_TOKEN_SCOPES as readonly string[]).includes(scope);
|
||
|
||
const getSessionMaxAge = (env: Bindings) => {
|
||
const days = clampNumber(Number(env.EDGE_EVER_SESSION_TTL_DAYS ?? DEFAULT_SESSION_TTL_DAYS), 1, MAX_SESSION_TTL_DAYS);
|
||
return days * 24 * 60 * 60;
|
||
};
|
||
|
||
const hashPassword = async (password: string) => {
|
||
const salt = crypto.getRandomValues(new Uint8Array(PASSWORD_SALT_BYTES));
|
||
const hash = await derivePasswordHash(password, salt, PASSWORD_HASH_ITERATIONS);
|
||
|
||
return [
|
||
PASSWORD_HASH_ALGORITHM,
|
||
PASSWORD_HASH_ITERATIONS,
|
||
base64UrlEncode(salt),
|
||
base64UrlEncode(hash),
|
||
].join("$");
|
||
};
|
||
|
||
const verifyPassword = async (password: string, passwordHash: string) => {
|
||
const [algorithm, iterationsRaw, saltRaw, hashRaw] = passwordHash.split("$");
|
||
const iterations = Number(iterationsRaw);
|
||
|
||
if (
|
||
algorithm !== PASSWORD_HASH_ALGORITHM ||
|
||
!Number.isInteger(iterations) ||
|
||
iterations < 100_000 ||
|
||
!saltRaw ||
|
||
!hashRaw
|
||
) {
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
const expected = base64UrlDecode(hashRaw);
|
||
const salt = base64UrlDecode(saltRaw);
|
||
const actual = await derivePasswordHash(password, salt, iterations);
|
||
|
||
return timingSafeEqual(actual, expected);
|
||
} catch {
|
||
return false;
|
||
}
|
||
};
|
||
|
||
const derivePasswordHash = async (password: string, salt: Uint8Array, iterations: number) => {
|
||
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(password), "PBKDF2", false, [
|
||
"deriveBits",
|
||
]);
|
||
const saltBuffer = salt.buffer.slice(salt.byteOffset, salt.byteOffset + salt.byteLength) as ArrayBuffer;
|
||
const bits = await crypto.subtle.deriveBits(
|
||
{
|
||
name: "PBKDF2",
|
||
hash: "SHA-256",
|
||
salt: saltBuffer,
|
||
iterations,
|
||
},
|
||
key,
|
||
PASSWORD_HASH_BYTES * 8
|
||
);
|
||
|
||
return new Uint8Array(bits);
|
||
};
|
||
|
||
const randomToken = (bytes: number) => {
|
||
const token = crypto.getRandomValues(new Uint8Array(bytes));
|
||
return base64UrlEncode(token);
|
||
};
|
||
|
||
const base64UrlEncode = (bytes: Uint8Array) => {
|
||
let binary = "";
|
||
|
||
for (const byte of bytes) {
|
||
binary += String.fromCharCode(byte);
|
||
}
|
||
|
||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||
};
|
||
|
||
const base64UrlDecode = (value: string) => {
|
||
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
|
||
const binary = atob(padded);
|
||
const bytes = new Uint8Array(binary.length);
|
||
|
||
for (let index = 0; index < binary.length; index += 1) {
|
||
bytes[index] = binary.charCodeAt(index);
|
||
}
|
||
|
||
return bytes;
|
||
};
|
||
|
||
const timingSafeEqual = (left: Uint8Array, right: Uint8Array) => {
|
||
const length = Math.max(left.length, right.length);
|
||
let diff = left.length ^ right.length;
|
||
|
||
for (let index = 0; index < length; index += 1) {
|
||
diff |= (left[index % left.length] ?? 0) ^ (right[index % right.length] ?? 0);
|
||
}
|
||
|
||
return diff === 0;
|
||
};
|
||
|
||
const mapNotebook = (row: NotebookRow): Notebook => ({
|
||
id: row.id,
|
||
parentId: row.parent_id,
|
||
name: row.name,
|
||
slug: row.slug,
|
||
icon: row.icon,
|
||
color: row.color,
|
||
sortOrder: row.sort_order,
|
||
memoCount: row.memo_count ?? 0,
|
||
lastMemoUpdatedAt: row.last_memo_updated_at,
|
||
createdAt: row.created_at,
|
||
updatedAt: row.updated_at,
|
||
});
|
||
|
||
const notebookSelectSql = (tail: string) => `
|
||
SELECT n.id,
|
||
n.parent_id,
|
||
n.name,
|
||
n.slug,
|
||
n.icon,
|
||
n.color,
|
||
n.sort_order,
|
||
COUNT(m.id) AS memo_count,
|
||
MAX(m.updated_at) AS last_memo_updated_at,
|
||
n.created_at,
|
||
n.updated_at
|
||
FROM notebooks n
|
||
LEFT JOIN memos m ON m.notebook_id = n.id AND m.is_deleted = 0
|
||
${tail}
|
||
`;
|
||
|
||
const mapMemoSummary = (row: MemoSummaryRow): MemoSummary => ({
|
||
id: row.id,
|
||
notebookId: row.notebook_id,
|
||
title: row.title,
|
||
excerpt: row.excerpt,
|
||
tags: parseJsonArray(row.tags_json),
|
||
isPinned: Boolean(row.is_pinned),
|
||
isArchived: Boolean(row.is_archived),
|
||
isDeleted: Boolean(row.is_deleted),
|
||
revision: row.revision,
|
||
createdAt: row.created_at,
|
||
updatedAt: row.updated_at,
|
||
deletedAt: row.deleted_at,
|
||
});
|
||
|
||
const mapMemoDetail = (row: MemoDetailRow): MemoDetail => ({
|
||
...mapMemoSummary(row),
|
||
contentJson: parseDoc(row.content_json),
|
||
contentMarkdown: row.content_markdown,
|
||
contentText: row.content_text,
|
||
sourceMemoIds: parseJsonArray(row.source_memo_ids),
|
||
mergeSourceCount: row.merge_source_count,
|
||
mergedIntoMemoId: row.merged_into_memo_id,
|
||
});
|
||
|
||
const mapMemoRevision = (row: MemoRevisionRow): MemoRevision => ({
|
||
id: row.id,
|
||
memoId: row.memo_id,
|
||
revision: row.revision,
|
||
title: row.title,
|
||
tags: parseJsonArray(row.tags_json),
|
||
contentMarkdown: row.content_markdown,
|
||
contentText: row.content_text,
|
||
contentHash: row.content_hash,
|
||
createdBy: row.created_by,
|
||
createdAt: row.created_at,
|
||
});
|
||
|
||
const mapResource = (row: ResourceRow): Resource => ({
|
||
id: row.id,
|
||
memoId: row.memo_id,
|
||
originalMemoId: row.original_memo_id,
|
||
kind: row.kind,
|
||
mimeType: row.mime_type,
|
||
filename: row.filename,
|
||
byteSize: row.byte_size,
|
||
sha256: row.sha256,
|
||
width: row.width,
|
||
height: row.height,
|
||
createdAt: row.created_at,
|
||
updatedAt: row.updated_at,
|
||
url: `/api/v1/resources/${row.id}/blob`,
|
||
});
|
||
|
||
const mapResourceListItem = (row: ResourceListRow): ResourceListItem => ({
|
||
...mapResource(row),
|
||
memoTitle: row.memo_title,
|
||
memoExcerpt: row.memo_excerpt,
|
||
memoDeleted: Boolean(row.memo_is_deleted),
|
||
});
|
||
|
||
const mapResourceStorageSummary = (row: ResourceStatsRow | null): ResourceStorageSummary => ({
|
||
totalCount: row?.total_count ?? 0,
|
||
totalBytes: row?.total_bytes ?? 0,
|
||
imageCount: row?.image_count ?? 0,
|
||
attachmentCount: row?.attachment_count ?? 0,
|
||
});
|
||
|
||
const mapApiToken = (row: ApiTokenRow): ApiToken => ({
|
||
id: row.id,
|
||
name: row.name,
|
||
token: row.token_value,
|
||
scopes: parseJsonArray(row.scopes_json),
|
||
lastUsedAt: row.last_used_at,
|
||
expiresAt: row.expires_at,
|
||
isRevoked: Boolean(row.is_revoked),
|
||
createdAt: row.created_at,
|
||
});
|
||
|
||
const mapTagSummary = (row: TagSummaryRow): TagSummary => ({
|
||
name: row.name,
|
||
memoCount: row.memo_count,
|
||
updatedAt: row.updated_at,
|
||
});
|
||
|
||
const getApiTokenRow = async (db: D1Database, id: string): Promise<ApiTokenRow | null> =>
|
||
db
|
||
.prepare(
|
||
`SELECT id, name, token_value, scopes_json, last_used_at, expires_at, is_revoked, created_at
|
||
FROM api_tokens
|
||
WHERE id = ?`
|
||
)
|
||
.bind(id)
|
||
.first<ApiTokenRow>();
|
||
|
||
const listNotebooks = async (db: D1Database): Promise<Notebook[]> => {
|
||
const rows = await db
|
||
.prepare(
|
||
notebookSelectSql(
|
||
`WHERE n.is_deleted = 0
|
||
GROUP BY n.id, n.parent_id, n.name, n.slug, n.icon, n.color, n.sort_order, n.created_at, n.updated_at
|
||
ORDER BY n.parent_id IS NOT NULL, n.sort_order ASC, n.name ASC`
|
||
)
|
||
)
|
||
.all<NotebookRow>();
|
||
|
||
return rows.results.map(mapNotebook);
|
||
};
|
||
|
||
const listTagSummaries = async (db: D1Database): Promise<TagSummary[]> => {
|
||
const rows = await db
|
||
.prepare(
|
||
`SELECT json_each.value AS name,
|
||
COUNT(DISTINCT m.id) AS memo_count,
|
||
MAX(m.updated_at) AS updated_at
|
||
FROM memos m, json_each(m.tags_json)
|
||
WHERE m.is_deleted = 0
|
||
AND trim(json_each.value) <> ''
|
||
GROUP BY json_each.value
|
||
ORDER BY lower(json_each.value) ASC`
|
||
)
|
||
.all<TagSummaryRow>();
|
||
|
||
return rows.results
|
||
.filter((row) => typeof row.name === "string" && row.name.trim())
|
||
.map(mapTagSummary);
|
||
};
|
||
|
||
const updateTagAcrossMemos = async (
|
||
db: D1Database,
|
||
oldTag: string,
|
||
nextTag: string | null,
|
||
actor: { actorType: "user" | "agent"; actorId: string | null },
|
||
actorLabel: string
|
||
) => {
|
||
const normalizedOld = normalizeTags([oldTag])[0];
|
||
const normalizedNext = nextTag === null ? null : normalizeTags([nextTag])[0];
|
||
|
||
if (!normalizedOld || normalizedOld === normalizedNext) {
|
||
return 0;
|
||
}
|
||
|
||
const rows = await db
|
||
.prepare(
|
||
`SELECT m.id, m.title, m.tags_json, c.content_text
|
||
FROM memos m
|
||
INNER JOIN memo_contents c ON c.memo_id = m.id
|
||
WHERE m.is_deleted = 0
|
||
AND EXISTS (
|
||
SELECT 1
|
||
FROM json_each(m.tags_json)
|
||
WHERE json_each.value = ?
|
||
)`
|
||
)
|
||
.bind(normalizedOld)
|
||
.all<MemoTagUpdateRow>();
|
||
|
||
const now = isoNow();
|
||
const statements: D1PreparedStatement[] = [];
|
||
let updated = 0;
|
||
|
||
for (const row of rows.results) {
|
||
const currentTags = parseJsonArray(row.tags_json);
|
||
|
||
if (!currentTags.includes(normalizedOld)) {
|
||
continue;
|
||
}
|
||
|
||
const nextTags = normalizeTags(
|
||
currentTags.flatMap((tag) => {
|
||
if (tag !== normalizedOld) {
|
||
return [tag];
|
||
}
|
||
|
||
return normalizedNext ? [normalizedNext] : [];
|
||
})
|
||
);
|
||
|
||
statements.push(
|
||
db
|
||
.prepare(
|
||
`UPDATE memos
|
||
SET tags_json = ?, updated_by = ?, updated_at = ?
|
||
WHERE id = ? AND is_deleted = 0`
|
||
)
|
||
.bind(JSON.stringify(nextTags), actorLabel, now, row.id),
|
||
db.prepare(`DELETE FROM memos_fts WHERE memo_id = ?`).bind(row.id),
|
||
db
|
||
.prepare(
|
||
`INSERT INTO memos_fts (memo_id, title, content_text, tags)
|
||
VALUES (?, ?, ?, ?)`
|
||
)
|
||
.bind(row.id, row.title, row.content_text, nextTags.join(" ")),
|
||
auditStatement(db, actor.actorType, actor.actorId, normalizedNext ? "tag.rename" : "tag.delete", "memo", row.id, {
|
||
from: normalizedOld,
|
||
to: normalizedNext,
|
||
})
|
||
);
|
||
updated += 1;
|
||
}
|
||
|
||
if (statements.length > 0) {
|
||
await db.batch(statements);
|
||
}
|
||
|
||
return updated;
|
||
};
|
||
|
||
const searchMemoSummaries = async (
|
||
db: D1Database,
|
||
options: { query?: string | null; notebookId?: string | null; limit: number }
|
||
): Promise<MemoSummary[]> => {
|
||
const q = options.query?.trim();
|
||
const notebookId = options.notebookId?.trim() || null;
|
||
const limit = clampNumber(options.limit, 1, 100);
|
||
|
||
if (q) {
|
||
const ftsQuery = toFtsQuery(q);
|
||
const likeQuery = `%${escapeLike(q)}%`;
|
||
|
||
if (ftsQuery) {
|
||
const rows = await db
|
||
.prepare(
|
||
`WITH raw_matches(memo_id, rank) AS (
|
||
SELECT memo_id, bm25(memos_fts)
|
||
FROM memos_fts
|
||
WHERE memos_fts MATCH ?
|
||
|
||
UNION ALL
|
||
|
||
SELECT m.id, 100.0
|
||
FROM memos m
|
||
INNER JOIN memo_contents c ON c.memo_id = m.id
|
||
WHERE m.title LIKE ? ESCAPE '\\'
|
||
OR c.content_text LIKE ? ESCAPE '\\'
|
||
OR m.tags_json LIKE ? ESCAPE '\\'
|
||
),
|
||
search_matches AS (
|
||
SELECT memo_id, MIN(rank) AS rank
|
||
FROM raw_matches
|
||
GROUP BY memo_id
|
||
)
|
||
SELECT m.id, m.notebook_id, m.title, m.excerpt, m.tags_json, m.is_pinned,
|
||
m.is_archived, m.is_deleted, m.created_at, m.updated_at, m.deleted_at, c.revision
|
||
FROM search_matches s
|
||
INNER JOIN memos m ON m.id = s.memo_id
|
||
INNER JOIN memo_contents c ON c.memo_id = m.id
|
||
WHERE m.is_deleted = 0
|
||
AND (? IS NULL OR m.notebook_id = ?)
|
||
ORDER BY s.rank ASC, m.is_pinned DESC, m.updated_at DESC
|
||
LIMIT ?`
|
||
)
|
||
.bind(ftsQuery, likeQuery, likeQuery, likeQuery, notebookId, notebookId, limit)
|
||
.all<MemoSummaryRow>();
|
||
|
||
return rows.results.map(mapMemoSummary);
|
||
}
|
||
}
|
||
|
||
const rows = await db
|
||
.prepare(
|
||
`SELECT m.id, m.notebook_id, m.title, m.excerpt, m.tags_json, m.is_pinned,
|
||
m.is_archived, m.is_deleted, m.created_at, m.updated_at, m.deleted_at, c.revision
|
||
FROM memos m
|
||
INNER JOIN memo_contents c ON c.memo_id = m.id
|
||
WHERE m.is_deleted = 0
|
||
AND (? IS NULL OR m.notebook_id = ?)
|
||
ORDER BY m.is_pinned DESC, m.updated_at DESC
|
||
LIMIT ?`
|
||
)
|
||
.bind(notebookId, notebookId, limit)
|
||
.all<MemoSummaryRow>();
|
||
|
||
return rows.results.map(mapMemoSummary);
|
||
};
|
||
|
||
const listMemosForMcp = async (
|
||
db: D1Database,
|
||
options: { notebookId?: string | null; limit: number; offset: number; includeContent: boolean }
|
||
) => {
|
||
const notebookId = options.notebookId?.trim() || null;
|
||
const limit = clampNumber(options.limit, 1, 100);
|
||
const offset = clampNumber(options.offset, 0, 100_000);
|
||
const pageSize = limit + 1;
|
||
|
||
if (options.includeContent) {
|
||
const rows = await db
|
||
.prepare(
|
||
`SELECT m.id, m.notebook_id, m.title, m.excerpt, m.tags_json, m.is_pinned,
|
||
m.is_archived, m.is_deleted, m.created_at, m.updated_at, m.deleted_at, c.revision,
|
||
c.content_json, c.content_markdown, c.content_text, c.content_hash,
|
||
m.source_memo_ids, m.merge_source_count, m.merged_into_memo_id
|
||
FROM memos m
|
||
INNER JOIN memo_contents c ON c.memo_id = m.id
|
||
WHERE m.is_deleted = 0
|
||
AND (? IS NULL OR m.notebook_id = ?)
|
||
ORDER BY m.updated_at DESC, m.id ASC
|
||
LIMIT ? OFFSET ?`
|
||
)
|
||
.bind(notebookId, notebookId, pageSize, offset)
|
||
.all<MemoDetailRow>();
|
||
const page = rows.results.slice(0, limit).map(mapMemoDetail);
|
||
|
||
return {
|
||
memos: page,
|
||
limit,
|
||
offset,
|
||
nextOffset: rows.results.length > limit ? offset + limit : null,
|
||
hasMore: rows.results.length > limit,
|
||
};
|
||
}
|
||
|
||
const rows = await db
|
||
.prepare(
|
||
`SELECT m.id, m.notebook_id, m.title, m.excerpt, m.tags_json, m.is_pinned,
|
||
m.is_archived, m.is_deleted, m.created_at, m.updated_at, m.deleted_at, c.revision
|
||
FROM memos m
|
||
INNER JOIN memo_contents c ON c.memo_id = m.id
|
||
WHERE m.is_deleted = 0
|
||
AND (? IS NULL OR m.notebook_id = ?)
|
||
ORDER BY m.updated_at DESC, m.id ASC
|
||
LIMIT ? OFFSET ?`
|
||
)
|
||
.bind(notebookId, notebookId, pageSize, offset)
|
||
.all<MemoSummaryRow>();
|
||
const page = rows.results.slice(0, limit).map(mapMemoSummary);
|
||
|
||
return {
|
||
memos: page,
|
||
limit,
|
||
offset,
|
||
nextOffset: rows.results.length > limit ? offset + limit : null,
|
||
hasMore: rows.results.length > limit,
|
||
};
|
||
};
|
||
|
||
const getNotebook = async (db: D1Database, id: string): Promise<Notebook | null> => {
|
||
const row = await db
|
||
.prepare(
|
||
notebookSelectSql(
|
||
`WHERE n.id = ? AND n.is_deleted = 0
|
||
GROUP BY n.id, n.parent_id, n.name, n.slug, n.icon, n.color, n.sort_order, n.created_at, n.updated_at`
|
||
)
|
||
)
|
||
.bind(id)
|
||
.first<NotebookRow>();
|
||
|
||
return row ? mapNotebook(row) : null;
|
||
};
|
||
|
||
const createNotebookRecord = async (
|
||
db: D1Database,
|
||
input: NotebookCreateInput & { sortOrder?: number },
|
||
actor: { actorType: "user" | "agent"; actorId: string | null }
|
||
) => {
|
||
const parentId = input.parentId ?? null;
|
||
|
||
if (parentId && !(await getNotebook(db, parentId))) {
|
||
throw new AppError("not_found", "Parent notebook not found", 404);
|
||
}
|
||
|
||
const id = createId("nb");
|
||
const now = isoNow();
|
||
const sortOrder = input.sortOrder ?? Date.now();
|
||
|
||
await db.batch([
|
||
db
|
||
.prepare(
|
||
`INSERT INTO notebooks (id, parent_id, name, slug, sort_order, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||
)
|
||
.bind(id, parentId, input.name, slugify(input.name), sortOrder, now, now),
|
||
auditStatement(db, actor.actorType, actor.actorId, "notebook.create", "notebook", id, {
|
||
name: input.name,
|
||
parentId,
|
||
sortOrder,
|
||
}),
|
||
]);
|
||
|
||
const notebook = await getNotebook(db, id);
|
||
|
||
if (!notebook) {
|
||
throw new AppError("not_found", "Notebook not found after create", 404);
|
||
}
|
||
|
||
return notebook;
|
||
};
|
||
|
||
const updateNotebookRecord = async (
|
||
db: D1Database,
|
||
id: string,
|
||
input: { name?: string; parentId?: string | null; sortOrder?: number },
|
||
actor: { actorType: "user" | "agent"; actorId: string | null }
|
||
) => {
|
||
const current = await getNotebook(db, id);
|
||
|
||
if (!current) {
|
||
throw new AppError("not_found", "Notebook not found", 404);
|
||
}
|
||
|
||
const nextName = input.name ?? current.name;
|
||
const nextParentId = input.parentId === undefined ? current.parentId : input.parentId;
|
||
const nextSortOrder = input.sortOrder ?? current.sortOrder;
|
||
const now = isoNow();
|
||
|
||
if (nextParentId === id) {
|
||
throw new AppError("bad_request", "Notebook cannot be its own parent", 400);
|
||
}
|
||
|
||
if (nextParentId) {
|
||
const parent = await getNotebook(db, nextParentId);
|
||
|
||
if (!parent) {
|
||
throw new AppError("not_found", "Parent notebook not found", 404);
|
||
}
|
||
|
||
if (await isNotebookDescendant(db, nextParentId, id)) {
|
||
throw new AppError("notebook_cycle", "Notebook cannot be moved into its own descendant.", 409);
|
||
}
|
||
}
|
||
|
||
await db.batch([
|
||
db
|
||
.prepare(
|
||
`UPDATE notebooks
|
||
SET name = ?, slug = ?, parent_id = ?, sort_order = ?, updated_at = ?
|
||
WHERE id = ? AND is_deleted = 0`
|
||
)
|
||
.bind(nextName, slugify(nextName), nextParentId ?? null, nextSortOrder, now, id),
|
||
auditStatement(db, actor.actorType, actor.actorId, "notebook.update", "notebook", id, input),
|
||
]);
|
||
|
||
const notebook = await getNotebook(db, id);
|
||
|
||
if (!notebook) {
|
||
throw new AppError("not_found", "Notebook not found after update", 404);
|
||
}
|
||
|
||
return notebook;
|
||
};
|
||
|
||
const isNotebookDescendant = async (db: D1Database, candidateId: string, ancestorId: string) => {
|
||
const row = await db
|
||
.prepare(
|
||
`WITH RECURSIVE descendants(id) AS (
|
||
SELECT id
|
||
FROM notebooks
|
||
WHERE parent_id = ? AND is_deleted = 0
|
||
|
||
UNION ALL
|
||
|
||
SELECT n.id
|
||
FROM notebooks n
|
||
INNER JOIN descendants d ON n.parent_id = d.id
|
||
WHERE n.is_deleted = 0
|
||
)
|
||
SELECT id
|
||
FROM descendants
|
||
WHERE id = ?
|
||
LIMIT 1`
|
||
)
|
||
.bind(ancestorId, candidateId)
|
||
.first<{ id: string }>();
|
||
|
||
return Boolean(row);
|
||
};
|
||
|
||
const getMemoDetailRow = async (
|
||
db: D1Database,
|
||
id: string,
|
||
includeDeleted = false
|
||
): Promise<MemoDetailRow | null> =>
|
||
db
|
||
.prepare(
|
||
`SELECT m.id, m.notebook_id, m.title, m.excerpt, m.tags_json, m.is_pinned,
|
||
m.is_archived, m.is_deleted, m.created_at, m.updated_at, m.deleted_at, c.revision,
|
||
c.content_json, c.content_markdown, c.content_text, c.content_hash,
|
||
m.source_memo_ids, m.merge_source_count, m.merged_into_memo_id
|
||
FROM memos m
|
||
INNER JOIN memo_contents c ON c.memo_id = m.id
|
||
WHERE m.id = ? AND (? = 1 OR m.is_deleted = 0)`
|
||
)
|
||
.bind(id, includeDeleted ? 1 : 0)
|
||
.first<MemoDetailRow>();
|
||
|
||
const getMemoDetail = async (db: D1Database, id: string, includeDeleted = false): Promise<MemoDetail | null> => {
|
||
const row = await getMemoDetailRow(db, id, includeDeleted);
|
||
return row ? mapMemoDetail(row) : null;
|
||
};
|
||
|
||
const deleteMemosRecord = async (
|
||
db: D1Database,
|
||
resourcesBucket: R2Bucket,
|
||
memoIds: string[],
|
||
permanent: boolean,
|
||
actor: { actorType: "user" | "agent"; actorId: string | null }
|
||
) => {
|
||
const uniqueMemoIds = Array.from(new Set(memoIds));
|
||
|
||
if (uniqueMemoIds.length === 0) {
|
||
return 0;
|
||
}
|
||
|
||
const placeholders = uniqueMemoIds.map(() => "?").join(", ");
|
||
const expectedDeletedState = permanent ? 1 : 0;
|
||
const rows = await db
|
||
.prepare(
|
||
`SELECT id
|
||
FROM memos
|
||
WHERE is_deleted = ? AND id IN (${placeholders})`
|
||
)
|
||
.bind(expectedDeletedState, ...uniqueMemoIds)
|
||
.all<{ id: string }>();
|
||
|
||
if (rows.results.length !== uniqueMemoIds.length) {
|
||
throw new AppError(
|
||
"missing_memos",
|
||
permanent ? "One or more memos cannot be permanently deleted." : "One or more memos cannot be deleted.",
|
||
400
|
||
);
|
||
}
|
||
|
||
const now = isoNow();
|
||
const statements: D1PreparedStatement[] = [];
|
||
|
||
if (permanent) {
|
||
const resourceRows = await db
|
||
.prepare(
|
||
`SELECT object_key
|
||
FROM resources
|
||
WHERE memo_id IN (${placeholders})`
|
||
)
|
||
.bind(...uniqueMemoIds)
|
||
.all<{ object_key: string }>();
|
||
const objectKeys = resourceRows.results.map((resource) => resource.object_key);
|
||
|
||
if (objectKeys.length > 0) {
|
||
await resourcesBucket.delete(objectKeys);
|
||
}
|
||
|
||
statements.push(
|
||
db.prepare(`DELETE FROM memos_fts WHERE memo_id IN (${placeholders})`).bind(...uniqueMemoIds),
|
||
db.prepare(`DELETE FROM resources WHERE memo_id IN (${placeholders})`).bind(...uniqueMemoIds),
|
||
db.prepare(`DELETE FROM memo_revisions WHERE memo_id IN (${placeholders})`).bind(...uniqueMemoIds),
|
||
db.prepare(`DELETE FROM memo_contents WHERE memo_id IN (${placeholders})`).bind(...uniqueMemoIds),
|
||
db.prepare(`DELETE FROM memos WHERE is_deleted = 1 AND id IN (${placeholders})`).bind(...uniqueMemoIds)
|
||
);
|
||
|
||
for (const memoId of uniqueMemoIds) {
|
||
statements.push(auditStatement(db, actor.actorType, actor.actorId, "memo.delete_permanent", "memo", memoId, {}));
|
||
}
|
||
} else {
|
||
statements.push(
|
||
db
|
||
.prepare(
|
||
`UPDATE memos
|
||
SET is_deleted = 1, deleted_at = ?, updated_at = ?
|
||
WHERE is_deleted = 0 AND id IN (${placeholders})`
|
||
)
|
||
.bind(now, now, ...uniqueMemoIds),
|
||
db.prepare(`DELETE FROM memos_fts WHERE memo_id IN (${placeholders})`).bind(...uniqueMemoIds)
|
||
);
|
||
|
||
for (const memoId of uniqueMemoIds) {
|
||
statements.push(auditStatement(db, actor.actorType, actor.actorId, "memo.delete", "memo", memoId, {}));
|
||
}
|
||
}
|
||
|
||
await db.batch(statements);
|
||
return uniqueMemoIds.length;
|
||
};
|
||
|
||
const emptyTrashMemosRecord = async (
|
||
db: D1Database,
|
||
resourcesBucket: R2Bucket,
|
||
actor: { actorType: "user" | "agent"; actorId: string | null }
|
||
) => {
|
||
const countRow = await db
|
||
.prepare(
|
||
`SELECT COUNT(*) AS count
|
||
FROM memos
|
||
WHERE is_deleted = 1`
|
||
)
|
||
.first<{ count: number }>();
|
||
const deleted = countRow?.count ?? 0;
|
||
|
||
if (deleted === 0) {
|
||
return 0;
|
||
}
|
||
|
||
const resourceRows = await db
|
||
.prepare(
|
||
`SELECT r.object_key
|
||
FROM resources r
|
||
INNER JOIN memos m ON m.id = r.memo_id
|
||
WHERE m.is_deleted = 1`
|
||
)
|
||
.all<{ object_key: string }>();
|
||
const objectKeys = resourceRows.results.map((resource) => resource.object_key);
|
||
|
||
if (objectKeys.length > 0) {
|
||
await resourcesBucket.delete(objectKeys);
|
||
}
|
||
|
||
await db.batch([
|
||
db.prepare(`DELETE FROM memos_fts WHERE memo_id IN (SELECT id FROM memos WHERE is_deleted = 1)`),
|
||
db.prepare(`UPDATE resources SET original_memo_id = NULL WHERE original_memo_id IN (SELECT id FROM memos WHERE is_deleted = 1)`),
|
||
db.prepare(`DELETE FROM resources WHERE memo_id IN (SELECT id FROM memos WHERE is_deleted = 1)`),
|
||
db.prepare(`DELETE FROM memo_revisions WHERE memo_id IN (SELECT id FROM memos WHERE is_deleted = 1)`),
|
||
db.prepare(`DELETE FROM memo_contents WHERE memo_id IN (SELECT id FROM memos WHERE is_deleted = 1)`),
|
||
db.prepare(`DELETE FROM memos WHERE is_deleted = 1`),
|
||
auditStatement(db, actor.actorType, actor.actorId, "memo.trash_empty", "trash", "memos", { deleted }),
|
||
]);
|
||
|
||
return deleted;
|
||
};
|
||
|
||
const isDemoMode = (env: Bindings) => env.EDGE_EVER_DEMO_MODE?.trim().toLowerCase() === "true";
|
||
|
||
const ensureDemoSeed = async (env: Bindings, options: { refreshResources?: boolean } = {}) => {
|
||
const db = env.DB;
|
||
const now = isoNow();
|
||
const statements: D1PreparedStatement[] = [];
|
||
const bucketName = env.EDGE_EVER_R2_BUCKET_NAME?.trim() || DEFAULT_R2_BUCKET_NAME;
|
||
|
||
for (const notebook of DEMO_SEED_NOTEBOOKS) {
|
||
statements.push(
|
||
db
|
||
.prepare(
|
||
`INSERT INTO notebooks (
|
||
id, parent_id, name, slug, icon, color, sort_order, is_deleted, created_at, updated_at, deleted_at
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, NULL)
|
||
ON CONFLICT(id) DO UPDATE SET
|
||
parent_id = excluded.parent_id,
|
||
name = excluded.name,
|
||
slug = excluded.slug,
|
||
icon = excluded.icon,
|
||
color = excluded.color,
|
||
sort_order = excluded.sort_order,
|
||
is_deleted = 0,
|
||
updated_at = excluded.updated_at,
|
||
deleted_at = NULL`
|
||
)
|
||
.bind(
|
||
notebook.id,
|
||
notebook.parentId,
|
||
notebook.name,
|
||
notebook.slug,
|
||
notebook.icon,
|
||
notebook.color,
|
||
notebook.sortOrder,
|
||
now,
|
||
now
|
||
)
|
||
);
|
||
}
|
||
|
||
for (const memo of DEMO_SEED_MEMOS) {
|
||
const contentJson = markdownToDoc(memo.markdown);
|
||
const contentText = docToText(contentJson);
|
||
const contentHash = await sha256(memo.markdown + JSON.stringify(contentJson));
|
||
|
||
statements.push(
|
||
db
|
||
.prepare(
|
||
`INSERT INTO memos (
|
||
id, notebook_id, title, excerpt, tags_json, is_pinned, is_archived, is_deleted,
|
||
created_by, updated_by, created_at, updated_at, deleted_at
|
||
) VALUES (?, ?, ?, ?, ?, ?, 0, 0, 'system', 'system', ?, ?, NULL)
|
||
ON CONFLICT(id) DO UPDATE SET
|
||
notebook_id = excluded.notebook_id,
|
||
title = excluded.title,
|
||
excerpt = excluded.excerpt,
|
||
tags_json = excluded.tags_json,
|
||
is_pinned = excluded.is_pinned,
|
||
is_archived = 0,
|
||
is_deleted = 0,
|
||
updated_by = 'system',
|
||
updated_at = excluded.updated_at,
|
||
deleted_at = NULL`
|
||
)
|
||
.bind(
|
||
memo.id,
|
||
memo.notebookId,
|
||
memo.title,
|
||
createExcerpt(contentText),
|
||
JSON.stringify(normalizeTags(memo.tags)),
|
||
memo.isPinned ? 1 : 0,
|
||
now,
|
||
now
|
||
),
|
||
db
|
||
.prepare(
|
||
`INSERT INTO memo_contents (
|
||
memo_id, content_json, content_markdown, content_text, content_hash, revision, created_at, updated_at
|
||
) VALUES (?, ?, ?, ?, ?, 0, ?, ?)
|
||
ON CONFLICT(memo_id) DO UPDATE SET
|
||
content_json = excluded.content_json,
|
||
content_markdown = excluded.content_markdown,
|
||
content_text = excluded.content_text,
|
||
content_hash = excluded.content_hash,
|
||
revision = 0,
|
||
updated_at = excluded.updated_at`
|
||
)
|
||
.bind(memo.id, JSON.stringify(contentJson), memo.markdown, contentText, contentHash, now, now),
|
||
db.prepare(`DELETE FROM memos_fts WHERE memo_id = ?`).bind(memo.id),
|
||
db
|
||
.prepare(
|
||
`INSERT INTO memos_fts (memo_id, title, content_text, tags)
|
||
VALUES (?, ?, ?, ?)`
|
||
)
|
||
.bind(memo.id, memo.title, contentText, memo.tags.join(" "))
|
||
);
|
||
}
|
||
|
||
const existingResourceIds = options.refreshResources
|
||
? new Set<string>()
|
||
: new Set(
|
||
(
|
||
await db
|
||
.prepare(`SELECT id FROM resources WHERE id IN (${DEMO_SEED_RESOURCES.map(() => "?").join(", ")})`)
|
||
.bind(...DEMO_SEED_RESOURCES.map((resource) => resource.id))
|
||
.all<{ id: string }>()
|
||
).results.map((resource) => resource.id)
|
||
);
|
||
|
||
for (const resource of DEMO_SEED_RESOURCES) {
|
||
const bytes = new TextEncoder().encode(resource.svg);
|
||
const objectKey = `demo/${resource.memoId}/${resource.id}.svg`;
|
||
|
||
if (options.refreshResources || !existingResourceIds.has(resource.id)) {
|
||
await env.RESOURCES.put(objectKey, bytes, {
|
||
httpMetadata: {
|
||
contentType: resource.mimeType,
|
||
cacheControl: "private, max-age=3600",
|
||
},
|
||
customMetadata: {
|
||
memoId: resource.memoId,
|
||
resourceId: resource.id,
|
||
filename: resource.filename,
|
||
demoSeed: "true",
|
||
},
|
||
});
|
||
}
|
||
|
||
statements.push(
|
||
db
|
||
.prepare(
|
||
`INSERT INTO resources (
|
||
id, memo_id, bucket_name, object_key, kind, mime_type, filename,
|
||
byte_size, sha256, width, height, metadata_json, is_deleted, created_at, updated_at, deleted_at
|
||
) VALUES (?, ?, ?, ?, 'image', ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, NULL)
|
||
ON CONFLICT(id) DO UPDATE SET
|
||
memo_id = excluded.memo_id,
|
||
bucket_name = excluded.bucket_name,
|
||
object_key = excluded.object_key,
|
||
kind = 'image',
|
||
mime_type = excluded.mime_type,
|
||
filename = excluded.filename,
|
||
byte_size = excluded.byte_size,
|
||
sha256 = excluded.sha256,
|
||
width = excluded.width,
|
||
height = excluded.height,
|
||
metadata_json = excluded.metadata_json,
|
||
is_deleted = 0,
|
||
updated_at = excluded.updated_at,
|
||
deleted_at = NULL`
|
||
)
|
||
.bind(
|
||
resource.id,
|
||
resource.memoId,
|
||
bucketName,
|
||
objectKey,
|
||
resource.mimeType,
|
||
resource.filename,
|
||
bytes.byteLength,
|
||
await sha256Bytes(bytes),
|
||
resource.width,
|
||
resource.height,
|
||
JSON.stringify({ source: "demo-seed" }),
|
||
now,
|
||
now
|
||
)
|
||
);
|
||
}
|
||
|
||
await db.batch(statements);
|
||
};
|
||
|
||
const resetDemoData = async (env: Bindings, scheduledTime: number) => {
|
||
const db = env.DB;
|
||
const memoPlaceholders = DEMO_SEED_MEMO_IDS.map(() => "?").join(", ");
|
||
const notebookPlaceholders = DEMO_SEED_NOTEBOOK_IDS.map(() => "?").join(", ");
|
||
const resourceRows = await db.prepare(`SELECT object_key FROM resources`).all<{ object_key: string }>();
|
||
const objectKeys = resourceRows.results.map((resource) => resource.object_key);
|
||
|
||
for (let index = 0; index < objectKeys.length; index += 1000) {
|
||
await env.RESOURCES.delete(objectKeys.slice(index, index + 1000));
|
||
}
|
||
|
||
await db.batch([
|
||
db.prepare(`DELETE FROM memos_fts`),
|
||
db.prepare(`DELETE FROM resources`),
|
||
db.prepare(`DELETE FROM memo_revisions`),
|
||
db.prepare(`DELETE FROM memo_contents WHERE memo_id NOT IN (${memoPlaceholders})`).bind(...DEMO_SEED_MEMO_IDS),
|
||
db.prepare(`DELETE FROM memos WHERE id NOT IN (${memoPlaceholders})`).bind(...DEMO_SEED_MEMO_IDS),
|
||
db.prepare(`UPDATE notebooks SET parent_id = NULL`),
|
||
db.prepare(`DELETE FROM notebooks WHERE id NOT IN (${notebookPlaceholders})`).bind(...DEMO_SEED_NOTEBOOK_IDS),
|
||
db.prepare(`DELETE FROM api_tokens`),
|
||
db.prepare(`DELETE FROM audit_events`),
|
||
]);
|
||
|
||
await ensureDemoSeed(env, { refreshResources: true });
|
||
await audit(db, "system", null, "demo.reset", "demo", "edgeever-demo", {
|
||
scheduledTime: new Date(scheduledTime).toISOString(),
|
||
seedMemoCount: DEMO_SEED_MEMOS.length,
|
||
});
|
||
};
|
||
|
||
const moveMemosToNotebook = async (
|
||
db: D1Database,
|
||
memoIds: string[],
|
||
notebookId: string,
|
||
actor: { actorType: "user" | "agent"; actorId: string | null },
|
||
actorLabel: string
|
||
) => {
|
||
const uniqueMemoIds = Array.from(new Set(memoIds));
|
||
|
||
if (uniqueMemoIds.length === 0) {
|
||
return 0;
|
||
}
|
||
|
||
const placeholders = uniqueMemoIds.map(() => "?").join(", ");
|
||
const rows = await db
|
||
.prepare(
|
||
`SELECT id, notebook_id
|
||
FROM memos
|
||
WHERE is_deleted = 0 AND id IN (${placeholders})`
|
||
)
|
||
.bind(...uniqueMemoIds)
|
||
.all<{ id: string; notebook_id: string }>();
|
||
|
||
if (rows.results.length !== uniqueMemoIds.length) {
|
||
throw new AppError("missing_memos", "One or more memos cannot be moved.", 400);
|
||
}
|
||
|
||
const now = isoNow();
|
||
const statements: D1PreparedStatement[] = [
|
||
db
|
||
.prepare(
|
||
`UPDATE memos
|
||
SET notebook_id = ?, updated_by = ?, updated_at = ?
|
||
WHERE is_deleted = 0 AND id IN (${placeholders})`
|
||
)
|
||
.bind(notebookId, actorLabel, now, ...uniqueMemoIds),
|
||
];
|
||
|
||
for (const row of rows.results) {
|
||
statements.push(
|
||
auditStatement(db, actor.actorType, actor.actorId, "memo.move", "memo", row.id, {
|
||
fromNotebookId: row.notebook_id,
|
||
toNotebookId: notebookId,
|
||
})
|
||
);
|
||
}
|
||
|
||
await db.batch(statements);
|
||
return uniqueMemoIds.length;
|
||
};
|
||
|
||
const mergeMemosRecord = async (
|
||
db: D1Database,
|
||
input: { memoIds: string[]; notebookId?: string; title?: string },
|
||
actor: { actorType: "user" | "agent"; actorId: string | null },
|
||
actorLabel: string
|
||
) => {
|
||
const uniqueMemoIds = Array.from(new Set(input.memoIds));
|
||
|
||
if (uniqueMemoIds.length < 2) {
|
||
throw new AppError("bad_request", "At least two memos are required to merge.", 400);
|
||
}
|
||
|
||
const placeholders = uniqueMemoIds.map(() => "?").join(", ");
|
||
const rows = await db
|
||
.prepare(
|
||
`SELECT m.id, m.notebook_id, m.title, m.excerpt, m.tags_json, m.is_pinned,
|
||
m.is_archived, m.is_deleted, m.created_at, m.updated_at, m.deleted_at, c.revision,
|
||
c.content_json, c.content_markdown, c.content_text, c.content_hash,
|
||
m.source_memo_ids, m.merge_source_count, m.merged_into_memo_id
|
||
FROM memos m
|
||
INNER JOIN memo_contents c ON c.memo_id = m.id
|
||
WHERE m.is_deleted = 0 AND m.id IN (${placeholders})`
|
||
)
|
||
.bind(...uniqueMemoIds)
|
||
.all<MemoDetailRow>();
|
||
|
||
if (rows.results.length !== uniqueMemoIds.length) {
|
||
throw new AppError("missing_memos", "One or more memos cannot be merged.", 400);
|
||
}
|
||
|
||
if (input.notebookId && !(await getNotebook(db, input.notebookId))) {
|
||
throw new AppError("not_found", "Target notebook not found", 404);
|
||
}
|
||
|
||
const ordered = uniqueMemoIds
|
||
.map((memoId) => rows.results.find((row) => row.id === memoId))
|
||
.filter((row): row is MemoDetailRow => Boolean(row));
|
||
const notebookId = input.notebookId ?? ordered[0].notebook_id;
|
||
const title = input.title || `合并笔记 ${new Date().toLocaleDateString("zh-CN")}`;
|
||
const mergedMarkdown = ordered.map((memo) => memo.content_markdown).join("\n\n---\n\n");
|
||
const contentJson = markdownToDoc(mergedMarkdown);
|
||
const contentText = docToText(contentJson);
|
||
const tags = Array.from(new Set(ordered.flatMap((memo) => parseJsonArray(memo.tags_json))));
|
||
const excerpt = createExcerpt(contentText || title);
|
||
const contentHash = await sha256(mergedMarkdown + JSON.stringify(contentJson));
|
||
const newMemoId = createId("memo");
|
||
const now = isoNow();
|
||
|
||
await db.batch([
|
||
db
|
||
.prepare(
|
||
`INSERT INTO memos (
|
||
id, notebook_id, title, excerpt, tags_json, source_memo_ids, merge_source_count,
|
||
created_by, updated_by, created_at, updated_at
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||
)
|
||
.bind(
|
||
newMemoId,
|
||
notebookId,
|
||
title,
|
||
excerpt,
|
||
JSON.stringify(tags),
|
||
JSON.stringify(uniqueMemoIds),
|
||
uniqueMemoIds.length,
|
||
actorLabel,
|
||
actorLabel,
|
||
now,
|
||
now
|
||
),
|
||
db
|
||
.prepare(
|
||
`INSERT INTO memo_contents (
|
||
memo_id, content_json, content_markdown, content_text, content_hash, revision, created_at, updated_at
|
||
) VALUES (?, ?, ?, ?, ?, 0, ?, ?)`
|
||
)
|
||
.bind(newMemoId, JSON.stringify(contentJson), mergedMarkdown, contentText, contentHash, now, now),
|
||
db
|
||
.prepare(
|
||
`INSERT INTO memos_fts (memo_id, title, content_text, tags)
|
||
VALUES (?, ?, ?, ?)`
|
||
)
|
||
.bind(newMemoId, title, contentText, tags.join(" ")),
|
||
db
|
||
.prepare(
|
||
`UPDATE memos
|
||
SET is_deleted = 1, deleted_at = ?, merged_into_memo_id = ?, merged_at = ?, updated_at = ?
|
||
WHERE id IN (${placeholders})`
|
||
)
|
||
.bind(now, newMemoId, now, now, ...uniqueMemoIds),
|
||
db.prepare(`DELETE FROM memos_fts WHERE memo_id IN (${placeholders})`).bind(...uniqueMemoIds),
|
||
db
|
||
.prepare(
|
||
`UPDATE resources
|
||
SET original_memo_id = COALESCE(original_memo_id, memo_id),
|
||
memo_id = ?,
|
||
updated_at = ?
|
||
WHERE memo_id IN (${placeholders})`
|
||
)
|
||
.bind(newMemoId, now, ...uniqueMemoIds),
|
||
auditStatement(db, actor.actorType, actor.actorId, "memo.merge", "memo", newMemoId, {
|
||
sourceMemoIds: uniqueMemoIds,
|
||
}),
|
||
]);
|
||
|
||
const memo = await getMemoDetail(db, newMemoId);
|
||
|
||
if (!memo) {
|
||
throw new AppError("not_found", "Merged memo not found after create.", 404);
|
||
}
|
||
|
||
return memo;
|
||
};
|
||
|
||
const createMemoRecord = async (
|
||
db: D1Database,
|
||
input: { notebookId: string; title?: string; contentMarkdown?: string; tags?: string[]; createdAt?: string; updatedAt?: string },
|
||
actor: { actorType: "user" | "agent"; actorId: string | null },
|
||
actorLabel: string
|
||
): Promise<MemoDetail> => {
|
||
const tags = normalizeTags(input.tags);
|
||
const contentMarkdown = input.contentMarkdown ?? "";
|
||
const contentJson = markdownToDoc(contentMarkdown);
|
||
const contentText = docToText(contentJson);
|
||
const title = normalizeMemoTitle(input.title);
|
||
const excerpt = createExcerpt(contentText);
|
||
const contentHash = await sha256(contentMarkdown + JSON.stringify(contentJson));
|
||
const id = createId("memo");
|
||
const now = isoNow();
|
||
const createdAt = input.createdAt ?? now;
|
||
const updatedAt = input.updatedAt ?? now;
|
||
|
||
await db.batch([
|
||
db
|
||
.prepare(
|
||
`INSERT INTO memos (
|
||
id, notebook_id, title, excerpt, tags_json, created_by, updated_by, created_at, updated_at
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||
)
|
||
.bind(id, input.notebookId, title, excerpt, JSON.stringify(tags), actorLabel, actorLabel, createdAt, updatedAt),
|
||
db
|
||
.prepare(
|
||
`INSERT INTO memo_contents (
|
||
memo_id, content_json, content_markdown, content_text, content_hash, revision, created_at, updated_at
|
||
) VALUES (?, ?, ?, ?, ?, 0, ?, ?)`
|
||
)
|
||
.bind(id, JSON.stringify(contentJson), contentMarkdown, contentText, contentHash, createdAt, updatedAt),
|
||
db
|
||
.prepare(
|
||
`INSERT INTO memos_fts (memo_id, title, content_text, tags)
|
||
VALUES (?, ?, ?, ?)`
|
||
)
|
||
.bind(id, title, contentText, tags.join(" ")),
|
||
auditStatement(db, actor.actorType, actor.actorId, "memo.create", "memo", id, {
|
||
notebookId: input.notebookId,
|
||
}),
|
||
]);
|
||
|
||
const memo = await getMemoDetail(db, id);
|
||
|
||
if (!memo) {
|
||
throw new Error("Memo was created but could not be read.");
|
||
}
|
||
|
||
return memo;
|
||
};
|
||
|
||
const updateMemoRecord = async (
|
||
db: D1Database,
|
||
id: string,
|
||
input: {
|
||
expectedRevision?: number;
|
||
notebookId?: string;
|
||
title?: string;
|
||
isPinned?: boolean;
|
||
contentMarkdown?: string;
|
||
tags?: string[];
|
||
createdAt?: string;
|
||
updatedAt?: string;
|
||
},
|
||
actor: { actorType: "user" | "agent"; actorId: string | null },
|
||
actorLabel: string
|
||
): Promise<{ memo: MemoDetail; error?: never; message?: never } | { error: string; message: string }> => {
|
||
const current = await getMemoDetailRow(db, id);
|
||
|
||
if (!current) {
|
||
return { error: "not_found", message: "Memo not found" };
|
||
}
|
||
|
||
if (input.expectedRevision !== undefined && input.expectedRevision !== current.revision) {
|
||
return { error: "revision_conflict", message: "Memo was updated elsewhere. Reload before saving." };
|
||
}
|
||
|
||
const isPinned = input.isPinned ?? Boolean(current.is_pinned);
|
||
const hasContentUpdate =
|
||
input.notebookId !== undefined ||
|
||
input.title !== undefined ||
|
||
input.contentMarkdown !== undefined ||
|
||
input.tags !== undefined ||
|
||
input.createdAt !== undefined ||
|
||
input.updatedAt !== undefined;
|
||
const now = isoNow();
|
||
const updatedAt = input.updatedAt ?? now;
|
||
|
||
if (!hasContentUpdate) {
|
||
if (input.isPinned === undefined || isPinned === Boolean(current.is_pinned)) {
|
||
const memo = await getMemoDetail(db, id);
|
||
|
||
if (!memo) {
|
||
return { error: "not_found", message: "Memo not found after update" };
|
||
}
|
||
|
||
return { memo };
|
||
}
|
||
|
||
await db.batch([
|
||
db
|
||
.prepare(
|
||
`UPDATE memos
|
||
SET is_pinned = ?, updated_by = ?, updated_at = ?, created_at = COALESCE(?, created_at)
|
||
WHERE id = ? AND is_deleted = 0`
|
||
)
|
||
.bind(isPinned ? 1 : 0, actorLabel, updatedAt, input.createdAt ?? null, id),
|
||
auditStatement(db, actor.actorType, actor.actorId, isPinned ? "memo.pin" : "memo.unpin", "memo", id, {}),
|
||
]);
|
||
|
||
const memo = await getMemoDetail(db, id);
|
||
|
||
if (!memo) {
|
||
return { error: "not_found", message: "Memo not found after update" };
|
||
}
|
||
|
||
return { memo };
|
||
}
|
||
|
||
const currentContentJson = parseDoc(current.content_json);
|
||
const contentJson = input.contentMarkdown !== undefined ? markdownToDoc(input.contentMarkdown) : currentContentJson;
|
||
const contentMarkdown =
|
||
input.contentMarkdown !== undefined ? input.contentMarkdown : docToMarkdown(contentJson);
|
||
const contentText = docToText(contentJson);
|
||
const title =
|
||
input.title !== undefined ? normalizeMemoTitle(input.title) : normalizeMemoTitle(current.title);
|
||
const tags = input.tags === undefined ? parseJsonArray(current.tags_json) : normalizeTags(input.tags);
|
||
const excerpt = createExcerpt(contentText);
|
||
const notebookId = input.notebookId ?? current.notebook_id;
|
||
const nextRevision = current.revision + 1;
|
||
const contentHash = await sha256(contentMarkdown + JSON.stringify(contentJson));
|
||
const revisionStatements = (await shouldSnapshotMemoRevision(db, current, title, JSON.stringify(tags), contentHash, updatedAt))
|
||
? [createMemoRevisionStatement(db, current, actorLabel, updatedAt)]
|
||
: [];
|
||
|
||
await db.batch([
|
||
...revisionStatements,
|
||
db
|
||
.prepare(
|
||
`UPDATE memos
|
||
SET notebook_id = ?, title = ?, excerpt = ?, tags_json = ?, is_pinned = ?, updated_by = ?, updated_at = ?, created_at = COALESCE(?, created_at)
|
||
WHERE id = ? AND is_deleted = 0`
|
||
)
|
||
.bind(notebookId, title, excerpt, JSON.stringify(tags), isPinned ? 1 : 0, actorLabel, updatedAt, input.createdAt ?? null, id),
|
||
db
|
||
.prepare(
|
||
`UPDATE memo_contents
|
||
SET content_json = ?, content_markdown = ?, content_text = ?, content_hash = ?,
|
||
revision = ?, updated_at = ?, created_at = COALESCE(?, created_at)
|
||
WHERE memo_id = ?`
|
||
)
|
||
.bind(JSON.stringify(contentJson), contentMarkdown, contentText, contentHash, nextRevision, updatedAt, input.createdAt ?? null, id),
|
||
db.prepare(`DELETE FROM memos_fts WHERE memo_id = ?`).bind(id),
|
||
db
|
||
.prepare(
|
||
`INSERT INTO memos_fts (memo_id, title, content_text, tags)
|
||
VALUES (?, ?, ?, ?)`
|
||
)
|
||
.bind(id, title, contentText, tags.join(" ")),
|
||
auditStatement(db, actor.actorType, actor.actorId, "memo.update", "memo", id, {
|
||
revision: nextRevision,
|
||
}),
|
||
]);
|
||
|
||
const memo = await getMemoDetail(db, id);
|
||
|
||
if (!memo) {
|
||
return { error: "not_found", message: "Memo not found after update" };
|
||
}
|
||
|
||
return { memo };
|
||
};
|
||
|
||
const getMemoRevisionRow = async (
|
||
db: D1Database,
|
||
memoId: string,
|
||
revisionId: string
|
||
): Promise<MemoRevisionRow | null> =>
|
||
db
|
||
.prepare(
|
||
`SELECT id, memo_id, revision, title, tags_json, content_json, content_markdown,
|
||
content_text, content_hash, created_by, created_at
|
||
FROM memo_revisions
|
||
WHERE id = ? AND memo_id = ?`
|
||
)
|
||
.bind(revisionId, memoId)
|
||
.first<MemoRevisionRow>();
|
||
|
||
const getLatestMemoRevisionRow = async (db: D1Database, memoId: string): Promise<MemoRevisionRow | null> =>
|
||
db
|
||
.prepare(
|
||
`SELECT id, memo_id, revision, title, tags_json, content_json, content_markdown,
|
||
content_text, content_hash, created_by, created_at
|
||
FROM memo_revisions
|
||
WHERE memo_id = ?
|
||
ORDER BY created_at DESC, revision DESC
|
||
LIMIT 1`
|
||
)
|
||
.bind(memoId)
|
||
.first<MemoRevisionRow>();
|
||
|
||
const createMemoRevisionStatement = (
|
||
db: D1Database,
|
||
current: MemoDetailRow,
|
||
actorLabel: string,
|
||
createdAt: string
|
||
) =>
|
||
db
|
||
.prepare(
|
||
`INSERT INTO memo_revisions (
|
||
id, memo_id, revision, title, content_json, content_markdown,
|
||
content_hash, created_by, created_at, tags_json, content_text
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||
)
|
||
.bind(
|
||
createId("rev"),
|
||
current.id,
|
||
current.revision,
|
||
current.title,
|
||
current.content_json,
|
||
current.content_markdown,
|
||
current.content_hash,
|
||
actorLabel,
|
||
createdAt,
|
||
current.tags_json,
|
||
current.content_text
|
||
);
|
||
|
||
const shouldSnapshotMemoRevision = async (
|
||
db: D1Database,
|
||
current: MemoDetailRow,
|
||
nextTitle: string | null,
|
||
nextTagsJson: string,
|
||
nextContentHash: string,
|
||
now: string
|
||
) => {
|
||
const changed =
|
||
(current.title ?? "") !== (nextTitle ?? "") ||
|
||
current.tags_json !== nextTagsJson ||
|
||
current.content_hash !== nextContentHash;
|
||
|
||
if (!changed) {
|
||
return false;
|
||
}
|
||
|
||
const latest = await getLatestMemoRevisionRow(db, current.id);
|
||
|
||
if (!latest) {
|
||
return true;
|
||
}
|
||
|
||
const alreadyCapturedCurrent =
|
||
(latest.title ?? "") === (current.title ?? "") &&
|
||
latest.tags_json === current.tags_json &&
|
||
latest.content_hash === current.content_hash;
|
||
|
||
if (alreadyCapturedCurrent) {
|
||
return false;
|
||
}
|
||
|
||
return Date.parse(now) - Date.parse(latest.created_at) >= REVISION_SNAPSHOT_INTERVAL_MS;
|
||
};
|
||
|
||
const getResourceRow = async (db: D1Database, id: string): Promise<ResourceRow | null> =>
|
||
db
|
||
.prepare(
|
||
`SELECT id, memo_id, original_memo_id, bucket_name, object_key, kind, mime_type,
|
||
filename, byte_size, sha256, width, height, created_at, updated_at
|
||
FROM resources
|
||
WHERE id = ? AND is_deleted = 0`
|
||
)
|
||
.bind(id)
|
||
.first<ResourceRow>();
|
||
|
||
const getResourceRowsForMemo = async (db: D1Database, memoId: string): Promise<ResourceRow[]> => {
|
||
const rows = await db
|
||
.prepare(
|
||
`SELECT id, memo_id, original_memo_id, bucket_name, object_key, kind, mime_type,
|
||
filename, byte_size, sha256, width, height, created_at, updated_at
|
||
FROM resources
|
||
WHERE memo_id = ?`
|
||
)
|
||
.bind(memoId)
|
||
.all<ResourceRow>();
|
||
|
||
return rows.results;
|
||
};
|
||
|
||
const parseJsonArray = (json: string): string[] => {
|
||
try {
|
||
const value = JSON.parse(json);
|
||
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
};
|
||
|
||
const parseDoc = (json: string): TiptapDoc => {
|
||
try {
|
||
const value = JSON.parse(json);
|
||
return value && typeof value === "object" ? (value as TiptapDoc) : emptyDoc();
|
||
} catch {
|
||
return emptyDoc();
|
||
}
|
||
};
|
||
|
||
const audit = async (
|
||
db: D1Database,
|
||
actorType: "user" | "agent" | "system",
|
||
actorId: string | null,
|
||
action: string,
|
||
entityType: string,
|
||
entityId: string,
|
||
metadata: unknown
|
||
) => auditStatement(db, actorType, actorId, action, entityType, entityId, metadata).run();
|
||
|
||
const auditStatement = (
|
||
db: D1Database,
|
||
actorType: "user" | "agent" | "system",
|
||
actorId: string | null,
|
||
action: string,
|
||
entityType: string,
|
||
entityId: string,
|
||
metadata: unknown
|
||
) =>
|
||
db
|
||
.prepare(
|
||
`INSERT INTO audit_events (
|
||
id, actor_type, actor_id, action, entity_type, entity_id, metadata_json, created_at
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||
)
|
||
.bind(createId("audit"), actorType, actorId, action, entityType, entityId, JSON.stringify(metadata ?? {}), isoNow());
|
||
|
||
const createId = (prefix: string) => `${prefix}_${crypto.randomUUID().replace(/-/g, "")}`;
|
||
|
||
const isoNow = () => new Date().toISOString();
|
||
|
||
const slugify = (value: string) =>
|
||
value
|
||
.trim()
|
||
.toLowerCase()
|
||
.replace(/['"]/g, "")
|
||
.replace(/[^\p{L}\p{N}]+/gu, "-")
|
||
.replace(/^-+|-+$/g, "")
|
||
.slice(0, 80);
|
||
|
||
const normalizeMemoTitle = (value: string | null | undefined) => {
|
||
const title = value?.trim();
|
||
return title || DEFAULT_MEMO_TITLE;
|
||
};
|
||
|
||
const clampNumber = (value: number, min: number, max: number) => {
|
||
if (Number.isNaN(value)) {
|
||
return min;
|
||
}
|
||
|
||
return Math.min(Math.max(value, min), max);
|
||
};
|
||
|
||
const toFtsQuery = (value: string) => {
|
||
const tokens = value.match(/[\p{L}\p{N}_]+/gu) ?? [];
|
||
return tokens
|
||
.slice(0, 8)
|
||
.map((token) => `"${token.replace(/"/g, '""')}"`)
|
||
.join(" ");
|
||
};
|
||
|
||
const escapeLike = (value: string) => value.replace(/[\\%_]/g, (character) => `\\${character}`);
|
||
|
||
const sha256 = async (value: string) => {
|
||
const bytes = new TextEncoder().encode(value);
|
||
return sha256Bytes(bytes);
|
||
};
|
||
|
||
const sha256Bytes = async (bytes: Uint8Array) => {
|
||
const digest = await crypto.subtle.digest("SHA-256", bytes.slice());
|
||
const hashArray = new Uint8Array(digest);
|
||
let hexString = "";
|
||
for (let i = 0; i < hashArray.length; i++) {
|
||
const hex = hashArray[i].toString(16);
|
||
hexString += hex.length === 1 ? "0" + hex : hex;
|
||
}
|
||
return hexString;
|
||
};
|
||
|
||
const inferImageExtension = (filename: string, mimeType: string) => {
|
||
const extension = /\.(png|jpe?g|gif|webp|avif)$/i.exec(filename)?.[0]?.toLowerCase();
|
||
|
||
if (extension) {
|
||
return extension === ".jpeg" ? ".jpg" : extension;
|
||
}
|
||
|
||
switch (mimeType) {
|
||
case "image/png":
|
||
return ".png";
|
||
case "image/jpeg":
|
||
return ".jpg";
|
||
case "image/gif":
|
||
return ".gif";
|
||
case "image/webp":
|
||
return ".webp";
|
||
case "image/avif":
|
||
return ".avif";
|
||
default:
|
||
return "";
|
||
}
|
||
};
|
||
|
||
const normalizeFilename = (filename: string) =>
|
||
filename
|
||
.trim()
|
||
.replace(/[\\/]/g, "-")
|
||
.replace(/[\u0000-\u001f\u007f]/g, "")
|
||
.slice(0, 160);
|
||
|
||
const contentDispositionInline = (filename: string | null) => {
|
||
if (!filename) {
|
||
return "inline";
|
||
}
|
||
|
||
const fallback = normalizeFilename(filename).replace(/"/g, "'");
|
||
return `inline; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
|
||
};
|
||
|
||
const decodeTagParam = (value: string) => {
|
||
try {
|
||
return decodeURIComponent(value).trim();
|
||
} catch {
|
||
return value.trim();
|
||
}
|
||
};
|
||
|
||
const notFound = (c: Context, message: string) =>
|
||
c.json(
|
||
{
|
||
error: {
|
||
code: "not_found",
|
||
message,
|
||
},
|
||
},
|
||
404
|
||
);
|
||
|
||
const badRequest = (c: Context, message: string) =>
|
||
c.json(
|
||
{
|
||
error: {
|
||
code: "bad_request",
|
||
message,
|
||
},
|
||
},
|
||
400
|
||
);
|
||
|
||
const apiError = (c: Context, code: string, message: string, status: number) =>
|
||
c.json(
|
||
{
|
||
error: {
|
||
code,
|
||
message,
|
||
},
|
||
},
|
||
status as 400
|
||
);
|
||
|
||
const conflict = (c: Context, code: string, message: string) =>
|
||
c.json(
|
||
{
|
||
error: {
|
||
code,
|
||
message,
|
||
},
|
||
},
|
||
409
|
||
);
|
||
|
||
const unauthorized = (c: Context, message: string) =>
|
||
c.json(
|
||
{
|
||
error: {
|
||
code: "unauthorized",
|
||
message,
|
||
},
|
||
},
|
||
401
|
||
);
|
||
|
||
const forbidden = (c: Context, message: string) =>
|
||
c.json(
|
||
{
|
||
error: {
|
||
code: "forbidden",
|
||
message,
|
||
},
|
||
},
|
||
403
|
||
);
|