dsh-mobile-plugin 0.5.20 → 0.5.22
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/README.md +3 -12
- package/assets/pets/README.md +9 -0
- package/assets/pets/deepy-whale/attention.gif +0 -0
- package/assets/pets/deepy-whale/done.gif +0 -0
- package/assets/pets/deepy-whale/error.gif +0 -0
- package/assets/pets/deepy-whale/idle.gif +0 -0
- package/assets/pets/deepy-whale/pack.json +54 -0
- package/assets/pets/deepy-whale/sleeping.gif +0 -0
- package/assets/pets/deepy-whale/working-3.gif +0 -0
- package/assets/pets/deepy-whale/working.gif +0 -0
- package/extensions/README.md +64 -0
- package/extensions/catalog.json +1 -0
- package/lib/client.js +318 -27
- package/lib/impl.js +21 -1
- package/lib/mobile/approvals.js +22 -2
- package/lib/mobile/cli/acp.js +34 -20
- package/lib/mobile/cli/mobile.js +8 -1
- package/lib/mobile/cli/service.js +151 -10
- package/lib/mobile/extension-catalog.js +244 -0
- package/lib/mobile/local-images.js +10 -5
- package/lib/mobile/monitor.js +5 -2
- package/lib/mobile/pet-tools.js +215 -0
- package/lib/mobile/pets.js +559 -0
- package/lib/mobile/session-flags.js +61 -8
- package/lib/mobile/session-local-image.js +9 -1
- package/lib/mobile/stats-collect.cjs +15 -7
- package/lib/mobile/stats-ledger.cjs +44 -5
- package/lib/mobile/wechat-tools.js +257 -0
- package/lib/mobile/wechat.js +489 -0
- package/lib/panel/index.html +12 -11
- package/lib/relay/pairing-payload.cjs +13 -5
- package/lib/relay/snapshot-auth.js +82 -0
- package/lib/relay/status.js +3 -0
- package/lib/routes/control-plane.js +74 -3
- package/lib/routes/mobile-api.js +54 -1
- package/lib/session-cleanup.cjs +22 -7
- package/lib/sidecar-facade-worker.cjs +8 -0
- package/lib/tailcat/host-process.cjs +266 -0
- package/lib/tailcat/ingress.cjs +98 -0
- package/lib/tailcat/session.cjs +264 -0
- package/lib/tailcat/wiring.cjs +96 -0
- package/package.json +10 -1
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 公众号文章的库:文章存在电脑上,手机只读。
|
|
5
|
+
*
|
|
6
|
+
* ## 为什么存在宿主这一侧
|
|
7
|
+
*
|
|
8
|
+
* 文章是 AI 在对话里写的,写在跑对话的那台电脑上;手机连着电脑看,不该把正文再抄一份到
|
|
9
|
+
* 手机里。好处是三样:手里这台手机换掉、清缓存都不丢;AI 返工时读的就是本机那一个文件,
|
|
10
|
+
* 不用另开一条「读接口」;以后要发出去,直接拿这份 HTML。
|
|
11
|
+
*
|
|
12
|
+
* ## 目录形状
|
|
13
|
+
*
|
|
14
|
+
* `<DSH_HOME>/storages/mobile-plugin/wechat/<id>/`
|
|
15
|
+
* · `article.json` —— 档案(标题 / 摘要 / 时间 / 来自哪条对话 / 字数 / 阅读时长)
|
|
16
|
+
* · `body.html` —— 正文,成品 HTML 原文,不转码不改写
|
|
17
|
+
* · `media/` —— 正文用到的图片,正文里写的是相对地址 `media/xxx.png`
|
|
18
|
+
*
|
|
19
|
+
* 正文里的图片写成相对地址(`media/xxx.png`),**不内联成 base64**:一篇带十几张图的文章
|
|
20
|
+
* 内联进正文,手机每次进页面都要重传好几 MB,而且 AI 也写不了那一大串。手机要画的时候按
|
|
21
|
+
* `handleArticleMedia` 一张一张取,正文本身一个字不改。
|
|
22
|
+
*
|
|
23
|
+
* ## id 是给用户复制的
|
|
24
|
+
*
|
|
25
|
+
* 用户会照着它跟我说话(「这篇 wxa1b2c3d4 改一下」),所以给一串短的、念得出来的字符;
|
|
26
|
+
* 标题会改,不能当标识。id 同时是目录名,所以形状卡得很死,别的形状一律不认 —— 这一条
|
|
27
|
+
* 顺带把路径穿越挡在门外。
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const crypto = require('node:crypto');
|
|
31
|
+
const fs = require('node:fs');
|
|
32
|
+
const fsp = require('node:fs/promises');
|
|
33
|
+
const path = require('node:path');
|
|
34
|
+
|
|
35
|
+
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
36
|
+
const { dshHome } = require('../shared/paths.js');
|
|
37
|
+
|
|
38
|
+
/** 文章 id:`wx` + 8 位小写十六进制。目录名、路径参数、用户嘴里念的都是这一串。 */
|
|
39
|
+
const ID_RE = /^wx[0-9a-f]{8}$/;
|
|
40
|
+
/** 正文上限:公众号一篇文章几十 KB 就算长文了,两 MB 是「明显不对劲」的那条线。 */
|
|
41
|
+
const MAX_HTML_BYTES = 2 * 1024 * 1024;
|
|
42
|
+
/** 摘要长度(字符):列表一行放不下更多。 */
|
|
43
|
+
const EXCERPT_CHARS = 80;
|
|
44
|
+
/** 阅读时长按 400 字/分钟估,与业界口径一致。 */
|
|
45
|
+
const CHARS_PER_MINUTE = 400;
|
|
46
|
+
/** 一张图的上限:与本机图片内联那条口径一致。 */
|
|
47
|
+
const MAX_MEDIA_BYTES = 8 * 1024 * 1024;
|
|
48
|
+
/** 媒体文件名:只认这套字符,不接受任何带斜杠的写法。 */
|
|
49
|
+
const MEDIA_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$/;
|
|
50
|
+
|
|
51
|
+
/** 库根。`DSH_MOBILE_WECHAT` 只给测试用。 */
|
|
52
|
+
function rootDir() {
|
|
53
|
+
return process.env.DSH_MOBILE_WECHAT || path.join(dshHome(), 'storages', 'mobile-plugin', 'wechat');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function articleDir(id) {
|
|
57
|
+
return path.join(rootDir(), id);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function articleFile(id) {
|
|
61
|
+
return path.join(articleDir(id), 'article.json');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function bodyFile(id) {
|
|
65
|
+
return path.join(articleDir(id), 'body.html');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function mediaDir(id) {
|
|
69
|
+
return path.join(articleDir(id), 'media');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 400 / 404 信封:与其余移动端接口同形。 */
|
|
73
|
+
function fail(res, status, code, message) {
|
|
74
|
+
return sendJson(res, status, { ok: false, error: { code, message } });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isRecord(value) {
|
|
78
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function textOf(value, max) {
|
|
82
|
+
if (typeof value !== 'string') return '';
|
|
83
|
+
const trimmed = value.trim();
|
|
84
|
+
return trimmed.length > max ? trimmed.slice(0, max) : trimmed;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 从成品 HTML 里剥出纯文本:先整块去掉 `<style>` / `<script>`(正文带样式块是常事,只剥
|
|
89
|
+
* 标签会把 CSS 当正文,摘要与字数一起失真),再剥标签、还实体、压空白。
|
|
90
|
+
*/
|
|
91
|
+
function plainTextOf(html) {
|
|
92
|
+
const stripped = String(html || '')
|
|
93
|
+
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
94
|
+
.replace(/<script[\s\S]*?<\/script>/gi, ' ');
|
|
95
|
+
return stripped
|
|
96
|
+
.replace(/<[^>]*>/g, ' ')
|
|
97
|
+
.replace(/ /gi, ' ')
|
|
98
|
+
.replace(/</gi, '<')
|
|
99
|
+
.replace(/>/gi, '>')
|
|
100
|
+
.replace(/&/gi, '&')
|
|
101
|
+
.replace(/"/gi, '"')
|
|
102
|
+
.replace(/&#(\d+);/g, (_all, code) => String.fromCharCode(Number(code)))
|
|
103
|
+
.replace(/\s+/g, ' ')
|
|
104
|
+
.trim();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function excerptOf(html, limit = EXCERPT_CHARS) {
|
|
108
|
+
const text = plainTextOf(html);
|
|
109
|
+
return text.length > limit ? `${text.slice(0, limit)}…` : text;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 阅读时长:按纯文本字数估,空文章也算 1 分钟(写 0 分钟既没意义又像出错)。 */
|
|
113
|
+
function readingMinutesOf(html) {
|
|
114
|
+
const chars = plainTextOf(html).replace(/\s/g, '').length;
|
|
115
|
+
return Math.max(1, Math.ceil(chars / CHARS_PER_MINUTE));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function charCountOf(html) {
|
|
119
|
+
return plainTextOf(html).replace(/\s/g, '').length;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 新 id:`wx` + 8 位十六进制。撞了就在调用点重来一次(几千篇才可能撞,不值得写循环外的逻辑)。 */
|
|
123
|
+
function newId() {
|
|
124
|
+
return `wx${crypto.randomBytes(4).toString('hex')}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 读一篇的档案 + 正文,给出列表与阅读页要的形状。
|
|
129
|
+
*
|
|
130
|
+
* 盘上坏了(JSON 不成形、正文不见了)一律按「这篇不存在」处理,不让一个坏目录把整页打不开。
|
|
131
|
+
*/
|
|
132
|
+
async function readArticle(id) {
|
|
133
|
+
if (!ID_RE.test(String(id || ''))) return null;
|
|
134
|
+
let meta;
|
|
135
|
+
try {
|
|
136
|
+
meta = JSON.parse(await fsp.readFile(articleFile(id), 'utf8'));
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
if (!isRecord(meta)) return null;
|
|
141
|
+
let html = '';
|
|
142
|
+
try {
|
|
143
|
+
html = await fsp.readFile(bodyFile(id), 'utf8');
|
|
144
|
+
} catch {
|
|
145
|
+
// 只有档案、还没有正文:这是正常的一步(先定标题、再写内容),不是坏数据。
|
|
146
|
+
html = '';
|
|
147
|
+
}
|
|
148
|
+
const updatedAt = typeof meta.updatedAt === 'number' ? meta.updatedAt : 0;
|
|
149
|
+
return {
|
|
150
|
+
id,
|
|
151
|
+
title: textOf(meta.title, 200) || '未命名',
|
|
152
|
+
excerpt: excerptOf(html),
|
|
153
|
+
updatedAt,
|
|
154
|
+
createdAt: typeof meta.createdAt === 'number' ? meta.createdAt : updatedAt,
|
|
155
|
+
sessionId: typeof meta.sessionId === 'string' ? meta.sessionId : '',
|
|
156
|
+
sessionTitle: typeof meta.sessionTitle === 'string' ? meta.sessionTitle : '',
|
|
157
|
+
chars: charCountOf(html),
|
|
158
|
+
minutes: readingMinutesOf(html),
|
|
159
|
+
hasBody: html.length > 0,
|
|
160
|
+
html,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 列表:只读 `article.json`(不读正文)—— 一篇几 MB 的正文,列一百篇就是几百 MB 的读。 */
|
|
165
|
+
async function listArticles() {
|
|
166
|
+
let entries;
|
|
167
|
+
try {
|
|
168
|
+
entries = await fsp.readdir(rootDir(), { withFileTypes: true });
|
|
169
|
+
} catch {
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
const rows = [];
|
|
173
|
+
for (const entry of entries) {
|
|
174
|
+
if (!entry.isDirectory() || !ID_RE.test(entry.name)) continue;
|
|
175
|
+
const article = await readArticleMetaOnly(entry.name);
|
|
176
|
+
if (article) rows.push(article);
|
|
177
|
+
}
|
|
178
|
+
// 新写的排前面:用户刚跟 AI 聊完就进来看这一篇,它必须一眼在顶上。
|
|
179
|
+
rows.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
180
|
+
return rows;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 列表项:档案 + 摘要 + 字数。
|
|
185
|
+
*
|
|
186
|
+
* 摘要与字数离不开正文,但**只读一遍正文、读完就丢**:列表要的几格(摘要 / 字数 / 时长)
|
|
187
|
+
* 都靠它算出来,逐篇缓存又会在 AI 改一次之后全部过期。
|
|
188
|
+
*/
|
|
189
|
+
async function readArticleMetaOnly(id) {
|
|
190
|
+
const article = await readArticle(id);
|
|
191
|
+
if (!article) return null;
|
|
192
|
+
const { html, ...rest } = article;
|
|
193
|
+
return rest;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 一项图片的字节:优先从电脑上的文件读,其次才认递上来的 base64。
|
|
198
|
+
*
|
|
199
|
+
* 路径来源只有一个 —— 调用方(模型)自己给的。所以这里不放宽成「任意文件」:只认图片
|
|
200
|
+
* 魔术字节,非图片的、读不到的一律当没给,别让一次写文章顺手把别的东西抄进 media 里。
|
|
201
|
+
*/
|
|
202
|
+
async function mediaBytesOf(item) {
|
|
203
|
+
const fromPath = textOf(item.path, 1024);
|
|
204
|
+
if (fromPath) {
|
|
205
|
+
const filePath = resolveLocalPath(fromPath);
|
|
206
|
+
if (!filePath) return null;
|
|
207
|
+
try {
|
|
208
|
+
const stat = await fsp.stat(filePath);
|
|
209
|
+
if (!stat.isFile() || stat.size <= 0 || stat.size > MAX_MEDIA_BYTES) return null;
|
|
210
|
+
const bytes = await fsp.readFile(filePath);
|
|
211
|
+
if (!looksLikeImage(bytes)) return null;
|
|
212
|
+
return bytes;
|
|
213
|
+
} catch {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const data = typeof item.base64 === 'string' ? item.base64.trim() : '';
|
|
218
|
+
if (!data) return null;
|
|
219
|
+
let bytes;
|
|
220
|
+
try {
|
|
221
|
+
bytes = Buffer.from(data, 'base64');
|
|
222
|
+
} catch {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
if (bytes.length === 0 || bytes.length > MAX_MEDIA_BYTES) return null;
|
|
226
|
+
if (!looksLikeImage(bytes)) return null;
|
|
227
|
+
return bytes;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** 路径统一走与正文图片同一条口径:绝对路径、`~/`、`file://`,相对地址按当前目录解析。 */
|
|
231
|
+
function resolveLocalPath(value) {
|
|
232
|
+
let text = String(value || '').trim();
|
|
233
|
+
if (!text) return null;
|
|
234
|
+
if (text.startsWith('<') && text.endsWith('>')) text = text.slice(1, -1).trim();
|
|
235
|
+
if (/^file:\/\//i.test(text)) {
|
|
236
|
+
try {
|
|
237
|
+
text = decodeURIComponent(text.slice(7));
|
|
238
|
+
} catch {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
} else if (text === '~' || text.startsWith('~/')) {
|
|
242
|
+
text = path.join(require('node:os').homedir(), text.slice(1));
|
|
243
|
+
}
|
|
244
|
+
return path.isAbsolute(text) ? path.normalize(text) : path.resolve(process.cwd(), text);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** 魔术字节:只认手机上画得出来的四种位图,不信扩展名。 */
|
|
248
|
+
function looksLikeImage(bytes) {
|
|
249
|
+
if (bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return true;
|
|
250
|
+
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return true;
|
|
251
|
+
if (bytes.length >= 6 && bytes.toString('latin1', 0, 6).startsWith('GIF8')) return true;
|
|
252
|
+
if (bytes.length >= 12 && bytes.toString('latin1', 0, 4) === 'RIFF' && bytes.toString('latin1', 8, 12) === 'WEBP') return true;
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* 写入一篇。新建与更新走同一条路:
|
|
258
|
+
* · 没给 id → 新建一篇全新的(只有标题也行,正文可以之后再补);
|
|
259
|
+
* · 给了 id → 更新那篇(标题 / 正文 / 图片,给什么改什么)。
|
|
260
|
+
*
|
|
261
|
+
* `images` 每项是两种形状之一:
|
|
262
|
+
* · `{name, path}` —— **优先**:电脑上一个图片文件的位置,宿主自己读过来复制进 media。
|
|
263
|
+
* 模型生成完一张图,手里只有那条路径(它自己用工具产出的图就落在那台机器上),
|
|
264
|
+
* 这才是它真能给的东西;
|
|
265
|
+
* · `{name, base64}` —— 兜底:字节由调用方直接递上来(App 那边、或者模型手上真有字节时)。
|
|
266
|
+
*
|
|
267
|
+
* 为什么以 `path` 为主:让模型把整张图的 base64 写进工具参数,等于让它把几兆字节抄一遍 ——
|
|
268
|
+
* 抄不动、也抄不对。给路径让它自己读,是它真做得到的那件事。
|
|
269
|
+
*/
|
|
270
|
+
async function writeArticle(input) {
|
|
271
|
+
const title = textOf(input.title, 200);
|
|
272
|
+
const htmlInput = typeof input.html === 'string' ? input.html : '';
|
|
273
|
+
if (Buffer.byteLength(htmlInput, 'utf8') > MAX_HTML_BYTES) {
|
|
274
|
+
const error = new Error('正文太大了(上限 2MB),图片请放到 media 里、正文只写图片地址。');
|
|
275
|
+
error.code = 'too-large';
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
let id = typeof input.id === 'string' ? input.id.trim() : '';
|
|
280
|
+
let existing = null;
|
|
281
|
+
if (id) {
|
|
282
|
+
existing = await readArticle(id);
|
|
283
|
+
if (!existing) {
|
|
284
|
+
const error = new Error(`没有找到编号为 ${id} 的文章。不传 id 会新建一篇。`);
|
|
285
|
+
error.code = 'not-found';
|
|
286
|
+
throw error;
|
|
287
|
+
}
|
|
288
|
+
} else {
|
|
289
|
+
id = newId();
|
|
290
|
+
while (await readArticle(id)) id = newId();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const now = Date.now();
|
|
294
|
+
const createdAt = existing ? existing.createdAt : now;
|
|
295
|
+
const nextTitle = title || (existing ? existing.title : '未命名');
|
|
296
|
+
|
|
297
|
+
await fsp.mkdir(articleDir(id), { recursive: true });
|
|
298
|
+
|
|
299
|
+
// 图片先落地:档案里的「这篇有图」必须在正文写完之后仍然成立,反过来的顺序会让一次
|
|
300
|
+
// 中途失败的写入留下一篇指向空目录的正文。
|
|
301
|
+
const media = Array.isArray(input.images) ? input.images : [];
|
|
302
|
+
const written = [];
|
|
303
|
+
const rejected = [];
|
|
304
|
+
if (media.length > 0) {
|
|
305
|
+
await fsp.mkdir(mediaDir(id), { recursive: true });
|
|
306
|
+
for (const item of media) {
|
|
307
|
+
if (!isRecord(item)) continue;
|
|
308
|
+
const name = textOf(item.name, 90);
|
|
309
|
+
if (!MEDIA_NAME_RE.test(name)) {
|
|
310
|
+
rejected.push(textOf(item.path, 120) || name || '(没有名字)');
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
const bytes = await mediaBytesOf(item);
|
|
314
|
+
if (!bytes) {
|
|
315
|
+
rejected.push(textOf(item.path, 120) || name);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
await fsp.writeFile(path.join(mediaDir(id), name), bytes);
|
|
319
|
+
written.push(name);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (typeof input.html === 'string') {
|
|
324
|
+
await fsp.writeFile(bodyFile(id), htmlInput, 'utf8');
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const meta = {
|
|
328
|
+
id,
|
|
329
|
+
title: nextTitle,
|
|
330
|
+
createdAt,
|
|
331
|
+
updatedAt: now,
|
|
332
|
+
sessionId: typeof input.sessionId === 'string' ? input.sessionId : (existing ? existing.sessionId : ''),
|
|
333
|
+
sessionTitle: typeof input.sessionTitle === 'string' ? input.sessionTitle : (existing ? existing.sessionTitle : ''),
|
|
334
|
+
};
|
|
335
|
+
// 先写临时文件再改名:宿主随时可能被杀,半个 JSON 会让这篇从此打不开。
|
|
336
|
+
const tmp = `${articleFile(id)}.tmp`;
|
|
337
|
+
await fsp.writeFile(tmp, JSON.stringify(meta, null, 2), 'utf8');
|
|
338
|
+
await fsp.rename(tmp, articleFile(id));
|
|
339
|
+
|
|
340
|
+
const article = await readArticle(id);
|
|
341
|
+
return { article, images: written, rejected };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ── 路由 ─────────────────────────────────────────────────────────────────────
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* `GET /wechat/articles` —— 列表 + 首页那几格总数。
|
|
348
|
+
*
|
|
349
|
+
* 总数与列表同源:不另存一份计数(存了就会跟列表对不上),七十篇以内扫一遍目录也就几毫秒。
|
|
350
|
+
*/
|
|
351
|
+
async function handleArticleList(_ctx, req, res) {
|
|
352
|
+
const rows = await listArticles();
|
|
353
|
+
const totals = rows.reduce(
|
|
354
|
+
(acc, row) => {
|
|
355
|
+
acc.count += 1;
|
|
356
|
+
acc.chars += row.chars;
|
|
357
|
+
if (row.hasBody) acc.withBody += 1;
|
|
358
|
+
if (row.updatedAt > acc.updatedAt) acc.updatedAt = row.updatedAt;
|
|
359
|
+
return acc;
|
|
360
|
+
},
|
|
361
|
+
{ count: 0, chars: 0, withBody: 0, updatedAt: 0 },
|
|
362
|
+
);
|
|
363
|
+
sendJson(res, 200, { ok: true, items: rows, totals });
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** `GET /wechat/article?id=` —— 一篇的正文(阅读页用)。 */
|
|
367
|
+
async function handleArticleRead(_ctx, req, res) {
|
|
368
|
+
const url = new URL(req.url, 'http://x');
|
|
369
|
+
const id = url.searchParams.get('id') || '';
|
|
370
|
+
const article = await readArticle(id);
|
|
371
|
+
if (!article) return fail(res, 404, 'article-not-found', '这篇不在了:可能删掉了,或者编号不对。');
|
|
372
|
+
sendJson(res, 200, {
|
|
373
|
+
ok: true,
|
|
374
|
+
article: {
|
|
375
|
+
id: article.id,
|
|
376
|
+
title: article.title,
|
|
377
|
+
excerpt: article.excerpt,
|
|
378
|
+
updatedAt: article.updatedAt,
|
|
379
|
+
createdAt: article.createdAt,
|
|
380
|
+
sessionId: article.sessionId,
|
|
381
|
+
sessionTitle: article.sessionTitle,
|
|
382
|
+
chars: article.chars,
|
|
383
|
+
minutes: article.minutes,
|
|
384
|
+
hasBody: article.hasBody,
|
|
385
|
+
html: article.html,
|
|
386
|
+
},
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* `GET /wechat/media?id=&name=` —— 正文里的图片(base64 JSON,与 `session/attachment` 同形)。
|
|
392
|
+
*
|
|
393
|
+
* 为什么走 JSON 而不是直接回字节:手机上取这张图要用 App 那份鉴权头,而图片最终是塞进
|
|
394
|
+
* 阅读页的 WebView 里渲染的,网页自己发出去的请求带不了鉴权头。所以由 App 取回字节、转成
|
|
395
|
+
* data URI 再交给网页 —— 形状与附件那条一致,也能直接复用同一套解包。
|
|
396
|
+
*
|
|
397
|
+
* 只认「这篇的 media 目录里、名字合规矩」的那一张:路径全由宿主拼,客户端给不了路径。
|
|
398
|
+
*/
|
|
399
|
+
async function handleArticleMedia(_ctx, req, res) {
|
|
400
|
+
const url = new URL(req.url, 'http://x');
|
|
401
|
+
const id = url.searchParams.get('id') || '';
|
|
402
|
+
const name = url.searchParams.get('name') || '';
|
|
403
|
+
if (!ID_RE.test(id) || !MEDIA_NAME_RE.test(name)) return fail(res, 400, 'bad-request', '参数不对。');
|
|
404
|
+
const filePath = path.join(mediaDir(id), name);
|
|
405
|
+
// 上一步已经把两个参数都收窄成不含斜杠的形状了;这里再判一次前缀,是让「拼错的路径」
|
|
406
|
+
// 在任何一处改动之后都落在这里而不是读到别处去。
|
|
407
|
+
if (!filePath.startsWith(mediaDir(id) + path.sep)) return fail(res, 400, 'bad-request', '参数不对。');
|
|
408
|
+
let bytes;
|
|
409
|
+
try {
|
|
410
|
+
const stat = await fsp.stat(filePath);
|
|
411
|
+
if (!stat.isFile() || stat.size > MAX_MEDIA_BYTES) throw new Error('bad');
|
|
412
|
+
bytes = await fsp.readFile(filePath);
|
|
413
|
+
} catch {
|
|
414
|
+
return fail(res, 404, 'media-not-found', '这张图不在了。');
|
|
415
|
+
}
|
|
416
|
+
sendJson(res, 200, { ok: true, mediaType: mediaTypeOf(name, bytes), data: bytes.toString('base64') });
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** 按魔术字节认类型(不信扩展名),认不出按 png 给 —— 微信里绝大多数是 png / jpeg。 */
|
|
420
|
+
function mediaTypeOf(name, bytes) {
|
|
421
|
+
if (bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50) return 'image/png';
|
|
422
|
+
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8) return 'image/jpeg';
|
|
423
|
+
if (bytes.length >= 12 && bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP') return 'image/webp';
|
|
424
|
+
if (bytes.length >= 6 && bytes.toString('ascii', 0, 3) === 'GIF') return 'image/gif';
|
|
425
|
+
if (/\.svg$/i.test(name)) return 'image/svg+xml';
|
|
426
|
+
return 'image/png';
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* `POST /wechat/article` —— 写一篇(App 侧目前不用,留给「在手机上删 / 改名」这类动作;
|
|
431
|
+
* AI 那条路走的是工具,见 `tools.js`)。
|
|
432
|
+
*/
|
|
433
|
+
async function handleArticleWrite(_ctx, req, res) {
|
|
434
|
+
const body = await readJsonBody(req, 8 * 1024 * 1024);
|
|
435
|
+
if (!isRecord(body)) return fail(res, 400, 'bad-request', '请求体不是对象。');
|
|
436
|
+
try {
|
|
437
|
+
const { article } = await writeArticle(body);
|
|
438
|
+
sendJson(res, 200, {
|
|
439
|
+
ok: true,
|
|
440
|
+
id: article.id,
|
|
441
|
+
title: article.title,
|
|
442
|
+
hasBody: article.hasBody,
|
|
443
|
+
chars: article.chars,
|
|
444
|
+
});
|
|
445
|
+
} catch (error) {
|
|
446
|
+
if (error && error.code === 'not-found') return fail(res, 404, 'article-not-found', error.message);
|
|
447
|
+
if (error && error.code === 'too-large') return fail(res, 413, 'too-large', error.message);
|
|
448
|
+
return fail(res, 500, 'write-failed', '没能写进去,稍后重试。');
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* `DELETE /wechat/article?id=` —— 删掉一篇(连正文带图片)。
|
|
454
|
+
*
|
|
455
|
+
* App 那边还没有入口,先留着:删一篇是「管理」里最该有的一件,接到界面上是一行按钮的事。
|
|
456
|
+
*/
|
|
457
|
+
async function handleArticleDelete(_ctx, req, res) {
|
|
458
|
+
// 删除用 GET 那条路径的形状(`?id=`),但只认 DELETE:一条 GET 不该能把文章删掉
|
|
459
|
+
// (浏览器预取、链接预览都会发 GET,那是最容易误删的一条路)。
|
|
460
|
+
if (String(req.method || 'GET').toUpperCase() !== 'DELETE') {
|
|
461
|
+
return fail(res, 405, 'method-not-allowed', '删除只接受 DELETE。');
|
|
462
|
+
}
|
|
463
|
+
const url = new URL(req.url, 'http://x');
|
|
464
|
+
const id = url.searchParams.get('id') || '';
|
|
465
|
+
if (!ID_RE.test(id)) return fail(res, 400, 'bad-request', '参数不对。');
|
|
466
|
+
try {
|
|
467
|
+
await fsp.rm(articleDir(id), { recursive: true, force: true });
|
|
468
|
+
} catch {
|
|
469
|
+
return fail(res, 500, 'delete-failed', '没能删掉,稍后重试。');
|
|
470
|
+
}
|
|
471
|
+
sendJson(res, 200, { ok: true });
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
module.exports = {
|
|
475
|
+
handleArticleList,
|
|
476
|
+
handleArticleRead,
|
|
477
|
+
handleArticleMedia,
|
|
478
|
+
handleArticleWrite,
|
|
479
|
+
handleArticleDelete,
|
|
480
|
+
// 工具那一侧直接用这几个(不经过 HTTP)
|
|
481
|
+
writeArticle,
|
|
482
|
+
readArticle,
|
|
483
|
+
listArticles,
|
|
484
|
+
plainTextOf,
|
|
485
|
+
excerptOf,
|
|
486
|
+
readingMinutesOf,
|
|
487
|
+
rootDir,
|
|
488
|
+
ID_RE,
|
|
489
|
+
};
|