@capyclaw/ppt-workbench 0.0.0-stage → 2.4.1

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.
Files changed (130) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +126 -3
  3. package/THIRD_PARTY_NOTICES.md +21 -0
  4. package/contract/ppt-contract.json +1529 -0
  5. package/cordis.patch.yml +25 -0
  6. package/core/audit.js +500 -0
  7. package/core/chart.js +199 -0
  8. package/core/color.js +80 -0
  9. package/core/deck-layout.js +195 -0
  10. package/core/formula.js +80 -0
  11. package/core/hfmath/hershey.js +1763 -0
  12. package/core/hfmath/index.js +1176 -0
  13. package/core/hfmath/stretch.js +41 -0
  14. package/core/hfmath/symbols.js +408 -0
  15. package/core/ops/browser.js +28 -0
  16. package/core/ops/chart.js +64 -0
  17. package/core/ops/deck.js +21 -0
  18. package/core/ops/dispatcher.js +2 -0
  19. package/core/ops/element.js +55 -0
  20. package/core/ops/history.js +11 -0
  21. package/core/ops/import.js +95 -0
  22. package/core/ops/index.js +39 -0
  23. package/core/ops/playback.js +9 -0
  24. package/core/ops/recolor.js +33 -0
  25. package/core/ops/slide.js +66 -0
  26. package/core/ops/table.js +85 -0
  27. package/core/ops/text.js +35 -0
  28. package/core/ops/view.js +13 -0
  29. package/core/ops.js +12 -0
  30. package/core/quick.js +52 -0
  31. package/core/shape-library.js +122 -0
  32. package/core/table.js +178 -0
  33. package/core/text.js +200 -0
  34. package/editor/app.js +5366 -0
  35. package/editor/heartbeat-worker.js +116 -0
  36. package/editor/index.html +89 -0
  37. package/editor/ppt-full-app.js +376 -0
  38. package/lib/host.mjs +98 -0
  39. package/package.json +53 -6
  40. package/server/audit.mjs +2 -0
  41. package/server/bridge-client.mjs +174 -0
  42. package/server/constants.mjs +20 -0
  43. package/server/contract-snapshots.mjs +62 -0
  44. package/server/contract.mjs +24 -0
  45. package/server/deck-layout.mjs +2 -0
  46. package/server/http-utils.mjs +2 -0
  47. package/server/infra/http.mjs +113 -0
  48. package/server/infra/runtime/context.mjs +128 -0
  49. package/server/infra/store/blob-store.mjs +109 -0
  50. package/server/infra/tuning.mjs +82 -0
  51. package/server/infra/util/fs-lock.mjs +132 -0
  52. package/server/infra/workspace/manager.mjs +657 -0
  53. package/server/mcp/dispatch.mjs +2 -0
  54. package/server/mcp/handler.mjs +2 -0
  55. package/server/mcp/instructions.mjs +40 -0
  56. package/server/mcp-http.mjs +3 -0
  57. package/server/ppt-shim.mjs +156 -0
  58. package/server/relay.mjs +245 -0
  59. package/server/routes/assets.mjs +2 -0
  60. package/server/routes/bridge.mjs +47 -0
  61. package/server/routes/editor.mjs +9 -0
  62. package/server/routes/import.mjs +258 -0
  63. package/server/routes/mcp.mjs +17 -0
  64. package/server/runtime/recovery.mjs +9 -0
  65. package/server/runtime/watch.mjs +17 -0
  66. package/server/shared/SHARED_MANIFEST.json +47 -0
  67. package/server/shared/asset-service.mjs +217 -0
  68. package/server/shared/editor-host.mjs +125 -0
  69. package/server/shared/kit/editor/lease.mjs +139 -0
  70. package/server/shared/kit/service/api.mjs +45 -0
  71. package/server/shared/kit/service/http.mjs +113 -0
  72. package/server/shared/kit/service/mcp-http.mjs +142 -0
  73. package/server/shared/kit/service/single-instance.mjs +72 -0
  74. package/server/shared/kit/tools/validate.mjs +115 -0
  75. package/server/shared/kit/tuning.mjs +82 -0
  76. package/server/shared/kit/util/fs-lock.mjs +132 -0
  77. package/server/shared/service-host.mjs +511 -0
  78. package/server/shared/state-service.mjs +249 -0
  79. package/server/shared/tool-host.mjs +341 -0
  80. package/server/shared/tuning.mjs +63 -0
  81. package/server/single-instance.mjs +2 -0
  82. package/server/store/backends/file-backend.mjs +13 -0
  83. package/server/store/checkpoints.mjs +28 -0
  84. package/server/store/doc.mjs +52 -0
  85. package/server/store/index.mjs +34 -0
  86. package/server/store/journal.mjs +9 -0
  87. package/server/store/lease.mjs +2 -0
  88. package/server/store/sanitize.mjs +32 -0
  89. package/server/store.mjs +2 -0
  90. package/server/tools/capabilities/apply.mjs +33 -0
  91. package/server/tools/capabilities/audit.mjs +24 -0
  92. package/server/tools/capabilities/chart.mjs +83 -0
  93. package/server/tools/capabilities/compose.mjs +97 -0
  94. package/server/tools/capabilities/contract.mjs +34 -0
  95. package/server/tools/capabilities/deck.mjs +27 -0
  96. package/server/tools/capabilities/element.mjs +67 -0
  97. package/server/tools/capabilities/export.mjs +95 -0
  98. package/server/tools/capabilities/formula.mjs +72 -0
  99. package/server/tools/capabilities/history.mjs +29 -0
  100. package/server/tools/capabilities/image.mjs +152 -0
  101. package/server/tools/capabilities/imagesearch.mjs +152 -0
  102. package/server/tools/capabilities/import.mjs +180 -0
  103. package/server/tools/capabilities/index.mjs +58 -0
  104. package/server/tools/capabilities/line.mjs +58 -0
  105. package/server/tools/capabilities/notes.mjs +33 -0
  106. package/server/tools/capabilities/present.mjs +34 -0
  107. package/server/tools/capabilities/recolor.mjs +60 -0
  108. package/server/tools/capabilities/screenshot.mjs +86 -0
  109. package/server/tools/capabilities/shape.mjs +64 -0
  110. package/server/tools/capabilities/slide.mjs +81 -0
  111. package/server/tools/capabilities/style.mjs +48 -0
  112. package/server/tools/capabilities/table.mjs +127 -0
  113. package/server/tools/capabilities/task.mjs +52 -0
  114. package/server/tools/capabilities/text.mjs +82 -0
  115. package/server/tools/domains/color.mjs +4 -0
  116. package/server/tools/domains/image.mjs +49 -0
  117. package/server/tools/domains/index.mjs +7 -0
  118. package/server/tools/domains/pptx-export.mjs +566 -0
  119. package/server/tools/domains/recolor.mjs +25 -0
  120. package/server/tools/domains/remote-image.mjs +291 -0
  121. package/server/tools/domains/slide.mjs +21 -0
  122. package/server/tools/domains/text-style.mjs +11 -0
  123. package/server/tools/domains/tooling.mjs +13 -0
  124. package/server/tools/index.mjs +14 -0
  125. package/server/tools/kernel/define.mjs +5 -0
  126. package/server/tools/kernel/ports.mjs +42 -0
  127. package/server/tools/kernel/registry.mjs +17 -0
  128. package/server/tools.mjs +18 -0
  129. package/server/tuning.mjs +86 -0
  130. package/server/validate.mjs +2 -0
