annotatable-html 0.0.0-stage → 1.38.3

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.
package/lib/serve.js ADDED
@@ -0,0 +1,293 @@
1
+ 'use strict';
2
+ /* annotatable serve —— 本地伴随服务器
3
+ * 静态伺服目录 + 受控写回端点,让可批注 HTML 在 localhost 上获得
4
+ * 零点击、零授权的全自动保存(同源 POST,仅监听 127.0.0.1)。 */
5
+
6
+ var http = require('http');
7
+ var fs = require('fs');
8
+ var path = require('path');
9
+ var os = require('os');
10
+ var core = require('./core'); /* v1.26.0:saveDocHtml(锁内 rev 复验+原子写+记票)与 readSideHist(/__annot/hist) */
11
+
12
+ var MIME = {
13
+ '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8',
14
+ '.js': 'text/javascript; charset=utf-8', '.json': 'application/json',
15
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
16
+ '.gif': 'image/gif', '.svg': 'image/svg+xml', '.ico': 'image/x-icon',
17
+ '.md': 'text/plain; charset=utf-8'
18
+ };
19
+
20
+ /* 防本机 CSRF:仅接受 JSON content-type(跨站简单请求会被此拦下),
21
+ 且 Origin 若存在必须为空(file://)或 localhost */
22
+ function sameOriginGuard(req, res) {
23
+ var ct = (req.headers['content-type'] || '');
24
+ var og = (req.headers['origin'] || 'null');
25
+ if (ct.indexOf('application/json') === -1) { res.writeHead(403); res.end('bad content-type'); return false; }
26
+ if (og !== 'null' && og.indexOf('http://localhost') !== 0 && og.indexOf('http://127.0.0.1') !== 0) { res.writeHead(403); res.end('bad origin'); return false; }
27
+ return true;
28
+ }
29
+ /* 根目录边界校验与请求路径解析(withinRoot/safeServePath 在 start() 内定义——root 是每次启动的伺服根) */
30
+ function readJsonBody(req, res, cap, cb) {
31
+ /* v1.28.1:必须按 Buffer 累积、end 时整体解码——旧写法对每个 chunk 独立 toString,
32
+ 多字节 UTF-8 字符恰跨 chunk 边界即被打成 U+FFFD(调研页批语「划线」变「���」、
33
+ 模板装饰 ═══ 变乱码实证;打中中文批注文本=静默数据腐蚀)。cap 按字节计。 */
34
+ var chunks = [], total = 0, dead = false;
35
+ req.on('data', function (c) {
36
+ if (dead) return;
37
+ chunks.push(c); total += c.length;
38
+ if (total > cap) { dead = true; req.destroy(); }
39
+ });
40
+ req.on('end', function () {
41
+ if (dead) return;
42
+ try { cb(JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
43
+ catch (e) { res.writeHead(400); res.end(String(e.message || e)); }
44
+ });
45
+ }
46
+
47
+ /* 探测占用者是不是自家 serve(POST /__annot/ping 应 pong);1s 超时/连不上按「不是」处理 */
48
+ function probeSelf(port, cb) {
49
+ var req = http.request({ host: '127.0.0.1', port: port, path: '/__annot/ping', method: 'POST', timeout: 1000 }, function (res) {
50
+ var b = '';
51
+ res.on('data', function (c) { b += c; });
52
+ res.on('end', function () { cb(b.indexOf('pong') !== -1); });
53
+ });
54
+ req.on('timeout', function () { req.destroy(); cb(false); });
55
+ req.on('error', function () { cb(false); });
56
+ req.end();
57
+ }
58
+
59
+ function start(root, port, opts) {
60
+ root = path.resolve(root || process.cwd());
61
+ if (port === undefined || port === null || port === '' || isNaN(port)) port = 8973; // --port 0 = 系统挑空闲端口,是合法值别当缺省吞掉
62
+ var studioProvider = opts && typeof opts.studioProvider === 'function' ? opts.studioProvider : null;
63
+
64
+ /* 根目录边界校验:resolve 后必须仍落在伺服根内(防 ../ 越界) */
65
+ function withinRoot(p) {
66
+ return p === root || p.indexOf(root + path.sep) === 0;
67
+ }
68
+ /* 请求路径 → 伺服根内绝对路径;越界返回 null */
69
+ function safeServePath(rel) {
70
+ var p = path.resolve(root, '.' + rel);
71
+ return withinRoot(p) ? p : null;
72
+ }
73
+
74
+ var server = http.createServer(function (req, res) {
75
+ var u;
76
+ try { u = decodeURIComponent(req.url.split('?')[0]); } catch (e) { res.writeHead(400); res.end('bad url'); return; }
77
+
78
+ /* 心跳探测:页面据此判断处于服务器模式 */
79
+ if (req.method === 'POST' && u === '/__annot/ping') { res.writeHead(200); res.end('pong'); return; }
80
+
81
+ /* live-sync 探测(v1.21.0):页面轮询本端点拿 rev/mtime,文件变了才热载,
82
+ 免去每秒全文拉取。路径先过 safeServePath 边界校验,再限 .html。 */
83
+ if (req.method === 'GET' && u === '/__annot/rev') {
84
+ var qs;
85
+ try { qs = new URL('http://x' + req.url).searchParams.get('path') || ''; } catch (e0) { qs = ''; }
86
+ try { qs = decodeURIComponent(qs); } catch (e1) { /* 保持原样 */ }
87
+ var rfp = safeServePath(qs);
88
+ if (!rfp) { res.writeHead(403); res.end('forbidden'); return; }
89
+ if (!/\.html?$/i.test(rfp)) { res.writeHead(403); res.end('只允许查询 .html'); return; }
90
+ fs.readFile(rfp, 'utf8', function (re1, txt) {
91
+ if (re1) { res.writeHead(404); res.end('not found'); return; }
92
+ var rm = txt.match(/"rev"\s*:\s*(\d+)/);
93
+ var replyRev = function (st) {
94
+ res.writeHead(200, { 'Content-Type': 'application/json' });
95
+ res.end(JSON.stringify({ rev: rm ? parseInt(rm[1], 10) : 0, mtime: st ? st.mtimeMs : 0, size: st ? st.size : txt.length }));
96
+ };
97
+ fs.stat(rfp, function (re3, st) { replyRev(re3 ? null : st); });
98
+ });
99
+ return;
100
+ }
101
+
102
+ /* 值守心跳查询(v1.33.0,issue#6):读 .annotatable/<file>.watch.json 报 watcher 存活与心跳年龄。
103
+ 只读端点;路径过 safeServePath 边界校验再限 .html,与 /__annot/rev 同一边界面。
104
+ 页面值守灯据此渲染 在岗/心跳超龄/换班中/无人值守,让人在朝深渊喊话前先看见灯是红的。 */
105
+ if (req.method === 'GET' && u === '/__annot/status') {
106
+ var ws0;
107
+ try { ws0 = new URL('http://x' + req.url).searchParams.get('path') || ''; } catch (e4) { ws0 = ''; }
108
+ try { ws0 = decodeURIComponent(ws0); } catch (e5) { /* 保持原样 */ }
109
+ var wsp = safeServePath(ws0);
110
+ if (!wsp) { res.writeHead(403); res.end('forbidden'); return; }
111
+ if (!/\.html?$/i.test(wsp)) { res.writeHead(403); res.end('只允许查询 .html'); return; }
112
+ var replyStatus = function (o) {
113
+ res.writeHead(200, { 'Content-Type': 'application/json' });
114
+ res.end(JSON.stringify(o));
115
+ };
116
+ fs.readFile(path.join(path.dirname(wsp), '.annotatable', path.basename(wsp) + '.watch.json'), 'utf8', function (we4, wt4) {
117
+ if (we4) { replyStatus({ watching: false, state: 'none', ageSec: -1 }); return; }
118
+ try {
119
+ var wj = JSON.parse(wt4);
120
+ replyStatus({
121
+ watching: wj.state === 'watching',
122
+ state: wj.state || 'none',
123
+ ageSec: Math.max(0, Math.round((Date.now() - (wj.lastBeat || 0)) / 1000)),
124
+ pid: wj.pid || 0, startedAt: wj.startedAt || 0
125
+ });
126
+ } catch (e6) { replyStatus({ watching: false, state: 'none', ageSec: -1 }); } // 撕裂心跳 ≈ 无心跳,按保守渲染
127
+ });
128
+ return;
129
+ }
130
+
131
+ /* 主题工坊页(v1.17.0):仅 annotatable studio 启动时提供,内存直出不落盘 */
132
+ if (req.method === 'GET' && u === '/__annot/studio') {
133
+ if (!studioProvider) { res.writeHead(404); res.end('studio 未随 annotatable studio 启动'); return; }
134
+ try {
135
+ res.writeHead(200, { 'Content-Type': MIME['.html'] });
136
+ res.end(studioProvider());
137
+ } catch (e) { res.writeHead(500); res.end(String(e.message || e)); }
138
+ return;
139
+ }
140
+
141
+ /* 主题工坊保存(v1.17.0):仅两种固定目标(伺服根目录 / ~/.annotatable),
142
+ 文件名服务端写死,无路径遍历面 */
143
+ if (req.method === 'POST' && u === '/__annot/theme') {
144
+ if (!sameOriginGuard(req, res)) return;
145
+ readJsonBody(req, res, 2 * 1024 * 1024, function (o) {
146
+ try {
147
+ if (!o || typeof o.css !== 'string' || (o.css && o.css.indexOf('\x00') !== -1)) throw new Error('bad payload');
148
+ if (o.css.length > 1024 * 1024) throw new Error('css 过大');
149
+ var target = o.scope === 'global'
150
+ ? path.join(os.homedir(), '.annotatable', 'annotatable.theme.css')
151
+ : path.join(root, 'annotatable.theme.css');
152
+ fs.mkdirSync(path.dirname(target), { recursive: true });
153
+ fs.writeFileSync(target, o.css);
154
+ res.writeHead(200, { 'Content-Type': 'application/json' });
155
+ res.end(JSON.stringify({ ok: true, path: target }));
156
+ } catch (e) {
157
+ res.writeHead(400); res.end(String(e.message || e));
158
+ }
159
+ });
160
+ return;
161
+ }
162
+
163
+ /* 受控写回:仅伺服目录内、仅 .html,防路径越界 */
164
+ if (req.method === 'POST' && u === '/__annot/save') {
165
+ if (!sameOriginGuard(req, res)) return;
166
+ readJsonBody(req, res, 20 * 1024 * 1024, function (o) {
167
+ try {
168
+ if (!o || typeof o.path !== 'string' || typeof o.html !== 'string') throw new Error('bad payload');
169
+ var rel = o.path;
170
+ try { rel = decodeURIComponent(rel); } catch (e0) { /* 保持原样 */ } // location.pathname 是百分号编码,中文文件名须解码
171
+ var target = path.resolve(root, '.' + rel);
172
+ if (target !== root && target.indexOf(root + path.sep) !== 0) throw new Error('路径越界');
173
+ if (!/\.html?$/i.test(target)) throw new Error('只允许写入 .html');
174
+ if (!fs.existsSync(target)) throw new Error('目标文件不在伺服目录中');
175
+ /* v1.26.0:rev 复验 + 文档锁 + 原子写 + 写票(saveDocHtml 内部完成)。
176
+ baseRev 缺省 = 老页面,跳过校验按现状放行;rev 不符 → 409+diskRev,
177
+ 浏览器收到后自动重基合并重试(serverSaveNow 的 409 分支)。 */
178
+ var sr = core.saveDocHtml(target, o.html, (typeof o.rev === 'number' ? o.rev : null), 'serve');
179
+ if (!sr.ok) {
180
+ res.writeHead(409, { 'Content-Type': 'application/json' });
181
+ res.end(JSON.stringify({ error: 'rev-conflict', diskRev: sr.diskRev }));
182
+ return;
183
+ }
184
+ res.writeHead(200); res.end('saved');
185
+ } catch (e) {
186
+ res.writeHead(400); res.end(String(e.message || e));
187
+ }
188
+ });
189
+ return;
190
+ }
191
+
192
+ /* v1.26.0 读侧车账本(修待改进⑬半边):serve 模式页面的「回退恢复原文」/回滚
193
+ 由此取历史快照(FS Access 模式浏览器直读 .annotatable/,不走这里)。
194
+ 只读、限伺服目录内 .html,core.histForFile 内部按 DOC_ID 归属校验防互染。 */
195
+ if (req.method === 'GET' && u === '/__annot/hist') {
196
+ var hq;
197
+ try { hq = new URL('http://x' + req.url).searchParams.get('path') || ''; } catch (e1) { hq = ''; }
198
+ try { hq = decodeURIComponent(hq); } catch (e2) { /* 保持原样 */ }
199
+ var hfp = safeServePath(hq);
200
+ if (!hfp) { res.writeHead(403); res.end('forbidden'); return; }
201
+ if (!/\.html?$/i.test(hfp)) { res.writeHead(403); res.end('只允许查询 .html'); return; }
202
+ try {
203
+ var hj = JSON.stringify(core.histForFile(hfp)); // 先算后写头:histForFile 抛错(如坏路径 ENOENT)走 404,不打崩 serve
204
+ res.writeHead(200, { 'Content-Type': 'application/json' });
205
+ res.end(hj);
206
+ } catch (e3) {
207
+ if (!res.headersSent) { res.writeHead(404); res.end('not found'); } else res.end();
208
+ }
209
+ return;
210
+ }
211
+
212
+ /* v1.31.0 写侧车账本(serve 页面档案缺失收口):此前 serve 模式只读不写,浏览器保存的
213
+ 全量历史(含批注层快照/大纸条)无处落盘——版本历史面板「缺批注层快照」、回滚残废。
214
+ 与读端点对称:限伺服目录内 .html;锁内 DOC_ID/文件名归属校验防互染
215
+ (core.writeHistForFile 内部完成,不符退 409),条数按盘上 store.histCaps 收敛。 */
216
+ if (req.method === 'POST' && u === '/__annot/hist') {
217
+ if (!sameOriginGuard(req, res)) return;
218
+ readJsonBody(req, res, 20 * 1024 * 1024, function (o) {
219
+ try {
220
+ if (!o || typeof o.path !== 'string' || !o.hist || typeof o.hist !== 'object') throw new Error('bad payload');
221
+ var grel = o.path;
222
+ try { grel = decodeURIComponent(grel); } catch (e0) { /* 保持原样 */ }
223
+ var gtarget = path.resolve(root, '.' + grel);
224
+ if (gtarget !== root && gtarget.indexOf(root + path.sep) !== 0) throw new Error('路径越界');
225
+ if (!/\.html?$/i.test(gtarget)) throw new Error('只允许写 .html 的档案');
226
+ if (!fs.existsSync(gtarget)) throw new Error('目标文件不在伺服目录中');
227
+ var gr = core.writeHistForFile(gtarget, o.hist);
228
+ if (!gr.ok) {
229
+ res.writeHead(409, { 'Content-Type': 'application/json' });
230
+ res.end(JSON.stringify({ error: gr.error }));
231
+ return;
232
+ }
233
+ res.writeHead(200); res.end('hist-saved');
234
+ } catch (e) {
235
+ res.writeHead(400); res.end(String(e.message || e));
236
+ }
237
+ });
238
+ return;
239
+ }
240
+
241
+ /* 静态伺服(限制在 root 内) */
242
+ var rel = u.replace(/^\/+/, '');
243
+ var fp = rel ? path.resolve(root, rel) : root;
244
+ if (fp !== root && fp.indexOf(root + path.sep) !== 0) { res.writeHead(403); res.end('forbidden'); return; }
245
+ fs.stat(fp, function (err, st) {
246
+ if (!err && st.isDirectory()) fp = path.join(fp, 'index.html');
247
+ fs.readFile(fp, function (err2, data) {
248
+ if (err2) { res.writeHead(404); res.end('not found'); return; }
249
+ res.writeHead(200, { 'Content-Type': MIME[path.extname(fp).toLowerCase()] || 'application/octet-stream' });
250
+ res.end(data);
251
+ });
252
+ });
253
+ });
254
+
255
+ /* v1.26.1:一个坏请求/边角 bug 不再打死整个 serve(值守生命线)——记日志继续伺服。
256
+ 代价:出异常的那条响应可能挂到浏览器超时重试,可接受;无此守卫时进程直接退出,全部自动保存失联。 */
257
+ process.on('uncaughtException', function (err) {
258
+ console.error('[' + new Date().toISOString() + '] serve 吞掉未捕获异常(继续伺服): ' + ((err && err.stack) || err));
259
+ });
260
+
261
+ /* v1.27.1:绑定失败必须说实话。此前 EADDRINUSE 没挂 error 监听,异常落进上面的
262
+ uncaughtException 兜底、嘴上打印「继续伺服」,实际服务器从未 bind 成功、事件循环
263
+ 清空后进程静默退出——值守以为活着,自动保存全失联。现如实报错并探测占用者身份后退出。 */
264
+ server.on('error', function (e) {
265
+ var code = (e && e.code) || 'UNKNOWN';
266
+ if (code !== 'EADDRINUSE') {
267
+ console.error('❌ serve 端口监听失败(' + code + '):' + ((e && e.message) || e));
268
+ process.exit(1);
269
+ return;
270
+ }
271
+ console.error('❌ 端口 ' + port + ' 已被占用,serve 没有启动。');
272
+ probeSelf(port, function (isAnnot) {
273
+ if (isAnnot) {
274
+ console.error(' 占用者就是另一个 annotatable serve——多半是上次值守/演示没停干净的孤儿进程(HANDOFF 已知坑)。');
275
+ console.error(' 处置二选一:直接复用现有 http://localhost:' + port + ';');
276
+ console.error(' 或 netstat -ano | findstr ":' + port + '" 查 PID 后 taskkill //F //PID <pid>,再重启。');
277
+ } else {
278
+ console.error(' 处置二选一:换端口重启(--port <其他>,或 --port 0 让系统挑空闲端口);');
279
+ console.error(' 或 netstat -ano | findstr ":' + port + '" 查占用者 PID,确认不要就 taskkill //F //PID <pid>。');
280
+ }
281
+ process.exit(1);
282
+ });
283
+ });
284
+
285
+ return new Promise(function (resolve) {
286
+ server.listen(port, '127.0.0.1', function () {
287
+ var actual = (server.address() && server.address().port) || port; // --port 0 时以系统实配为准
288
+ resolve({ url: 'http://localhost:' + actual, root: root, port: actual });
289
+ });
290
+ });
291
+ }
292
+
293
+ module.exports = { start: start };