dsh-fishpai 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,494 @@
1
+ /**
2
+ * `/fishpai/api/*` HTTP 路由:浏览器侧唯一的读写入口。
3
+ *
4
+ * 安全边界(这是浏览器与文件系统之间唯一的一道门):
5
+ * 1. **同源校验**:`sec-fetch-site: cross-site` 直接拒;`Origin` 缺失视为同源,
6
+ * 但 `Origin: null`(沙箱 iframe / `data:` 文档)一律拒。写方法额外要求
7
+ * `content-type: application/json`——跨站的简单请求会在浏览器侧先被拦下。
8
+ * 2. **只认 docKey,不认路径**:客户端传索引里的键,宿主自己查出绝对路径,
9
+ * 再用 `resolveInCwd` 复检(挡 `../`、挡符号链接逃逸、挡非白名单扩展名)。
10
+ * docKey 必须存在于**该 sessionId 工作目录**的索引里,所以跨会话读要先知道对方的
11
+ * sessionId **与** docKey;本机单用户场景下这条不是授权边界,同源校验才是。
12
+ * 3. **revision 守卫**:写入**必须**带 `baseRevision`(数字),缺失、非法或不匹配一律拒绝:
13
+ * 路由层回 400/409,存储层把"没有版本号"也当冲突。绝不静默覆盖人的手改。
14
+ * 4. **新建只有两处**:`POST /upload`(图片资产,落在文档同级的 `assets/`)与
15
+ * `POST /doc`(文档,路径由宿主在 `.fishpai/docs/` 下生成)。两处的文件名都由宿主生成,
16
+ * 客户端给的名字只当作一段可读词、不参与路径拼接;大小与格式在 `store.saveAsset` 里卡。
17
+ * 正文仍只能由 `/doc` 改。
18
+ */
19
+ import fs from 'node:fs'
20
+ import { render as renderCore, themeCatalog, hljsPreviewCss, isFontSize } from '../core/render.mjs'
21
+ import { clearCustomTheme, customCatalogEntry, themeFor, CUSTOM_THEME_KEY } from './custom-theme.mjs'
22
+ import { colorPresets } from '../core/runtime.mjs'
23
+ import { splitBlocks } from '../core/markdown.mjs'
24
+ import { attachPlaceholdersToBlocks, extractPlaceholders, reanchorNotes } from '../core/notes.mjs'
25
+ import * as store from './store.mjs'
26
+ import { listLocalImages, makeImageResolver, readAsset } from './assets.mjs'
27
+
28
+ export const API_PREFIX = '/fishpai/api'
29
+
30
+ const MAX_BODY = 8 * 1024 * 1024
31
+
32
+ function json(res, code, payload) {
33
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
34
+ res.end(JSON.stringify(payload))
35
+ }
36
+
37
+ function fail(res, code, message) {
38
+ json(res, code, { ok: false, error: message })
39
+ }
40
+
41
+ export function sameOrigin(req) {
42
+ const host = req.headers && req.headers.host
43
+ if (!host) return false
44
+ if (String(req.headers['sec-fetch-site'] || '').toLowerCase() === 'cross-site') return false
45
+ const origin = req.headers.origin
46
+ // 没有 Origin 的是非浏览器客户端或同源 GET;`null` 只可能来自沙箱 iframe / data: 文档,
47
+ // 属于不可信上下文,绝不能与"没有 Origin"混为一谈。
48
+ if (origin === undefined) return true
49
+ if (origin === 'null') return false
50
+ try {
51
+ return new URL(origin).host === host
52
+ } catch {
53
+ return false
54
+ }
55
+ }
56
+
57
+ function readBody(req) {
58
+ return new Promise((resolve, reject) => {
59
+ const chunks = []
60
+ let size = 0
61
+ let overflowed = false
62
+ req.on('data', (c) => {
63
+ if (overflowed) return // 超限后继续把流读干,好让 413 有机会回出去
64
+ size += c.length
65
+ if (size > MAX_BODY) {
66
+ overflowed = true
67
+ chunks.length = 0
68
+ return
69
+ }
70
+ chunks.push(c)
71
+ })
72
+ req.on('end', () => {
73
+ if (overflowed) return reject(new OversizeError())
74
+ if (!chunks.length) return resolve({})
75
+ try {
76
+ resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
77
+ } catch (error) {
78
+ reject(new Error(`请求体不是合法 JSON: ${error.message}`))
79
+ }
80
+ })
81
+ req.on('error', reject)
82
+ })
83
+ }
84
+
85
+ /** 请求体超过上限:单独一个类型,好回 413 而不是 400。 */
86
+ export class OversizeError extends Error {
87
+ constructor() {
88
+ super(`请求体超过 ${Math.round(MAX_BODY / 1024 / 1024)}MB 上限`)
89
+ }
90
+ }
91
+
92
+ function readText(abs) {
93
+ try {
94
+ return fs.readFileSync(abs, 'utf8')
95
+ } catch {
96
+ return ''
97
+ }
98
+ }
99
+
100
+ /** 从索引里按 docKey 查出文档绝对路径,并复检它仍在会话工作目录内。 */
101
+ function docPathByKey(cwd, key) {
102
+ if (!key) throw new Error('缺少 docKey')
103
+ const entry = store.readIndex(cwd).docs[key]
104
+ if (!entry) throw new Error(`没有这个文档:${key}`)
105
+ return store.resolveInCwd(cwd, entry.path)
106
+ }
107
+
108
+ function publicBlock(b) {
109
+ return {
110
+ index: b.index,
111
+ id: b.id,
112
+ kind: b.kind,
113
+ variant: b.variant,
114
+ lang: b.lang,
115
+ level: b.level,
116
+ headingPath: b.headingPath,
117
+ startLine: b.startLine,
118
+ endLine: b.endLine,
119
+ hash: b.hash,
120
+ preview: b.text.split('\n')[0].slice(0, 60),
121
+ }
122
+ }
123
+
124
+ function publicHistory(history) {
125
+ return (history || []).map((h) => ({ id: h.id, rev: h.rev, at: h.at, by: h.by, chars: h.chars, label: h.label || null }))
126
+ }
127
+
128
+ /**
129
+ * 面板要显示的那一层「当前正文」:块、批注锚点、行内占位、图片清单。
130
+ *
131
+ * **块的 id 一律走 `splitBlocks`**:`fishpai_write` 的 block_id 与批注锚点用的就是它。
132
+ * 预览渲染器自己也返回 blocks,但那是给 HTML 锚点(`<fp-block data-b>`)用的,
133
+ * 且随 `macCodeBlock` 等渲染选项变化——把它当面板的块清单会让「块/第 N 块」与批注对不上号。
134
+ *
135
+ * `/doc` 与 `/render` 预览共用这一份,避免两处各算一套看起来一样、其实会漂移的视图。
136
+ */
137
+ function liveSurface({ cwd, key, abs, markdown }) {
138
+ const blocks = splitBlocks(markdown)
139
+ const state = key ? store.readState(cwd, key) : null
140
+ return {
141
+ blocks: blocks.map(publicBlock),
142
+ notes: state ? reanchorNotes(state.notes, blocks) : [],
143
+ placeholders: attachPlaceholdersToBlocks(extractPlaceholders(markdown), blocks),
144
+ images: listLocalImages({ markdown, cwd, docPath: abs }),
145
+ }
146
+ }
147
+
148
+ function docPayload({ cwd, key }) {
149
+ const abs = docPathByKey(cwd, key)
150
+ const state = store.readState(cwd, key)
151
+ if (!state) throw new Error(`文档状态缺失:${key}`)
152
+ const markdown = readText(abs)
153
+ return {
154
+ doc: {
155
+ key,
156
+ path: abs,
157
+ title: store.docTitle(markdown, abs),
158
+ markdown,
159
+ revision: state.revision,
160
+ updatedAt: state.updatedAt,
161
+ updatedBy: state.updatedBy,
162
+ baseline: state.baseline ? { rev: state.baseline.rev, at: state.baseline.at, by: state.baseline.by } : null,
163
+ },
164
+ meta: {
165
+ // 状态里的主题可能已经被移除(上游主题删过两套):退回默认,别让文档打不开。
166
+ // `custom` 走磁盘(工作目录那唯一一套),文件被删也会退回默认——面板因此不会显示一个选不中的项。
167
+ theme: themeFor({ cwd, name: state.theme }).key,
168
+ color: state.color,
169
+ font: state.font,
170
+ fontSize: state.fontSize,
171
+ footnotes: state.footnotes !== false,
172
+ macCodeBlock: state.macCodeBlock !== false,
173
+ mobile: !!state.mobile,
174
+ },
175
+ ...liveSurface({ cwd, key, abs, markdown }),
176
+ history: publicHistory(state.history),
177
+ // 刻意不回 `docs` / `active`:那是**全量** sessionId → docKey 映射,
178
+ // 面板不用它(「最近打开」走 /state),回给任何能发同源 GET 的调用方只是白送别人的会话 id。
179
+ }
180
+ }
181
+
182
+ /**
183
+ * 建一个 `/fishpai/api` 前缀处理器。
184
+ *
185
+ * @param {{resolveCwd: (sessionId: string) => string, log?: (msg: string) => void}} deps
186
+ * @returns {(req: object, res: object) => Promise<void>}
187
+ */
188
+ export function createApiHandler({ resolveCwd, log = () => {} }) {
189
+ function sessionOf(source, url) {
190
+ const sessionId = String(source.sessionId || url.searchParams.get('sessionId') || '')
191
+ if (!sessionId) throw new Error('缺少 sessionId')
192
+ const cwd = resolveCwd(sessionId)
193
+ if (!cwd) throw new Error(`无法解析会话工作目录:${sessionId}`)
194
+ return { sessionId, cwd }
195
+ }
196
+
197
+ return async function handle(req, res) {
198
+ if (!sameOrigin(req)) return fail(res, 403, '拒绝跨站请求')
199
+ const url = new URL(req.url || '/', 'http://localhost')
200
+ const route = `${req.method} ${url.pathname.slice(API_PREFIX.length)}`
201
+
202
+ try {
203
+ if (route === 'GET /themes') {
204
+ // 「自定义主题」是**工作目录级**的,所以要问会话要 cwd。
205
+ // 没带 sessionId 的调用方照旧只拿内置主题(面板一定会带)。
206
+ const sessionId = String(url.searchParams.get('sessionId') || '')
207
+ const cwd = sessionId ? resolveCwd(sessionId) : null
208
+ const custom = cwd ? customCatalogEntry(cwd) : null
209
+ return json(res, 200, {
210
+ ok: true,
211
+ themes: custom ? [...themeCatalog(), custom] : themeCatalog(),
212
+ presets: colorPresets(),
213
+ fonts: ['sans', 'serif', 'mono'],
214
+ sizes: ['14px', '15px', '16px', '17px', '18px'],
215
+ })
216
+ }
217
+
218
+ if (route === 'GET /state') {
219
+ const { sessionId, cwd } = sessionOf({}, url)
220
+ const key = store.activeKey(cwd, sessionId)
221
+ const state = key ? store.readState(cwd, key) : null
222
+ const pending = store.pendingOpenRequest(state, sessionId)
223
+ const entry = key ? store.readIndex(cwd).docs[key] : null
224
+ return json(res, 200, {
225
+ ok: true,
226
+ cwd,
227
+ active: entry
228
+ ? {
229
+ key,
230
+ path: entry.path,
231
+ title: entry.title,
232
+ revision: state ? state.revision : entry.revision,
233
+ // 主题也一起报:模型用 fishpai_theme 换了主题时 revision **不会变**,
234
+ // 面板只看 revision 就发现不了。这里报**解析后**的 key(自定义主题被删就退回 default)。
235
+ theme: state ? themeFor({ cwd, name: state.theme }).key : 'default',
236
+ }
237
+ : null,
238
+ openRequest: pending ? { key, at: pending } : null,
239
+ docs: store.listDocs(cwd),
240
+ })
241
+ }
242
+
243
+ if (route === 'GET /doc') {
244
+ const { cwd } = sessionOf({}, url)
245
+ return json(res, 200, { ok: true, ...docPayload({ cwd, key: String(url.searchParams.get('docKey') || '') }) })
246
+ }
247
+
248
+ if (route === 'GET /asset') {
249
+ const { cwd } = sessionOf({}, url)
250
+ const abs = docPathByKey(cwd, String(url.searchParams.get('docKey') || ''))
251
+ const asset = readAsset({ cwd, docPath: abs, src: String(url.searchParams.get('src') || '') })
252
+ if (!asset) return fail(res, 404, '图片不可读')
253
+ res.writeHead(200, { 'content-type': asset.mime, 'cache-control': 'no-cache' })
254
+ return res.end(asset.bytes)
255
+ }
256
+
257
+ // ── 写方法:先卡内容类型 ────────────────────────────────
258
+ const type = String(req.headers['content-type'] || '')
259
+ if (!type.includes('application/json')) return fail(res, 415, '写操作必须使用 application/json')
260
+ const body = await readBody(req)
261
+
262
+ if (route === 'PUT /doc') {
263
+ const { sessionId, cwd } = sessionOf(body, url)
264
+ const key = String(body.docKey || '')
265
+ const abs = docPathByKey(cwd, key)
266
+ // 正文是用户唯一的资产:缺字段不等于"清空"。要清空就显式传 `markdown: ''`。
267
+ if (typeof body.markdown !== 'string') {
268
+ return fail(res, 400, 'markdown 必须是字符串(要清空正文请显式传空字符串)')
269
+ }
270
+ // 版本号在路由层先卡类型:这是调用方的编程错误,给 400 比让它落进"版本不匹配"里更好读。
271
+ // 真正的不覆盖保证在 store.saveDoc("没有版本号"也算冲突),这里是第二道、不是唯一一道。
272
+ if (body.baseRevision === undefined || body.baseRevision === null) {
273
+ return fail(res, 400, '写入必须带 baseRevision(面板版本过旧时刷新页面)')
274
+ }
275
+ if (typeof body.baseRevision !== 'number' || !Number.isFinite(body.baseRevision)) {
276
+ return fail(res, 400, 'baseRevision 必须是数字')
277
+ }
278
+ const saved = store.saveDoc({
279
+ cwd,
280
+ docPath: abs,
281
+ markdown: body.markdown,
282
+ baseRevision: body.baseRevision,
283
+ by: 'human',
284
+ meta: body.meta || {},
285
+ })
286
+ if (!saved.ok) return json(res, 409, { ok: false, conflict: true, revision: saved.revision, markdown: saved.markdown, key })
287
+ store.setActive(cwd, sessionId, key)
288
+ // 带历史一起回:每次写入都会留一份快照,面板的「历史」抽屉因此不用再手动刷新
289
+ return json(res, 200, {
290
+ ok: true,
291
+ revision: saved.state.revision,
292
+ updatedAt: saved.state.updatedAt,
293
+ history: publicHistory(saved.state.history),
294
+ })
295
+ }
296
+
297
+ if (route === 'POST /doc') {
298
+ // 人在面板上自己起一篇(空文档 + 一个标题行)。与 fishpai_open 建的是同一类文件,
299
+ // 区别只在 by:'human'、baseline 保持 null(模型还没写过,就不编造"上次写入的版本")。
300
+ const { sessionId, cwd } = sessionOf(body, url)
301
+ const created = store.createDoc({ cwd, title: body.title })
302
+ store.setActive(cwd, sessionId, created.key)
303
+ return json(res, 200, { ok: true, docKey: created.key, path: created.path })
304
+ }
305
+
306
+ if (route === 'POST /active') {
307
+ // 面板上从「最近打开」切一篇:只改这个会话的当前文档,不碰打开请求
308
+ //(打开请求归 /tab-opened,那是"模型刚 open 了一篇"的通道)
309
+ const { sessionId, cwd } = sessionOf(body, url)
310
+ const key = String(body.docKey || '')
311
+ docPathByKey(cwd, key) // 不存在就抛,回一个可读的 400
312
+ store.setActive(cwd, sessionId, key)
313
+ return json(res, 200, { ok: true, docKey: key })
314
+ }
315
+
316
+ if (route === 'POST /theme') {
317
+ // 面板主题列表里那个 ×:删掉工作目录级的那一套「自定义主题」(只有一套)。
318
+ // 当前文档若正用着它,顺手退回「默认公众号」,不留"选不中的主题"。
319
+ const { sessionId, cwd } = sessionOf(body, url)
320
+ if (String(body.action || '') !== 'clear') return fail(res, 400, '未知 action(可用 clear)')
321
+ const removed = clearCustomTheme(cwd)
322
+ let reverted = false
323
+ if (removed) {
324
+ const key = store.activeKey(cwd, sessionId)
325
+ const entry = key ? store.readIndex(cwd).docs[key] : null
326
+ const docPath = entry ? store.resolveInCwd(cwd, entry.path) : null
327
+ if (docPath && store.readState(cwd, key)?.theme === CUSTOM_THEME_KEY) {
328
+ store.updateMeta({ cwd, docPath, meta: { theme: 'default' } })
329
+ reverted = true
330
+ }
331
+ }
332
+ return json(res, 200, { ok: true, removed, reverted })
333
+ }
334
+
335
+ if (route === 'POST /upload') {
336
+ // 面板上直接粘贴/拖进来的图片:存到**文档同级的 assets/**,回一个相对文档目录的 src,
337
+ // 面板把它当成 `![](assets/xxx.png)` 插进正文。正文一个字都不在这里改(改由人/模型写)。
338
+ const { cwd } = sessionOf(body, url)
339
+ const key = String(body.docKey || '')
340
+ const abs = docPathByKey(cwd, key)
341
+ const saved = store.saveAsset({ cwd, docPath: abs, name: body.name, mime: body.mime, data: body.data })
342
+ return json(res, 200, { ok: true, src: saved.src, path: saved.path, bytes: saved.bytes })
343
+ }
344
+
345
+ if (route === 'POST /meta') {
346
+ const { sessionId, cwd } = sessionOf(body, url)
347
+ const key = String(body.docKey || '')
348
+ const abs = docPathByKey(cwd, key)
349
+ const state = store.updateMeta({ cwd, docPath: abs, meta: body.meta || {} })
350
+ if (!state) return fail(res, 404, '文档状态缺失')
351
+ store.setActive(cwd, sessionId, key)
352
+ return json(res, 200, { ok: true })
353
+ }
354
+
355
+ if (route === 'POST /notes') {
356
+ const { cwd } = sessionOf(body, url)
357
+ const key = String(body.docKey || '')
358
+ const abs = docPathByKey(cwd, key)
359
+ const action = String(body.action || 'add')
360
+ if (action === 'add') {
361
+ // 引用片段由宿主按当前正文补全:客户端只给块 id(旧了就用块序号兜底),
362
+ // 避免"批注引用"与正文不一致。命中块后把 id 归一化到它——留着已失效的旧 id,
363
+ // 只会让下一次重锚多绕一圈、并在人再改一次之后彻底失锚。
364
+ const note = { ...(body.note || {}) }
365
+ const blocks = splitBlocks(readText(abs))
366
+ const block = blocks.find((b) => b.id === note.blockId) || blocks.find((b) => b.index === note.blockIndex)
367
+ if (block) {
368
+ note.blockId = block.id
369
+ if (!note.quote) note.quote = block.text.slice(0, 200)
370
+ }
371
+ if (!store.addNote({ cwd, docPath: abs, note })) return fail(res, 404, '文档状态缺失')
372
+ } else if (action === 'update') {
373
+ if (!store.updateNote({ cwd, docPath: abs, id: String(body.id || ''), patch: body.patch || {} })) return fail(res, 404, '批注不存在')
374
+ } else if (action === 'remove') {
375
+ const removed = store.removeNote({ cwd, docPath: abs, id: String(body.id || '') })
376
+ if (removed === null) return fail(res, 404, '文档状态缺失')
377
+ if (!removed) return fail(res, 404, '批注不存在')
378
+ } else {
379
+ return fail(res, 400, `未知 action: ${action}`)
380
+ }
381
+ const state = store.readState(cwd, key)
382
+ return json(res, 200, { ok: true, notes: reanchorNotes(state.notes, splitBlocks(readText(abs))) })
383
+ }
384
+
385
+ if (route === 'POST /render') {
386
+ const { cwd } = sessionOf(body, url)
387
+ const key = String(body.docKey || '')
388
+ const abs = docPathByKey(cwd, key)
389
+ const state = store.readState(cwd, key)
390
+ const markdown = typeof body.markdown === 'string' ? body.markdown : readText(abs)
391
+ const meta = { ...(state || {}), ...(body.meta || {}) }
392
+ const common = {
393
+ theme: themeFor({ cwd, name: meta.theme }).theme,
394
+ // 状态里存着的色也要再判一次:它是**原样拼进 style 属性**的那个字段,
395
+ // 而 `body.meta` 能盖过状态(下面的 spread)——写入侧校验收口在这里补第二道。
396
+ color: store.isThemeColor(meta.color) ? meta.color : undefined,
397
+ font: meta.font,
398
+ // 字号是第二个原样插值进 style 的字段,同样能靠 `body.meta` 绕过写入侧:
399
+ // 不合法就不往下传,由渲染层回落 16px(渲染层自己也有一道,见 `isFontSize`)。
400
+ fontSize: isFontSize(meta.fontSize) ? meta.fontSize : undefined,
401
+ footnotes: meta.footnotes !== false,
402
+ macCodeBlock: meta.macCodeBlock !== false,
403
+ }
404
+ if (String(body.mode) === 'publish') {
405
+ const out = renderCore(markdown, {
406
+ ...common,
407
+ publish: true,
408
+ simple: !!body.simple,
409
+ // 微信结构兼容层(把文字包进 <span>)只在复制/导出这条路径上开:
410
+ // 微信编辑器的结构校验用「内容高度 ÷ 行框矩形数」估行高,而含行内元素的段落
411
+ // 一行会被拆成多个矩形 → 被误判成"行高小于字体大小、文字重叠"。
412
+ // 默认渲染路径(golden 守着的那条)一个字节都不动。
413
+ wrapText: true,
414
+ // 同一条兼容层:微信粘进去会剥掉最外层 wrapper 的样式,字号只挂在 wrapper 上
415
+ // 等于没设——正文退回 16px,「字号」控件看着像没用。推进到文字所在的块级元素上。
416
+ promoteFontSize: true,
417
+ // 同一条兼容层:微信的安全过滤按属性名过,`background` 简写不在名单里、
418
+ // `background-color` 才在 —— 不拆的话引用块的框、表头底色粘过去就没了。
419
+ wechatBackground: true,
420
+ imageResolver: makeImageResolver({ cwd, docPath: abs }),
421
+ })
422
+ return json(res, 200, { ok: true, mode: 'publish', html: out.html, themeKey: out.themeKey, themeName: out.themeName })
423
+ }
424
+ const out = renderCore(markdown, { ...common, annotate: true })
425
+ return json(res, 200, {
426
+ ok: true,
427
+ mode: 'preview',
428
+ html: out.html,
429
+ themeKey: out.themeKey,
430
+ themeName: out.themeName,
431
+ // 预览 iframe 是沙箱 srcdoc,里面没有 highlight.js 样式表:代码块的 token
432
+ // 只有 class、没有颜色,预览看起来是黑的,而复制/导出的成品是彩色的。
433
+ // 把同一份色表(hljs-map.json)生成 CSS 一起带下去,客户端注入 srcdoc。
434
+ // 发布产物不吃它(走 inlineCodeStyles 内联),golden 不受影响。
435
+ hljsCss: hljsPreviewCss(),
436
+ // 「脚注 / Mac 代码框」这两个开关能不能点,由**渲染结果**说话:
437
+ // 以前面板拿正则猜正文(只认带 `//` 的链接、只认 ``` 围栏),
438
+ // 于是 `github.com/x/y` 生成了「参考资料」而开关是灰的、缩进式代码块也漏。
439
+ linkCount: out.linkCount,
440
+ hasCode: out.hasCode,
441
+ // 预览跟着本地正文走,所以块/占位/图片/批注锚点也一起回带——
442
+ // 面板因此不必等"保存 + 重载"才看得到自己刚写的东西。
443
+ ...liveSurface({ cwd, key, abs, markdown }),
444
+ })
445
+ }
446
+
447
+ if (route === 'POST /history') {
448
+ const { cwd } = sessionOf(body, url)
449
+ const key = String(body.docKey || '')
450
+ const abs = docPathByKey(cwd, key)
451
+ const state = store.readState(cwd, key)
452
+ if (!state) return fail(res, 404, '文档状态缺失')
453
+ const action = String(body.action || 'list')
454
+ if (action === 'list') {
455
+ return json(res, 200, { ok: true, history: publicHistory(state.history) })
456
+ }
457
+ if (action === 'stash') {
458
+ // 把一段不落盘的文本存进历史:冲突时保住用户的未保存草稿,正文一个字不动
459
+ const stashed = store.stash({ cwd, docPath: abs, markdown: String(body.markdown ?? ''), by: 'human', label: body.label })
460
+ if (!stashed) return fail(res, 404, '文档状态缺失')
461
+ return json(res, 200, { ok: true, entry: stashed.entry, revision: stashed.revision })
462
+ }
463
+ if (action === 'restore') {
464
+ const found = store.readHistoryEntry(cwd, key, body.id !== undefined ? body.id : Number(body.rev))
465
+ if (!found) return fail(res, 404, '找不到这一版历史')
466
+ const saved = store.saveDoc({ cwd, docPath: abs, markdown: found.content, baseRevision: state.revision, by: 'human' })
467
+ if (!saved.ok) return json(res, 409, { ok: false, conflict: true, revision: saved.revision, markdown: saved.markdown, key })
468
+ return json(res, 200, { ok: true, revision: saved.state.revision })
469
+ }
470
+ if (action === 'rebase') {
471
+ const next = store.rebase({ cwd, docPath: abs, by: 'human' })
472
+ return json(res, 200, { ok: true, revision: next ? next.revision : state.revision })
473
+ }
474
+ return fail(res, 400, `未知 action: ${action}`)
475
+ }
476
+
477
+ if (route === 'POST /tab-opened') {
478
+ const { sessionId, cwd } = sessionOf(body, url)
479
+ const key = String(body.docKey || '')
480
+ const abs = docPathByKey(cwd, key)
481
+ store.consumeOpenRequest({ cwd, docPath: abs, sessionId })
482
+ store.setActive(cwd, sessionId, key)
483
+ return json(res, 200, { ok: true })
484
+ }
485
+
486
+ return fail(res, 404, `未知接口:${route}`)
487
+ } catch (error) {
488
+ const message = error && error.message ? error.message : String(error)
489
+ log(`[fishpai] ${route} 失败: ${message}`)
490
+ if (error instanceof OversizeError) return fail(res, 413, message)
491
+ return fail(res, 400, message)
492
+ }
493
+ }
494
+ }