@@ -0,0 +1,258 @@
1
+ /**
2
+ * PPTX 导入中转路由。
3
+ *
4
+ * 浏览器端导入需要 File 对象,而 MCP 工具在 Node 进程里拿到的是路径/字节。
5
+ * 这里负责:
6
+ * POST <base>/import-file 原始字节上传 → 临时文件 + 同源下载地址
7
+ * GET <base>/import-file/:id 编辑器页面取回原始字节
8
+ * POST <base>/import-result 编辑器回传导入结果
9
+ * GET <base>/import-result?req= MCP 工具轮询结果
10
+ *
11
+ * 文件与结果都是短生命周期:结果回传后删除临时文件,结果按 TTL 清理。
12
+ */
13
+ import crypto from 'node:crypto'
14
+ import fs from 'node:fs'
15
+ import path from 'node:path'
16
+ import { readBody, send } from '../infra/http.mjs'
17
+ import { renderDeckToPptx } from '../tools/domains/pptx-export.mjs'
18
+
19
+ const PPTX_MIME = 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
20
+ const MAX_UPLOAD_BYTES = 128 * 1024 * 1024
21
+ const MAX_RESULT_BYTES = 2 * 1024 * 1024
22
+ const RESULT_TTL_MS = 10 * 60 * 1000
23
+ const FILE_TTL_MS = 60 * 60 * 1000
24
+ const ID_RE = /^[a-f0-9]{16,64}$/
25
+
26
+ function readRawBody(req, limit) {
27
+ return new Promise((resolve, reject) => {
28
+ let size = 0
29
+ let settled = false
30
+ const chunks = []
31
+ const fail = (error) => {
32
+ if (settled) return
33
+ settled = true
34
+ reject(error)
35
+ }
36
+ req.on('data', (chunk) => {
37
+ if (settled) return
38
+ size += chunk.length
39
+ if (size > limit) {
40
+ const error = new Error('pptx 文件超过大小上限')
41
+ error.code = 'BODY_TOO_LARGE'
42
+ fail(error)
43
+ req.resume()
44
+ return
45
+ }
46
+ chunks.push(chunk)
47
+ })
48
+ req.on('end', () => {
49
+ if (settled) return
50
+ settled = true
51
+ resolve(Buffer.concat(chunks))
52
+ })
53
+ req.on('error', fail)
54
+ req.on('close', () => {
55
+ if (!req.readableEnded) fail(new Error('客户端在上传完成前断开'))
56
+ })
57
+ })
58
+ }
59
+
60
+ function safeName(value) {
61
+ let name = String(value || 'import.pptx')
62
+ try { name = decodeURIComponent(name) } catch { /* 保留原值 */ }
63
+ name = path.basename(name).replace(/[\\/:*?"<>|]/g, '_').slice(0, 120)
64
+ return name.toLowerCase().endsWith('.pptx') ? name : `${name || 'import'}.pptx`
65
+ }
66
+
67
+ function jsonResult(body) {
68
+ return {
69
+ done: true,
70
+ ok: !!body.ok,
71
+ error: body.error ? String(body.error).slice(0, 2000) : '',
72
+ summary: body.summary && typeof body.summary === 'object' ? body.summary : null,
73
+ ts: Date.now(),
74
+ }
75
+ }
76
+
77
+ export function createImportRoutes({ basePath, importDir, store }) {
78
+ const prefix = String(basePath || '').replace(/\/+$/, '')
79
+ if (!importDir) throw new Error('createImportRoutes 需要 importDir')
80
+ fs.mkdirSync(importDir, { recursive: true })
81
+ const sourceRecord = path.join(importDir, 'last-open.json')
82
+
83
+ /* 启动时清掉上一个进程遗留的临时文件(失败不影响服务启动)。 */
84
+ try {
85
+ const now = Date.now()
86
+ for (const entry of fs.readdirSync(importDir)) {
87
+ if (!entry.endsWith('.pptx')) continue
88
+ const file = path.join(importDir, entry)
89
+ try {
90
+ if (now - fs.statSync(file).mtimeMs > FILE_TTL_MS) fs.rmSync(file, { force: true })
91
+ } catch { /* 单个文件清理失败忽略 */ }
92
+ }
93
+ } catch { /* 目录不可读忽略 */ }
94
+
95
+ const results = new Map()
96
+ let saving = false
97
+ const timer = setInterval(() => {
98
+ const now = Date.now()
99
+ for (const [key, value] of results) {
100
+ if (now - value.ts > RESULT_TTL_MS) results.delete(key)
101
+ }
102
+ }, 60_000)
103
+ timer.unref?.()
104
+
105
+ return async function importRoutes(req, res, u) {
106
+ const pathname = u.pathname
107
+ if (prefix && pathname !== prefix && !pathname.startsWith(prefix + '/')) return false
108
+ const sub = prefix ? pathname.slice(prefix.length) : pathname
109
+
110
+ /* 原始字节上传 */
111
+ if (sub === '/import-file' && req.method === 'POST') {
112
+ let body
113
+ try {
114
+ body = await readRawBody(req, MAX_UPLOAD_BYTES)
115
+ } catch (error) {
116
+ const status = error?.code === 'BODY_TOO_LARGE' ? 413 : 400
117
+ send(req, res, status, { error: String(error.message || error) })
118
+ return true
119
+ }
120
+ if (!body.length) {
121
+ send(req, res, 400, { error: '上传内容为空' })
122
+ return true
123
+ }
124
+ /* ZIP 魔数:pptx 必须是 OOXML 包,提前拦住误传的图片/JSON。 */
125
+ if (body[0] !== 0x50 || body[1] !== 0x4b) {
126
+ send(req, res, 400, { error: '不是有效的 PPTX 文件(缺少 ZIP/OOXML 头)' })
127
+ return true
128
+ }
129
+ const id = crypto.randomBytes(12).toString('hex')
130
+ const name = safeName(req.headers['x-file-name'])
131
+ const file = path.join(importDir, `${id}.pptx`)
132
+ try {
133
+ await fs.promises.writeFile(file, body)
134
+ } catch (error) {
135
+ send(req, res, 500, { error: '写入临时文件失败:' + String(error.message || error) })
136
+ return true
137
+ }
138
+ /* 记录源文件路径:编辑器“保存回原文件”按钮据此写回。 */
139
+ const sourceHeader = req.headers['x-source-path']
140
+ if (sourceHeader) {
141
+ try {
142
+ const sourcePath = decodeURIComponent(String(sourceHeader))
143
+ if (sourcePath) {
144
+ fs.writeFileSync(sourceRecord, JSON.stringify({ path: sourcePath, name, ts: Date.now() }))
145
+ }
146
+ } catch { /* 源路径只影响保存按钮,不阻塞导入 */ }
147
+ }
148
+ send(req, res, 200, {
149
+ ok: true,
150
+ id,
151
+ name,
152
+ bytes: body.length,
153
+ url: `${prefix}/import-file/${id}`,
154
+ })
155
+ return true
156
+ }
157
+
158
+ /* 编辑器取回原始字节 */
159
+ if (sub.startsWith('/import-file/') && (req.method === 'GET' || req.method === 'HEAD')) {
160
+ const id = sub.slice('/import-file/'.length)
161
+ if (!ID_RE.test(id)) {
162
+ send(req, res, 404, { error: '导入文件不存在' })
163
+ return true
164
+ }
165
+ const file = path.join(importDir, `${id}.pptx`)
166
+ let data
167
+ try {
168
+ data = fs.readFileSync(file)
169
+ } catch {
170
+ send(req, res, 404, { error: '导入文件不存在或已清理' })
171
+ return true
172
+ }
173
+ res.writeHead(200, {
174
+ 'content-type': PPTX_MIME,
175
+ 'content-length': data.length,
176
+ 'cache-control': 'no-store',
177
+ 'x-content-type-options': 'nosniff',
178
+ 'referrer-policy': 'no-referrer',
179
+ })
180
+ res.end(req.method === 'HEAD' ? undefined : data)
181
+ return true
182
+ }
183
+
184
+ /* 编辑器回传导入结果 */
185
+ if (sub === '/import-result' && req.method === 'POST') {
186
+ let body
187
+ try {
188
+ body = await readBody(req, MAX_RESULT_BYTES)
189
+ } catch (error) {
190
+ send(req, res, error?.code === 'BODY_TOO_LARGE' ? 413 : 400, { error: String(error.message || error) })
191
+ return true
192
+ }
193
+ if (!body || typeof body.req !== 'string' || !body.req) {
194
+ send(req, res, 400, { error: '缺少 import req' })
195
+ return true
196
+ }
197
+ results.set(body.req, jsonResult(body))
198
+ if (body.id && ID_RE.test(String(body.id))) {
199
+ try { fs.rmSync(path.join(importDir, `${body.id}.pptx`), { force: true }) } catch { /* 忽略 */ }
200
+ }
201
+ send(req, res, 200, { ok: true })
202
+ return true
203
+ }
204
+
205
+ /* MCP 工具轮询结果 */
206
+ if (sub === '/import-result' && (req.method === 'GET' || req.method === 'HEAD')) {
207
+ const reqId = u.searchParams.get('req') || ''
208
+ const result = reqId ? results.get(reqId) : null
209
+ send(req, res, 200, result || { done: false })
210
+ return true
211
+ }
212
+
213
+ /* 编辑器“保存回原文件”:把当前 deck 导出到导入时记录的源路径,先备份。 */
214
+ if (sub === '/save-pptx' && req.method === 'POST') {
215
+ if (!store) {
216
+ send(req, res, 503, { error: 'store 未就绪' })
217
+ return true
218
+ }
219
+ if (saving) {
220
+ send(req, res, 409, { error: '已有保存任务在进行中' })
221
+ return true
222
+ }
223
+ let record = null
224
+ try { record = JSON.parse(fs.readFileSync(sourceRecord, 'utf8')) } catch { /* 没有记录 */ }
225
+ const target = String(record?.path || '')
226
+ if (!target || !/\.pptx$/i.test(target)) {
227
+ send(req, res, 409, { error: '当前 deck 不是从本机 .pptx 打开的;请让 agent 用 ppt_export_pptx 另存' })
228
+ return true
229
+ }
230
+ const deck = store.get().deck
231
+ if (!deck || !Array.isArray(deck.slides) || !deck.slides.length) {
232
+ send(req, res, 409, { error: '当前 deck 没有幻灯片' })
233
+ return true
234
+ }
235
+
236
+ saving = true
237
+ try {
238
+ const host = req.headers.host || `127.0.0.1:39311`
239
+ const { buffer, stats } = await renderDeckToPptx(deck, {
240
+ assetBase: `http://${host}${prefix}`,
241
+ includeNotes: true,
242
+ })
243
+ const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+$/, '')
244
+ const backup = target.replace(/\.pptx$/i, `.bak-${stamp}.pptx`)
245
+ if (fs.existsSync(target)) fs.copyFileSync(target, backup)
246
+ fs.writeFileSync(target, buffer)
247
+ send(req, res, 200, { ok: true, path: target, backup, bytes: buffer.length, stats })
248
+ } catch (error) {
249
+ send(req, res, 500, { error: '保存回原文件失败(可能被 PowerPoint 占用):' + String(error.message || error) })
250
+ } finally {
251
+ saving = false
252
+ }
253
+ return true
254
+ }
255
+
256
+ return false
257
+ }
258
+ }
@@ -0,0 +1,17 @@
1
+ /** MCP 端点路由:协议细节由 server/mcp/handler 处理。 */
2
+ import { send } from '../http-utils.mjs'
3
+
4
+ export function createMcpRoutes({ mcpHandler, getMcpHandler } = {}) {
5
+ return [
6
+ async (req, res, u) => {
7
+ if (u.pathname !== '/mcp') return false
8
+ const handler = typeof getMcpHandler === 'function' ? getMcpHandler() : mcpHandler
9
+ if (!handler) {
10
+ send(req, res, 503, { ok: false, error: 'MCP tools not ready' })
11
+ return true
12
+ }
13
+ await handler(req, res)
14
+ return true
15
+ },
16
+ ]
17
+ }
@@ -0,0 +1,9 @@
1
+ /** 崩溃恢复:通用 replay 由 DocumentSession.recover 提供,本文件只注入 PPT 的 VIEW_OPS。 */
2
+ import { VIEW_OPS } from '../constants.mjs'
3
+
4
+ export async function replayPending({ store, jsonP, applyOp }) {
5
+ return store.recover({
6
+ apply: (cmd) => applyOp(jsonP, cmd),
7
+ isViewOp: (cmd) => VIEW_OPS.has(cmd.op),
8
+ })
9
+ }
@@ -0,0 +1,17 @@
1
+ /** 兼容层:长轮询的通用实现位于 @capyclaw/document-workbench/session。 */
2
+ import { watchSession } from '@capyclaw/document-workbench/session'
3
+ import { getTuning } from '../tuning.mjs'
4
+
5
+ export function watchDoc(store, seq0, shot0, ui0, timeoutS, aborted) {
6
+ const tuning = getTuning()
7
+ return watchSession(store, {
8
+ seq: seq0,
9
+ shot: shot0,
10
+ ui: ui0,
11
+ timeoutS,
12
+ isAborted: aborted,
13
+ pollMs: tuning.watchPollMs,
14
+ timeoutSec: tuning.watchTimeoutSec,
15
+ maxTimeoutSec: tuning.watchTimeoutMaxSec,
16
+ })
17
+ }
@@ -0,0 +1,47 @@
1
+ {
2
+ "version": "0.1.0",
3
+ "packages": {
4
+ "service-host": {
5
+ "package": "dsh-service-host",
6
+ "version": "0.1.0",
7
+ "apiVersion": "1.0.0",
8
+ "sha256": "232b9140d1ba60435999df02a743d944b2b9c81bc5d16dafd3ebc7d24437d7bf"
9
+ },
10
+ "tool-host": {
11
+ "package": "dsh-tool-host",
12
+ "version": "0.1.0",
13
+ "apiVersion": "1.0.0",
14
+ "sha256": "4c8b34977fba79facb498807b844d65993b56267db945e8d157c11e93dd78a48"
15
+ },
16
+ "state-service": {
17
+ "package": "dsh-state-service",
18
+ "version": "0.1.0",
19
+ "apiVersion": "1.0.0",
20
+ "sha256": "725607c3ede320e4fb8cedc3068bfe1bdb0eb68ce65f8d071e95259195161bc4"
21
+ },
22
+ "asset-service": {
23
+ "package": "dsh-asset-service",
24
+ "version": "0.1.0",
25
+ "apiVersion": "1.0.0",
26
+ "sha256": "2bb1dd432188e518adedd0faf8caa1f611aa7c3830fc030cf4733541762a8bf5"
27
+ },
28
+ "editor-host": {
29
+ "package": "dsh-editor-host",
30
+ "version": "0.1.0",
31
+ "apiVersion": "1.0.0",
32
+ "sha256": "a2cdda07cdb61ee258dd20fffd489d93fbf88e891cd2c5f5d9f434544ca8cfea"
33
+ }
34
+ },
35
+ "kit": {
36
+ "tuning.mjs": "c7f3feaf2da1e8ab9e2e47ca1f12a6796cc01e0be46c431031fcedfa2336e870",
37
+ "service/api.mjs": "94fa4ced06ec0abe56071abe7cde0316765508995376b323b2734214e2520762",
38
+ "service/http.mjs": "a80346c6fabf893cecff89507a3575b6f342a4db77a375013f1f03f1f1439af3",
39
+ "service/mcp-http.mjs": "a45fa4ab71e5ee385203fe33893c76e1bc34807d9b1f251bb8df4bd6ee86f444",
40
+ "tools/validate.mjs": "ed7bc55d4fcbab406f883d54f587e44cf24f06be6e75456bdcbdd1b2cf9739be",
41
+ "editor/lease.mjs": "5c4e9cb37079b6fd8541deb208a780e144f394befafdb027fb589dc715ef3374",
42
+ "util/fs-lock.mjs": "0eb754f067466ccf90505242e80e4a4de248db5aaa7040e825efa66da386105a"
43
+ },
44
+ "pkgRoot": {
45
+ "dsh-service-host/tuning.mjs": "51d4145d8a94b0adea989924fd65042fc372e343a47fdceeac97d418e9766347"
46
+ }
47
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * dsh-asset-service —— 通用对象资产仓库。
3
+ *
4
+ * 功能插件不再各自实现 `/assets/<sha256>`、base64 上传、content-type 与缓存头。
5
+ * 资产按 sha256 去重,url 为 `/assets/<id>`。
6
+ *
7
+ * 两种后端:
8
+ * - workspace:storeClient(ctx.workspace 的 scoped client),资产归当前 work;
9
+ * - file:本地目录,适合无 workspace 的独立运行/测试。
10
+ */
11
+ import crypto from 'node:crypto'
12
+ import fs from 'node:fs'
13
+ import path from 'node:path'
14
+ import { send, readBody } from './kit/service/http.mjs'
15
+ import { createServiceApi } from './kit/service/api.mjs'
16
+
17
+ function sha256(buffer) {
18
+ return crypto.createHash('sha256').update(buffer).digest('hex')
19
+ }
20
+
21
+ const ASSET_ID_RE = /^[a-f0-9]{64}$/
22
+
23
+ function validId(id) {
24
+ return ASSET_ID_RE.test(String(id || ''))
25
+ }
26
+
27
+ function normalizeBuffer(value) {
28
+ if (Buffer.isBuffer(value)) return value
29
+ if (value instanceof Uint8Array) return Buffer.from(value)
30
+ throw new Error('asset: 数据必须是 Buffer/Uint8Array')
31
+ }
32
+
33
+ /** 基于 ctx.workspace 的 scoped WorkspaceClient。 */
34
+ export function createWorkspaceAssetStore({ storeClient, workId = 'main' } = {}) {
35
+ if (!storeClient || typeof storeClient.put !== 'function') throw new Error('createWorkspaceAssetStore 需要 storeClient')
36
+ return {
37
+ kind: 'workspace',
38
+ async put(value, contentType = 'application/octet-stream') {
39
+ const buffer = normalizeBuffer(value)
40
+ const id = sha256(buffer)
41
+ const key = `assets/${id}`
42
+ storeClient.put(workId, key, buffer, { contentType })
43
+ return { id, key, url: `/assets/${id}`, size: buffer.length, contentType }
44
+ },
45
+ get(id) {
46
+ if (!validId(id)) return null
47
+ const key = `assets/${id}`
48
+ try {
49
+ const data = storeClient.get(workId, key)
50
+ if (!data) return null
51
+ const meta = storeClient.list(workId).find((item) => item.key === key)
52
+ return { data, contentType: meta?.contentType || 'application/octet-stream' }
53
+ } catch {
54
+ /* 缺失/权限错误按 404 处理,不能把 500 泄漏给资产路由。 */
55
+ return null
56
+ }
57
+ },
58
+ has(id) {
59
+ if (!validId(id)) return false
60
+ try { return !!storeClient.get(workId, `assets/${id}`) } catch { return false }
61
+ },
62
+ }
63
+ }
64
+
65
+ /** 本地目录后端:<dir>/<sha256> + <dir>/<sha256>.meta.json。 */
66
+ export function createFileAssetStore({ dir } = {}) {
67
+ if (!dir) throw new Error('createFileAssetStore 需要 dir')
68
+ fs.mkdirSync(dir, { recursive: true })
69
+ return {
70
+ kind: 'file',
71
+ async put(value, contentType = 'application/octet-stream') {
72
+ const buffer = normalizeBuffer(value)
73
+ const id = sha256(buffer)
74
+ const file = path.join(dir, id)
75
+ const metaFile = file + '.meta.json'
76
+ const existingOk = (() => {
77
+ try { return fs.statSync(file).size === buffer.length } catch { return false }
78
+ })()
79
+ if (!existingOk) {
80
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
81
+ fs.writeFileSync(tmp, buffer)
82
+ fs.renameSync(tmp, file)
83
+ }
84
+ const metaTmp = `${metaFile}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
85
+ fs.writeFileSync(metaTmp, JSON.stringify({ contentType, size: buffer.length }))
86
+ fs.renameSync(metaTmp, metaFile)
87
+ return { id, key: id, url: `/assets/${id}`, size: buffer.length, contentType }
88
+ },
89
+ get(id) {
90
+ if (!validId(id)) return null
91
+ const file = path.join(dir, path.basename(String(id)))
92
+ try {
93
+ const data = fs.readFileSync(file)
94
+ /* 内容寻址仓库必须复核 hash:截断/篡改的对象不能静默返回。 */
95
+ if (sha256(data) !== id) {
96
+ console.error(`[asset-service] 对象校验失败,拒绝返回: ${id}`)
97
+ return null
98
+ }
99
+ let contentType = 'application/octet-stream'
100
+ try { contentType = JSON.parse(fs.readFileSync(file + '.meta.json', 'utf8')).contentType || contentType } catch { /* 默认 */ }
101
+ return { data, contentType }
102
+ } catch {
103
+ return null
104
+ }
105
+ },
106
+ has(id) {
107
+ if (!validId(id)) return false
108
+ try { return fs.existsSync(path.join(dir, path.basename(String(id)))) } catch { return false }
109
+ },
110
+ }
111
+ }
112
+
113
+ /** 防止 text/html / svg 在 loopback 同源执行脚本;图片仍按原 MIME 内联。 */
114
+ function safeContentType(type) {
115
+ const base = String(type || '').split(';')[0].trim().toLowerCase() || 'application/octet-stream'
116
+ if (base === 'text/html' || base === 'application/xhtml+xml' || base === 'image/svg+xml') {
117
+ return 'text/plain; charset=utf-8'
118
+ }
119
+ return base
120
+ }
121
+
122
+ /**
123
+ * service-host 兼容的资产路由。
124
+ * GET/HEAD `/assets/<id>`(同时兼容 `<basePath>/assets/<id>`);
125
+ * POST `/assets` 或 `<basePath>/assets`(JSON: { data: base64, contentType })。
126
+ */
127
+ export function createAssetRoutes({ assetStore, basePath = '', maxUploadBytes = 10 * 1024 * 1024 } = {}) {
128
+ const prefix = basePath === '/' ? '' : String(basePath || '').replace(/\/+$/, '')
129
+ const getPrefixes = ['/assets/', prefix ? `${prefix}/assets/` : null].filter(Boolean)
130
+ const postPaths = new Set(['/assets', prefix ? `${prefix}/assets` : null].filter(Boolean))
131
+ return async function assetRoutes(req, res, u) {
132
+ const p = u.pathname
133
+ if (req.method === 'GET' || req.method === 'HEAD') {
134
+ const getPrefix = getPrefixes.find((candidate) => p.startsWith(candidate))
135
+ if (getPrefix) {
136
+ if (!assetStore) { send(req, res, 404, { error: 'asset store unavailable' }); return true }
137
+ const id = decodeURIComponent(p.slice(getPrefix.length))
138
+ if (!validId(id)) { send(req, res, 404, { error: 'asset not found' }); return true }
139
+ const asset = assetStore.get(id)
140
+ if (!asset) { send(req, res, 404, { error: 'asset not found' }); return true }
141
+ res.writeHead(200, {
142
+ 'content-type': safeContentType(asset.contentType),
143
+ 'content-length': asset.data.length,
144
+ 'cache-control': 'public, max-age=31536000, immutable',
145
+ 'x-content-type-options': 'nosniff',
146
+ 'referrer-policy': 'no-referrer',
147
+ })
148
+ res.end(req.method === 'HEAD' ? undefined : asset.data)
149
+ return true
150
+ }
151
+ }
152
+ if (postPaths.has(p) && req.method === 'POST') {
153
+ if (!assetStore) { send(req, res, 503, { error: 'asset store unavailable' }); return true }
154
+ let body
155
+ try { body = await readBody(req, maxUploadBytes) } catch (error) {
156
+ const status = error?.code === 'BODY_TOO_LARGE' ? 413 : 400
157
+ send(req, res, status, { error: String(error.message || error) })
158
+ return true
159
+ }
160
+ if (!body || typeof body.data !== 'string' || !body.data) {
161
+ send(req, res, 400, { error: '缺少图片数据' })
162
+ return true
163
+ }
164
+ const buffer = Buffer.from(body.data, 'base64')
165
+ if (!buffer.length) { send(req, res, 400, { error: '图片数据为空' }); return true }
166
+ const asset = await assetStore.put(buffer, body.contentType || 'application/octet-stream')
167
+ send(req, res, 200, { ok: true, ...asset })
168
+ return true
169
+ }
170
+ return false
171
+ }
172
+ }
173
+
174
+ export function createAssetHost(config = {}) {
175
+ const defaultStore = config.storeClient
176
+ ? createWorkspaceAssetStore({ storeClient: config.storeClient, workId: config.workId })
177
+ : config.dir
178
+ ? createFileAssetStore({ dir: config.dir })
179
+ : null
180
+
181
+ return {
182
+ api: createServiceApi('assets', '1.0.0', ['workspace-store', 'file-store', 'routes', 'sha256', 'mount']),
183
+ version: '0.1.0',
184
+ createStore: createWorkspaceAssetStore,
185
+ createFileStore: createFileAssetStore,
186
+ routes: createAssetRoutes,
187
+ store: defaultStore,
188
+ /**
189
+ * 每个 service-host app 只挂一次资产路由,避免多个插件重复争抢 /assets。
190
+ * @param {object} hostApp createServiceApp() 返回值
191
+ * @param {{ store?: object, basePath?: string }} options
192
+ */
193
+ mount(hostApp, { store = defaultStore, basePath = '' } = {}) {
194
+ if (!hostApp || typeof hostApp.use !== 'function') throw new Error('assets.mount 需要 service-host app')
195
+ if (!store) throw new Error('assets.mount: 没有可用 store')
196
+ if (hostApp.__dshAssetsMounted) return () => {}
197
+ hostApp.__dshAssetsMounted = true
198
+ const unuse = hostApp.use(createAssetRoutes({ assetStore: store, basePath }))
199
+ let disposed = false
200
+ return () => {
201
+ if (disposed) return
202
+ disposed = true
203
+ /* 真正卸载路由;否则热重载后旧 store 仍会抢答 /assets。 */
204
+ if (typeof unuse === 'function') unuse()
205
+ hostApp.__dshAssetsMounted = false
206
+ }
207
+ },
208
+ async put(value, contentType) {
209
+ if (!defaultStore) throw new Error('asset-service: 未配置 storeClient/dir')
210
+ return defaultStore.put(value, contentType)
211
+ },
212
+ get(id) {
213
+ if (!defaultStore) throw new Error('asset-service: 未配置 storeClient/dir')
214
+ return defaultStore.get(id)
215
+ },
216
+ }
217
+ }