6952 lines
399 KiB
TypeScript
6952 lines
399 KiB
TypeScript
import {
|
||
createExcerpt,
|
||
DEFAULT_MEMO_TITLE,
|
||
docToMarkdown,
|
||
docToText,
|
||
emptyDoc,
|
||
ApiTokenCreateSchema,
|
||
ChangePasswordSchema,
|
||
DeleteMemosSchema,
|
||
LoginSchema,
|
||
LoginDeviceSessionUpdateSchema,
|
||
markdownToDoc,
|
||
isSuspiciousMemoOverwrite,
|
||
isMemoEditBindingValid,
|
||
JsonBackupResourceMetadataSchema,
|
||
MemoCreateSchema,
|
||
MemoUpdateSchema,
|
||
TemplateCreateSchema,
|
||
TemplateUpdateSchema,
|
||
TemplateUseSchema,
|
||
MergeMemosSchema,
|
||
MoveMemosSchema,
|
||
normalizeTags,
|
||
TagRenameSchema,
|
||
UserCreateSchema,
|
||
UserUpdateSchema,
|
||
NotebookCreateSchema,
|
||
NotebookUpdateSchema,
|
||
RestoreJsonMemosSchema,
|
||
RestoreJsonNotebooksSchema,
|
||
type ApiToken,
|
||
type CreatedApiToken,
|
||
type MemoDetail,
|
||
type MemoEditSession,
|
||
type MemoRevision,
|
||
type MemoSummary,
|
||
type MemoUpdateInput,
|
||
type MemoTemplate,
|
||
type TemplateUpdateInput,
|
||
type JsonBackupMemo,
|
||
type JsonBackupNotebook,
|
||
type JsonBackupResource,
|
||
type JsonBackupRevision,
|
||
type Notebook,
|
||
type NotebookCreateInput,
|
||
type Resource,
|
||
type ResourceListItem,
|
||
type ResourceStorageSummary,
|
||
type TagSummary,
|
||
type TiptapDoc,
|
||
type InstanceUser,
|
||
} 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";
|
||
import { hasBootstrapCredential, isSupportedPasswordHash, verifyBootstrapPassword } from "./auth-bootstrap";
|
||
import {
|
||
isDatabaseNotReadyError,
|
||
isUnauthenticatedAccessEnabled,
|
||
resolveInstanceAuthMode,
|
||
type InstanceAuthMode,
|
||
} from "./auth-state";
|
||
import {
|
||
isDemoModeEnabled,
|
||
isProtectedDemoAccount,
|
||
resolveDemoPasswordHash,
|
||
shouldUpsertDemoSeedRecord,
|
||
} from "./demo-mode";
|
||
import {
|
||
groupLoginDeviceSessions,
|
||
resolveSessionDeviceId,
|
||
type LoginDeviceSessionRow,
|
||
} from "./auth-session-devices";
|
||
import {
|
||
checkLoginRateLimit,
|
||
clearLoginAttempts,
|
||
recordLoginFailure,
|
||
resolveLoginRateLimitConfig,
|
||
type LoginAttemptKey,
|
||
} from "./auth-login-limiter";
|
||
import {
|
||
decodeDemoAttachment,
|
||
DEMO_ATTACHMENT_MARKDOWN_EN,
|
||
DEMO_ATTACHMENT_MARKDOWN_ZH,
|
||
DEMO_ATTACHMENT_RESOURCES,
|
||
} from "./demo-attachments";
|
||
import { createCloudflareStorageAdapter } from "./cloudflare-storage-adapter";
|
||
import type {
|
||
BlobStoreAdapter,
|
||
CloudflareStorageBindings,
|
||
DatabaseAdapter,
|
||
PreparedStatementAdapter,
|
||
StorageAdapter,
|
||
} from "./storage-contract";
|
||
|
||
// Compatibility aliases keep the existing SQL-heavy implementation small
|
||
// while routing its dependency through the platform-neutral contract above.
|
||
// New code should use DatabaseAdapter/BlobStoreAdapter directly.
|
||
type D1Database = DatabaseAdapter;
|
||
type D1PreparedStatement = PreparedStatementAdapter;
|
||
type R2Bucket = BlobStoreAdapter;
|
||
|
||
type Bindings = CloudflareStorageBindings & {
|
||
/** The only persistence dependency exposed to application code. */
|
||
storage: StorageAdapter;
|
||
EDGE_EVER_AUTH_USERNAME?: string;
|
||
EDGE_EVER_RUNTIME?: string;
|
||
EDGE_EVER_AUTH_PASSWORD?: string;
|
||
EDGE_EVER_AUTH_PASSWORD_HASH?: string;
|
||
EDGE_EVER_SESSION_TTL_DAYS?: string;
|
||
EDGE_EVER_AUTH_LOGIN_WINDOW_SECONDS?: string;
|
||
EDGE_EVER_AUTH_LOGIN_USERNAME_MAX_ATTEMPTS?: string;
|
||
EDGE_EVER_AUTH_LOGIN_USERNAME_COOLDOWN_SECONDS?: string;
|
||
EDGE_EVER_AUTH_LOGIN_IP_MAX_ATTEMPTS?: string;
|
||
EDGE_EVER_AUTH_LOGIN_IP_COOLDOWN_SECONDS?: string;
|
||
EDGE_EVER_R2_BUCKET_NAME?: string;
|
||
EDGE_EVER_DEMO_MODE?: string;
|
||
EDGE_EVER_LOCAL_DEMO_SEED?: string;
|
||
EDGE_EVER_ALLOW_UNAUTHENTICATED?: string;
|
||
};
|
||
|
||
type WorkerBindings = Omit<Bindings, "storage">;
|
||
|
||
type AuthContext = {
|
||
kind: "user" | "agent";
|
||
actorType: "user" | "agent";
|
||
actorId: string | null;
|
||
username: string;
|
||
displayName: string | null;
|
||
scopes: string[];
|
||
workspaceId: string;
|
||
role: "owner" | "member";
|
||
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;
|
||
content_text?: string | null;
|
||
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 MemoListSortMode = "updated-desc" | "created-desc" | "title-asc";
|
||
type MemoListFilterMode = "all" | "tagged" | "untagged" | "pinned";
|
||
|
||
type MobileSyncChangeRow = {
|
||
id: number;
|
||
entity_type: "notebook" | "memo";
|
||
entity_id: string;
|
||
operation: "upsert" | "delete";
|
||
};
|
||
|
||
type MemoListCursor = {
|
||
sort: MemoListSortMode;
|
||
id: string;
|
||
pinned?: number;
|
||
updatedAt?: string;
|
||
createdAt?: string;
|
||
deletedAt?: string | null;
|
||
title?: string;
|
||
};
|
||
|
||
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 MemoTemplateRow = {
|
||
id: string;
|
||
name: string;
|
||
description: string | null;
|
||
title: string | null;
|
||
content_json: string;
|
||
content_markdown: string;
|
||
tags_json: string;
|
||
created_at: string;
|
||
updated_at: 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 BackupRevisionRow = MemoRevisionRow;
|
||
|
||
type MemoEditSessionRow = {
|
||
id: string;
|
||
memo_id: string;
|
||
actor_type: "user" | "agent";
|
||
actor_id: string | null;
|
||
base_revision: number;
|
||
base_content_hash: string;
|
||
expires_at: string;
|
||
};
|
||
|
||
type UserRow = {
|
||
id: string;
|
||
username: string;
|
||
password_hash: string;
|
||
display_name: string | null;
|
||
is_disabled: number;
|
||
};
|
||
|
||
type InstanceUserRow = UserRow & {
|
||
last_login_at: string | null;
|
||
created_at: string;
|
||
role: "owner" | "member";
|
||
};
|
||
|
||
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;
|
||
workspace_id: 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 DEFAULT_WORKSPACE_ID = "ws_default";
|
||
const DEFAULT_MEMO_LIST_LIMIT = 100;
|
||
const MAX_MEMO_LIST_LIMIT = 200;
|
||
const UNTITLED_MEMO_TITLE = "无标题笔记";
|
||
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 = 400;
|
||
const MAX_SESSION_TTL_DAYS = 400;
|
||
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 },
|
||
{ id: "nb_demo_features_en", parentId: "nb_projects", name: "Feature Demos", slug: "feature-demos", icon: "notebook", color: "#0e7490", sortOrder: 22 },
|
||
];
|
||
const DEMO_SEED_MEMOS_ZH = [
|
||
{
|
||
id: "memo_demo_overview",
|
||
notebookId: "nb_demo_features",
|
||
title: "🌟 欢迎使用 EdgeEver",
|
||
tags: ["overview", "features", "demo"],
|
||
isPinned: true,
|
||
markdown:
|
||
"## 🌟 欢迎使用 EdgeEver\n\n> **EdgeEver** 是一款基于 Cloudflare 的开源 Serverless 个人知识库。保留经典的**印象笔记三栏布局**与优雅体验,同时提供 **100% 免费**、数据完全掌控、可视化双向编辑与 AI Agent (MCP) 原生集成。\n\n---\n\n### 🚀 1. 核心优势对比 (可视化表格编辑)\n\n*提示:在线模式下点击下方表格单元格即可直接修改文字;在编辑器模式下右键可快捷添加或删除行列。*\n\n| 核心维度 | 印象笔记 (Evernote) | Obsidian | EdgeEver |\n| --- | --- | --- | --- |\n| **托管成本** | 商业订阅 ($15+/月) | 官方同步 ($5/月) | **100% 免费 (Cloudflare 免费额度)** |\n| **部署方式** | 封闭中心化云端 | 本地文件 + 插件配置 | **Serverless 自建 (零维护)** |\n| **交互体验** | 经典三栏 (广告较多) | 双栏/多面板 (移动端较重) | **经典三栏 + 原生 App / PWA** |\n| **数据与 API** | 封闭,导出一键设限 | 本地 Markdown | **标准 D1 SQLite / REST API / MCP** |\n| **AI Agent 支持** | 限制/仅特定版本 | 需配置第三方插件 | **原生支持 MCP 端点与 OpenAPI** |\n\n---\n\n### 📢 2. 微信公众号与跨平台一键排版复制 (WeChat / Substack / 博客)\n\nEdgeEver 专为自媒体与内容创作者设计,支持将当前笔记一键转换为精美排版并复制:\n\n- [x] 公众号与周刊:一键复制并无缝粘贴至微信公众号、Substack、竹白 Newsletter 后台\n- [x] 博客与平台:完美兼容 WordPress、Medium、Ghost 及各大富文本编辑器\n- [x] 零格式损失:自动内联精致 CSS 样式,代码高亮、表格与图片完美内嵌\n\n> 💡 **创作者体验提示**:尝试点击右上角操作栏的公众号复制按钮,直接粘贴到微信公众号、Substack 或 WordPress 后台,即可体验优雅的排版渲染!\n\n---\n\n### 🎨 3. 双视图编辑与可配置主题 (Dual View & Themes)\n\nEdgeEver 支持在**所见即所得富文本**与 **Markdown 源码**之间无缝切换(点击右上角 `</>` 按钮)。\n\n1. **经典三栏笔记本与树状无限层级**:结构清晰,轻松管理大量笔记。\n2. **预设 8 款经典编辑器主题**:摸鱼绿、红白色系、石墨极简风、留白禅意风、摸鱼票据风、橄榄手记、薄荷青等排版风格(可在 **个人中心 / 设置** 中自由切换)。\n3. **多端无缝同步与 WebP 压缩**:Web PWA / iOS & Android 原生 App,图片上传前本地自动压缩 (节省 50% ~ 90%)。\n\n> 💡 **小贴士**:所有富文本样式与主题块在切换至 Markdown 源码时均能保持完美兼容,不会丢失任何格式元数据。\n\n---\n\n### 4. 过去一周的交互优化 (v0.2.9 ~ v1.5.16)\n\n根据 07月18日 ~ 07月24日的代码 Commit 与正式 Release 日志,过去一周实际上线的核心交互优化包括:\n\n- [x] 原生移动端与离线同步:重构 Android 原生客户端,引入 SQLite 本地镜像、增量同步与离线编辑队列;首次同步可边加载边浏览,并补充自动聚焦、上传占位、应用内更新和平滑页面切换\n- [x] 写作、表格与搜索效率:新增桌面专注模式、实时字数统计和可视化表格增删编辑;优化移动端宽表格;支持选中文本快捷查找/替换、匹配高亮与自动滚动定位\n- [x] Mermaid、代码与附件体验:Web、PWA 与原生 App 支持 Mermaid 编辑和渲染,并提供多款图表主题;恢复多语言代码高亮和一键复制;新增文档、音视频、压缩包等通用附件卡片\n- [x] 主题与跨平台排版复制:支持 8 款正文编辑器主题;微信公众号复制可内联受保护图片、转换不兼容格式、保留 Mermaid 尺寸,并提供明确的加载与结果反馈\n- [x] 账户、版本与 Demo 操作:个人中心可查看并强制退出其他登录设备;系统信息可提示新版本并展示发布信息;Demo 数据支持一键恢复且保留当前登录状态\n\n---\n\n### 🎯 5. 沉浸式专注模式与快捷体验 (Focus Mode & Shortcuts)\n\n为提供极致写作体验,EdgeEver 引入了多项高效写作辅助工具:\n\n1. **Zen 专注写作模式**:快捷键 `Cmd/Ctrl + Shift + F` 或点击顶部图标,隐藏侧边栏与干扰元素,专注内容创作。\n2. **快捷选词搜索与替换**:选中文本后使用快捷键即可发起全局搜索或编辑器内快速替换。\n3. **实时高亮定位**:搜索词在编辑器与笔记列表中高亮定位,方便快速查阅。\n\n---\n\n### 💻 6. 代码高亮与快捷复制 (Syntax Highlighting)\n\n支持 Python、TypeScript、Rust、SQL 等数十种编程语言高亮,并在右上角提供一键复制功能:\n\n```python\nimport requests\n\n# 使用 REST API 获取 EdgeEver 笔记本列表\ndef fetch_edgeever_notebooks(instance_url: str, token: str):\n headers = {\"Authorization\": f\"Bearer {token}\"}\n response = requests.get(f\"{instance_url}/api/v1/notebooks\", headers=headers)\n \n if response.status_code == 200:\n data = response.json()\n print(f\"成功加载 {len(data['data'])} 个笔记本!\")\n return data[\"data\"]\n return []\n```\n\n---\n\n### 📊 7. Mermaid 交互式图表与精致主题 (Diagram Themes)\n\n在代码块中使用 `mermaid` 标记,即可在富文本与预览界面中即时渲染多种常见的交互式图表,并支持多款**精致图表主题**选择与复制尺寸保真:\n\n#### 1️⃣ 架构流程图 (Flowchart)\n```mermaid\nflowchart TD\n subgraph Client[\"📱 客户端生态\"]\n A[\"Web / PWA 浏览器\"]\n B[\"iOS / Android 原生 App\"]\n end\n\n subgraph Backend[\"⚡ Cloudflare 后端服务\"]\n C[\"Cloudflare Workers API\"]\n D[(\"D1 SQLite 数据库\")]\n E[(\"R2 资源存储\")]\n end\n\n A & B --> C\n C <--> D & E\n```\n\n#### 2️⃣ 交互时序图 (Sequence Diagram)\n```mermaid\nsequenceDiagram\n autonumber\n actor User as 用户\n participant App as 客户端 App\n participant Worker as Cloudflare Worker API\n participant D1 as D1 数据库\n\n User->>App: 编辑并保存笔记\n App->>Worker: POST /api/v1/memos (提交更改)\n Worker->>D1: 写入笔记 & 更新修订版本\n D1-->>Worker: 返回成功 (revision + 1)\n Worker-->>App: 200 OK (同步最新游标)\n App-->>User: 界面显示「已保存」\n```\n\n#### 3️⃣ 状态转换图 (State Diagram)\n```mermaid\nstateDiagram-v2\n [*] --> 草稿箱: 新建笔记\n 草稿箱 --> 已发布: 保存并归档\n 已发布 --> 置顶笔记: 快捷置顶\n 置顶笔记 --> 已发布: 取消置顶\n 已发布 --> 回收站: 移入回收站\n 回收站 --> 已发布: 恢复笔记\n 回收站 --> [*]: 彻底删除\n```\n\n---\n\n### 🖼️ 8. 多媒体与图片集成 (Rich Media Integration)\n\n支持拖拽或粘贴插入图片与文件附件。本地浏览器会在上传前自动进行高保真 WebP 压缩:\n\n\n\n---\n\n### 🤖 9. 面向 AI Agent 的原生生态 (Agent-Ready)\n\nEdgeEver 为 AI 时代而设计,原生提供 MCP 端点:\n\n1. **REST API**:提供完整标准的 `/api/openapi.json` 定义。\n2. **MCP 端点**:部署在 `/mcp`,AI Agent(如 Antigravity, Claude Code, Cursor)可以直接连接并读写您的笔记库。\n3. **数据自动化**:实现自动归档、Markdown 导入导出、飞书多维表格 / Notion 数据库同步。\n\n---\n\n> 🎯 **快速体验建议**:\n> - 试试在顶部点击「复制到微信公众号」,体验优雅的内容排版导出;\n> - 试试在 **个人中心 / 设置** 中切换「编辑器主题」,挑选喜欢的写作配色风格;\n> - 试试按下 `Cmd/Ctrl + Shift + F` 进入专注模式,感受沉浸式写作;\n> - 试试在左侧列表中按住 `Cmd` / `Ctrl` 选中多篇笔记,点击底栏「合并」;\n> - 试试点击侧边栏或设置中的「恢复 Demo 数据」,随时重置演示状态!"
|
||
},
|
||
];
|
||
const DEMO_SEED_REVISIONS = [
|
||
{
|
||
id: "rev_demo_revision_1",
|
||
memoId: "memo_demo_overview",
|
||
revision: 1,
|
||
title: "🌟 欢迎使用 EdgeEver",
|
||
markdown:
|
||
"## 🌟 欢迎使用 EdgeEver(草稿)\n\n- 印象笔记经典三栏与自建 Serverless\n- 可视化表格与 Markdown 源码双向切换",
|
||
},
|
||
{
|
||
id: "rev_demo_revision_1_en",
|
||
memoId: "memo_demo_overview_en",
|
||
revision: 1,
|
||
title: "🌟 Welcome to EdgeEver",
|
||
markdown:
|
||
"## 🌟 Welcome to EdgeEver (Draft)\n\n- Classic Evernote 3-pane layout & Serverless self-hosted\n- Visual table editing & Markdown source toggle",
|
||
},
|
||
];
|
||
const DEMO_MEMO_ENGLISH = {
|
||
memo_demo_overview: {
|
||
title: "🌟 Welcome to EdgeEver",
|
||
markdown:
|
||
"## 🌟 Welcome to EdgeEver\n\n> **EdgeEver** is an open-source, serverless personal knowledge base built on Cloudflare. It retains the classic **Evernote-style three-pane layout**, while offering **100% free hosting**, full data ownership, visual editing, and native AI Agent (MCP) integration.\n\n---\n\n### 🚀 1. Feature Comparison (Visual Table Editing)\n\n*Tip: Click any cell in the table below to edit directly; right-click in editor mode to insert/delete rows or columns.*\n\n| Metric | Evernote | Obsidian | EdgeEver |\n| --- | --- | --- | --- |\n| **Hosting Cost** | Commercial ($15+/mo) | Sync Plan ($5/mo) | **100% Free (Cloudflare Free Tier)** |\n| **Deployment** | Closed Cloud | Local Files + Plugins | **Serverless Self-Hosted (0 Maintenance)** |\n| **User Experience**| Classic 3-Pane (Heavy Ads) | Multi-Pane (Heavy Mobile) | **Classic 3-Pane + Native Apps & PWA** |\n| **Data & API** | Proprietary & Locked | Local Markdown | **Standard D1 SQLite / REST API / MCP** |\n| **AI Integration** | Limited / Paid | Requires 3rd Party Plugins| **Native MCP Endpoint & OpenAPI** |\n\n---\n\n### 📢 2. One-Click Cross-Platform Rich-Text Export (WeChat / Substack / Medium / WordPress)\n\nDesigned for content creators and writers, EdgeEver supports exporting notes to **elegantly formatted rich-text in one click**:\n\n- [x] Official Accounts & Newsletters: One-click copy & paste directly into WeChat, Substack, and Mailchimp\n- [x] Blogs & Platforms: Full compatibility with WordPress, Medium, Ghost, and major rich-text editors\n- [x] Zero Formatting Loss: Inlines CSS styling with code highlighting, tables, and images embedded seamlessly\n\n> 💡 **Creator Tip**: Click the copy button in the top action bar and paste directly into WeChat, Substack, or WordPress editor to see elegant formatting!\n\n---\n\n### 🎨 3. Dual-View & Configurable Themes\n\nSeamlessly toggle between **WYSIWYG Rich Text** and **Markdown Source** via the `</>` toggle in the top right.\n\n1. **Classic 3-Pane Notebook & Infinite Hierarchy**: Organized layout for managing notes.\n2. **8 Preset Editor Themes**: Moyu Green, Red & White, Graphite Minimal, Zen Whitespace, Moyu Ticket, Olive Journal, Mint Breeze, etc. (switchable in **User Settings / Profile**).\n3. **Multi-device Sync & WebP Compression**: Web PWA / iOS & Android Native Apps, saves 50% ~ 90% bandwidth.\n\n> 💡 **Tip**: All rich text formatting and themed blocks remain 100% compatible when toggling to Markdown mode.\n\n---\n\n### 4. Past Week UX Improvements (v0.2.9 ~ v1.5.16)\n\nBased on the actual commits and formal releases from July 18 to July 24, the key interaction improvements shipped over the past week include:\n\n- [x] Native Mobile & Offline Sync: Rebuilt the native Android client with a local SQLite mirror, incremental sync, and an offline edit queue; initial sync now reveals notes progressively, with editor autofocus, upload placeholders, in-app updates, and smoother navigation\n- [x] Writing, Tables & Search: Added desktop Focus Mode, live character counts, and visual table editing; improved wide tables on mobile; added selected-text find/replace shortcuts, match highlighting, and automatic scrolling\n- [x] Mermaid, Code & Attachments: Added Mermaid editing and rendering across Web, PWA, and native apps with selectable themes; restored multilingual syntax highlighting and one-click code copying; added attachment cards for documents, media, archives, and other files\n- [x] Themes & Cross-Platform Publishing: Added 8 editor themes; WeChat copy now embeds protected images, converts unsupported formats, preserves Mermaid dimensions, and reports clear loading and result states\n- [x] Accounts, Releases & Demo Controls: Personal Center now lists active devices and can revoke other sessions; System Info reports releases and available updates; one-tap Demo reset preserves the current login session\n\n---\n\n### 🎯 5. Immersive Focus Mode & Efficiency Shortcuts\n\nTo provide the ultimate writing experience, EdgeEver includes powerful productivity tools:\n\n1. **Zen Focus Mode**: Press `Cmd/Ctrl + Shift + F` or click the focus icon to hide sidebars and eliminate distractions.\n2. **Quick Text Search & Replace**: Highlight text and launch instant global search or in-editor replacement.\n3. **Real-time Search Highlighting**: Locate search terms with clear highlights across the editor and note lists.\n\n---\n\n### 💻 6. Syntax Highlighting & Quick Copy\n\nSupports dozens of programming languages (Python, TypeScript, Rust, SQL, etc.) with one-click code copying:\n\n```python\nimport requests\n\n# Fetch EdgeEver notebooks via REST API\ndef fetch_edgeever_notebooks(instance_url: str, token: str):\n headers = {\"Authorization\": f\"Bearer {token}\"}\n response = requests.get(f\"{instance_url}/api/v1/notebooks\", headers=headers)\n \n if response.status_code == 200:\n data = response.json()\n print(f\"Successfully fetched {len(data['data'])} notebooks!\")\n return data[\"data\"]\n return []\n```\n\n---\n\n### 📊 7. Interactive Mermaid Diagrams & Themes\n\nUse standard `mermaid` fenced code blocks to render multiple popular diagram types in real time, with support for **selectable diagram themes** and dimension-preserved copying:\n\n#### 1️⃣ System Flowchart\n```mermaid\nflowchart TD\n subgraph Client[\"📱 Clients Ecosystem\"]\n A[\"Web / PWA\"]\n B[\"iOS / Android Apps\"]\n end\n\n subgraph Backend[\"⚡ Cloudflare Backend Services\"]\n C[\"Cloudflare Workers API\"]\n D[(\"D1 SQLite DB\")]\n E[(\"R2 Object Storage\")]\n end\n\n A & B --> C\n C <--> D & E\n```\n\n#### 2️⃣ Interactive Sequence Diagram\n```mermaid\nsequenceDiagram\n autonumber\n actor User as User\n participant App as EdgeEver Client\n participant Worker as Cloudflare Worker API\n participant D1 as D1 Database\n\n User->>App: Edit and save note\n App->>Worker: POST /api/v1/memos (Save changes)\n Worker->>D1: Write note content & update revision\n D1-->>Worker: Return success (revision + 1)\n Worker-->>App: 200 OK (Latest sync cursor)\n App-->>User: Status updated to \"Saved\"\n```\n\n#### 3️⃣ State Transition Diagram\n```mermaid\nstateDiagram-v2\n [*] --> Drafts: Create new note\n Drafts --> Published: Save & Archive\n Published --> Pinned: Pin to top\n Pinned --> Published: Unpin note\n Published --> Trash: Move to trash\n Trash --> Published: Restore note\n Trash --> [*]: Delete permanently\n```\n\n---\n\n### 🖼️ 8. Rich Media & Image Attachments\n\nDrag-and-drop or paste images into your notes. Images are compressed locally before being saved to R2 storage:\n\n\n\n---\n\n### 🤖 9. Native AI Agent Ecosystem (Agent-Ready)\n\nDesigned natively for the AI era:\n\n1. **REST API**: Provides standard OpenAPI definitions at `/api/openapi.json`.\n2. **MCP Endpoint**: Accessible at `/mcp`, allowing AI Agents to read/write notes directly.\n3. **Automated Workflows**: Auto-tagging, export/import, sync with Notion or Feishu.\n\n---\n\n> 🎯 **Quick Try**:\n> - Click \"Copy for WeChat / Publishing\" in the top toolbar to experience seamless content export;\n> - Switch **Editor Themes** in **User Settings / Profile** to customize your writing style;\n> - Press `Cmd/Ctrl + Shift + F` to toggle Focus Mode for distraction-free writing;\n> - Multi-select notes in the left list using `Cmd` / `Ctrl` and click \"Merge\" at the bottom;\n> - Click \"Reset Demo Data\" in the sidebar or settings to reset the demo state at any time!"
|
||
},
|
||
} as const;
|
||
const DEMO_SEED_MEMOS_EN = DEMO_SEED_MEMOS_ZH.map((memo) => {
|
||
const english = DEMO_MEMO_ENGLISH[memo.id as keyof typeof DEMO_MEMO_ENGLISH];
|
||
if (!english) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
...memo,
|
||
id: `${memo.id}_en`,
|
||
notebookId: "nb_demo_features_en",
|
||
title: english.title,
|
||
markdown: `${english.markdown}${DEMO_ATTACHMENT_MARKDOWN_EN}`,
|
||
};
|
||
}).filter((memo): memo is NonNullable<typeof memo> => memo !== null);
|
||
const DEMO_SEED_MEMOS_ZH_WITH_ATTACHMENTS = DEMO_SEED_MEMOS_ZH.map((memo) => ({
|
||
...memo,
|
||
markdown: `${memo.markdown}${DEMO_ATTACHMENT_MARKDOWN_ZH}`,
|
||
}));
|
||
const DEMO_SEED_MEMOS = [...DEMO_SEED_MEMOS_ZH_WITH_ATTACHMENTS, ...DEMO_SEED_MEMOS_EN];
|
||
const DEMO_SEED_RESOURCES = [
|
||
{
|
||
id: "res_demo_cat_image",
|
||
memoId: "memo_demo_overview",
|
||
filename: "cute-cat-demo.svg",
|
||
mimeType: "image/svg+xml",
|
||
width: 960,
|
||
height: 540,
|
||
svg:
|
||
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="960" height="540" viewBox="0 0 960 540" role="img" aria-label="EdgeEver cat image demo"><defs><clipPath id="catClip"><rect width="960" height="540" rx="32" /></clipPath></defs><g clip-path="url(#catClip)"><image href="data:image/jpeg;base64,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" width="960" height="540" preserveAspectRatio="xMidYMid slice" /></g><rect width="960" height="540" rx="32" fill="none" stroke="#0f766e" stroke-width="4" stroke-opacity="0.3" /></svg>'},
|
||
] as const;
|
||
const DEMO_SEED_ATTACHMENT_RESOURCES = [...DEMO_SEED_RESOURCES, ...DEMO_ATTACHMENT_RESOURCES];
|
||
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", "null"],
|
||
allowHeaders: ["Content-Type", "Authorization"],
|
||
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||
credentials: true,
|
||
})
|
||
);
|
||
|
||
app.use(
|
||
"/mcp",
|
||
cors({
|
||
origin: ["http://127.0.0.1:5173", "http://localhost:5173", "null"],
|
||
allowHeaders: ["Content-Type", "Authorization"],
|
||
allowMethods: ["GET", "POST", "OPTIONS"],
|
||
credentials: true,
|
||
})
|
||
);
|
||
|
||
app.get("/api/health", async (c) => {
|
||
const authMode = await getInstanceAuthMode(c.env);
|
||
|
||
if (authMode === "unconfigured") {
|
||
return authNotConfigured(c);
|
||
}
|
||
|
||
return c.json({
|
||
ok: true,
|
||
name: "edgeever",
|
||
runtime: c.env.EDGE_EVER_RUNTIME ?? "cloudflare-workers",
|
||
authMode,
|
||
});
|
||
});
|
||
|
||
app.get("/api/openapi.json", (c) => c.json(openApiSpec));
|
||
|
||
app.get("/api/v1/auth/session", async (c) => {
|
||
const authMode = await getInstanceAuthMode(c.env);
|
||
|
||
if (authMode === "unconfigured") {
|
||
return authNotConfigured(c);
|
||
}
|
||
|
||
if (authMode === "disabled") {
|
||
return c.json({
|
||
authRequired: false,
|
||
authenticated: true,
|
||
demoMode: isDemoMode(c.env) || isLocalDemoSeedEnabled(c.env),
|
||
user: {
|
||
id: "local",
|
||
username: "owner",
|
||
displayName: "Owner",
|
||
role: "owner",
|
||
},
|
||
});
|
||
}
|
||
|
||
const auth = await authenticateRequest(c, false);
|
||
|
||
return c.json({
|
||
authRequired: true,
|
||
authenticated: Boolean(auth && auth.kind === "user"),
|
||
demoMode: isDemoMode(c.env) || isLocalDemoSeedEnabled(c.env),
|
||
user:
|
||
auth && auth.kind === "user"
|
||
? {
|
||
id: auth.actorId,
|
||
username: auth.username,
|
||
displayName: auth.displayName,
|
||
role: auth.role,
|
||
}
|
||
: null,
|
||
});
|
||
});
|
||
|
||
app.get("/api/v1/auth/sessions", async (c) => {
|
||
const auth = await authenticateRequest(c, true);
|
||
|
||
if (!auth || auth.kind !== "user" || !auth.actorId || !auth.sessionId) {
|
||
return unauthorized(c, "An interactive user session is required.");
|
||
}
|
||
|
||
const now = isoNow();
|
||
const rows = await c.env.storage.db.prepare(
|
||
`SELECT id, device_id, user_agent, device_label, ip_address, ip_country, ip_region, expires_at, created_at, last_seen_at
|
||
FROM sessions
|
||
WHERE user_id = ?
|
||
AND revoked_at IS NULL
|
||
AND expires_at > ?
|
||
ORDER BY COALESCE(last_seen_at, created_at) DESC
|
||
LIMIT 200`
|
||
)
|
||
.bind(auth.actorId, now)
|
||
.all<LoginDeviceSessionRow>();
|
||
|
||
return c.json({
|
||
sessions: groupLoginDeviceSessions(rows.results, auth.sessionId).slice(0, 50),
|
||
});
|
||
});
|
||
|
||
app.patch("/api/v1/auth/sessions/:sessionId", zValidator("json", LoginDeviceSessionUpdateSchema), async (c) => {
|
||
const auth = await authenticateRequest(c, true);
|
||
|
||
if (!auth || auth.kind !== "user" || !auth.actorId || !auth.sessionId) {
|
||
return unauthorized(c, "An interactive user session is required.");
|
||
}
|
||
|
||
const sessionId = c.req.param("sessionId");
|
||
const input = c.req.valid("json");
|
||
const now = isoNow();
|
||
const session = await c.env.storage.db.prepare(
|
||
`SELECT id, device_id FROM sessions
|
||
WHERE id = ? AND user_id = ? AND revoked_at IS NULL AND expires_at > ?`
|
||
).bind(sessionId, auth.actorId, now).first<{ id: string; device_id: string | null }>();
|
||
|
||
if (!session) return notFound(c, "Login session not found.");
|
||
|
||
const statement = session.device_id
|
||
? c.env.storage.db.prepare(
|
||
`UPDATE sessions SET device_label = ?
|
||
WHERE user_id = ? AND device_id = ? AND revoked_at IS NULL`
|
||
).bind(input.label || null, auth.actorId, session.device_id)
|
||
: c.env.storage.db.prepare(`UPDATE sessions SET device_label = ? WHERE id = ?`).bind(input.label || null, session.id);
|
||
|
||
await c.env.storage.db.batch([
|
||
statement,
|
||
auditStatement(c.env.storage.db, "user", auth.actorId, "auth.session_label_update", "session", session.id, {
|
||
label: input.label || null,
|
||
}),
|
||
]);
|
||
|
||
return c.json({ ok: true });
|
||
});
|
||
|
||
app.delete("/api/v1/auth/sessions", async (c) => {
|
||
const auth = await authenticateRequest(c, true);
|
||
|
||
if (!auth || auth.kind !== "user" || !auth.actorId || !auth.sessionId) {
|
||
return unauthorized(c, "An interactive user session is required.");
|
||
}
|
||
|
||
const now = isoNow();
|
||
await c.env.storage.db.batch([
|
||
c.env.storage.db.prepare(
|
||
`UPDATE sessions
|
||
SET revoked_at = ?
|
||
WHERE user_id = ? AND id != ? AND revoked_at IS NULL AND expires_at > ?`
|
||
).bind(now, auth.actorId, auth.sessionId, now),
|
||
auditStatement(c.env.storage.db, "user", auth.actorId, "auth.sessions_revoke_others", "session", auth.sessionId, {}),
|
||
]);
|
||
|
||
return c.json({ ok: true });
|
||
});
|
||
|
||
app.delete("/api/v1/auth/sessions/:sessionId", async (c) => {
|
||
const auth = await authenticateRequest(c, true);
|
||
|
||
if (!auth || auth.kind !== "user" || !auth.actorId || !auth.sessionId) {
|
||
return unauthorized(c, "An interactive user session is required.");
|
||
}
|
||
|
||
const sessionId = c.req.param("sessionId");
|
||
if (sessionId === auth.sessionId) {
|
||
return apiError(c, "current_session_cannot_be_revoked", "The current session cannot be revoked here.", 400);
|
||
}
|
||
|
||
const now = isoNow();
|
||
const session = await c.env.storage.db.prepare(
|
||
`SELECT id, device_id FROM sessions
|
||
WHERE id = ? AND user_id = ? AND revoked_at IS NULL AND expires_at > ?`
|
||
)
|
||
.bind(sessionId, auth.actorId, now)
|
||
.first<{ id: string; device_id: string | null }>();
|
||
|
||
if (!session) {
|
||
return notFound(c, "Login session not found.");
|
||
}
|
||
|
||
const currentSession = await c.env.storage.db.prepare(`SELECT device_id FROM sessions WHERE id = ? AND user_id = ?`)
|
||
.bind(auth.sessionId, auth.actorId)
|
||
.first<{ device_id: string | null }>();
|
||
|
||
if (session.device_id && currentSession?.device_id === session.device_id) {
|
||
return apiError(c, "current_session_cannot_be_revoked", "The current device cannot be revoked here.", 400);
|
||
}
|
||
|
||
await c.env.storage.db.batch([
|
||
session.device_id
|
||
? c.env.storage.db.prepare(
|
||
`UPDATE sessions SET revoked_at = ?
|
||
WHERE user_id = ? AND device_id = ? AND revoked_at IS NULL`
|
||
).bind(now, auth.actorId, session.device_id)
|
||
: c.env.storage.db.prepare(`UPDATE sessions SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL`).bind(now, session.id),
|
||
auditStatement(c.env.storage.db, "user", auth.actorId, "auth.session_revoke", "session", session.id, {}),
|
||
]);
|
||
|
||
return c.json({ ok: true });
|
||
});
|
||
|
||
app.post("/api/v1/auth/login", zValidator("json", LoginSchema), async (c) => {
|
||
const authMode = await getInstanceAuthMode(c.env);
|
||
if (authMode === "unconfigured") {
|
||
return authNotConfigured(c);
|
||
}
|
||
|
||
const input = c.req.valid("json");
|
||
const loginAttemptKeys = await getLoginAttemptKeys(c, input.username);
|
||
const loginRateLimitConfig = resolveLoginRateLimitConfig(c.env);
|
||
const currentRateLimit = await checkLoginRateLimit(c.env.storage.db, loginAttemptKeys, loginRateLimitConfig);
|
||
|
||
if (currentRateLimit.retryAfterSeconds > 0) {
|
||
return tooManyLoginAttempts(c, currentRateLimit.retryAfterSeconds);
|
||
}
|
||
|
||
const user = await verifyLogin(c.env, input.username, input.password);
|
||
|
||
if (!user) {
|
||
const updatedRateLimit = await recordLoginFailure(
|
||
c.env.storage.db,
|
||
loginAttemptKeys,
|
||
loginRateLimitConfig,
|
||
);
|
||
|
||
if (updatedRateLimit.retryAfterSeconds > 0) {
|
||
await audit(
|
||
c.env.storage.db,
|
||
"system",
|
||
null,
|
||
"auth.login_rate_limited",
|
||
"auth",
|
||
loginAttemptKeys[0]?.key ?? "unknown",
|
||
{ retryAfterSeconds: updatedRateLimit.retryAfterSeconds },
|
||
);
|
||
return tooManyLoginAttempts(c, updatedRateLimit.retryAfterSeconds);
|
||
}
|
||
|
||
return unauthorized(c, "Username or password is incorrect.");
|
||
}
|
||
|
||
await clearLoginAttempts(c.env.storage.db, loginAttemptKeys);
|
||
|
||
const workspace = await ensureUserWorkspace(c.env.storage.db, user.id, user.username);
|
||
const session = await createSession(c, user, input.deviceId);
|
||
setSessionCookie(c, session.token, session.maxAge);
|
||
|
||
await c.env.storage.db.batch([
|
||
c.env.storage.db.prepare(`UPDATE users SET last_login_at = ?, updated_at = ? WHERE id = ?`).bind(
|
||
isoNow(),
|
||
isoNow(),
|
||
user.id
|
||
),
|
||
auditStatement(c.env.storage.db, "user", user.id, "auth.login", "session", session.id, {
|
||
username: user.username,
|
||
}),
|
||
]);
|
||
|
||
return c.json({
|
||
authRequired: true,
|
||
authenticated: true,
|
||
demoMode: isDemoMode(c.env) || isLocalDemoSeedEnabled(c.env),
|
||
sessionToken: session.token,
|
||
user: {
|
||
id: user.id,
|
||
username: user.username,
|
||
displayName: user.display_name,
|
||
role: workspace.role,
|
||
},
|
||
});
|
||
});
|
||
|
||
app.post("/api/v1/auth/change-password", zValidator("json", ChangePasswordSchema), async (c) => {
|
||
const auth = await authenticateSession(c, true);
|
||
|
||
if (!auth || auth.kind !== "user" || !auth.actorId || !auth.sessionId) {
|
||
return unauthorized(c, "An interactive user session is required.");
|
||
}
|
||
|
||
if (isDemoMode(c.env)) {
|
||
return forbidden(c, "The demo environment does not allow changing login passwords.");
|
||
}
|
||
|
||
const input = c.req.valid("json");
|
||
const user = await c.env.storage.db.prepare(
|
||
`SELECT id, username, password_hash, display_name, is_disabled
|
||
FROM users
|
||
WHERE id = ? AND is_disabled = 0`
|
||
)
|
||
.bind(auth.actorId)
|
||
.first<UserRow>();
|
||
|
||
if (!user || !(await verifyPassword(input.currentPassword, user.password_hash))) {
|
||
return apiError(c, "invalid_current_password", "Current password is incorrect.", 400);
|
||
}
|
||
|
||
const now = isoNow();
|
||
const passwordHash = await hashPassword(input.newPassword);
|
||
|
||
await c.env.storage.db.batch([
|
||
c.env.storage.db.prepare(`UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?`).bind(
|
||
passwordHash,
|
||
now,
|
||
user.id
|
||
),
|
||
c.env.storage.db.prepare(
|
||
`UPDATE sessions SET revoked_at = ?
|
||
WHERE user_id = ? AND id != ? AND revoked_at IS NULL`
|
||
).bind(now, user.id, auth.sessionId),
|
||
auditStatement(c.env.storage.db, "user", user.id, "auth.password_change", "user", user.id, {}),
|
||
]);
|
||
|
||
return c.json({ ok: true });
|
||
});
|
||
|
||
app.get("/api/v1/users", async (c) => {
|
||
const auth = await authenticateSession(c, true);
|
||
if (!auth) return unauthorized(c, "Authentication required.");
|
||
c.set("auth", auth);
|
||
const denied = requireOwner(c);
|
||
if (denied) return denied;
|
||
|
||
const rows = await c.env.storage.db.prepare(
|
||
`SELECT u.id, u.username, u.password_hash, u.display_name, u.is_disabled,
|
||
u.last_login_at, u.created_at, wm.role
|
||
FROM users u
|
||
INNER JOIN workspace_members wm ON wm.user_id = u.id
|
||
ORDER BY wm.role = 'owner' DESC, u.created_at ASC`
|
||
).all<InstanceUserRow>();
|
||
|
||
return c.json({ users: rows.results.map(mapInstanceUser) });
|
||
});
|
||
|
||
app.post("/api/v1/users", zValidator("json", UserCreateSchema), async (c) => {
|
||
const auth = await authenticateSession(c, true);
|
||
if (!auth) return unauthorized(c, "Authentication required.");
|
||
c.set("auth", auth);
|
||
const denied = requireOwner(c);
|
||
if (denied) return denied;
|
||
|
||
const input = c.req.valid("json");
|
||
const existing = await c.env.storage.db.prepare(`SELECT id FROM users WHERE username = ?`).bind(input.username).first();
|
||
if (existing) return conflict(c, "username_exists", "Username already exists.");
|
||
|
||
const userId = createId("usr");
|
||
const workspaceId = createId("ws");
|
||
const now = isoNow();
|
||
const passwordHash = await hashPassword(input.password);
|
||
const notebooks = createDefaultNotebookRows(workspaceId, now);
|
||
const statements = [
|
||
c.env.storage.db.prepare(
|
||
`INSERT INTO users (id, username, password_hash, display_name, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?)`
|
||
).bind(userId, input.username, passwordHash, input.displayName ?? input.username, now, now),
|
||
c.env.storage.db.prepare(`INSERT INTO workspaces (id, name, is_personal, created_at, updated_at) VALUES (?, ?, 1, ?, ?)`)
|
||
.bind(workspaceId, `${input.displayName ?? input.username}'s workspace`, now, now),
|
||
c.env.storage.db.prepare(`INSERT INTO workspace_members (workspace_id, user_id, role, created_at) VALUES (?, ?, 'member', ?)`)
|
||
.bind(workspaceId, userId, now),
|
||
...notebooks.map((notebook) => c.env.storage.db.prepare(
|
||
`INSERT INTO notebooks (id, workspace_id, parent_id, name, slug, icon, color, sort_order, created_at, updated_at)
|
||
VALUES (?, ?, NULL, ?, ?, 'notebook', ?, ?, ?, ?)`
|
||
).bind(notebook.id, workspaceId, notebook.name, notebook.slug, notebook.color, notebook.sortOrder, now, now)),
|
||
auditStatement(c.env.storage.db, "user", c.get("auth").actorId, "user.create", "user", userId, { username: input.username }),
|
||
];
|
||
await c.env.storage.db.batch(statements);
|
||
|
||
const user = await getInstanceUser(c.env.storage.db, userId);
|
||
return c.json({ user: user ? mapInstanceUser(user) : null }, 201);
|
||
});
|
||
|
||
app.patch("/api/v1/users/:id", zValidator("json", UserUpdateSchema), async (c) => {
|
||
const auth = await authenticateSession(c, true);
|
||
if (!auth) return unauthorized(c, "Authentication required.");
|
||
c.set("auth", auth);
|
||
const denied = requireOwner(c);
|
||
if (denied) return denied;
|
||
|
||
const userId = c.req.param("id");
|
||
const input = c.req.valid("json");
|
||
const current = await getInstanceUser(c.env.storage.db, userId);
|
||
if (!current) return notFound(c, "User not found");
|
||
if (
|
||
isProtectedDemoAccount(c.env.EDGE_EVER_DEMO_MODE, c.env.EDGE_EVER_AUTH_USERNAME, current.username)
|
||
&& (input.password !== undefined || input.isDisabled !== undefined)
|
||
) {
|
||
return forbidden(c, "The demo owner account uses fixed credentials and cannot be modified.");
|
||
}
|
||
if (current.role === "owner" && input.isDisabled === true) {
|
||
return badRequest(c, "The instance owner cannot be disabled.");
|
||
}
|
||
|
||
const updates: string[] = [];
|
||
const binds: unknown[] = [];
|
||
if (input.displayName !== undefined) {
|
||
updates.push("display_name = ?");
|
||
binds.push(input.displayName);
|
||
}
|
||
if (input.password !== undefined) {
|
||
updates.push("password_hash = ?");
|
||
binds.push(await hashPassword(input.password));
|
||
}
|
||
if (input.isDisabled !== undefined) {
|
||
updates.push("is_disabled = ?");
|
||
binds.push(input.isDisabled ? 1 : 0);
|
||
}
|
||
updates.push("updated_at = ?");
|
||
binds.push(isoNow(), userId);
|
||
|
||
const statements = [
|
||
c.env.storage.db.prepare(`UPDATE users SET ${updates.join(", ")} WHERE id = ?`).bind(...binds),
|
||
auditStatement(c.env.storage.db, "user", c.get("auth").actorId, "user.update", "user", userId, {
|
||
passwordReset: input.password !== undefined,
|
||
isDisabled: input.isDisabled,
|
||
}),
|
||
];
|
||
if (input.password !== undefined || input.isDisabled === true) {
|
||
statements.push(c.env.storage.db.prepare(`UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL`).bind(isoNow(), userId));
|
||
}
|
||
await c.env.storage.db.batch(statements);
|
||
|
||
const user = await getInstanceUser(c.env.storage.db, userId);
|
||
return c.json({ user: user ? mapInstanceUser(user) : null });
|
||
});
|
||
|
||
app.post("/api/v1/auth/logout", async (c) => {
|
||
const token = getCookie(c, SESSION_COOKIE) ?? getBearerToken(c);
|
||
|
||
if (token) {
|
||
await revokeSession(c.env.storage.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 authMode = await getInstanceAuthMode(c.env);
|
||
|
||
if (authMode === "unconfigured") {
|
||
return authNotConfigured(c);
|
||
}
|
||
|
||
if (authMode === "disabled") {
|
||
c.set("auth", {
|
||
kind: "user",
|
||
actorType: "user",
|
||
actorId: null,
|
||
username: "owner",
|
||
displayName: "Owner",
|
||
scopes: [],
|
||
workspaceId: DEFAULT_WORKSPACE_ID,
|
||
role: "owner",
|
||
});
|
||
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.storage.db.prepare(
|
||
`SELECT id, name, token_value, scopes_json, last_used_at, expires_at, is_revoked, created_at, workspace_id
|
||
FROM api_tokens
|
||
WHERE workspace_id = ?
|
||
ORDER BY is_revoked ASC, created_at DESC
|
||
LIMIT 200`
|
||
).bind(getWorkspaceId(c)).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.storage.db.batch([
|
||
c.env.storage.db.prepare(
|
||
`INSERT INTO api_tokens (id, workspace_id, name, token_hash, token_value, scopes_json, expires_at, created_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||
).bind(id, getWorkspaceId(c), input.name, await sha256(token), token, JSON.stringify(scopes), input.expiresAt ?? null, now),
|
||
auditStatement(c.env.storage.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.storage.db, id, getWorkspaceId(c));
|
||
|
||
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.storage.db.batch([
|
||
c.env.storage.db.prepare(`DELETE FROM api_tokens WHERE id = ? AND workspace_id = ?`).bind(id, getWorkspaceId(c)),
|
||
auditStatement(c.env.storage.db, actor.actorType, actor.actorId, "api_token.delete", "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.storage.db.prepare(
|
||
notebookSelectSql(
|
||
`WHERE n.workspace_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
|
||
ORDER BY n.parent_id IS NOT NULL, n.sort_order ASC, n.name ASC`
|
||
)
|
||
).bind(getWorkspaceId(c)).all<NotebookRow>();
|
||
|
||
return c.json({ notebooks: rows.results.map(mapNotebook) });
|
||
});
|
||
|
||
app.get("/api/v1/sync/bootstrap", async (c) => {
|
||
const denied = requireScopes(c, "read:notebooks", "read:memos");
|
||
|
||
if (denied) {
|
||
return denied;
|
||
}
|
||
|
||
const workspaceId = getWorkspaceId(c);
|
||
const limit = clampNumber(Number(c.req.query("limit") ?? 100), 1, 200);
|
||
const afterId = c.req.query("afterId")?.trim() ?? "";
|
||
const [notebookRows, memoRows, totalRow, cursorRow] = await Promise.all([
|
||
c.env.storage.db.prepare(
|
||
`SELECT n.id, n.parent_id, n.name, n.slug, n.icon, n.color, n.sort_order,
|
||
n.created_at, n.updated_at, COUNT(m.id) AS memo_count, MAX(m.updated_at) AS last_memo_updated_at
|
||
FROM notebooks n
|
||
LEFT JOIN memos m ON m.notebook_id = n.id AND m.workspace_id = n.workspace_id AND m.is_deleted = 0
|
||
WHERE n.workspace_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
|
||
ORDER BY n.sort_order ASC, n.name ASC`
|
||
).bind(workspaceId).all<NotebookRow>(),
|
||
c.env.storage.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, mc.revision,
|
||
mc.content_json, mc.content_markdown, mc.content_text, mc.content_hash,
|
||
m.source_memo_ids, m.merge_source_count, m.merged_into_memo_id
|
||
FROM memos m
|
||
INNER JOIN memo_contents mc ON mc.memo_id = m.id
|
||
WHERE m.workspace_id = ? AND m.id > ?
|
||
ORDER BY m.id ASC
|
||
LIMIT ?`
|
||
).bind(workspaceId, afterId, limit + 1).all<MemoDetailRow>(),
|
||
c.env.storage.db.prepare(`SELECT COUNT(*) AS count FROM memos WHERE workspace_id = ?`).bind(workspaceId).first<{ count: number }>(),
|
||
c.env.storage.db.prepare(
|
||
`SELECT w.created_at AS sync_identity, COALESCE(MAX(c.id), 0) AS cursor
|
||
FROM workspaces w
|
||
LEFT JOIN mobile_sync_changes c ON c.workspace_id = w.id
|
||
WHERE w.id = ?
|
||
GROUP BY w.created_at`
|
||
).bind(workspaceId).first<{ cursor: number; sync_identity: string }>(),
|
||
]);
|
||
const page = memoRows.results.slice(0, limit);
|
||
const totalCount = totalRow?.count ?? page.length;
|
||
const nextAfterId = memoRows.results.length > limit ? page.at(-1)?.id ?? null : null;
|
||
|
||
return c.json({
|
||
notebooks: notebookRows.results.map(mapNotebook),
|
||
memos: page.map(mapMemoDetail),
|
||
snapshotCursor: cursorRow?.cursor ?? 0,
|
||
syncIdentity: cursorRow?.sync_identity,
|
||
totalCount,
|
||
nextAfterId,
|
||
});
|
||
});
|
||
|
||
app.get("/api/v1/sync/changes", async (c) => {
|
||
const denied = requireScopes(c, "read:notebooks", "read:memos");
|
||
|
||
if (denied) {
|
||
return denied;
|
||
}
|
||
|
||
const workspaceId = getWorkspaceId(c);
|
||
const cursor = clampNumber(Number(c.req.query("cursor") ?? 0), 0, Number.MAX_SAFE_INTEGER);
|
||
const limit = clampNumber(Number(c.req.query("limit") ?? 100), 1, 200);
|
||
const [rows, cursorRow] = await Promise.all([
|
||
c.env.storage.db.prepare(
|
||
`SELECT id, entity_type, entity_id, operation
|
||
FROM mobile_sync_changes
|
||
WHERE workspace_id = ? AND id > ?
|
||
ORDER BY id ASC
|
||
LIMIT ?`
|
||
).bind(workspaceId, cursor, limit + 1).all<MobileSyncChangeRow>(),
|
||
c.env.storage.db.prepare(
|
||
`SELECT w.created_at AS sync_identity, COALESCE(MAX(c.id), 0) AS cursor
|
||
FROM workspaces w
|
||
LEFT JOIN mobile_sync_changes c ON c.workspace_id = w.id
|
||
WHERE w.id = ?
|
||
GROUP BY w.created_at`
|
||
).bind(workspaceId).first<{ cursor: number; sync_identity: string }>(),
|
||
]);
|
||
const page = rows.results.slice(0, limit);
|
||
const memoIds = Array.from(new Set(page.filter((change) => change.entity_type === "memo" && change.operation === "upsert").map((change) => change.entity_id)));
|
||
const notebookIds = Array.from(new Set(page.filter((change) => change.entity_type === "notebook" && change.operation === "upsert").map((change) => change.entity_id)));
|
||
const memoPlaceholders = memoIds.map(() => "?").join(", ");
|
||
const notebookPlaceholders = notebookIds.map(() => "?").join(", ");
|
||
const [memoRows, notebookRows] = await Promise.all([
|
||
memoIds.length > 0
|
||
? c.env.storage.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, mc.revision,
|
||
mc.content_json, mc.content_markdown, mc.content_text, mc.content_hash,
|
||
m.source_memo_ids, m.merge_source_count, m.merged_into_memo_id
|
||
FROM memos m
|
||
INNER JOIN memo_contents mc ON mc.memo_id = m.id
|
||
WHERE m.workspace_id = ? AND m.id IN (${memoPlaceholders})`
|
||
).bind(workspaceId, ...memoIds).all<MemoDetailRow>()
|
||
: Promise.resolve({ results: [] as MemoDetailRow[] }),
|
||
notebookIds.length > 0
|
||
? c.env.storage.db.prepare(
|
||
`SELECT n.id, n.parent_id, n.name, n.slug, n.icon, n.color, n.sort_order,
|
||
n.created_at, n.updated_at, COUNT(m.id) AS memo_count, MAX(m.updated_at) AS last_memo_updated_at
|
||
FROM notebooks n
|
||
LEFT JOIN memos m ON m.notebook_id = n.id AND m.workspace_id = n.workspace_id AND m.is_deleted = 0
|
||
WHERE n.workspace_id = ? AND n.is_deleted = 0 AND n.id IN (${notebookPlaceholders})
|
||
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(workspaceId, ...notebookIds).all<NotebookRow>()
|
||
: Promise.resolve({ results: [] as NotebookRow[] }),
|
||
]);
|
||
const memosById = new Map(memoRows.results.map((row) => [row.id, mapMemoDetail(row)]));
|
||
const notebooksById = new Map(notebookRows.results.map((row) => [row.id, mapNotebook(row)]));
|
||
const changes = page.map((change) => {
|
||
if (change.entity_type === "memo") {
|
||
const memo = change.operation === "upsert" ? memosById.get(change.entity_id) ?? null : null;
|
||
return { cursor: change.id, entityType: change.entity_type, entityId: change.entity_id, operation: memo ? "upsert" as const : "delete" as const, notebook: null, memo };
|
||
}
|
||
|
||
const notebook = change.operation === "upsert" ? notebooksById.get(change.entity_id) ?? null : null;
|
||
return { cursor: change.id, entityType: change.entity_type, entityId: change.entity_id, operation: notebook ? "upsert" as const : "delete" as const, notebook, memo: null };
|
||
});
|
||
|
||
return c.json({
|
||
changes,
|
||
cursor: page.at(-1)?.id ?? cursor,
|
||
hasMore: rows.results.length > limit,
|
||
serverCursor: cursorRow?.cursor ?? 0,
|
||
syncIdentity: cursorRow?.sync_identity,
|
||
});
|
||
});
|
||
|
||
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.storage.db, getWorkspaceId(c), 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.storage.db, getWorkspaceId(c), 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 workspaceId = getWorkspaceId(c);
|
||
const current = await getNotebook(c.env.storage.db, workspaceId, 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.storage.db.prepare(`SELECT COUNT(*) AS count FROM notebooks WHERE workspace_id = ? AND parent_id = ? AND is_deleted = 0`)
|
||
.bind(workspaceId, id)
|
||
.first<{ count: number }>(),
|
||
c.env.storage.db.prepare(`SELECT COUNT(*) AS count FROM memos WHERE workspace_id = ? AND notebook_id = ? AND is_deleted = 0`)
|
||
.bind(workspaceId, 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.storage.db.prepare(
|
||
`UPDATE notebooks
|
||
SET is_deleted = 1, deleted_at = ?, updated_at = ?
|
||
WHERE id = ? AND workspace_id = ? AND slug <> 'inbox'`
|
||
)
|
||
.bind(now, now, id, workspaceId)
|
||
.run();
|
||
|
||
await audit(c.env.storage.db, actor.actorType, actor.actorId, "notebook.delete", "notebook", id, {});
|
||
return c.json({ ok: true });
|
||
});
|
||
|
||
app.post("/api/v1/notebooks/:id/restore", async (c) => {
|
||
const denied = requireScopes(c, "write:notebooks");
|
||
if (denied) return denied;
|
||
|
||
const id = c.req.param("id");
|
||
const workspaceId = getWorkspaceId(c);
|
||
const actor = getAuditActor(c);
|
||
const current = await c.env.storage.db.prepare(
|
||
`SELECT id FROM notebooks WHERE id = ? AND workspace_id = ? AND is_deleted = 1`
|
||
).bind(id, workspaceId).first<{ id: string }>();
|
||
if (!current) return notFound(c, "Deleted notebook not found");
|
||
|
||
const now = isoNow();
|
||
await c.env.storage.db.batch([
|
||
c.env.storage.db.prepare(
|
||
`UPDATE notebooks SET is_deleted = 0, deleted_at = NULL, updated_at = ? WHERE id = ? AND workspace_id = ?`
|
||
).bind(now, id, workspaceId),
|
||
auditStatement(c.env.storage.db, actor.actorType, actor.actorId, "notebook.restore", "notebook", id, {}),
|
||
]);
|
||
const notebook = await getNotebook(c.env.storage.db, workspaceId, id);
|
||
if (!notebook) return notFound(c, "Notebook not found after restore");
|
||
return c.json({ notebook });
|
||
});
|
||
|
||
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.storage.db, getWorkspaceId(c)) });
|
||
});
|
||
|
||
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.storage.db, getWorkspaceId(c), 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.storage.db, getWorkspaceId(c), tag, null, actor, actorLabel);
|
||
|
||
return c.json({ ok: true, updated });
|
||
});
|
||
|
||
app.get("/api/v1/templates", async (c) => {
|
||
const rows = await c.env.storage.db.prepare(
|
||
`SELECT id, name, description, title, content_json, content_markdown, tags_json, created_at, updated_at
|
||
FROM memo_templates
|
||
WHERE workspace_id = ?
|
||
ORDER BY updated_at DESC, name ASC`
|
||
).bind(getWorkspaceId(c)).all<MemoTemplateRow>();
|
||
|
||
return c.json({ templates: rows.results.map(mapMemoTemplate) });
|
||
});
|
||
|
||
app.post("/api/v1/templates", zValidator("json", TemplateCreateSchema), async (c) => {
|
||
const input = c.req.valid("json");
|
||
const workspaceId = getWorkspaceId(c);
|
||
const memo = input.memoId ? await getMemoDetail(c.env.storage.db, workspaceId, input.memoId) : null;
|
||
if (input.memoId && !memo) {
|
||
return notFound(c, "Memo not found");
|
||
}
|
||
|
||
const id = createId("template");
|
||
const now = new Date().toISOString();
|
||
const title = memo?.title ?? (input.title?.trim() || null);
|
||
const contentMarkdown = memo?.contentMarkdown ?? input.contentMarkdown ?? "";
|
||
const tags = memo?.tags ?? input.tags ?? [];
|
||
const contentJson = memo?.contentJson ?? markdownToDoc(contentMarkdown);
|
||
await c.env.storage.db.prepare(
|
||
`INSERT INTO memo_templates (
|
||
id, workspace_id, name, description, title, content_json, content_markdown, tags_json, created_at, updated_at
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||
).bind(
|
||
id,
|
||
workspaceId,
|
||
input.name.trim(),
|
||
input.description?.trim() || null,
|
||
title,
|
||
JSON.stringify(contentJson),
|
||
contentMarkdown,
|
||
JSON.stringify(tags),
|
||
now,
|
||
now,
|
||
).run();
|
||
|
||
const template = await getMemoTemplate(c.env.storage.db, workspaceId, id);
|
||
const actor = getAuditActor(c);
|
||
await audit(c.env.storage.db, actor.actorType, actor.actorId, "template.create", "template", id, { memoId: input.memoId ?? null });
|
||
return c.json({ template }, 201);
|
||
});
|
||
|
||
app.patch("/api/v1/templates/:id", zValidator("json", TemplateUpdateSchema), async (c) => {
|
||
const id = c.req.param("id");
|
||
const input = c.req.valid("json");
|
||
const workspaceId = getWorkspaceId(c);
|
||
const current = await getMemoTemplateRow(c.env.storage.db, workspaceId, id);
|
||
if (!current) {
|
||
return notFound(c, "Template not found");
|
||
}
|
||
|
||
const contentMarkdown = input.contentMarkdown ?? current.content_markdown;
|
||
const contentJson = input.contentMarkdown !== undefined
|
||
? markdownToDoc(contentMarkdown)
|
||
: JSON.parse(current.content_json);
|
||
const tags = input.tags ?? parseJsonArray(current.tags_json);
|
||
const now = new Date().toISOString();
|
||
await c.env.storage.db.prepare(
|
||
`UPDATE memo_templates
|
||
SET name = ?, description = ?, title = ?, content_json = ?, content_markdown = ?, tags_json = ?, updated_at = ?
|
||
WHERE id = ? AND workspace_id = ?`
|
||
).bind(
|
||
input.name ?? current.name,
|
||
input.description !== undefined ? input.description?.trim() || null : current.description,
|
||
input.title !== undefined ? input.title?.trim() || null : current.title,
|
||
JSON.stringify(contentJson),
|
||
contentMarkdown,
|
||
JSON.stringify(tags),
|
||
now,
|
||
id,
|
||
workspaceId,
|
||
).run();
|
||
|
||
const template = await getMemoTemplate(c.env.storage.db, workspaceId, id);
|
||
const actor = getAuditActor(c);
|
||
await audit(c.env.storage.db, actor.actorType, actor.actorId, "template.update", "template", id, {});
|
||
return c.json({ template });
|
||
});
|
||
|
||
app.post("/api/v1/templates/:id/use", zValidator("json", TemplateUseSchema), async (c) => {
|
||
const id = c.req.param("id");
|
||
const input = c.req.valid("json");
|
||
const workspaceId = getWorkspaceId(c);
|
||
const template = await getMemoTemplate(c.env.storage.db, workspaceId, id);
|
||
if (!template) {
|
||
return notFound(c, "Template not found");
|
||
}
|
||
|
||
const memo = await createMemoRecord(c.env.storage.db, workspaceId, {
|
||
notebookId: input.notebookId,
|
||
title: template.title ?? undefined,
|
||
contentMarkdown: template.contentMarkdown,
|
||
tags: template.tags,
|
||
}, getAuditActor(c), getActorLabel(c));
|
||
const actor = getAuditActor(c);
|
||
await audit(c.env.storage.db, actor.actorType, actor.actorId, "template.use", "template", id, { memoId: memo.id });
|
||
return c.json({ memo });
|
||
});
|
||
|
||
app.delete("/api/v1/templates/:id", async (c) => {
|
||
const id = c.req.param("id");
|
||
const workspaceId = getWorkspaceId(c);
|
||
const current = await getMemoTemplateRow(c.env.storage.db, workspaceId, id);
|
||
if (!current) {
|
||
return notFound(c, "Template not found");
|
||
}
|
||
|
||
await c.env.storage.db.prepare(`DELETE FROM memo_templates WHERE id = ? AND workspace_id = ?`).bind(id, workspaceId).run();
|
||
const actor = getAuditActor(c);
|
||
await audit(c.env.storage.db, actor.actorType, actor.actorId, "template.delete", "template", id, {});
|
||
return c.json({ ok: true });
|
||
});
|
||
|
||
app.get("/api/v1/memos", async (c) => {
|
||
const denied = requireScopes(c, "read:memos");
|
||
|
||
if (denied) {
|
||
return denied;
|
||
}
|
||
|
||
const notebookId = c.req.query("notebookId");
|
||
const includeNotebookDescendants = c.req.query("includeDescendants") === "1";
|
||
const q = c.req.query("q")?.trim();
|
||
const includeTrash = c.req.query("trash") === "1";
|
||
const sort = normalizeMemoListSort(c.req.query("sort"));
|
||
const filter = normalizeMemoListFilter(c.req.query("filter"));
|
||
const limit = clampNumber(Number(c.req.query("limit") ?? DEFAULT_MEMO_LIST_LIMIT), 1, MAX_MEMO_LIST_LIMIT);
|
||
const cursor = decodeMemoListCursor(c.req.query("cursor"), sort);
|
||
const deletedClause = includeTrash ? "m.is_deleted = 1" : "m.is_deleted = 0";
|
||
const titleSortExpression = `LOWER(COALESCE(NULLIF(m.title, ''), '${UNTITLED_MEMO_TITLE}'))`;
|
||
const baseConditions = ["m.workspace_id = ?", deletedClause];
|
||
const baseBinds: unknown[] = [getWorkspaceId(c)];
|
||
|
||
if (notebookId) {
|
||
if (includeNotebookDescendants) {
|
||
baseConditions.push(
|
||
`m.notebook_id IN (
|
||
WITH RECURSIVE descendants(id) AS (
|
||
SELECT id
|
||
FROM notebooks
|
||
WHERE workspace_id = ? AND id = ? AND is_deleted = 0
|
||
|
||
UNION
|
||
|
||
SELECT n.id
|
||
FROM notebooks n
|
||
INNER JOIN descendants d ON n.parent_id = d.id
|
||
WHERE n.workspace_id = ? AND n.is_deleted = 0
|
||
)
|
||
SELECT id FROM descendants
|
||
)`
|
||
);
|
||
baseBinds.push(getWorkspaceId(c), notebookId, getWorkspaceId(c));
|
||
} else {
|
||
baseConditions.push("m.notebook_id = ?");
|
||
baseBinds.push(notebookId);
|
||
}
|
||
}
|
||
|
||
if (filter === "tagged") {
|
||
baseConditions.push("m.tags_json <> '[]'");
|
||
} else if (filter === "untagged") {
|
||
baseConditions.push("m.tags_json = '[]'");
|
||
} else if (filter === "pinned") {
|
||
baseConditions.push("m.is_pinned = 1");
|
||
}
|
||
|
||
const getOrderBy = () => {
|
||
if (includeTrash) {
|
||
return "m.deleted_at DESC, m.id DESC";
|
||
}
|
||
|
||
if (sort === "created-desc") {
|
||
return "m.is_pinned DESC, m.created_at DESC, m.id DESC";
|
||
}
|
||
|
||
if (sort === "title-asc") {
|
||
return `m.is_pinned DESC, ${titleSortExpression} ASC, m.updated_at DESC, m.id DESC`;
|
||
}
|
||
|
||
return "m.is_pinned DESC, m.updated_at DESC, m.id DESC";
|
||
};
|
||
|
||
const cursorConditions = [...baseConditions];
|
||
const cursorBinds = [...baseBinds];
|
||
|
||
if (cursor) {
|
||
if (includeTrash) {
|
||
cursorConditions.push("(m.deleted_at < ? OR (m.deleted_at = ? AND m.id < ?))");
|
||
cursorBinds.push(cursor.deletedAt ?? "", cursor.deletedAt ?? "", cursor.id);
|
||
} else if (sort === "created-desc") {
|
||
cursorConditions.push("(m.is_pinned < ? OR (m.is_pinned = ? AND (m.created_at < ? OR (m.created_at = ? AND m.id < ?))))");
|
||
cursorBinds.push(cursor.pinned ?? 0, cursor.pinned ?? 0, cursor.createdAt ?? "", cursor.createdAt ?? "", cursor.id);
|
||
} else if (sort === "title-asc") {
|
||
cursorConditions.push(
|
||
`(m.is_pinned < ? OR (m.is_pinned = ? AND (${titleSortExpression} > ? OR (${titleSortExpression} = ? AND (m.updated_at < ? OR (m.updated_at = ? AND m.id < ?))))))`
|
||
);
|
||
cursorBinds.push(cursor.pinned ?? 0, cursor.pinned ?? 0, cursor.title ?? "", cursor.title ?? "", cursor.updatedAt ?? "", cursor.updatedAt ?? "", cursor.id);
|
||
} else {
|
||
cursorConditions.push("(m.is_pinned < ? OR (m.is_pinned = ? AND (m.updated_at < ? OR (m.updated_at = ? AND m.id < ?))))");
|
||
cursorBinds.push(cursor.pinned ?? 0, cursor.pinned ?? 0, cursor.updatedAt ?? "", cursor.updatedAt ?? "", cursor.id);
|
||
}
|
||
}
|
||
|
||
const pageLimit = limit + 1;
|
||
|
||
if (q) {
|
||
const ftsQuery = toFtsQuery(q);
|
||
const likeQuery = `%${escapeLike(q)}%`;
|
||
|
||
if (ftsQuery) {
|
||
const searchPrefix = [ftsQuery, likeQuery, likeQuery, likeQuery];
|
||
const [rows, totalRow] = await Promise.all([
|
||
c.env.storage.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, mc.revision,
|
||
mc.content_text
|
||
FROM search_matches s
|
||
INNER JOIN memos m ON m.id = s.memo_id
|
||
INNER JOIN memo_contents mc ON mc.memo_id = m.id
|
||
WHERE ${cursorConditions.join(" AND ")}
|
||
ORDER BY ${getOrderBy()}
|
||
LIMIT ?`
|
||
)
|
||
.bind(...searchPrefix, ...cursorBinds, pageLimit)
|
||
.all<MemoSummaryRow>(),
|
||
c.env.storage.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 ${baseConditions.join(" AND ")}`
|
||
)
|
||
.bind(...searchPrefix, ...baseBinds)
|
||
.first<{ count: number }>(),
|
||
]);
|
||
|
||
const page = rows.results.slice(0, limit);
|
||
const nextCursor = rows.results.length > limit ? encodeMemoListCursor(page[page.length - 1], sort, includeTrash) : null;
|
||
|
||
return c.json({ memos: page.map(mapMemoSummary), totalCount: totalRow?.count ?? page.length, nextCursor });
|
||
}
|
||
|
||
const searchConditions = [...baseConditions, "(m.title LIKE ? ESCAPE '\\' OR mc.content_text LIKE ? ESCAPE '\\' OR m.tags_json LIKE ? ESCAPE '\\')"];
|
||
const searchBinds = [...baseBinds, likeQuery, likeQuery, likeQuery];
|
||
const searchCursorConditions = [...cursorConditions, "(m.title LIKE ? ESCAPE '\\' OR mc.content_text LIKE ? ESCAPE '\\' OR m.tags_json LIKE ? ESCAPE '\\')"];
|
||
const searchCursorBinds = [...cursorBinds, likeQuery, likeQuery, likeQuery];
|
||
const [rows, totalRow] = await Promise.all([
|
||
c.env.storage.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, mc.revision,
|
||
mc.content_text
|
||
FROM memos m
|
||
INNER JOIN memo_contents mc ON mc.memo_id = m.id
|
||
WHERE ${searchCursorConditions.join(" AND ")}
|
||
ORDER BY ${getOrderBy()}
|
||
LIMIT ?`
|
||
)
|
||
.bind(...searchCursorBinds, pageLimit)
|
||
.all<MemoSummaryRow>(),
|
||
c.env.storage.db.prepare(
|
||
`SELECT COUNT(*) AS count
|
||
FROM memos m
|
||
INNER JOIN memo_contents mc ON mc.memo_id = m.id
|
||
WHERE ${searchConditions.join(" AND ")}`
|
||
)
|
||
.bind(...searchBinds)
|
||
.first<{ count: number }>(),
|
||
]);
|
||
|
||
const page = rows.results.slice(0, limit);
|
||
const nextCursor = rows.results.length > limit ? encodeMemoListCursor(page[page.length - 1], sort, includeTrash) : null;
|
||
|
||
return c.json({ memos: page.map(mapMemoSummary), totalCount: totalRow?.count ?? page.length, nextCursor });
|
||
}
|
||
|
||
const [rows, totalRow] = await Promise.all([
|
||
c.env.storage.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, mc.revision,
|
||
mc.content_text
|
||
FROM memos m
|
||
INNER JOIN memo_contents mc ON mc.memo_id = m.id
|
||
WHERE ${cursorConditions.join(" AND ")}
|
||
ORDER BY ${getOrderBy()}
|
||
LIMIT ?`
|
||
)
|
||
.bind(...cursorBinds, pageLimit)
|
||
.all<MemoSummaryRow>(),
|
||
c.env.storage.db.prepare(
|
||
`SELECT COUNT(*) AS count
|
||
FROM memos m
|
||
WHERE ${baseConditions.join(" AND ")}`
|
||
)
|
||
.bind(...baseBinds)
|
||
.first<{ count: number }>(),
|
||
]);
|
||
|
||
const page = rows.results.slice(0, limit);
|
||
const nextCursor = rows.results.length > limit ? encodeMemoListCursor(page[page.length - 1], sort, includeTrash) : null;
|
||
|
||
return c.json({ memos: page.map(mapMemoSummary), totalCount: totalRow?.count ?? page.length, nextCursor });
|
||
});
|
||
|
||
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.storage.db.batch([
|
||
c.env.storage.db.prepare(
|
||
`INSERT INTO memos (
|
||
id, workspace_id, notebook_id, title, excerpt, tags_json, created_by, updated_by, created_at, updated_at
|
||
) SELECT ?, ?, id, ?, ?, ?, ?, ?, ?, ? FROM notebooks WHERE id = ? AND workspace_id = ? AND is_deleted = 0`
|
||
).bind(id, getWorkspaceId(c), title, excerpt, JSON.stringify(tags), actorLabel, actorLabel, createdAt, updatedAt, input.notebookId, getWorkspaceId(c)),
|
||
c.env.storage.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.storage.db.prepare(
|
||
`INSERT INTO memos_fts (memo_id, title, content_text, tags)
|
||
VALUES (?, ?, ?, ?)`
|
||
).bind(id, title, contentText, tags.join(" ")),
|
||
auditStatement(c.env.storage.db, actor.actorType, actor.actorId, "memo.create", "memo", id, {
|
||
notebookId: input.notebookId,
|
||
}),
|
||
]);
|
||
|
||
return c.json({ memo: await getMemoDetail(c.env.storage.db, getWorkspaceId(c), 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.storage.db, getWorkspaceId(c), 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.storage.db, getWorkspaceId(c), 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.storage.db, c.env.storage.resources, getWorkspaceId(c), 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.storage.db, c.env.storage.resources, getWorkspaceId(c), 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.storage.db, getWorkspaceId(c), c.req.param("id"), includeDeleted);
|
||
|
||
if (!memo) {
|
||
return notFound(c, "Memo not found");
|
||
}
|
||
|
||
return c.json({ memo });
|
||
});
|
||
|
||
app.post("/api/v1/memos/:id/edit-sessions", async (c) => {
|
||
const denied = requireScopes(c, "write:memos");
|
||
|
||
if (denied) {
|
||
return denied;
|
||
}
|
||
|
||
const memoId = c.req.param("id");
|
||
const current = await getMemoDetailRow(c.env.storage.db, getWorkspaceId(c), memoId);
|
||
|
||
if (!current) {
|
||
return notFound(c, "Memo not found");
|
||
}
|
||
|
||
const actor = getAuditActor(c);
|
||
const now = isoNow();
|
||
const session: MemoEditSession = {
|
||
id: createId("edit"),
|
||
memoId,
|
||
baseRevision: current.revision,
|
||
baseContentHash: current.content_hash,
|
||
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60_000).toISOString(),
|
||
};
|
||
|
||
await c.env.storage.db.batch([
|
||
c.env.storage.db.prepare(`DELETE FROM memo_edit_sessions WHERE expires_at <= ?`).bind(now),
|
||
c.env.storage.db.prepare(
|
||
`INSERT INTO memo_edit_sessions (
|
||
id, memo_id, actor_type, actor_id, base_revision, base_content_hash,
|
||
expires_at, created_at, updated_at
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||
).bind(
|
||
session.id,
|
||
memoId,
|
||
actor.actorType,
|
||
actor.actorId,
|
||
session.baseRevision,
|
||
session.baseContentHash,
|
||
session.expiresAt,
|
||
now,
|
||
now
|
||
),
|
||
]);
|
||
|
||
return c.json({ editSession: session });
|
||
});
|
||
|
||
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.storage.db, getWorkspaceId(c), 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.storage.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.storage.db, getWorkspaceId(c), memoId);
|
||
|
||
if (!current) {
|
||
return notFound(c, "Memo not found");
|
||
}
|
||
|
||
const revision = await getMemoRevisionRow(c.env.storage.db, getWorkspaceId(c), 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.storage.db.batch([
|
||
createMemoRevisionStatement(c.env.storage.db, current, actorLabel, now),
|
||
c.env.storage.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.storage.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.storage.db.prepare(`DELETE FROM memos_fts WHERE memo_id = ?`).bind(memoId),
|
||
c.env.storage.db.prepare(
|
||
`INSERT INTO memos_fts (memo_id, title, content_text, tags)
|
||
VALUES (?, ?, ?, ?)`
|
||
).bind(memoId, title, contentText, tags.join(" ")),
|
||
auditStatement(c.env.storage.db, actor.actorType, actor.actorId, "memo.revision_restore", "memo", memoId, {
|
||
revisionId,
|
||
restoredRevision: revision.revision,
|
||
revision: nextRevision,
|
||
}),
|
||
]);
|
||
|
||
return c.json({ memo: await getMemoDetail(c.env.storage.db, getWorkspaceId(c), memoId) });
|
||
});
|
||
|
||
app.get("/api/v1/exports/markdown", async (c) => {
|
||
const denied = requireScopes(c, "read:memos", "read:resources");
|
||
|
||
if (denied) {
|
||
return denied;
|
||
}
|
||
|
||
const limit = clampNumber(Number(c.req.query("limit") ?? 50), 1, 100);
|
||
const offset = clampNumber(Number(c.req.query("offset") ?? 0), 0, 1_000_000);
|
||
const [memoRows, totalRow] = await Promise.all([
|
||
c.env.storage.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, mc.revision,
|
||
mc.content_json, mc.content_markdown, mc.content_text, mc.content_hash,
|
||
m.source_memo_ids, m.merge_source_count, m.merged_into_memo_id
|
||
FROM memos m
|
||
INNER JOIN memo_contents mc ON mc.memo_id = m.id
|
||
WHERE m.workspace_id = ? AND m.is_deleted = 0
|
||
ORDER BY m.created_at ASC, m.id ASC
|
||
LIMIT ? OFFSET ?`
|
||
)
|
||
.bind(getWorkspaceId(c), limit, offset)
|
||
.all<MemoDetailRow>(),
|
||
c.env.storage.db.prepare(`SELECT COUNT(*) AS count FROM memos WHERE workspace_id = ? AND is_deleted = 0`).bind(getWorkspaceId(c)).first<{ count: number }>(),
|
||
]);
|
||
|
||
const memoIds = memoRows.results.map((row) => row.id);
|
||
let resources: Resource[] = [];
|
||
|
||
if (memoIds.length > 0) {
|
||
const placeholders = memoIds.map(() => "?").join(", ");
|
||
const resourceRows = await c.env.storage.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
|
||
FROM resources
|
||
WHERE is_deleted = 0 AND memo_id IN (${placeholders})
|
||
ORDER BY memo_id ASC, created_at ASC, id ASC`
|
||
)
|
||
.bind(...memoIds)
|
||
.all<ResourceRow>();
|
||
resources = resourceRows.results.map(mapResource);
|
||
}
|
||
|
||
const totalCount = totalRow?.count ?? memoRows.results.length;
|
||
const nextOffset = offset + memoRows.results.length < totalCount ? offset + memoRows.results.length : null;
|
||
|
||
return c.json({
|
||
memos: memoRows.results.map(mapMemoDetail),
|
||
resources,
|
||
totalCount,
|
||
nextOffset,
|
||
});
|
||
});
|
||
|
||
app.get("/api/v1/backups/json", async (c) => {
|
||
const denied = requireScopes(c, "read:memos", "read:resources");
|
||
|
||
if (denied) {
|
||
return denied;
|
||
}
|
||
|
||
const limit = clampNumber(Number(c.req.query("limit") ?? 25), 1, 50);
|
||
const offset = clampNumber(Number(c.req.query("offset") ?? 0), 0, 1_000_000);
|
||
const [memoRows, totalRow] = await Promise.all([
|
||
c.env.storage.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, mc.revision,
|
||
mc.content_json, mc.content_markdown, mc.content_text, mc.content_hash,
|
||
m.source_memo_ids, m.merge_source_count, m.merged_into_memo_id
|
||
FROM memos m
|
||
INNER JOIN memo_contents mc ON mc.memo_id = m.id
|
||
WHERE m.workspace_id = ? AND m.is_deleted = 0
|
||
ORDER BY m.created_at ASC, m.id ASC
|
||
LIMIT ? OFFSET ?`
|
||
)
|
||
.bind(getWorkspaceId(c), limit, offset)
|
||
.all<MemoDetailRow>(),
|
||
c.env.storage.db.prepare(`SELECT COUNT(*) AS count FROM memos WHERE workspace_id = ? AND is_deleted = 0`).bind(getWorkspaceId(c)).first<{ count: number }>(),
|
||
]);
|
||
const memoIds = memoRows.results.map((row) => row.id);
|
||
let resources: Resource[] = [];
|
||
let revisions: JsonBackupRevision[] = [];
|
||
|
||
if (memoIds.length > 0) {
|
||
const placeholders = memoIds.map(() => "?").join(", ");
|
||
const [resourceRows, revisionRows] = await Promise.all([
|
||
c.env.storage.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 is_deleted = 0 AND memo_id IN (${placeholders})
|
||
ORDER BY memo_id ASC, created_at ASC, id ASC`
|
||
)
|
||
.bind(...memoIds)
|
||
.all<ResourceRow>(),
|
||
c.env.storage.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 IN (${placeholders})
|
||
ORDER BY memo_id ASC, revision ASC, created_at ASC`
|
||
)
|
||
.bind(...memoIds)
|
||
.all<BackupRevisionRow>(),
|
||
]);
|
||
resources = resourceRows.results.map(mapResource);
|
||
revisions = revisionRows.results.map(mapJsonBackupRevision);
|
||
}
|
||
|
||
const totalCount = totalRow?.count ?? memoRows.results.length;
|
||
const nextOffset = offset + memoRows.results.length < totalCount ? offset + memoRows.results.length : null;
|
||
|
||
return c.json({
|
||
memos: memoRows.results.map(mapMemoDetail),
|
||
resources,
|
||
revisions,
|
||
totalCount,
|
||
nextOffset,
|
||
});
|
||
});
|
||
|
||
app.post("/api/v1/restores/json/notebooks", zValidator("json", RestoreJsonNotebooksSchema), async (c) => {
|
||
const userOnly = requireUser(c);
|
||
if (userOnly) {
|
||
return userOnly;
|
||
}
|
||
|
||
await restoreJsonNotebooks(c.env.storage.db, getWorkspaceId(c), c.req.valid("json").notebooks as JsonBackupNotebook[]);
|
||
return c.json({ ok: true });
|
||
});
|
||
|
||
app.post("/api/v1/restores/json/memos", zValidator("json", RestoreJsonMemosSchema), async (c) => {
|
||
const userOnly = requireUser(c);
|
||
if (userOnly) {
|
||
return userOnly;
|
||
}
|
||
|
||
await restoreJsonMemos(c.env.storage.db, getWorkspaceId(c), c.req.valid("json").memos as JsonBackupMemo[]);
|
||
return c.json({ ok: true });
|
||
});
|
||
|
||
app.put("/api/v1/restores/json/resources/:id", async (c) => {
|
||
const userOnly = requireUser(c);
|
||
if (userOnly) {
|
||
return userOnly;
|
||
}
|
||
|
||
const form = await c.req.raw.formData();
|
||
const file = form.get("file");
|
||
const metadataValue = form.get("metadata");
|
||
if (!(file instanceof File) || typeof metadataValue !== "string") {
|
||
return badRequest(c, "Restore resource file and metadata are required.");
|
||
}
|
||
|
||
let metadataInput: unknown;
|
||
try {
|
||
metadataInput = JSON.parse(metadataValue);
|
||
} catch {
|
||
return badRequest(c, "Restore resource metadata must be valid JSON.");
|
||
}
|
||
|
||
const parsed = JsonBackupResourceMetadataSchema.safeParse(metadataInput);
|
||
if (!parsed.success || parsed.data.id !== c.req.param("id")) {
|
||
return badRequest(c, "Restore resource metadata is invalid.");
|
||
}
|
||
|
||
const metadata = parsed.data as JsonBackupResource;
|
||
const memo = await getMemoDetail(c.env.storage.db, getWorkspaceId(c), metadata.memoId);
|
||
if (!memo) {
|
||
return notFound(c, "Restore target memo not found.");
|
||
}
|
||
|
||
const maxBytes = metadata.kind === "image" ? MAX_IMAGE_UPLOAD_BYTES : MAX_ATTACHMENT_UPLOAD_BYTES;
|
||
if (file.size <= 0 || file.size > maxBytes) {
|
||
return apiError(c, "upload_too_large", "Backup resource size is invalid.", 413);
|
||
}
|
||
|
||
const filename = normalizeFilename(metadata.filename || file.name) || `${metadata.kind}-${metadata.id}`;
|
||
const objectKey = `workspaces/${getWorkspaceId(c)}/restores/${metadata.memoId}/${metadata.id}/${Date.now()}-${filename}`;
|
||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||
const foreignResource = await c.env.storage.db.prepare(
|
||
`SELECT r.id FROM resources r INNER JOIN memos m ON m.id = r.memo_id
|
||
WHERE r.id = ? AND m.workspace_id <> ? LIMIT 1`
|
||
).bind(metadata.id, getWorkspaceId(c)).first<{ id: string }>();
|
||
if (foreignResource) {
|
||
return conflict(c, "cross_workspace_id_conflict", "Backup resource ID is already used by another user.");
|
||
}
|
||
const previous = await c.env.storage.db.prepare(
|
||
`SELECT r.object_key FROM resources r INNER JOIN memos m ON m.id = r.memo_id WHERE r.id = ? AND m.workspace_id = ?`
|
||
).bind(metadata.id, getWorkspaceId(c)).first<{ object_key: string }>();
|
||
const originalMemo = metadata.originalMemoId
|
||
? await c.env.storage.db.prepare(`SELECT id FROM memos WHERE id = ? AND workspace_id = ?`).bind(metadata.originalMemoId, getWorkspaceId(c)).first<{ id: string }>()
|
||
: null;
|
||
|
||
await c.env.storage.resources.put(objectKey, bytes, {
|
||
httpMetadata: { contentType: metadata.mimeType ?? file.type ?? "application/octet-stream" },
|
||
customMetadata: { memoId: metadata.memoId, resourceId: metadata.id, restored: "true" },
|
||
});
|
||
|
||
try {
|
||
const now = isoNow();
|
||
await c.env.storage.db.prepare(
|
||
`INSERT INTO resources (
|
||
id, memo_id, original_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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, NULL)
|
||
ON CONFLICT(id) DO UPDATE SET
|
||
memo_id = excluded.memo_id,
|
||
original_memo_id = excluded.original_memo_id,
|
||
bucket_name = excluded.bucket_name,
|
||
object_key = excluded.object_key,
|
||
kind = excluded.kind,
|
||
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(
|
||
metadata.id,
|
||
metadata.memoId,
|
||
originalMemo?.id ?? null,
|
||
c.env.EDGE_EVER_R2_BUCKET_NAME?.trim() || DEFAULT_R2_BUCKET_NAME,
|
||
objectKey,
|
||
metadata.kind,
|
||
metadata.mimeType ?? file.type ?? null,
|
||
filename,
|
||
bytes.byteLength,
|
||
await sha256Bytes(bytes),
|
||
metadata.width,
|
||
metadata.height,
|
||
JSON.stringify({ source: "edgeever-zip-import" }),
|
||
metadata.createdAt,
|
||
now
|
||
).run();
|
||
} catch (error) {
|
||
await c.env.storage.resources.delete(objectKey);
|
||
throw error;
|
||
}
|
||
|
||
if (previous?.object_key && previous.object_key !== objectKey) {
|
||
await c.env.storage.resources.delete(previous.object_key);
|
||
}
|
||
|
||
return c.json({ ok: true });
|
||
});
|
||
|
||
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.storage.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
|
||
INNER JOIN memos m ON m.id = r.memo_id
|
||
WHERE m.workspace_id = ? AND r.is_deleted = 0
|
||
ORDER BY r.created_at DESC
|
||
LIMIT ?`
|
||
)
|
||
.bind(getWorkspaceId(c), limit)
|
||
.all<ResourceListRow>(),
|
||
c.env.storage.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 r
|
||
INNER JOIN memos m ON m.id = r.memo_id
|
||
WHERE m.workspace_id = ? AND r.is_deleted = 0`
|
||
).bind(getWorkspaceId(c)).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.storage.db, getWorkspaceId(c), 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 = `workspaces/${getWorkspaceId(c)}/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.storage.resources.put(objectKey, processed.bytes, {
|
||
httpMetadata: {
|
||
contentType: processed.mimeType,
|
||
cacheControl: "private, max-age=3600",
|
||
},
|
||
customMetadata: {
|
||
memoId: input.memoId,
|
||
resourceId,
|
||
filename,
|
||
},
|
||
});
|
||
|
||
try {
|
||
await c.env.storage.db.batch([
|
||
c.env.storage.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.storage.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.storage.resources.delete(objectKey);
|
||
throw error;
|
||
}
|
||
|
||
const resource = await getResourceRow(c.env.storage.db, getWorkspaceId(c), 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 = `workspaces/${getWorkspaceId(c)}/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.storage.resources.put(objectKey, input.bytes, {
|
||
httpMetadata: {
|
||
contentType: input.mimeType,
|
||
cacheControl: "private, max-age=3600",
|
||
},
|
||
customMetadata: {
|
||
memoId: input.memoId,
|
||
resourceId,
|
||
filename,
|
||
},
|
||
});
|
||
|
||
try {
|
||
await c.env.storage.db.batch([
|
||
c.env.storage.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.storage.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.storage.resources.delete(objectKey);
|
||
throw error;
|
||
}
|
||
|
||
const resource = await getResourceRow(c.env.storage.db, getWorkspaceId(c), 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 100 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.storage.db, getWorkspaceId(c), c.req.param("id"));
|
||
|
||
if (!resource) {
|
||
return notFound(c, "Resource not found");
|
||
}
|
||
|
||
const object = await c.env.storage.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",
|
||
resource.kind === "image"
|
||
? contentDispositionInline(resource.filename)
|
||
: contentDispositionAttachment(resource.filename)
|
||
);
|
||
headers.set("X-Content-Type-Options", "nosniff");
|
||
|
||
return new Response(object.body, { headers });
|
||
});
|
||
|
||
app.post("/api/v1/demo/reset", async (c) => {
|
||
if (!isDemoMode(c.env) && !isLocalDemoSeedEnabled(c.env)) {
|
||
return c.json(
|
||
{
|
||
error: {
|
||
code: "demo_mode_disabled",
|
||
message: "Demo reset is only available when demo mode or local demo seed is enabled",
|
||
},
|
||
},
|
||
400
|
||
);
|
||
}
|
||
|
||
await resetDemoData(c.env, Date.now());
|
||
return c.json({
|
||
success: true,
|
||
message: "Demo seed data successfully restored",
|
||
});
|
||
});
|
||
|
||
app.patch("/api/v1/memos/:id", zValidator("json", MemoUpdateSchema), async (c) => {
|
||
const denied = requireScopes(c, "write:memos");
|
||
|
||
if (denied) {
|
||
return denied;
|
||
}
|
||
|
||
return updateMemoFromInput(c, c.req.param("id"), c.req.valid("json"));
|
||
});
|
||
|
||
app.post("/api/v1/memos/:id/save", zValidator("json", MemoUpdateSchema), async (c) => {
|
||
const denied = requireScopes(c, "write:memos");
|
||
|
||
if (denied) {
|
||
return denied;
|
||
}
|
||
|
||
return updateMemoFromInput(c, c.req.param("id"), c.req.valid("json"));
|
||
});
|
||
|
||
const updateMemoFromInput = async (c: AppContext, id: string, input: MemoUpdateInput) => {
|
||
const actor = getAuditActor(c);
|
||
const actorLabel = getActorLabel(c);
|
||
const workspaceId = getWorkspaceId(c);
|
||
const current = await getMemoDetailRow(c.env.storage.db, workspaceId, 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 hasDocumentUpdate = input.contentJson !== undefined || input.contentMarkdown !== undefined;
|
||
let editSession: MemoEditSessionRow | null = null;
|
||
|
||
if (hasDocumentUpdate) {
|
||
if (!input.editSessionId || !input.expectedContentHash || input.expectedRevision === undefined) {
|
||
return c.json(
|
||
{ error: { code: "edit_session_required", message: "A bound edit session is required to save note content." } },
|
||
428
|
||
);
|
||
}
|
||
|
||
if (input.expectedContentHash !== current.content_hash) {
|
||
return c.json(
|
||
{ error: { code: "content_conflict", message: "Note content changed after this edit session started." } },
|
||
409
|
||
);
|
||
}
|
||
|
||
editSession = await c.env.storage.db.prepare(
|
||
`SELECT id, memo_id, actor_type, actor_id, base_revision, base_content_hash, expires_at
|
||
FROM memo_edit_sessions
|
||
WHERE id = ? AND memo_id = ? AND actor_type = ? AND actor_id IS ? AND expires_at > ?`
|
||
)
|
||
.bind(input.editSessionId, id, actor.actorType, actor.actorId, isoNow())
|
||
.first<MemoEditSessionRow>();
|
||
|
||
if (
|
||
!editSession ||
|
||
!isMemoEditBindingValid(
|
||
{ memoId: id, revision: current.revision, contentHash: current.content_hash },
|
||
{
|
||
id: editSession.id,
|
||
memoId: editSession.memo_id,
|
||
baseRevision: editSession.base_revision,
|
||
baseContentHash: editSession.base_content_hash,
|
||
},
|
||
{
|
||
editSessionId: input.editSessionId,
|
||
memoId: id,
|
||
expectedRevision: input.expectedRevision,
|
||
expectedContentHash: input.expectedContentHash,
|
||
}
|
||
)
|
||
) {
|
||
return c.json(
|
||
{ error: { code: "edit_session_conflict", message: "The edit session is stale or belongs to another note." } },
|
||
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.storage.db, workspaceId, id) });
|
||
}
|
||
|
||
await c.env.storage.db.batch([
|
||
c.env.storage.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.storage.db, actor.actorType, actor.actorId, isPinned ? "memo.pin" : "memo.unpin", "memo", id, {}),
|
||
]);
|
||
|
||
return c.json({ memo: await getMemoDetail(c.env.storage.db, workspaceId, 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);
|
||
if (
|
||
!input.allowDestructiveOverwrite &&
|
||
isSuspiciousMemoOverwrite(current.title, current.content_text, title, contentText)
|
||
) {
|
||
return c.json(
|
||
{
|
||
error: {
|
||
code: "suspicious_memo_overwrite",
|
||
message: "Save blocked because the title changed while most of the note content disappeared.",
|
||
},
|
||
},
|
||
409
|
||
);
|
||
}
|
||
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.storage.db, current, title, JSON.stringify(tags), contentHash, updatedAt))
|
||
? [createMemoRevisionStatement(c.env.storage.db, current, actorLabel, updatedAt)]
|
||
: [];
|
||
const editSessionStatements = editSession
|
||
? [
|
||
c.env.storage.db.prepare(
|
||
`UPDATE memo_edit_sessions
|
||
SET base_revision = ?, base_content_hash = ?, updated_at = ?
|
||
WHERE id = ? AND memo_id = ? AND base_revision = ? AND base_content_hash = ?`
|
||
).bind(nextRevision, contentHash, updatedAt, editSession.id, id, current.revision, current.content_hash),
|
||
]
|
||
: [
|
||
c.env.storage.db.prepare(
|
||
`UPDATE memo_edit_sessions
|
||
SET base_revision = ?, base_content_hash = ?, updated_at = ?
|
||
WHERE memo_id = ? AND actor_type = ? AND actor_id IS ?
|
||
AND base_revision = ? AND base_content_hash = ? AND expires_at > ?`
|
||
).bind(
|
||
nextRevision,
|
||
contentHash,
|
||
updatedAt,
|
||
id,
|
||
actor.actorType,
|
||
actor.actorId,
|
||
current.revision,
|
||
current.content_hash,
|
||
updatedAt
|
||
),
|
||
];
|
||
|
||
await c.env.storage.db.batch([
|
||
...revisionStatements,
|
||
c.env.storage.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 workspace_id = ? AND is_deleted = 0
|
||
AND EXISTS (SELECT 1 FROM notebooks n WHERE n.id = ? AND n.workspace_id = ? AND n.is_deleted = 0)`
|
||
).bind(notebookId, title, excerpt, JSON.stringify(tags), isPinned ? 1 : 0, actorLabel, updatedAt, input.createdAt ?? null, id, workspaceId, notebookId, workspaceId),
|
||
c.env.storage.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.storage.db.prepare(`DELETE FROM memos_fts WHERE memo_id = ?`).bind(id),
|
||
c.env.storage.db.prepare(
|
||
`INSERT INTO memos_fts (memo_id, title, content_text, tags)
|
||
VALUES (?, ?, ?, ?)`
|
||
).bind(id, title, contentText, tags.join(" ")),
|
||
...editSessionStatements,
|
||
auditStatement(c.env.storage.db, actor.actorType, actor.actorId, "memo.update", "memo", id, {
|
||
revision: nextRevision,
|
||
}),
|
||
]);
|
||
|
||
return c.json({ memo: await getMemoDetail(c.env.storage.db, workspaceId, 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();
|
||
const workspaceId = getWorkspaceId(c);
|
||
|
||
if (permanent) {
|
||
const current = await getMemoDetailRow(c.env.storage.db, workspaceId, id, true);
|
||
|
||
if (!current || current.is_deleted === 0) {
|
||
return notFound(c, "Memo not found in trash");
|
||
}
|
||
|
||
const resources = await getResourceRowsForMemo(c.env.storage.db, workspaceId, id);
|
||
|
||
if (resources.length > 0) {
|
||
await c.env.storage.resources.delete(resources.map((resource) => resource.object_key));
|
||
}
|
||
|
||
await c.env.storage.db.batch([
|
||
c.env.storage.db.prepare(`DELETE FROM memos_fts WHERE memo_id = ?`).bind(id),
|
||
c.env.storage.db.prepare(`DELETE FROM resources WHERE memo_id = ?`).bind(id),
|
||
c.env.storage.db.prepare(`DELETE FROM memo_revisions WHERE memo_id = ?`).bind(id),
|
||
c.env.storage.db.prepare(`DELETE FROM memo_contents WHERE memo_id = ?`).bind(id),
|
||
c.env.storage.db.prepare(`DELETE FROM memos WHERE id = ? AND workspace_id = ? AND is_deleted = 1`).bind(id, workspaceId),
|
||
auditStatement(c.env.storage.db, actor.actorType, actor.actorId, "memo.delete_permanent", "memo", id, {}),
|
||
]);
|
||
|
||
return c.json({ ok: true });
|
||
}
|
||
|
||
await c.env.storage.db.batch([
|
||
c.env.storage.db.prepare(
|
||
`UPDATE memos
|
||
SET is_deleted = 1, deleted_at = ?, updated_at = ?
|
||
WHERE id = ? AND workspace_id = ? AND is_deleted = 0`
|
||
).bind(now, now, id, workspaceId),
|
||
c.env.storage.db.prepare(
|
||
`UPDATE resources
|
||
SET is_deleted = 1, deleted_at = ?, updated_at = ?
|
||
WHERE memo_id = ? AND is_deleted = 0`
|
||
).bind(now, now, id),
|
||
c.env.storage.db.prepare(`DELETE FROM memos_fts WHERE memo_id = ?`).bind(id),
|
||
auditStatement(c.env.storage.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 workspaceId = getWorkspaceId(c);
|
||
const current = await getMemoDetailRow(c.env.storage.db, workspaceId, 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();
|
||
const originalNotebook = await getNotebook(c.env.storage.db, workspaceId, current.notebook_id);
|
||
const inbox = await c.env.storage.db.prepare(`SELECT id FROM notebooks WHERE workspace_id = ? AND slug = 'inbox' AND is_deleted = 0 LIMIT 1`).bind(workspaceId).first<{ id: string }>();
|
||
const restoreNotebookId = originalNotebook ? current.notebook_id : inbox?.id;
|
||
|
||
if (!restoreNotebookId) {
|
||
return conflict(c, "restore_notebook_missing", "Original notebook was deleted and the default inbox is unavailable.");
|
||
}
|
||
|
||
await c.env.storage.db.batch([
|
||
c.env.storage.db.prepare(
|
||
`UPDATE memos
|
||
SET notebook_id = ?, is_deleted = 0, deleted_at = NULL, updated_at = ?
|
||
WHERE id = ? AND workspace_id = ? AND is_deleted = 1`
|
||
).bind(restoreNotebookId, now, id, workspaceId),
|
||
c.env.storage.db.prepare(
|
||
`UPDATE resources
|
||
SET is_deleted = 0, deleted_at = NULL, updated_at = ?
|
||
WHERE memo_id = ? AND is_deleted = 1`
|
||
).bind(now, id),
|
||
c.env.storage.db.prepare(`DELETE FROM memos_fts WHERE memo_id = ?`).bind(id),
|
||
c.env.storage.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.storage.db, actor.actorType, actor.actorId, "memo.restore", "memo", id, {
|
||
fromNotebookId: current.notebook_id,
|
||
toNotebookId: restoreNotebookId,
|
||
}),
|
||
]);
|
||
|
||
return c.json({ memo: await getMemoDetail(c.env.storage.db, workspaceId, 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.storage.db, getWorkspaceId(c), 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 = {
|
||
async fetch(request: Request, env: WorkerBindings, ctx: ExecutionContext) {
|
||
const runtimeEnv = {
|
||
...env,
|
||
storage: createCloudflareStorageAdapter(env),
|
||
} as Bindings;
|
||
|
||
if (isLocalDemoSeedEnabled(runtimeEnv)) {
|
||
await ensureLocalDemoSeed(runtimeEnv);
|
||
}
|
||
|
||
return app.fetch(request, runtimeEnv, ctx);
|
||
},
|
||
async scheduled(controller: ScheduledController, env: WorkerBindings, ctx: ExecutionContext) {
|
||
const runtimeEnv = {
|
||
...env,
|
||
storage: createCloudflareStorageAdapter(env),
|
||
} as Bindings;
|
||
|
||
if (!isDemoMode(runtimeEnv)) {
|
||
return;
|
||
}
|
||
|
||
ctx.waitUntil(resetDemoData(runtimeEnv, controller.scheduledTime, { resetCredentials: true }));
|
||
},
|
||
};
|
||
|
||
app.notFound((c) =>
|
||
c.json(
|
||
{
|
||
error: {
|
||
code: "not_found",
|
||
message: "Route not found",
|
||
},
|
||
},
|
||
404
|
||
)
|
||
);
|
||
|
||
app.onError((error, c) => {
|
||
if (error instanceof AppError) {
|
||
return apiError(c, error.code, error.message, error.status);
|
||
}
|
||
|
||
if (isDatabaseNotReadyError(error)) {
|
||
console.error("EdgeEver database readiness check failed", error);
|
||
return databaseNotReady(c);
|
||
}
|
||
|
||
console.error("Unhandled EdgeEver API error", error);
|
||
return apiError(c, "internal_error", "An unexpected server error occurred.", 500);
|
||
});
|
||
|
||
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, notebook, time range, pin state, or resource presence.",
|
||
inputSchema: {
|
||
type: "object",
|
||
additionalProperties: false,
|
||
properties: {
|
||
query: { type: "string" },
|
||
notebookId: { type: "string" },
|
||
tags: { type: "array", items: { type: "string" } },
|
||
createdAfter: { type: "string", format: "date-time" },
|
||
createdBefore: { type: "string", format: "date-time" },
|
||
updatedAfter: { type: "string", format: "date-time" },
|
||
updatedBefore: { type: "string", format: "date-time" },
|
||
isPinned: { type: "boolean" },
|
||
hasResources: { type: "boolean" },
|
||
limit: { type: "integer", minimum: 1, maximum: 50 },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "list_memos",
|
||
description: "List 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" },
|
||
includeDeleted: { type: "boolean" },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "get_memo",
|
||
description: "Read a memo with Markdown content.",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["memoId"],
|
||
additionalProperties: false,
|
||
properties: {
|
||
memoId: { type: "string" },
|
||
includeDeleted: { type: "boolean" },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
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: "trash_memos",
|
||
description: "Move one or more active memos to trash. Use dryRun to preview affected memos.",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["memoIds"],
|
||
additionalProperties: false,
|
||
properties: {
|
||
memoIds: { type: "array", minItems: 1, maxItems: 100, items: { type: "string" } },
|
||
dryRun: { type: "boolean" },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "restore_memos",
|
||
description: "Restore one or more trashed memos. If the original notebook is gone, memos are restored to the default inbox.",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["memoIds"],
|
||
additionalProperties: false,
|
||
properties: {
|
||
memoIds: { type: "array", minItems: 1, maxItems: 100, items: { type: "string" } },
|
||
dryRun: { type: "boolean" },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "move_memos",
|
||
description: "Move one or more active memos to another notebook. Use dryRun to preview affected memos.",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["memoIds", "notebookId"],
|
||
additionalProperties: false,
|
||
properties: {
|
||
memoIds: { type: "array", minItems: 1, maxItems: 100, items: { type: "string" } },
|
||
notebookId: { type: "string" },
|
||
dryRun: { type: "boolean" },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "add_tags_to_memos",
|
||
description: "Add tags to one or more active memos. Use dryRun to preview changed tags.",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["memoIds", "tags"],
|
||
additionalProperties: false,
|
||
properties: {
|
||
memoIds: { type: "array", minItems: 1, maxItems: 100, items: { type: "string" } },
|
||
tags: { type: "array", minItems: 1, maxItems: 20, items: { type: "string" } },
|
||
dryRun: { type: "boolean" },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "remove_tags_from_memos",
|
||
description: "Remove tags from one or more active memos. Use dryRun to preview changed tags.",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["memoIds", "tags"],
|
||
additionalProperties: false,
|
||
properties: {
|
||
memoIds: { type: "array", minItems: 1, maxItems: 100, items: { type: "string" } },
|
||
tags: { type: "array", minItems: 1, maxItems: 20, items: { type: "string" } },
|
||
dryRun: { type: "boolean" },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "rename_tag",
|
||
description: "Rename a tag across all active memos. This merges into an existing tag with the same normalized name.",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["from", "to"],
|
||
additionalProperties: false,
|
||
properties: {
|
||
from: { type: "string" },
|
||
to: { type: "string" },
|
||
dryRun: { type: "boolean" },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "delete_tag",
|
||
description: "Remove a tag from all active memos.",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["tag"],
|
||
additionalProperties: false,
|
||
properties: {
|
||
tag: { type: "string" },
|
||
dryRun: { type: "boolean" },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
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: "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: "upload_memo_attachment",
|
||
description: "Upload a base64-encoded attachment resource to a memo and return Markdown link text that can be inserted into memo content.",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["memoId", "filename", "mimeType", "dataBase64"],
|
||
additionalProperties: false,
|
||
properties: {
|
||
memoId: { type: "string" },
|
||
filename: { type: "string" },
|
||
mimeType: { type: "string" },
|
||
dataBase64: { type: "string" },
|
||
label: { type: "string" },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "list_memo_resources",
|
||
description: "List active resources attached to a memo.",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["memoId"],
|
||
additionalProperties: false,
|
||
properties: {
|
||
memoId: { type: "string" },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "list_resources",
|
||
description: "List active workspace resources with storage summary.",
|
||
inputSchema: {
|
||
type: "object",
|
||
additionalProperties: false,
|
||
properties: {
|
||
limit: { type: "integer", minimum: 1, maximum: 500 },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "list_memo_revisions",
|
||
description: "List revision history for a memo.",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["memoId"],
|
||
additionalProperties: false,
|
||
properties: {
|
||
memoId: { type: "string" },
|
||
limit: { type: "integer", minimum: 1, maximum: 100 },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "restore_memo_revision",
|
||
description: "Restore a memo to a previous revision. Use dryRun to preview the target revision.",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["memoId", "revisionId"],
|
||
additionalProperties: false,
|
||
properties: {
|
||
memoId: { type: "string" },
|
||
revisionId: { type: "string" },
|
||
dryRun: { type: "boolean" },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
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: {},
|
||
},
|
||
},
|
||
{
|
||
name: "get_workspace_stats",
|
||
description: "Get notebook, memo, tag, and resource counts for workspace diagnostics.",
|
||
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.storage.db, {
|
||
workspaceId: auth.workspaceId,
|
||
query: getOptionalString(args.query),
|
||
notebookId: getOptionalString(args.notebookId),
|
||
tags: getOptionalStringArray(args.tags),
|
||
createdAfter: getOptionalString(args.createdAfter),
|
||
createdBefore: getOptionalString(args.createdBefore),
|
||
updatedAfter: getOptionalString(args.updatedAfter),
|
||
updatedBefore: getOptionalString(args.updatedBefore),
|
||
isPinned: typeof args.isPinned === "boolean" ? args.isPinned : null,
|
||
hasResources: typeof args.hasResources === "boolean" ? args.hasResources : null,
|
||
limit: clampNumber(Number(args.limit ?? 20), 1, 50),
|
||
}),
|
||
};
|
||
}
|
||
case "list_memos": {
|
||
assertScope(auth, "read:memos");
|
||
return await listMemosForMcp(c.env.storage.db, {
|
||
workspaceId: auth.workspaceId,
|
||
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,
|
||
includeDeleted: args.includeDeleted === true,
|
||
});
|
||
}
|
||
case "get_memo": {
|
||
assertScope(auth, "read:memos");
|
||
const memoId = getRequiredString(args.memoId, "memoId");
|
||
const memo = await getMemoDetail(c.env.storage.db, auth.workspaceId, memoId, args.includeDeleted === true);
|
||
|
||
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.storage.db, auth.workspaceId, {
|
||
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.storage.db,
|
||
auth.workspaceId,
|
||
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 "trash_memos": {
|
||
assertScope(auth, "write:memos");
|
||
const memoIds = getRequiredStringArray(args.memoIds, "memoIds");
|
||
|
||
if (args.dryRun === true) {
|
||
return { dryRun: true, memos: await getMemosForBulkAction(c.env.storage.db, auth.workspaceId, memoIds, 0) };
|
||
}
|
||
|
||
const deleted = await deleteMemosRecord(c.env.storage.db, c.env.storage.resources, auth.workspaceId, memoIds, false, getAuditActor(c));
|
||
return { ok: true, deleted };
|
||
}
|
||
case "restore_memos": {
|
||
assertScope(auth, "write:memos");
|
||
const memoIds = getRequiredStringArray(args.memoIds, "memoIds");
|
||
|
||
if (args.dryRun === true) {
|
||
return { dryRun: true, memos: await getMemosForBulkAction(c.env.storage.db, auth.workspaceId, memoIds, 1) };
|
||
}
|
||
|
||
const restored = await restoreMemosRecord(c.env.storage.db, auth.workspaceId, memoIds, getAuditActor(c));
|
||
return { ok: true, restored };
|
||
}
|
||
case "upload_memo_image": {
|
||
assertScope(auth, "write:resources");
|
||
const memoId = getRequiredString(args.memoId, "memoId");
|
||
const memo = await getMemoDetail(c.env.storage.db, auth.workspaceId, 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 decodeBase64Data(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.storage.db, auth.workspaceId, notebookId);
|
||
|
||
if (!target) {
|
||
throw new AppError("not_found", "Target notebook not found", 404);
|
||
}
|
||
|
||
if (args.dryRun === true) {
|
||
return { dryRun: true, targetNotebook: target, memos: await getMemosForBulkAction(c.env.storage.db, auth.workspaceId, memoIds, 0) };
|
||
}
|
||
|
||
const actor = getAuditActor(c);
|
||
const actorLabel = getActorLabel(c);
|
||
const moved = await moveMemosToNotebook(c.env.storage.db, auth.workspaceId, memoIds, notebookId, actor, actorLabel);
|
||
|
||
return { ok: true, moved };
|
||
}
|
||
case "add_tags_to_memos": {
|
||
assertScope(auth, "write:tags");
|
||
return await updateTagsForMemos(c.env.storage.db, {
|
||
workspaceId: auth.workspaceId,
|
||
memoIds: getRequiredStringArray(args.memoIds, "memoIds"),
|
||
tags: getRequiredStringArray(args.tags, "tags"),
|
||
mode: "add",
|
||
dryRun: args.dryRun === true,
|
||
actor: getAuditActor(c),
|
||
actorLabel: getActorLabel(c),
|
||
});
|
||
}
|
||
case "remove_tags_from_memos": {
|
||
assertScope(auth, "write:tags");
|
||
return await updateTagsForMemos(c.env.storage.db, {
|
||
workspaceId: auth.workspaceId,
|
||
memoIds: getRequiredStringArray(args.memoIds, "memoIds"),
|
||
tags: getRequiredStringArray(args.tags, "tags"),
|
||
mode: "remove",
|
||
dryRun: args.dryRun === true,
|
||
actor: getAuditActor(c),
|
||
actorLabel: getActorLabel(c),
|
||
});
|
||
}
|
||
case "rename_tag": {
|
||
assertScope(auth, "write:tags");
|
||
const from = getRequiredString(args.from, "from");
|
||
const to = getRequiredString(args.to, "to");
|
||
|
||
if (args.dryRun === true) {
|
||
return await previewTagRename(c.env.storage.db, auth.workspaceId, from, to);
|
||
}
|
||
|
||
const updated = await updateTagAcrossMemos(c.env.storage.db, auth.workspaceId, from, to, getAuditActor(c), getActorLabel(c));
|
||
return { ok: true, updated };
|
||
}
|
||
case "delete_tag": {
|
||
assertScope(auth, "write:tags");
|
||
const tag = getRequiredString(args.tag, "tag");
|
||
|
||
if (args.dryRun === true) {
|
||
return await previewTagRename(c.env.storage.db, auth.workspaceId, tag, null);
|
||
}
|
||
|
||
const updated = await updateTagAcrossMemos(c.env.storage.db, auth.workspaceId, tag, null, getAuditActor(c), getActorLabel(c));
|
||
return { ok: true, updated };
|
||
}
|
||
case "merge_memos": {
|
||
assertScope(auth, "write:memos");
|
||
const actor = getAuditActor(c);
|
||
const actorLabel = getActorLabel(c);
|
||
const memo = await mergeMemosRecord(
|
||
c.env.storage.db,
|
||
auth.workspaceId,
|
||
{
|
||
memoIds: getRequiredStringArray(args.memoIds, "memoIds"),
|
||
notebookId: getOptionalString(args.notebookId) ?? undefined,
|
||
title: getOptionalString(args.title) ?? undefined,
|
||
},
|
||
actor,
|
||
actorLabel
|
||
);
|
||
|
||
return { memo };
|
||
}
|
||
case "upload_memo_attachment": {
|
||
assertScope(auth, "write:resources");
|
||
const memoId = getRequiredString(args.memoId, "memoId");
|
||
const memo = await getMemoDetail(c.env.storage.db, auth.workspaceId, memoId);
|
||
|
||
if (!memo) {
|
||
throw new AppError("not_found", "Memo not found", 404);
|
||
}
|
||
|
||
const filename = getRequiredString(args.filename, "filename");
|
||
const bytes = await decodeBase64Data(getRequiredString(args.dataBase64, "dataBase64"));
|
||
const resource = await createAttachmentResource(c, {
|
||
memoId,
|
||
filename,
|
||
mimeType: getRequiredString(args.mimeType, "mimeType"),
|
||
bytes,
|
||
actor: getAuditActor(c),
|
||
});
|
||
const label = getOptionalString(args.label) ?? normalizeFilename(filename) ?? "attachment";
|
||
|
||
return {
|
||
resource,
|
||
markdownLink: `[${escapeMarkdownLinkLabel(label)}](${resource.url})`,
|
||
};
|
||
}
|
||
case "list_memo_resources": {
|
||
assertScope(auth, "read:resources");
|
||
const memoId = getRequiredString(args.memoId, "memoId");
|
||
const memo = await getMemoDetail(c.env.storage.db, auth.workspaceId, memoId, true);
|
||
|
||
if (!memo) {
|
||
throw new AppError("not_found", "Memo not found", 404);
|
||
}
|
||
|
||
return { resources: await listResourcesForMemo(c.env.storage.db, auth.workspaceId, memoId) };
|
||
}
|
||
case "list_resources": {
|
||
assertScope(auth, "read:resources");
|
||
return await listResourcesForMcp(c.env.storage.db, auth.workspaceId, clampNumber(Number(args.limit ?? 100), 1, 500));
|
||
}
|
||
case "list_memo_revisions": {
|
||
assertScope(auth, "read:memos");
|
||
return {
|
||
revisions: await listMemoRevisions(
|
||
c.env.storage.db,
|
||
auth.workspaceId,
|
||
getRequiredString(args.memoId, "memoId"),
|
||
clampNumber(Number(args.limit ?? 50), 1, 100)
|
||
),
|
||
};
|
||
}
|
||
case "restore_memo_revision": {
|
||
assertScope(auth, "write:memos");
|
||
const memoId = getRequiredString(args.memoId, "memoId");
|
||
const revisionId = getRequiredString(args.revisionId, "revisionId");
|
||
const revision = await getMemoRevisionRow(c.env.storage.db, auth.workspaceId, memoId, revisionId);
|
||
|
||
if (!revision) {
|
||
throw new AppError("not_found", "Memo revision not found", 404);
|
||
}
|
||
|
||
if (args.dryRun === true) {
|
||
return { dryRun: true, revision: mapMemoRevision(revision) };
|
||
}
|
||
|
||
return { memo: await restoreMemoRevisionRecord(c.env.storage.db, auth.workspaceId, memoId, revisionId, getAuditActor(c), getActorLabel(c)) };
|
||
}
|
||
case "move_notebook": {
|
||
assertScope(auth, "write:notebooks");
|
||
const actor = getAuditActor(c);
|
||
const notebook = await updateNotebookRecord(
|
||
c.env.storage.db,
|
||
auth.workspaceId,
|
||
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.storage.db,
|
||
auth.workspaceId,
|
||
{
|
||
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.storage.db, auth.workspaceId) };
|
||
}
|
||
case "list_tags": {
|
||
assertScope(auth, "read:tags");
|
||
return { tags: await listTagSummaries(c.env.storage.db, auth.workspaceId) };
|
||
}
|
||
case "get_workspace_stats": {
|
||
assertScope(auth, "read:memos");
|
||
return await getWorkspaceStats(c.env.storage.db, auth.workspaceId);
|
||
}
|
||
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 decodeBase64Data = 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 data: " + (error as Error).message, 400);
|
||
}
|
||
};
|
||
|
||
const escapeMarkdownImageAlt = (value: string) => value.replace(/[\\[\]]/g, "\\$&");
|
||
const escapeMarkdownLinkLabel = (value: string) => value.replace(/[\\[\]]/g, "\\$&");
|
||
|
||
const getInstanceAuthMode = async (env: Bindings): Promise<InstanceAuthMode> => {
|
||
if (!env.storage.db || typeof env.storage.db.prepare !== "function") {
|
||
throw new AppError(
|
||
"database_not_ready",
|
||
"Database is not ready. Bind the D1 database as DB and apply the remote migrations.",
|
||
503,
|
||
);
|
||
}
|
||
|
||
let user: { id: string } | null;
|
||
try {
|
||
user = await env.storage.db.prepare(`SELECT id FROM users WHERE is_disabled = 0 LIMIT 1`).first<{ id: string }>();
|
||
} catch (error) {
|
||
if (isDatabaseNotReadyError(error)) {
|
||
throw new AppError(
|
||
"database_not_ready",
|
||
"Database is not ready. Bind the D1 database as DB and apply the remote migrations.",
|
||
503,
|
||
);
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
return resolveInstanceAuthMode({
|
||
allowUnauthenticated: isUnauthenticatedAccessEnabled(env.EDGE_EVER_ALLOW_UNAUTHENTICATED),
|
||
hasBootstrapCredential: hasBootstrapCredential(
|
||
env.EDGE_EVER_AUTH_PASSWORD,
|
||
env.EDGE_EVER_AUTH_PASSWORD_HASH,
|
||
),
|
||
hasEnabledUser: Boolean(user),
|
||
});
|
||
};
|
||
|
||
const getLoginAttemptKeys = async (c: AppContext, username: string): Promise<LoginAttemptKey[]> => {
|
||
const keys: LoginAttemptKey[] = [{ scope: "username", key: await sha256(username.trim()) }];
|
||
const clientIp = getClientIp(c);
|
||
|
||
if (clientIp) {
|
||
keys.push({ scope: "ip", key: await sha256(clientIp) });
|
||
}
|
||
|
||
return keys;
|
||
};
|
||
|
||
const getClientIp = (c: Context) => {
|
||
const cloudflareIp = c.req.header("CF-Connecting-IP")?.trim();
|
||
if (cloudflareIp) return cloudflareIp;
|
||
|
||
const realIp = c.req.header("X-Real-IP")?.trim();
|
||
if (realIp) return realIp;
|
||
|
||
const forwardedIp = c.req.header("X-Forwarded-For")?.split(",", 1)[0]?.trim();
|
||
return forwardedIp || null;
|
||
};
|
||
|
||
const tooManyLoginAttempts = (c: Context, retryAfterSeconds: number) => {
|
||
c.header("Retry-After", String(retryAfterSeconds));
|
||
return apiError(c, "login_rate_limited", "Too many login attempts. Try again later.", 429);
|
||
};
|
||
|
||
const verifyLogin = async (env: Bindings, username: string, password: string): Promise<UserRow | null> => {
|
||
const normalizedUsername = username.trim();
|
||
const existingUser = await getUserByUsername(env.storage.db, normalizedUsername);
|
||
|
||
if (existingUser) {
|
||
if (await verifyPassword(password, existingUser.password_hash)) {
|
||
return existingUser;
|
||
}
|
||
|
||
if (!isSupportedPasswordHash(existingUser.password_hash)) {
|
||
throw new AppError(
|
||
"password_hash_invalid",
|
||
"This account has an invalid password hash. Reset it with the EdgeEver password reset command.",
|
||
503,
|
||
);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
const configuredHash = env.EDGE_EVER_AUTH_PASSWORD_HASH?.trim();
|
||
const configuredPassword = env.EDGE_EVER_AUTH_PASSWORD;
|
||
|
||
if (!configuredHash && !configuredPassword) {
|
||
return null;
|
||
}
|
||
|
||
const configuredUsername = env.EDGE_EVER_AUTH_USERNAME?.trim() || "admin";
|
||
|
||
if (normalizedUsername !== configuredUsername) {
|
||
return null;
|
||
}
|
||
|
||
const passwordMatches = await verifyBootstrapPassword(
|
||
password,
|
||
configuredPassword,
|
||
configuredHash,
|
||
verifyPassword,
|
||
);
|
||
|
||
if (!passwordMatches) {
|
||
return null;
|
||
}
|
||
|
||
const now = isoNow();
|
||
const userId = createId("usr");
|
||
const passwordHash = await hashPassword(password);
|
||
|
||
await env.storage.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.storage.db, normalizedUsername);
|
||
};
|
||
|
||
const getUserByUsername = async (db: DatabaseAdapter, 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 getInstanceUser = (db: D1Database, userId: string) =>
|
||
db.prepare(
|
||
`SELECT u.id, u.username, u.password_hash, u.display_name, u.is_disabled,
|
||
u.last_login_at, u.created_at, wm.role
|
||
FROM users u
|
||
INNER JOIN workspace_members wm ON wm.user_id = u.id
|
||
WHERE u.id = ?`
|
||
).bind(userId).first<InstanceUserRow>();
|
||
|
||
const mapInstanceUser = (row: InstanceUserRow): InstanceUser => ({
|
||
id: row.id,
|
||
username: row.username,
|
||
displayName: row.display_name,
|
||
role: row.role,
|
||
isDisabled: Boolean(row.is_disabled),
|
||
lastLoginAt: row.last_login_at,
|
||
createdAt: row.created_at,
|
||
});
|
||
|
||
const ensureUserWorkspace = async (db: D1Database, userId: string, username: string) => {
|
||
const existing = await db.prepare(
|
||
`SELECT workspace_id, role FROM workspace_members WHERE user_id = ? LIMIT 1`
|
||
).bind(userId).first<{ workspace_id: string; role: "owner" | "member" }>();
|
||
if (existing) {
|
||
await ensureWorkspaceTemplateSeed(db, existing.workspace_id);
|
||
return { workspaceId: existing.workspace_id, role: existing.role };
|
||
}
|
||
|
||
const defaultOwner = await db.prepare(
|
||
`SELECT user_id FROM workspace_members WHERE workspace_id = ? LIMIT 1`
|
||
).bind(DEFAULT_WORKSPACE_ID).first<{ user_id: string }>();
|
||
if (!defaultOwner) {
|
||
await db.prepare(
|
||
`INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'owner')`
|
||
).bind(DEFAULT_WORKSPACE_ID, userId).run();
|
||
const claimed = await db.prepare(
|
||
`SELECT workspace_id, role FROM workspace_members WHERE user_id = ? LIMIT 1`
|
||
).bind(userId).first<{ workspace_id: string; role: "owner" | "member" }>();
|
||
if (claimed) {
|
||
await ensureWorkspaceTemplateSeed(db, claimed.workspace_id);
|
||
return { workspaceId: claimed.workspace_id, role: claimed.role };
|
||
}
|
||
}
|
||
|
||
const workspaceId = createId("ws");
|
||
const now = isoNow();
|
||
const notebooks = createDefaultNotebookRows(workspaceId, now);
|
||
await db.batch([
|
||
db.prepare(`INSERT INTO workspaces (id, name, is_personal, created_at, updated_at) VALUES (?, ?, 1, ?, ?)`)
|
||
.bind(workspaceId, `${username}'s workspace`, now, now),
|
||
db.prepare(`INSERT INTO workspace_members (workspace_id, user_id, role, created_at) VALUES (?, ?, 'member', ?)`)
|
||
.bind(workspaceId, userId, now),
|
||
...notebooks.map((notebook) => db.prepare(
|
||
`INSERT INTO notebooks (id, workspace_id, parent_id, name, slug, icon, color, sort_order, created_at, updated_at)
|
||
VALUES (?, ?, NULL, ?, ?, 'notebook', ?, ?, ?, ?)`
|
||
).bind(notebook.id, workspaceId, notebook.name, notebook.slug, notebook.color, notebook.sortOrder, now, now)),
|
||
]);
|
||
await ensureWorkspaceTemplateSeed(db, workspaceId);
|
||
return { workspaceId, role: "member" as const };
|
||
};
|
||
|
||
const createDefaultNotebookRows = (workspaceId: string, _now: string) => [
|
||
{ id: `${workspaceId}_inbox`, name: "等待分类", slug: "inbox", color: "#0f766e", sortOrder: 10 },
|
||
{ id: `${workspaceId}_projects`, name: "工作项目", slug: "work-projects", color: "#2563eb", sortOrder: 20 },
|
||
{ id: `${workspaceId}_learning`, name: "学习资料", slug: "learning-resources", color: "#7c3aed", sortOrder: 30 },
|
||
{ id: `${workspaceId}_creative`, name: "灵感创作", slug: "creative-ideas", color: "#db2777", sortOrder: 40 },
|
||
{ id: `${workspaceId}_personal`, name: "生活个人", slug: "personal-life", color: "#ea580c", sortOrder: 50 },
|
||
];
|
||
|
||
const ensureWorkspaceTemplateSeed = async (db: D1Database, workspaceId: string) => {
|
||
const now = isoNow();
|
||
const templateId = `${workspaceId}_template_project_weekly`;
|
||
const contentMarkdown = "## 本周进展\n\n- \n\n## 关键成果\n\n- \n\n## 风险与阻塞\n\n- \n\n## 下周计划\n\n- [ ] \n\n## 需要协助\n\n- ";
|
||
const contentJson = markdownToDoc(contentMarkdown);
|
||
|
||
await db.prepare(
|
||
`INSERT OR IGNORE INTO memo_templates (
|
||
id, workspace_id, name, description, title, content_json, content_markdown, tags_json, created_at, updated_at
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||
).bind(
|
||
templateId,
|
||
workspaceId,
|
||
"项目周报模板",
|
||
"每周同步项目进展、风险与下一步计划",
|
||
"项目周报|第 {{周次}} 周",
|
||
JSON.stringify(contentJson),
|
||
contentMarkdown,
|
||
JSON.stringify(["项目管理", "周报"]),
|
||
now,
|
||
now,
|
||
).run();
|
||
};
|
||
|
||
const createSession = async (c: AppContext, user: UserRow, requestedDeviceId?: string) => {
|
||
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 userAgent = c.req.header("User-Agent") ?? null;
|
||
const deviceId = resolveSessionDeviceId(requestedDeviceId, userAgent, id);
|
||
const ip = c.req.header("CF-Connecting-IP");
|
||
const ipHash = ip ? await sha256(ip) : null;
|
||
const cf = c.req.raw.cf as { country?: string; region?: string } | undefined;
|
||
const ipCountry = c.req.header("CF-IPCountry") ?? cf?.country ?? null;
|
||
const ipRegion = cf?.region ?? null;
|
||
|
||
await c.env.storage.db.batch([
|
||
c.env.storage.db.prepare(
|
||
`UPDATE sessions SET revoked_at = ?
|
||
WHERE user_id = ? AND device_id = ? AND revoked_at IS NULL`
|
||
).bind(now, user.id, deviceId),
|
||
c.env.storage.db.prepare(
|
||
`INSERT INTO sessions (
|
||
id, user_id, token_hash, device_id, user_agent, ip_hash, device_label, ip_address, ip_country, ip_region, expires_at, created_at, last_seen_at
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||
).bind(id, user.id, await sha256(token), deviceId, userAgent, ipHash, null, ip ?? null, ipCountry, ipRegion, expiresAt, now, now),
|
||
]);
|
||
|
||
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 sessionAuth = await authenticateSessionToken(c, token, touch);
|
||
|
||
if (sessionAuth) {
|
||
return sessionAuth;
|
||
}
|
||
|
||
const row = await c.env.storage.db.prepare(
|
||
`SELECT id, name, token_value, scopes_json, last_used_at, expires_at, is_revoked, created_at, workspace_id
|
||
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.storage.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),
|
||
workspaceId: row.workspace_id,
|
||
role: "member",
|
||
tokenId: row.id,
|
||
};
|
||
};
|
||
|
||
const authenticateSessionToken = async (c: AppContext, token: string, touch: boolean): Promise<AuthContext | null> => {
|
||
const row = await c.env.storage.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.storage.db.prepare(`UPDATE sessions SET last_seen_at = ? WHERE id = ?`).bind(isoNow(), row.id).run();
|
||
}
|
||
|
||
const workspace = await ensureUserWorkspace(c.env.storage.db, row.user_id, row.username);
|
||
|
||
return {
|
||
kind: "user",
|
||
actorType: "user",
|
||
actorId: row.user_id,
|
||
username: row.username,
|
||
displayName: row.display_name,
|
||
scopes: [],
|
||
workspaceId: workspace.workspaceId,
|
||
role: workspace.role,
|
||
sessionId: row.id,
|
||
};
|
||
};
|
||
|
||
const authenticateSession = async (c: AppContext, touch: boolean): Promise<AuthContext | null> => {
|
||
const token = getCookie(c, SESSION_COOKIE);
|
||
|
||
if (!token) {
|
||
return null;
|
||
}
|
||
|
||
return authenticateSessionToken(c, token, touch);
|
||
};
|
||
|
||
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 getWorkspaceId = (c: AppContext) => c.get("auth").workspaceId;
|
||
|
||
const requireOwner = (c: AppContext) => {
|
||
const auth = c.get("auth");
|
||
return auth?.kind === "user" && auth.role === "owner"
|
||
? null
|
||
: forbidden(c, "Only the instance owner can manage users.");
|
||
};
|
||
|
||
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 || createExcerpt(row.content_text ?? ""),
|
||
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,
|
||
contentHash: row.content_hash,
|
||
sourceMemoIds: parseJsonArray(row.source_memo_ids),
|
||
mergeSourceCount: row.merge_source_count,
|
||
mergedIntoMemoId: row.merged_into_memo_id,
|
||
});
|
||
|
||
const mapMemoTemplate = (row: MemoTemplateRow): MemoTemplate => ({
|
||
id: row.id,
|
||
name: row.name,
|
||
description: row.description,
|
||
title: row.title,
|
||
contentJson: parseDoc(row.content_json),
|
||
contentMarkdown: row.content_markdown,
|
||
tags: parseJsonArray(row.tags_json),
|
||
createdAt: row.created_at,
|
||
updatedAt: row.updated_at,
|
||
});
|
||
|
||
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 mapJsonBackupRevision = (row: BackupRevisionRow): JsonBackupRevision => ({
|
||
id: row.id,
|
||
memoId: row.memo_id,
|
||
revision: row.revision,
|
||
title: row.title,
|
||
tags: parseJsonArray(row.tags_json),
|
||
contentJson: parseDoc(row.content_json),
|
||
contentMarkdown: row.content_markdown,
|
||
contentText: row.content_text,
|
||
contentHash: row.content_hash,
|
||
createdBy: row.created_by,
|
||
createdAt: row.created_at,
|
||
});
|
||
|
||
const restoreJsonNotebooks = async (db: D1Database, workspaceId: string, notebooks: JsonBackupNotebook[]) => {
|
||
await assertIdsAvailableInWorkspace(db, "notebooks", workspaceId, notebooks.map((notebook) => notebook.id));
|
||
const importedIds = new Set(notebooks.map((notebook) => notebook.id));
|
||
const externalParentIds = notebooks
|
||
.map((notebook) => notebook.parentId)
|
||
.filter((id): id is string => Boolean(id) && !importedIds.has(id as string));
|
||
await assertNotebookIdsInWorkspace(db, workspaceId, externalParentIds);
|
||
const statements = notebooks.map((notebook) =>
|
||
db.prepare(
|
||
`INSERT INTO notebooks (
|
||
id, workspace_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,
|
||
workspaceId,
|
||
notebook.parentId,
|
||
notebook.name,
|
||
notebook.slug,
|
||
notebook.icon,
|
||
notebook.color,
|
||
notebook.sortOrder,
|
||
notebook.createdAt,
|
||
notebook.updatedAt
|
||
)
|
||
);
|
||
|
||
await db.batch(statements);
|
||
};
|
||
|
||
const restoreJsonMemos = async (db: D1Database, workspaceId: string, backups: JsonBackupMemo[]) => {
|
||
await assertIdsAvailableInWorkspace(db, "memos", workspaceId, backups.map((backup) => backup.memo.id));
|
||
await assertNotebookIdsInWorkspace(db, workspaceId, backups.map((backup) => backup.memo.notebookId));
|
||
for (const backup of backups) {
|
||
const memo = backup.memo;
|
||
const contentJson = parseDoc(JSON.stringify(memo.contentJson));
|
||
const contentMarkdown = memo.contentMarkdown || docToMarkdown(contentJson);
|
||
const contentText = docToText(contentJson);
|
||
const contentHash = await sha256(contentMarkdown + JSON.stringify(contentJson));
|
||
const title = normalizeMemoTitle(memo.title);
|
||
const tags = normalizeTags(memo.tags);
|
||
|
||
if (backup.revisions.some((revision) => revision.memoId !== memo.id)) {
|
||
throw new AppError("invalid_backup", "A backup revision belongs to a different memo.", 400);
|
||
}
|
||
|
||
await db.batch([
|
||
db.prepare(
|
||
`INSERT INTO memos (
|
||
id, workspace_id, notebook_id, title, excerpt, tags_json, is_pinned, is_archived, is_deleted,
|
||
source_memo_ids, merge_source_count, merged_into_memo_id,
|
||
created_by, updated_by, created_at, updated_at, deleted_at
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, NULL, 'restore', 'restore', ?, ?, 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 = excluded.is_archived,
|
||
is_deleted = 0,
|
||
source_memo_ids = excluded.source_memo_ids,
|
||
merge_source_count = excluded.merge_source_count,
|
||
merged_into_memo_id = NULL,
|
||
updated_by = 'restore',
|
||
updated_at = excluded.updated_at,
|
||
deleted_at = NULL`
|
||
).bind(
|
||
memo.id,
|
||
workspaceId,
|
||
memo.notebookId,
|
||
title,
|
||
createExcerpt(contentText),
|
||
JSON.stringify(tags),
|
||
memo.isPinned ? 1 : 0,
|
||
memo.isArchived ? 1 : 0,
|
||
JSON.stringify(memo.sourceMemoIds),
|
||
memo.mergeSourceCount,
|
||
memo.createdAt,
|
||
memo.updatedAt
|
||
),
|
||
db.prepare(
|
||
`INSERT INTO memo_contents (
|
||
memo_id, content_json, content_markdown, content_text, content_hash, revision, created_at, updated_at
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||
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 = excluded.revision,
|
||
updated_at = excluded.updated_at`
|
||
).bind(
|
||
memo.id,
|
||
JSON.stringify(contentJson),
|
||
contentMarkdown,
|
||
contentText,
|
||
contentHash,
|
||
memo.revision,
|
||
memo.createdAt,
|
||
memo.updatedAt
|
||
),
|
||
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, title, contentText, tags.join(" ")),
|
||
db.prepare(`DELETE FROM memo_revisions WHERE memo_id = ?`).bind(memo.id),
|
||
]);
|
||
|
||
for (let index = 0; index < backup.revisions.length; index += 50) {
|
||
const statements = backup.revisions.slice(index, index + 50).map((revision) => {
|
||
const revisionJson = parseDoc(JSON.stringify(revision.contentJson));
|
||
const revisionMarkdown = revision.contentMarkdown || docToMarkdown(revisionJson);
|
||
const revisionText = docToText(revisionJson);
|
||
return 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
ON CONFLICT(id) DO UPDATE SET
|
||
memo_id = excluded.memo_id,
|
||
revision = excluded.revision,
|
||
title = excluded.title,
|
||
content_json = excluded.content_json,
|
||
content_markdown = excluded.content_markdown,
|
||
content_hash = excluded.content_hash,
|
||
created_by = excluded.created_by,
|
||
created_at = excluded.created_at,
|
||
tags_json = excluded.tags_json,
|
||
content_text = excluded.content_text`
|
||
).bind(
|
||
revision.id,
|
||
memo.id,
|
||
revision.revision,
|
||
normalizeMemoTitle(revision.title),
|
||
JSON.stringify(revisionJson),
|
||
revisionMarkdown,
|
||
revision.contentHash || "",
|
||
revision.createdBy,
|
||
revision.createdAt,
|
||
JSON.stringify(normalizeTags(revision.tags)),
|
||
revisionText
|
||
);
|
||
});
|
||
await db.batch(statements);
|
||
}
|
||
}
|
||
|
||
await audit(db, "user", null, "backup.restore", "backup", createId("restore"), {
|
||
memoCount: backups.length,
|
||
});
|
||
};
|
||
|
||
const assertIdsAvailableInWorkspace = async (
|
||
db: D1Database,
|
||
table: "notebooks" | "memos",
|
||
workspaceId: string,
|
||
ids: string[],
|
||
) => {
|
||
if (ids.length === 0) return;
|
||
const placeholders = ids.map(() => "?").join(", ");
|
||
const collision = await db.prepare(
|
||
`SELECT id FROM ${table} WHERE workspace_id <> ? AND id IN (${placeholders}) LIMIT 1`
|
||
).bind(workspaceId, ...ids).first<{ id: string }>();
|
||
if (collision) {
|
||
throw new AppError("cross_workspace_id_conflict", "Backup contains an ID already used by another user.", 409);
|
||
}
|
||
};
|
||
|
||
const assertNotebookIdsInWorkspace = async (db: D1Database, workspaceId: string, ids: string[]) => {
|
||
const uniqueIds = Array.from(new Set(ids));
|
||
if (uniqueIds.length === 0) return;
|
||
const placeholders = uniqueIds.map(() => "?").join(", ");
|
||
const rows = await db.prepare(
|
||
`SELECT id FROM notebooks WHERE workspace_id = ? AND id IN (${placeholders})`
|
||
).bind(workspaceId, ...uniqueIds).all<{ id: string }>();
|
||
if (rows.results.length !== uniqueIds.length) {
|
||
throw new AppError("invalid_backup_workspace", "Backup references a notebook outside the current workspace.", 400);
|
||
}
|
||
};
|
||
|
||
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, workspaceId: string): Promise<ApiTokenRow | null> =>
|
||
db
|
||
.prepare(
|
||
`SELECT id, name, token_value, scopes_json, last_used_at, expires_at, is_revoked, created_at, workspace_id
|
||
FROM api_tokens
|
||
WHERE id = ? AND workspace_id = ?`
|
||
)
|
||
.bind(id, workspaceId)
|
||
.first<ApiTokenRow>();
|
||
|
||
const listNotebooks = async (db: D1Database, workspaceId: string): Promise<Notebook[]> => {
|
||
const rows = await db
|
||
.prepare(
|
||
notebookSelectSql(
|
||
`WHERE n.workspace_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
|
||
ORDER BY n.parent_id IS NOT NULL, n.sort_order ASC, n.name ASC`
|
||
)
|
||
)
|
||
.bind(workspaceId).all<NotebookRow>();
|
||
|
||
return rows.results.map(mapNotebook);
|
||
};
|
||
|
||
const listTagSummaries = async (db: D1Database, workspaceId: string): 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.workspace_id = ? AND m.is_deleted = 0
|
||
AND trim(json_each.value) <> ''
|
||
GROUP BY json_each.value
|
||
ORDER BY lower(json_each.value) ASC`
|
||
)
|
||
.bind(workspaceId).all<TagSummaryRow>();
|
||
|
||
return rows.results
|
||
.filter((row) => typeof row.name === "string" && row.name.trim())
|
||
.map(mapTagSummary);
|
||
};
|
||
|
||
const updateTagAcrossMemos = async (
|
||
db: D1Database,
|
||
workspaceId: string,
|
||
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.workspace_id = ? AND m.is_deleted = 0
|
||
AND EXISTS (
|
||
SELECT 1
|
||
FROM json_each(m.tags_json)
|
||
WHERE json_each.value = ?
|
||
)`
|
||
)
|
||
.bind(workspaceId, 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 workspace_id = ? AND is_deleted = 0`
|
||
)
|
||
.bind(JSON.stringify(nextTags), actorLabel, now, row.id, workspaceId),
|
||
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 previewTagRename = async (db: D1Database, workspaceId: string, oldTag: string, nextTag: string | null) => {
|
||
const normalizedOld = normalizeTags([oldTag])[0];
|
||
const normalizedNext = nextTag === null ? null : normalizeTags([nextTag])[0];
|
||
|
||
if (!normalizedOld || normalizedOld === normalizedNext) {
|
||
return { dryRun: true, updated: 0, changes: [] };
|
||
}
|
||
|
||
const rows = await getMemoRowsByTag(db, workspaceId, normalizedOld);
|
||
const changes = rows.map((row) => {
|
||
const currentTags = parseJsonArray(row.tags_json);
|
||
const nextTags = normalizeTags(
|
||
currentTags.flatMap((tag) => {
|
||
if (tag !== normalizedOld) {
|
||
return [tag];
|
||
}
|
||
|
||
return normalizedNext ? [normalizedNext] : [];
|
||
})
|
||
);
|
||
|
||
return {
|
||
memoId: row.id,
|
||
title: row.title,
|
||
currentTags,
|
||
nextTags,
|
||
};
|
||
});
|
||
|
||
return { dryRun: true, updated: changes.length, changes };
|
||
};
|
||
|
||
const getMemoRowsByTag = async (db: D1Database, workspaceId: string, tag: string) => {
|
||
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.workspace_id = ? AND m.is_deleted = 0
|
||
AND EXISTS (
|
||
SELECT 1
|
||
FROM json_each(m.tags_json)
|
||
WHERE json_each.value = ?
|
||
)`
|
||
)
|
||
.bind(workspaceId, tag)
|
||
.all<MemoTagUpdateRow>();
|
||
|
||
return rows.results;
|
||
};
|
||
|
||
const updateTagsForMemos = async (
|
||
db: D1Database,
|
||
input: {
|
||
workspaceId: string;
|
||
memoIds: string[];
|
||
tags: string[];
|
||
mode: "add" | "remove";
|
||
dryRun: boolean;
|
||
actor: { actorType: "user" | "agent"; actorId: string | null };
|
||
actorLabel: string;
|
||
}
|
||
) => {
|
||
const memoIds = Array.from(new Set(input.memoIds));
|
||
const tags = normalizeTags(input.tags);
|
||
|
||
if (memoIds.length === 0 || tags.length === 0) {
|
||
throw new AppError("invalid_params", "memoIds and tags must include at least one item", 400);
|
||
}
|
||
|
||
const placeholders = memoIds.map(() => "?").join(", ");
|
||
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.workspace_id = ? AND m.is_deleted = 0 AND m.id IN (${placeholders})`
|
||
)
|
||
.bind(input.workspaceId, ...memoIds)
|
||
.all<MemoTagUpdateRow>();
|
||
|
||
if (rows.results.length !== memoIds.length) {
|
||
throw new AppError("missing_memos", "One or more memos cannot be updated.", 400);
|
||
}
|
||
|
||
const changes = rows.results
|
||
.map((row) => {
|
||
const currentTags = parseJsonArray(row.tags_json);
|
||
const nextTags =
|
||
input.mode === "add"
|
||
? normalizeTags([...currentTags, ...tags])
|
||
: currentTags.filter((tag) => !tags.includes(tag));
|
||
|
||
return {
|
||
memoId: row.id,
|
||
title: row.title,
|
||
currentTags,
|
||
nextTags,
|
||
contentText: row.content_text,
|
||
};
|
||
})
|
||
.filter((change) => JSON.stringify(change.currentTags) !== JSON.stringify(change.nextTags));
|
||
|
||
if (input.dryRun) {
|
||
return {
|
||
dryRun: true,
|
||
updated: changes.length,
|
||
changes: changes.map(({ contentText: _contentText, ...change }) => change),
|
||
};
|
||
}
|
||
|
||
if (changes.length === 0) {
|
||
return { ok: true, updated: 0 };
|
||
}
|
||
|
||
const now = isoNow();
|
||
const statements: D1PreparedStatement[] = [];
|
||
|
||
for (const change of changes) {
|
||
statements.push(
|
||
db
|
||
.prepare(
|
||
`UPDATE memos
|
||
SET tags_json = ?, updated_by = ?, updated_at = ?
|
||
WHERE id = ? AND workspace_id = ? AND is_deleted = 0`
|
||
)
|
||
.bind(JSON.stringify(change.nextTags), input.actorLabel, now, change.memoId, input.workspaceId),
|
||
db.prepare(`DELETE FROM memos_fts WHERE memo_id = ?`).bind(change.memoId),
|
||
db
|
||
.prepare(
|
||
`INSERT INTO memos_fts (memo_id, title, content_text, tags)
|
||
VALUES (?, ?, ?, ?)`
|
||
)
|
||
.bind(change.memoId, change.title, change.contentText, change.nextTags.join(" ")),
|
||
auditStatement(db, input.actor.actorType, input.actor.actorId, input.mode === "add" ? "tag.add" : "tag.remove", "memo", change.memoId, {
|
||
tags,
|
||
})
|
||
);
|
||
}
|
||
|
||
await db.batch(statements);
|
||
return { ok: true, updated: changes.length };
|
||
};
|
||
|
||
const searchMemoSummaries = async (
|
||
db: D1Database,
|
||
options: {
|
||
workspaceId: string;
|
||
query?: string | null;
|
||
notebookId?: string | null;
|
||
tags?: string[];
|
||
createdAfter?: string | null;
|
||
createdBefore?: string | null;
|
||
updatedAfter?: string | null;
|
||
updatedBefore?: string | null;
|
||
isPinned?: boolean | null;
|
||
hasResources?: boolean | null;
|
||
limit: number;
|
||
}
|
||
): Promise<MemoSummary[]> => {
|
||
const q = options.query?.trim();
|
||
const notebookId = options.notebookId?.trim() || null;
|
||
const tags = normalizeTags(options.tags ?? []);
|
||
const limit = clampNumber(options.limit, 1, 100);
|
||
const filters = ["m.workspace_id = ?", "m.is_deleted = 0"];
|
||
const binds: unknown[] = [options.workspaceId];
|
||
|
||
if (notebookId) {
|
||
filters.push("m.notebook_id = ?");
|
||
binds.push(notebookId);
|
||
}
|
||
|
||
for (const tag of tags) {
|
||
filters.push("EXISTS (SELECT 1 FROM json_each(m.tags_json) WHERE json_each.value = ?)");
|
||
binds.push(tag);
|
||
}
|
||
|
||
if (options.createdAfter) {
|
||
filters.push("m.created_at >= ?");
|
||
binds.push(options.createdAfter);
|
||
}
|
||
|
||
if (options.createdBefore) {
|
||
filters.push("m.created_at <= ?");
|
||
binds.push(options.createdBefore);
|
||
}
|
||
|
||
if (options.updatedAfter) {
|
||
filters.push("m.updated_at >= ?");
|
||
binds.push(options.updatedAfter);
|
||
}
|
||
|
||
if (options.updatedBefore) {
|
||
filters.push("m.updated_at <= ?");
|
||
binds.push(options.updatedBefore);
|
||
}
|
||
|
||
if (options.isPinned !== null && options.isPinned !== undefined) {
|
||
filters.push("m.is_pinned = ?");
|
||
binds.push(options.isPinned ? 1 : 0);
|
||
}
|
||
|
||
if (options.hasResources !== null && options.hasResources !== undefined) {
|
||
filters.push(
|
||
options.hasResources
|
||
? "EXISTS (SELECT 1 FROM resources r WHERE r.memo_id = m.id AND r.is_deleted = 0)"
|
||
: "NOT EXISTS (SELECT 1 FROM resources r WHERE r.memo_id = m.id AND r.is_deleted = 0)"
|
||
);
|
||
}
|
||
|
||
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,
|
||
c.content_text
|
||
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 ${filters.join(" AND ")}
|
||
ORDER BY s.rank ASC, m.is_pinned DESC, m.updated_at DESC
|
||
LIMIT ?`
|
||
)
|
||
.bind(ftsQuery, likeQuery, likeQuery, likeQuery, ...binds, 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,
|
||
c.content_text
|
||
FROM memos m
|
||
INNER JOIN memo_contents c ON c.memo_id = m.id
|
||
WHERE ${filters.join(" AND ")}
|
||
ORDER BY m.is_pinned DESC, m.updated_at DESC
|
||
LIMIT ?`
|
||
)
|
||
.bind(...binds, limit)
|
||
.all<MemoSummaryRow>();
|
||
|
||
return rows.results.map(mapMemoSummary);
|
||
};
|
||
|
||
const listMemosForMcp = async (
|
||
db: D1Database,
|
||
options: { workspaceId: string; notebookId?: string | null; limit: number; offset: number; includeContent: boolean; includeDeleted: 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;
|
||
const deletedFilter = options.includeDeleted ? "1 = 1" : "m.is_deleted = 0";
|
||
|
||
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.workspace_id = ? AND ${deletedFilter}
|
||
AND (? IS NULL OR m.notebook_id = ?)
|
||
ORDER BY m.updated_at DESC, m.id ASC
|
||
LIMIT ? OFFSET ?`
|
||
)
|
||
.bind(options.workspaceId, 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,
|
||
c.content_text
|
||
FROM memos m
|
||
INNER JOIN memo_contents c ON c.memo_id = m.id
|
||
WHERE m.workspace_id = ? AND ${deletedFilter}
|
||
AND (? IS NULL OR m.notebook_id = ?)
|
||
ORDER BY m.updated_at DESC, m.id ASC
|
||
LIMIT ? OFFSET ?`
|
||
)
|
||
.bind(options.workspaceId, 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, workspaceId: string, id: string): Promise<Notebook | null> => {
|
||
const row = await db
|
||
.prepare(
|
||
notebookSelectSql(
|
||
`WHERE n.id = ? AND n.workspace_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, workspaceId)
|
||
.first<NotebookRow>();
|
||
|
||
return row ? mapNotebook(row) : null;
|
||
};
|
||
|
||
const createNotebookRecord = async (
|
||
db: D1Database,
|
||
workspaceId: string,
|
||
input: NotebookCreateInput & { sortOrder?: number },
|
||
actor: { actorType: "user" | "agent"; actorId: string | null }
|
||
) => {
|
||
const parentId = input.parentId ?? null;
|
||
|
||
if (parentId && !(await getNotebook(db, workspaceId, 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, workspace_id, parent_id, name, slug, sort_order, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||
)
|
||
.bind(id, workspaceId, 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, workspaceId, id);
|
||
|
||
if (!notebook) {
|
||
throw new AppError("not_found", "Notebook not found after create", 404);
|
||
}
|
||
|
||
return notebook;
|
||
};
|
||
|
||
const updateNotebookRecord = async (
|
||
db: D1Database,
|
||
workspaceId: string,
|
||
id: string,
|
||
input: { name?: string; parentId?: string | null; sortOrder?: number },
|
||
actor: { actorType: "user" | "agent"; actorId: string | null }
|
||
) => {
|
||
const current = await getNotebook(db, workspaceId, 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, workspaceId, nextParentId);
|
||
|
||
if (!parent) {
|
||
throw new AppError("not_found", "Parent notebook not found", 404);
|
||
}
|
||
|
||
if (await isNotebookDescendant(db, workspaceId, 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 workspace_id = ? AND is_deleted = 0`
|
||
)
|
||
.bind(nextName, slugify(nextName), nextParentId ?? null, nextSortOrder, now, id, workspaceId),
|
||
auditStatement(db, actor.actorType, actor.actorId, "notebook.update", "notebook", id, input),
|
||
]);
|
||
|
||
const notebook = await getNotebook(db, workspaceId, id);
|
||
|
||
if (!notebook) {
|
||
throw new AppError("not_found", "Notebook not found after update", 404);
|
||
}
|
||
|
||
return notebook;
|
||
};
|
||
|
||
const isNotebookDescendant = async (db: D1Database, workspaceId: string, candidateId: string, ancestorId: string) => {
|
||
const row = await db
|
||
.prepare(
|
||
`WITH RECURSIVE descendants(id) AS (
|
||
SELECT id
|
||
FROM notebooks
|
||
WHERE workspace_id = ? AND 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.workspace_id = ? AND n.is_deleted = 0
|
||
)
|
||
SELECT id
|
||
FROM descendants
|
||
WHERE id = ?
|
||
LIMIT 1`
|
||
)
|
||
.bind(workspaceId, ancestorId, workspaceId, candidateId)
|
||
.first<{ id: string }>();
|
||
|
||
return Boolean(row);
|
||
};
|
||
|
||
const getMemoDetailRow = async (
|
||
db: D1Database,
|
||
workspaceId: string,
|
||
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 m.workspace_id = ? AND (? = 1 OR m.is_deleted = 0)`
|
||
)
|
||
.bind(id, workspaceId, includeDeleted ? 1 : 0)
|
||
.first<MemoDetailRow>();
|
||
|
||
const getMemoDetail = async (db: D1Database, workspaceId: string, id: string, includeDeleted = false): Promise<MemoDetail | null> => {
|
||
const row = await getMemoDetailRow(db, workspaceId, id, includeDeleted);
|
||
return row ? mapMemoDetail(row) : null;
|
||
};
|
||
|
||
const getMemoTemplateRow = async (db: D1Database, workspaceId: string, id: string): Promise<MemoTemplateRow | null> =>
|
||
db.prepare(
|
||
`SELECT id, name, description, title, content_json, content_markdown, tags_json, created_at, updated_at
|
||
FROM memo_templates
|
||
WHERE id = ? AND workspace_id = ?`
|
||
).bind(id, workspaceId).first<MemoTemplateRow>();
|
||
|
||
const getMemoTemplate = async (db: D1Database, workspaceId: string, id: string): Promise<MemoTemplate | null> => {
|
||
const row = await getMemoTemplateRow(db, workspaceId, id);
|
||
return row ? mapMemoTemplate(row) : null;
|
||
};
|
||
|
||
const deleteMemosRecord = async (
|
||
db: D1Database,
|
||
resourcesBucket: R2Bucket,
|
||
workspaceId: string,
|
||
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 workspace_id = ? AND is_deleted = ? AND id IN (${placeholders})`
|
||
)
|
||
.bind(workspaceId, 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 workspace_id = ? AND is_deleted = 1 AND id IN (${placeholders})`).bind(workspaceId, ...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 workspace_id = ? AND is_deleted = 0 AND id IN (${placeholders})`
|
||
)
|
||
.bind(now, now, workspaceId, ...uniqueMemoIds),
|
||
db
|
||
.prepare(
|
||
`UPDATE resources
|
||
SET is_deleted = 1, deleted_at = ?, updated_at = ?
|
||
WHERE is_deleted = 0 AND memo_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 getMemosForBulkAction = async (db: D1Database, workspaceId: string, memoIds: string[], deletedState: 0 | 1) => {
|
||
const uniqueMemoIds = Array.from(new Set(memoIds));
|
||
|
||
if (uniqueMemoIds.length === 0) {
|
||
return [];
|
||
}
|
||
|
||
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_text
|
||
FROM memos m
|
||
INNER JOIN memo_contents c ON c.memo_id = m.id
|
||
WHERE m.workspace_id = ? AND m.is_deleted = ?
|
||
AND m.id IN (${placeholders})
|
||
ORDER BY m.updated_at DESC, m.id ASC`
|
||
)
|
||
.bind(workspaceId, deletedState, ...uniqueMemoIds)
|
||
.all<MemoSummaryRow>();
|
||
|
||
if (rows.results.length !== uniqueMemoIds.length) {
|
||
throw new AppError("missing_memos", "One or more memos cannot be found for this action in the expected state.", 400);
|
||
}
|
||
|
||
return rows.results.map(mapMemoSummary);
|
||
};
|
||
|
||
const restoreMemosRecord = async (
|
||
db: D1Database,
|
||
workspaceId: string,
|
||
memoIds: string[],
|
||
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 rows = await db
|
||
.prepare(
|
||
`SELECT m.id, m.notebook_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.workspace_id = ? AND m.is_deleted = 1 AND m.id IN (${placeholders})`
|
||
)
|
||
.bind(workspaceId, ...uniqueMemoIds)
|
||
.all<{ id: string; notebook_id: string; title: string | null; tags_json: string; content_text: string }>();
|
||
|
||
if (rows.results.length !== uniqueMemoIds.length) {
|
||
throw new AppError("missing_memos", "One or more memos cannot be restored.", 400);
|
||
}
|
||
|
||
const notebookIds = Array.from(new Set(rows.results.map((row) => row.notebook_id)));
|
||
const notebookPlaceholders = notebookIds.map(() => "?").join(", ");
|
||
const notebookRows = await db
|
||
.prepare(`SELECT id FROM notebooks WHERE workspace_id = ? AND is_deleted = 0 AND id IN (${notebookPlaceholders})`)
|
||
.bind(workspaceId, ...notebookIds)
|
||
.all<{ id: string }>();
|
||
const activeNotebookIds = new Set(notebookRows.results.map((row) => row.id));
|
||
|
||
const needsInbox = rows.results.some((row) => !activeNotebookIds.has(row.notebook_id));
|
||
|
||
const inbox = needsInbox
|
||
? await db.prepare(`SELECT id FROM notebooks WHERE workspace_id = ? AND slug = 'inbox' AND is_deleted = 0 LIMIT 1`).bind(workspaceId).first<{ id: string }>()
|
||
: null;
|
||
if (needsInbox && !inbox) {
|
||
throw new AppError("restore_notebook_missing", "Original notebooks were deleted and the default inbox is unavailable.", 409);
|
||
}
|
||
|
||
const now = isoNow();
|
||
const statements: D1PreparedStatement[] = [];
|
||
|
||
for (const row of rows.results) {
|
||
const restoreNotebookId = activeNotebookIds.has(row.notebook_id) ? row.notebook_id : inbox!.id;
|
||
const tags = parseJsonArray(row.tags_json);
|
||
|
||
statements.push(
|
||
db
|
||
.prepare(
|
||
`UPDATE memos
|
||
SET notebook_id = ?, is_deleted = 0, deleted_at = NULL, updated_at = ?
|
||
WHERE id = ? AND workspace_id = ? AND is_deleted = 1`
|
||
)
|
||
.bind(restoreNotebookId, now, row.id, workspaceId),
|
||
db
|
||
.prepare(
|
||
`UPDATE resources
|
||
SET is_deleted = 0, deleted_at = NULL, updated_at = ?
|
||
WHERE memo_id = ? AND is_deleted = 1`
|
||
)
|
||
.bind(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, tags.join(" ")),
|
||
auditStatement(db, actor.actorType, actor.actorId, "memo.restore", "memo", row.id, {
|
||
fromNotebookId: row.notebook_id,
|
||
toNotebookId: restoreNotebookId,
|
||
})
|
||
);
|
||
}
|
||
|
||
await db.batch(statements);
|
||
return uniqueMemoIds.length;
|
||
};
|
||
|
||
const emptyTrashMemosRecord = async (
|
||
db: D1Database,
|
||
resourcesBucket: R2Bucket,
|
||
workspaceId: string,
|
||
actor: { actorType: "user" | "agent"; actorId: string | null }
|
||
) => {
|
||
const countRow = await db
|
||
.prepare(
|
||
`SELECT COUNT(*) AS count
|
||
FROM memos
|
||
WHERE workspace_id = ? AND is_deleted = 1`
|
||
)
|
||
.bind(workspaceId).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.workspace_id = ? AND m.is_deleted = 1`
|
||
)
|
||
.bind(workspaceId).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 workspace_id = ? AND is_deleted = 1)`).bind(workspaceId),
|
||
db.prepare(`UPDATE resources SET original_memo_id = NULL WHERE original_memo_id IN (SELECT id FROM memos WHERE workspace_id = ? AND is_deleted = 1)`).bind(workspaceId),
|
||
db.prepare(`DELETE FROM resources WHERE memo_id IN (SELECT id FROM memos WHERE workspace_id = ? AND is_deleted = 1)`).bind(workspaceId),
|
||
db.prepare(`DELETE FROM memo_revisions WHERE memo_id IN (SELECT id FROM memos WHERE workspace_id = ? AND is_deleted = 1)`).bind(workspaceId),
|
||
db.prepare(`DELETE FROM memo_contents WHERE memo_id IN (SELECT id FROM memos WHERE workspace_id = ? AND is_deleted = 1)`).bind(workspaceId),
|
||
db.prepare(`DELETE FROM memos WHERE workspace_id = ? AND is_deleted = 1`).bind(workspaceId),
|
||
auditStatement(db, actor.actorType, actor.actorId, "memo.trash_empty", "trash", "memos", { deleted }),
|
||
]);
|
||
|
||
return deleted;
|
||
};
|
||
|
||
const isDemoMode = (env: Bindings) => isDemoModeEnabled(env.EDGE_EVER_DEMO_MODE);
|
||
const isLocalDemoSeedEnabled = (env: Bindings) =>
|
||
env.EDGE_EVER_LOCAL_DEMO_SEED?.trim().toLowerCase() === "true";
|
||
|
||
let localDemoSeedPromise: Promise<void> | null = null;
|
||
|
||
const ensureLocalDemoSeed = (env: Bindings) => {
|
||
localDemoSeedPromise ??= (async () => {
|
||
const memoPlaceholders = DEMO_SEED_MEMO_IDS.map(() => "?").join(", ");
|
||
await env.storage.db.batch([
|
||
env.storage.db.prepare(`DELETE FROM mobile_sync_changes`),
|
||
env.storage.db.prepare(`DELETE FROM memos_fts`),
|
||
env.storage.db.prepare(`DELETE FROM resources`),
|
||
env.storage.db.prepare(`DELETE FROM memo_revisions`),
|
||
env.storage.db.prepare(`DELETE FROM memo_contents WHERE memo_id NOT IN (${memoPlaceholders})`).bind(...DEMO_SEED_MEMO_IDS),
|
||
env.storage.db.prepare(`DELETE FROM memos WHERE id NOT IN (${memoPlaceholders})`).bind(...DEMO_SEED_MEMO_IDS),
|
||
]);
|
||
|
||
await ensureDemoSeed(env, { overwriteExisting: true, refreshResources: true });
|
||
await audit(env.storage.db, "system", null, "demo.local_seed", "demo", "edgeever-local", {
|
||
seedMemoCount: DEMO_SEED_MEMOS.length,
|
||
mode: "sync-seed",
|
||
});
|
||
})().catch((error) => {
|
||
localDemoSeedPromise = null;
|
||
throw error;
|
||
});
|
||
|
||
return localDemoSeedPromise;
|
||
};
|
||
|
||
const ensureDemoSeed = async (
|
||
env: Bindings,
|
||
options: { overwriteExisting?: boolean; refreshResources?: boolean } = {},
|
||
) => {
|
||
const db = env.storage.db;
|
||
const now = isoNow();
|
||
const statements: D1PreparedStatement[] = [];
|
||
const bucketName = env.EDGE_EVER_R2_BUCKET_NAME?.trim() || DEFAULT_R2_BUCKET_NAME;
|
||
const overwriteExisting = options.overwriteExisting === true;
|
||
const existingNotebookIds = overwriteExisting
|
||
? new Set<string>()
|
||
: new Set(
|
||
(
|
||
await db
|
||
.prepare(`SELECT id FROM notebooks WHERE id IN (${DEMO_SEED_NOTEBOOK_IDS.map(() => "?").join(", ")})`)
|
||
.bind(...DEMO_SEED_NOTEBOOK_IDS)
|
||
.all<{ id: string }>()
|
||
).results.map((notebook) => notebook.id),
|
||
);
|
||
const existingMemoIds = overwriteExisting
|
||
? new Set<string>()
|
||
: new Set(
|
||
(
|
||
await db
|
||
.prepare(`SELECT id FROM memos WHERE id IN (${DEMO_SEED_MEMO_IDS.map(() => "?").join(", ")})`)
|
||
.bind(...DEMO_SEED_MEMO_IDS)
|
||
.all<{ id: string }>()
|
||
).results.map((memo) => memo.id),
|
||
);
|
||
|
||
for (const notebook of DEMO_SEED_NOTEBOOKS) {
|
||
if (!shouldUpsertDemoSeedRecord(existingNotebookIds, notebook.id, overwriteExisting)) {
|
||
continue;
|
||
}
|
||
|
||
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 isOverviewSeedMemo = memo.id === "memo_demo_overview" || memo.id === "memo_demo_overview_en";
|
||
if (!overwriteExisting && !isOverviewSeedMemo && existingMemoIds.has(memo.id)) {
|
||
continue;
|
||
}
|
||
|
||
const contentJson = markdownToDoc(memo.markdown);
|
||
const applyDemoImageWidths = (nodes: any[]) => {
|
||
for (const node of nodes) {
|
||
if (node.type === "image") {
|
||
node.attrs = { ...node.attrs, width: 35 };
|
||
}
|
||
if (Array.isArray(node.content)) {
|
||
applyDemoImageWidths(node.content);
|
||
}
|
||
}
|
||
};
|
||
if (Array.isArray(contentJson.content)) {
|
||
applyDemoImageWidths(contentJson.content);
|
||
}
|
||
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 (?, ?, ?, ?, ?, ?, ?, ?)
|
||
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 = excluded.revision,
|
||
updated_at = excluded.updated_at`
|
||
)
|
||
.bind(
|
||
memo.id,
|
||
JSON.stringify(contentJson),
|
||
memo.markdown,
|
||
contentText,
|
||
contentHash,
|
||
"revision" in memo ? memo.revision : 0,
|
||
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(" "))
|
||
);
|
||
}
|
||
|
||
for (const revision of DEMO_SEED_REVISIONS) {
|
||
const contentJson = markdownToDoc(revision.markdown);
|
||
const contentHash = await sha256(revision.markdown + JSON.stringify(contentJson));
|
||
|
||
statements.push(
|
||
db
|
||
.prepare(
|
||
`INSERT INTO memo_revisions (
|
||
id, memo_id, revision, title, content_json, content_markdown, content_hash,
|
||
created_by, created_at
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, 'system', ?)
|
||
ON CONFLICT(id) DO UPDATE SET
|
||
memo_id = excluded.memo_id,
|
||
revision = excluded.revision,
|
||
title = excluded.title,
|
||
content_json = excluded.content_json,
|
||
content_markdown = excluded.content_markdown,
|
||
content_hash = excluded.content_hash`
|
||
)
|
||
.bind(
|
||
revision.id,
|
||
revision.memoId,
|
||
revision.revision,
|
||
revision.title,
|
||
JSON.stringify(contentJson),
|
||
revision.markdown,
|
||
contentHash,
|
||
now,
|
||
),
|
||
);
|
||
}
|
||
|
||
const existingResourceIds = options.refreshResources || overwriteExisting
|
||
? new Set<string>()
|
||
: new Set(
|
||
(
|
||
await db
|
||
.prepare(`SELECT id FROM resources WHERE id IN (${DEMO_SEED_ATTACHMENT_RESOURCES.map(() => "?").join(", ")})`)
|
||
.bind(...DEMO_SEED_ATTACHMENT_RESOURCES.map((resource) => resource.id))
|
||
.all<{ id: string }>()
|
||
).results.map((resource) => resource.id)
|
||
);
|
||
|
||
for (const resource of DEMO_SEED_ATTACHMENT_RESOURCES) {
|
||
if (!shouldUpsertDemoSeedRecord(existingResourceIds, resource.id, overwriteExisting)) {
|
||
continue;
|
||
}
|
||
|
||
const isImageSeed = "svg" in resource;
|
||
const bytes = isImageSeed ? new TextEncoder().encode(resource.svg) : decodeDemoAttachment(resource);
|
||
const extension = isImageSeed ? "svg" : resource.filename.split(".").pop() || "bin";
|
||
const objectKey = `demo/${resource.memoId}/${resource.id}.${extension}`;
|
||
|
||
if (options.refreshResources || !existingResourceIds.has(resource.id)) {
|
||
await env.storage.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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, NULL)
|
||
ON CONFLICT(id) DO UPDATE SET
|
||
memo_id = excluded.memo_id,
|
||
bucket_name = excluded.bucket_name,
|
||
object_key = excluded.object_key,
|
||
kind = excluded.kind,
|
||
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,
|
||
isImageSeed ? "image" : "attachment",
|
||
resource.mimeType,
|
||
resource.filename,
|
||
bytes.byteLength,
|
||
await sha256Bytes(bytes),
|
||
isImageSeed ? resource.width : null,
|
||
isImageSeed ? resource.height : null,
|
||
JSON.stringify({ source: "demo-seed" }),
|
||
now,
|
||
now
|
||
)
|
||
);
|
||
}
|
||
|
||
if (statements.length > 0) {
|
||
await db.batch(statements);
|
||
}
|
||
};
|
||
|
||
const resetDemoData = async (
|
||
env: Bindings,
|
||
scheduledTime: number,
|
||
options: { resetCredentials?: boolean } = {}
|
||
) => {
|
||
const db = env.storage.db;
|
||
const now = isoNow();
|
||
const demoUsername = env.EDGE_EVER_AUTH_USERNAME?.trim() || "admin";
|
||
const demoPasswordHash = await resolveDemoPasswordHash(
|
||
env.EDGE_EVER_AUTH_PASSWORD,
|
||
env.EDGE_EVER_AUTH_PASSWORD_HASH,
|
||
hashPassword,
|
||
);
|
||
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.storage.resources.delete(objectKeys.slice(index, index + 1000));
|
||
}
|
||
|
||
const resetStatements: D1PreparedStatement[] = [
|
||
db.prepare(`DELETE FROM mobile_sync_changes`),
|
||
db.prepare(`DELETE FROM memos_fts`),
|
||
db.prepare(`DELETE FROM resources`),
|
||
db.prepare(`DELETE FROM memo_revisions`),
|
||
db.prepare(`DELETE FROM memo_contents`),
|
||
db.prepare(`DELETE FROM memos`),
|
||
db.prepare(`UPDATE notebooks SET parent_id = NULL`),
|
||
db.prepare(`DELETE FROM notebooks`),
|
||
db.prepare(`DELETE FROM api_tokens`),
|
||
db.prepare(`DELETE FROM audit_events`),
|
||
];
|
||
|
||
if (options.resetCredentials && demoPasswordHash) {
|
||
resetStatements.push(
|
||
db.prepare(`UPDATE users SET password_hash = ?, updated_at = ? WHERE username = ? AND is_disabled = 0`)
|
||
.bind(demoPasswordHash, now, demoUsername),
|
||
db.prepare(
|
||
`UPDATE sessions SET revoked_at = ?
|
||
WHERE user_id IN (SELECT id FROM users WHERE username = ? AND is_disabled = 0)
|
||
AND revoked_at IS NULL`
|
||
).bind(now, demoUsername),
|
||
);
|
||
}
|
||
|
||
await db.batch(resetStatements);
|
||
|
||
await ensureDemoSeed(env, { overwriteExisting: true, 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,
|
||
workspaceId: string,
|
||
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 workspace_id = ? AND is_deleted = 0 AND id IN (${placeholders})`
|
||
)
|
||
.bind(workspaceId, ...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 workspace_id = ? AND is_deleted = 0 AND id IN (${placeholders})`
|
||
)
|
||
.bind(notebookId, actorLabel, now, workspaceId, ...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,
|
||
workspaceId: string,
|
||
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.workspace_id = ? AND m.is_deleted = 0 AND m.id IN (${placeholders})`
|
||
)
|
||
.bind(workspaceId, ...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, workspaceId, 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 = resolveMergedMemoTitle(input.title, ordered);
|
||
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, workspace_id, notebook_id, title, excerpt, tags_json, source_memo_ids, merge_source_count,
|
||
created_by, updated_by, created_at, updated_at
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||
)
|
||
.bind(
|
||
newMemoId,
|
||
workspaceId,
|
||
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 workspace_id = ? AND id IN (${placeholders})`
|
||
)
|
||
.bind(now, newMemoId, now, now, workspaceId, ...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, workspaceId, newMemoId);
|
||
|
||
if (!memo) {
|
||
throw new AppError("not_found", "Merged memo not found after create.", 404);
|
||
}
|
||
|
||
return memo;
|
||
};
|
||
|
||
const createMemoRecord = async (
|
||
db: D1Database,
|
||
workspaceId: string,
|
||
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, workspace_id, notebook_id, title, excerpt, tags_json, created_by, updated_by, created_at, updated_at
|
||
) SELECT ?, ?, id, ?, ?, ?, ?, ?, ?, ? FROM notebooks WHERE id = ? AND workspace_id = ? AND is_deleted = 0`
|
||
)
|
||
.bind(id, workspaceId, title, excerpt, JSON.stringify(tags), actorLabel, actorLabel, createdAt, updatedAt, input.notebookId, workspaceId),
|
||
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, workspaceId, id);
|
||
|
||
if (!memo) {
|
||
throw new Error("Memo was created but could not be read.");
|
||
}
|
||
|
||
return memo;
|
||
};
|
||
|
||
const updateMemoRecord = async (
|
||
db: D1Database,
|
||
workspaceId: string,
|
||
id: string,
|
||
input: {
|
||
expectedRevision?: number;
|
||
notebookId?: string;
|
||
title?: string;
|
||
isPinned?: boolean;
|
||
contentJson?: TiptapDoc;
|
||
contentMarkdown?: string;
|
||
tags?: string[];
|
||
createdAt?: string;
|
||
updatedAt?: string;
|
||
allowDestructiveOverwrite?: boolean;
|
||
},
|
||
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, workspaceId, 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.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)) {
|
||
const memo = await getMemoDetail(db, workspaceId, 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 workspace_id = ? AND is_deleted = 0`
|
||
)
|
||
.bind(isPinned ? 1 : 0, actorLabel, updatedAt, input.createdAt ?? null, id, workspaceId),
|
||
auditStatement(db, actor.actorType, actor.actorId, isPinned ? "memo.pin" : "memo.unpin", "memo", id, {}),
|
||
]);
|
||
|
||
const memo = await getMemoDetail(db, workspaceId, id);
|
||
|
||
if (!memo) {
|
||
return { error: "not_found", message: "Memo not found after update" };
|
||
}
|
||
|
||
return { memo };
|
||
}
|
||
|
||
const currentContentJson = parseDoc(current.content_json);
|
||
const contentJson =
|
||
input.contentJson !== undefined
|
||
? input.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);
|
||
if (
|
||
!input.allowDestructiveOverwrite &&
|
||
isSuspiciousMemoOverwrite(current.title, current.content_text, title, contentText)
|
||
) {
|
||
return {
|
||
error: "suspicious_memo_overwrite",
|
||
message: "Save blocked because the title changed while most of the note content disappeared.",
|
||
};
|
||
}
|
||
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 workspace_id = ? AND is_deleted = 0
|
||
AND EXISTS (SELECT 1 FROM notebooks n WHERE n.id = ? AND n.workspace_id = ? AND n.is_deleted = 0)`
|
||
)
|
||
.bind(notebookId, title, excerpt, JSON.stringify(tags), isPinned ? 1 : 0, actorLabel, updatedAt, input.createdAt ?? null, id, workspaceId, notebookId, workspaceId),
|
||
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, workspaceId, id);
|
||
|
||
if (!memo) {
|
||
return { error: "not_found", message: "Memo not found after update" };
|
||
}
|
||
|
||
return { memo };
|
||
};
|
||
|
||
const getMemoRevisionRow = async (
|
||
db: D1Database,
|
||
workspaceId: string,
|
||
memoId: string,
|
||
revisionId: string
|
||
): Promise<MemoRevisionRow | null> =>
|
||
db
|
||
.prepare(
|
||
`SELECT mr.id, mr.memo_id, mr.revision, mr.title, mr.tags_json, mr.content_json, mr.content_markdown,
|
||
mr.content_text, mr.content_hash, mr.created_by, mr.created_at
|
||
FROM memo_revisions mr
|
||
INNER JOIN memos m ON m.id = mr.memo_id
|
||
WHERE mr.id = ? AND mr.memo_id = ? AND m.workspace_id = ?`
|
||
)
|
||
.bind(revisionId, memoId, workspaceId)
|
||
.first<MemoRevisionRow>();
|
||
|
||
const listMemoRevisions = async (db: D1Database, workspaceId: string, memoId: string, limit: number): Promise<MemoRevision[]> => {
|
||
const memo = await getMemoDetail(db, workspaceId, memoId, true);
|
||
|
||
if (!memo) {
|
||
throw new AppError("not_found", "Memo not found", 404);
|
||
}
|
||
|
||
const rows = await 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 rows.results.map(mapMemoRevision);
|
||
};
|
||
|
||
const restoreMemoRevisionRecord = async (
|
||
db: D1Database,
|
||
workspaceId: string,
|
||
memoId: string,
|
||
revisionId: string,
|
||
actor: { actorType: "user" | "agent"; actorId: string | null },
|
||
actorLabel: string
|
||
) => {
|
||
const current = await getMemoDetailRow(db, workspaceId, memoId);
|
||
|
||
if (!current) {
|
||
throw new AppError("not_found", "Memo not found", 404);
|
||
}
|
||
|
||
const revision = await getMemoRevisionRow(db, workspaceId, memoId, revisionId);
|
||
|
||
if (!revision) {
|
||
throw new AppError("not_found", "Memo revision not found", 404);
|
||
}
|
||
|
||
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 db.batch([
|
||
createMemoRevisionStatement(db, current, actorLabel, now),
|
||
db
|
||
.prepare(
|
||
`UPDATE memos
|
||
SET title = ?, excerpt = ?, tags_json = ?, updated_by = ?, updated_at = ?
|
||
WHERE id = ? AND workspace_id = ? AND is_deleted = 0`
|
||
)
|
||
.bind(title, excerpt, JSON.stringify(tags), actorLabel, now, memoId, workspaceId),
|
||
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),
|
||
db.prepare(`DELETE FROM memos_fts WHERE memo_id = ?`).bind(memoId),
|
||
db
|
||
.prepare(
|
||
`INSERT INTO memos_fts (memo_id, title, content_text, tags)
|
||
VALUES (?, ?, ?, ?)`
|
||
)
|
||
.bind(memoId, title, contentText, tags.join(" ")),
|
||
auditStatement(db, actor.actorType, actor.actorId, "memo.revision_restore", "memo", memoId, {
|
||
revisionId,
|
||
restoredRevision: revision.revision,
|
||
revision: nextRevision,
|
||
}),
|
||
]);
|
||
|
||
const memo = await getMemoDetail(db, workspaceId, memoId);
|
||
|
||
if (!memo) {
|
||
throw new AppError("not_found", "Memo not found after revision restore", 404);
|
||
}
|
||
|
||
return memo;
|
||
};
|
||
|
||
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, workspaceId: string, id: string): Promise<ResourceRow | null> =>
|
||
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
|
||
FROM resources r
|
||
INNER JOIN memos m ON m.id = r.memo_id
|
||
WHERE r.id = ? AND m.workspace_id = ? AND r.is_deleted = 0`
|
||
)
|
||
.bind(id, workspaceId)
|
||
.first<ResourceRow>();
|
||
|
||
const getResourceRowsForMemo = async (db: D1Database, workspaceId: string, memoId: string): Promise<ResourceRow[]> => {
|
||
const rows = await 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
|
||
FROM resources r
|
||
INNER JOIN memos m ON m.id = r.memo_id
|
||
WHERE r.memo_id = ? AND m.workspace_id = ?`
|
||
)
|
||
.bind(memoId, workspaceId)
|
||
.all<ResourceRow>();
|
||
|
||
return rows.results;
|
||
};
|
||
|
||
const listResourcesForMemo = async (db: D1Database, workspaceId: string, memoId: string): Promise<Resource[]> => {
|
||
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 r
|
||
INNER JOIN memos m ON m.id = r.memo_id
|
||
WHERE r.memo_id = ? AND m.workspace_id = ? AND r.is_deleted = 0
|
||
ORDER BY r.created_at ASC, r.id ASC`
|
||
)
|
||
.bind(memoId, workspaceId)
|
||
.all<ResourceRow>();
|
||
|
||
return rows.results.map(mapResource);
|
||
};
|
||
|
||
const listResourcesForMcp = async (db: D1Database, workspaceId: string, limit: number) => {
|
||
const [rows, stats] = await Promise.all([
|
||
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
|
||
INNER JOIN memos m ON m.id = r.memo_id
|
||
WHERE m.workspace_id = ? AND r.is_deleted = 0
|
||
ORDER BY r.created_at DESC
|
||
LIMIT ?`
|
||
)
|
||
.bind(workspaceId, limit)
|
||
.all<ResourceListRow>(),
|
||
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 r
|
||
INNER JOIN memos m ON m.id = r.memo_id
|
||
WHERE m.workspace_id = ? AND r.is_deleted = 0`
|
||
)
|
||
.bind(workspaceId).first<ResourceStatsRow>(),
|
||
]);
|
||
|
||
return {
|
||
resources: rows.results.map(mapResourceListItem),
|
||
summary: mapResourceStorageSummary(stats),
|
||
};
|
||
};
|
||
|
||
const getWorkspaceStats = async (db: D1Database, workspaceId: string) => {
|
||
const [memoCounts, notebookCount, tagCount, resourceStats] = await Promise.all([
|
||
db
|
||
.prepare(
|
||
`SELECT
|
||
COUNT(*) AS total,
|
||
COALESCE(SUM(CASE WHEN is_deleted = 0 THEN 1 ELSE 0 END), 0) AS active,
|
||
COALESCE(SUM(CASE WHEN is_deleted = 1 THEN 1 ELSE 0 END), 0) AS trashed,
|
||
COALESCE(SUM(CASE WHEN is_deleted = 0 AND is_pinned = 1 THEN 1 ELSE 0 END), 0) AS pinned,
|
||
COALESCE(SUM(CASE WHEN is_deleted = 0 AND tags_json = '[]' THEN 1 ELSE 0 END), 0) AS untagged
|
||
FROM memos WHERE workspace_id = ?`
|
||
)
|
||
.bind(workspaceId).first<{ total: number; active: number; trashed: number; pinned: number; untagged: number }>(),
|
||
db.prepare(`SELECT COUNT(*) AS count FROM notebooks WHERE workspace_id = ? AND is_deleted = 0`).bind(workspaceId).first<{ count: number }>(),
|
||
db
|
||
.prepare(
|
||
`SELECT COUNT(DISTINCT json_each.value) AS count
|
||
FROM memos m, json_each(m.tags_json)
|
||
WHERE m.workspace_id = ? AND m.is_deleted = 0 AND trim(json_each.value) <> ''`
|
||
)
|
||
.bind(workspaceId).first<{ count: number }>(),
|
||
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 r
|
||
INNER JOIN memos m ON m.id = r.memo_id
|
||
WHERE m.workspace_id = ? AND r.is_deleted = 0`
|
||
)
|
||
.bind(workspaceId).first<ResourceStatsRow>(),
|
||
]);
|
||
|
||
return {
|
||
memos: {
|
||
total: memoCounts?.total ?? 0,
|
||
active: memoCounts?.active ?? 0,
|
||
trashed: memoCounts?.trashed ?? 0,
|
||
pinned: memoCounts?.pinned ?? 0,
|
||
untagged: memoCounts?.untagged ?? 0,
|
||
},
|
||
notebooks: {
|
||
active: notebookCount?.count ?? 0,
|
||
},
|
||
tags: {
|
||
active: tagCount?.count ?? 0,
|
||
},
|
||
resources: mapResourceStorageSummary(resourceStats),
|
||
};
|
||
};
|
||
|
||
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 isCustomMemoTitle = (value: string | null | undefined) => {
|
||
const title = value?.trim();
|
||
return Boolean(title && title !== DEFAULT_MEMO_TITLE);
|
||
};
|
||
|
||
const resolveMergedMemoTitle = (inputTitle: string | undefined, sourceMemos: Array<{ title: string | null }>) => {
|
||
const title = inputTitle?.trim();
|
||
if (title) {
|
||
return title;
|
||
}
|
||
|
||
return sourceMemos.find((memo) => isCustomMemoTitle(memo.title))?.title?.trim() ?? `合并笔记 ${new Date().toLocaleDateString("zh-CN")}`;
|
||
};
|
||
|
||
const normalizeMemoListSort = (value: string | undefined): MemoListSortMode =>
|
||
value === "created-desc" || value === "title-asc" ? value : "updated-desc";
|
||
|
||
const normalizeMemoListFilter = (value: string | undefined): MemoListFilterMode =>
|
||
value === "tagged" || value === "untagged" || value === "pinned" ? value : "all";
|
||
|
||
const clampNumber = (value: number, min: number, max: number) => {
|
||
if (Number.isNaN(value)) {
|
||
return min;
|
||
}
|
||
|
||
return Math.min(Math.max(value, min), max);
|
||
};
|
||
|
||
const encodeMemoListCursor = (memo: MemoSummaryRow, sort: MemoListSortMode, includeTrash: boolean) => {
|
||
const cursor: MemoListCursor = {
|
||
sort,
|
||
id: memo.id,
|
||
};
|
||
|
||
if (includeTrash) {
|
||
cursor.deletedAt = memo.deleted_at;
|
||
} else {
|
||
cursor.pinned = memo.is_pinned;
|
||
}
|
||
|
||
if (sort === "created-desc") {
|
||
cursor.createdAt = memo.created_at;
|
||
} else if (sort === "title-asc") {
|
||
cursor.title = normalizeMemoTitle(memo.title).toLocaleLowerCase();
|
||
cursor.updatedAt = memo.updated_at;
|
||
} else {
|
||
cursor.updatedAt = memo.updated_at;
|
||
}
|
||
|
||
const bytes = new TextEncoder().encode(JSON.stringify(cursor));
|
||
let binary = "";
|
||
|
||
for (const byte of bytes) {
|
||
binary += String.fromCharCode(byte);
|
||
}
|
||
|
||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||
};
|
||
|
||
const decodeMemoListCursor = (value: string | undefined, sort: MemoListSortMode): MemoListCursor | null => {
|
||
if (!value) {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
|
||
const binary = atob(padded);
|
||
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
||
const cursor = JSON.parse(new TextDecoder().decode(bytes)) as Partial<MemoListCursor>;
|
||
|
||
if (cursor.sort !== sort || typeof cursor.id !== "string") {
|
||
return null;
|
||
}
|
||
|
||
return cursor as MemoListCursor;
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
|
||
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 contentDispositionAttachment = (filename: string | null) => {
|
||
if (!filename) {
|
||
return "attachment";
|
||
}
|
||
|
||
const fallback = normalizeFilename(filename).replace(/"/g, "'");
|
||
return `attachment; 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 authNotConfigured = (c: Context) =>
|
||
apiError(
|
||
c,
|
||
"auth_not_configured",
|
||
"Authentication is not configured. Set EDGE_EVER_AUTH_PASSWORD as a Worker Secret and redeploy.",
|
||
503,
|
||
);
|
||
|
||
const databaseNotReady = (c: Context) =>
|
||
apiError(
|
||
c,
|
||
"database_not_ready",
|
||
"Database is not ready. Bind the D1 database as DB and apply the remote migrations.",
|
||
503,
|
||
);
|
||
|
||
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
|
||
);
|