@ggboy123/dsh-paper-reader 1.3.3 → 1.3.5

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GGboya
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -11,6 +11,8 @@
11
11
 
12
12
  > **👉 普通用户直接看「安装 → 方式一」**:装 DeepSeek 官方桌面端,添加插件即可,不需要任何开发环境。本仓库面向的是想自己装插件、或想改代码的用户。
13
13
 
14
+ > 💬 **QQ 交流群:1127244508** —— 安装/使用问题、功能建议、bug 反馈都欢迎来聊。
15
+
14
16
  功能迁移自本地独立产品 pdfqa(Go 实现的论文伴读工具)。原则:agent 循环 / 模型层 / 会话持久化全部交给 dsh 框架,插件只做「转录 + 检索 + 阅读器 UI」的薄壳组合。
15
17
 
16
18
  **安装后不改动官方界面** —— 侧栏底部多一行「📚 论文伴读」开关,点击才进入伴读模式(左侧变为文献库树),再点即还原官方工作区列表:
@@ -37,14 +39,14 @@
37
39
 
38
40
  ## 安装
39
41
 
40
- ### 方式一:官方桌面端装插件(推荐)
42
+ ### 普通用户:桌面端装好即用(推荐)
43
+
44
+ **两步**:装 DeepSeek 官方桌面端 → 里面直接装插件。
41
45
 
42
- **DeepSeek 官方桌面端**([deepseek.com/download](https://www.deepseek.com/download/) 下载,即 DeepSeek Harness 桌面版):
46
+ 1. 下载安装 [DeepSeek 官方桌面端](https://www.deepseek.com/download/)(即 DeepSeek Harness 桌面版),登录 DeepSeek 账号
47
+ 2. 侧栏 → **插件** → **添加插件**,输入 `@ggboy123/dsh-paper-reader@latest`,装完点进插件详情打开「启用」开关,**重启桌面端**
43
48
 
44
- 1. 安装并启动,登录 DeepSeek 账号
45
- 2. 侧栏 → **插件** → **添加插件**,输入 `@ggboy123/dsh-paper-reader@1.3.3` 安装
46
- 3. 装完默认**停用**:点进插件详情,打开「启用」开关
47
- 4. **重启桌面端**(热启用状态下打开论文的链路会静默失效,重启进开机组合才正常,实测)
49
+ 完成。侧栏底部点「📚 论文伴读」进入文献库,上传 PDF 开始用。
48
50
 
49
51
  完整流程演示(添加插件 → 启用 → 重启 → 打开论文直接提问):
50
52
 
@@ -53,23 +55,28 @@
53
55
  > 已在官方桌面端 V0.2.0-rc.2(内置 dsh 0.2.0-rc.2,macOS arm64)实测通过:侧栏文献库、新建专题、上传 PDF、PDF 阅读器、原生对话伴读、历史/新建对话按钮全部可用。
54
56
  > 需要 ≥1.2.0:更早版本会被 0.2.x 运行时的 peer 兼容校验拒载(插件列表里显示异常)。
55
57
 
56
- > 曾经的 PaperReader 桌面版(基于社区 DSH Desktop 的预装打包)**已不再维护**——统一收敛到官方桌面端 + 插件的形式,老用户请迁移到本方式。
58
+ > 想直接在客户端里逛插件市场(4000+ 插件、分类、评论、一键安装),可另装社区插件 [`dshmarket`](https://github.com/dsh-market/dsh-market),本插件已在其目录中。
59
+
60
+ **排坑(都是实测踩过的)**:
57
61
 
58
- ### 方式二:命令行 dsh(开发者)
62
+ - **升级后插件开关会回到停用**:侧栏 → 插件 → 打开「启用」→ 重启。症状有迷惑性——文献树还在但点论文没反应(浏览器侧缓存活着、宿主侧已下线),其实是没启用
63
+ - **添加时务必带 `@latest`**:不带版本号对已装过的插件是 no-op(dsh 的插件管理器按首次记录的版本处理,不会追新)
64
+ - 旧版 PaperReader 桌面版(社区 DSH Desktop 预装打包)**已停止维护**,请迁移到本方式
65
+
66
+ ### 开发者:命令行 dsh
59
67
 
60
68
  ```bash
61
- dsh plugin --profile web add @ggboy123/dsh-paper-reader
69
+ dsh plugin --profile web add @ggboy123/dsh-paper-reader@latest
62
70
  ```
63
71
 
64
72
  > npm 包名走 `@ggboy123` 作用域:裸名 `dsh-paper-reader` 已被另一个插件占用(那是「输入文献、吐精读报告」的一次性分析工具,与本插件的「阅读器 + 会话伴读」定位不同)。
65
73
  > 不要用 `github:GGboya/dsh-paper-reader` 安装 —— dist 不入库,git 安装会导致 profile 起不来(构建产物只随 npm 包分发)。
66
74
 
67
- ### 升级
68
-
69
- **已装过的用户升级必须带显式版本号**——不带版本的 `add` 对已存在的依赖是 no-op(pnpm 按首次安装时记录的版本范围解析,不会追新):
75
+ **升级**:用 `@latest`(或显式版本号)——不带任何版本/tag 的 `add` 对已装过的依赖是 no-op(实测):
70
76
 
71
77
  ```bash
72
- dsh plugin --profile web add @ggboy123/dsh-paper-reader@1.3.3
78
+ dsh plugin --profile web add @ggboy123/dsh-paper-reader@latest # 永远装最新
79
+ # dsh plugin --profile web add @ggboy123/dsh-paper-reader@1.3.4 # 或钉住具体版本
73
80
  # 重启 dsh web 生效;浏览器 Cmd+Shift+R 强刷,避免旧阅读器页面缓存
74
81
  ```
75
82
 
@@ -79,7 +86,7 @@ dsh plugin --profile web add @ggboy123/dsh-paper-reader@1.3.3
79
86
  grep '"version"' ~/.dsh/profiles/web/node_modules/@ggboy123/dsh-paper-reader/package.json
80
87
  ```
81
88
 
82
- > pnpm 若开了供应链策略 `minimumReleaseAge`(发布 N 小时内的新包拒装):要么等过窗口期,要么在 `~/.dsh/profiles/web/.npmrc` 加一行 `minimum-release-age-exclude[]=@ggboy123/dsh-paper-reader`(只豁免本插件,不动全局策略)。
89
+ > pnpm 若开了供应链策略 `minimumReleaseAge`(发布 N 小时内的新包拒装,桌面端默认开):要么等过窗口期,要么在 `~/.dsh/profiles/web/.npmrc` 加一行 `minimum-release-age-exclude[]=@ggboy123/dsh-paper-reader`(只豁免本插件,不动全局策略)。桌面端的豁免清单在 `~/.dsh/profiles/desktop/pnpm-workspace.yaml` 的 `minimumReleaseAgeExclude`。
83
90
 
84
91
  ### 参与开发(本地调试)
85
92
 
package/dist/host.js CHANGED
@@ -4,7 +4,7 @@ import { createReadStream, readFileSync } from 'node:fs';
4
4
  import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
5
5
  import { randomUUID } from 'node:crypto';
6
6
  import { basename, join } from 'node:path';
7
- import { deletePaper, deleteTopic, listPapers, listTopics, paperRefFor, renamePaper, renameTopic, resolveDataDir, resolvePaper, } from "./library.js";
7
+ import { deletePaper, deleteTopic, listPapers, listTopics, paperRefFor, renamePaper, renameTopic, resolveDataDir, resolvePaper, resolveUploadTopic, validateEntryBoundary, validateEntryName, } from "./library.js";
8
8
  import { readTranscript, transcribePaper } from "./transcribe.js";
9
9
  import { pdfVariantPath, restartTranslation, startTranslation, zhStatus } from "./translate.js";
10
10
  import { clearTranslateConfig, maskApiKey, readTranslateConfig, resolveTranslateEndpoint, testTranslateEndpoint, writeTranslateConfig, } from "./translate-config.js";
@@ -16,12 +16,32 @@ function json(res, status, body) {
16
16
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': buf.length });
17
17
  res.end(buf);
18
18
  }
19
+ /**
20
+ * 错误文案判别:带 `.code` 的 Node errno 系统错误 → 固定中文文案(不回显原始错误,
21
+ * 避免把 dataDir/目标绝对路径带进响应体);不带 `.code` 的是我们自己抛的中文业务 Error
22
+ * (专题不存在/已存在同名/名字不能包含… 等)→ 保留 `e.message`,用户仍能定位问题。
23
+ */
24
+ function readableError(err) {
25
+ const code = err?.code;
26
+ if (typeof code === 'string' && code)
27
+ return '系统错误,请稍后重试';
28
+ return err instanceof Error ? err.message : String(err);
29
+ }
19
30
  async function readBody(req) {
20
31
  const chunks = [];
21
32
  for await (const c of req)
22
33
  chunks.push(c);
23
34
  const raw = Buffer.concat(chunks).toString('utf8');
24
- return raw ? JSON.parse(raw) : {};
35
+ if (!raw)
36
+ return {};
37
+ let parsed;
38
+ try {
39
+ parsed = JSON.parse(raw);
40
+ }
41
+ catch {
42
+ return {}; // 畸形 JSON 与整包 null/非对象同待遇:路由据此给可读 400(不再 500)
43
+ }
44
+ return parsed !== null && typeof parsed === 'object' ? parsed : {};
25
45
  }
26
46
  /** header 元数据解码:客户端 encodeURIComponent 过(HTTP header 值不允许非 Latin-1,如中文文件名);非字符串/未编码原样返回。 */
27
47
  function decodeHdr(v) {
@@ -122,7 +142,7 @@ export function registerRoutes(ctx, config) {
122
142
  return workspace.workspaceId;
123
143
  }
124
144
  catch (err) {
125
- console.warn('[dsh-paper-reader] workspace 注册失败(回退 cwd 模式):', err);
145
+ console.warn('[dsh-paper-reader] workspace 注册失败(回退 cwd 模式):', readableError(err));
126
146
  return undefined;
127
147
  }
128
148
  };
@@ -182,14 +202,20 @@ export function registerRoutes(ctx, config) {
182
202
  json(res, 200, { dataDir, topics: listTopics(dataDir), papers });
183
203
  return;
184
204
  }
185
- // 新建专题(目录)
205
+ // 新建专题(目录)。校验与 rename 的 to 侧一致(validateEntryName:边界 + 命名规范),
206
+ // 保证「能创建的必然能删除/改名」(R12:创建/删除对称)。
186
207
  if (sub === '/api/library/topic' && req.method === 'POST') {
187
208
  const body = (await readBody(req));
188
- const name = body.name?.trim();
189
- if (!name || /[/\\]|\.\./.test(name)) {
190
- json(res, 400, { error: '专题名不能为空,且不能包含 / \\ ..' });
209
+ if (typeof body.name !== 'string' || !body.name.trim()) {
210
+ json(res, 400, { error: '专题名不能为空' });
211
+ return;
212
+ }
213
+ const err = validateEntryName(body.name);
214
+ if (err) {
215
+ json(res, 400, { error: err });
191
216
  return;
192
217
  }
218
+ const name = body.name.trim();
193
219
  await mkdir(join(dataDir, name), { recursive: true });
194
220
  json(res, 200, { ok: true, topic: name });
195
221
  return;
@@ -203,11 +229,24 @@ export function registerRoutes(ctx, config) {
203
229
  json(res, 400, { error: '缺少 x-dpr-topic / x-dpr-name 头' });
204
230
  return;
205
231
  }
232
+ // 词法层:与其余 CRUD 同强度(../ / \ 隐藏名 控制字符 超长 空串)
233
+ const errName = validateEntryName(topic);
234
+ if (errName) {
235
+ json(res, 400, { error: errName });
236
+ return;
237
+ }
206
238
  if (!filename.toLowerCase().endsWith('.pdf')) {
207
239
  json(res, 400, { error: '只支持 PDF 文件' });
208
240
  return;
209
241
  }
210
242
  const safeName = filename.replaceAll('/', '_').replaceAll('\\', '_');
243
+ // 文件名也做边界类校验(空/分隔符/../NUL),与删除侧对称:upload 能创建的必须能被删
244
+ const stem = safeName.slice(0, -4); // 去掉 .pdf
245
+ const errStem = validateEntryBoundary(stem);
246
+ if (errStem) {
247
+ json(res, 400, { error: errStem });
248
+ return;
249
+ }
211
250
  const chunks = [];
212
251
  for await (const c of req)
213
252
  chunks.push(c);
@@ -216,10 +255,24 @@ export function registerRoutes(ctx, config) {
216
255
  json(res, 400, { error: '文件内容为空' });
217
256
  return;
218
257
  }
219
- const dir = join(dataDir, topic.trim());
220
- await mkdir(dir, { recursive: true });
221
- const target = join(dir, safeName);
222
- await writeFile(target, buf);
258
+ // realpath 子路径层:拒绝「data/<topic> → 库外」符号链接逃逸写(与 CRUD 同一套 primitive)
259
+ let dir;
260
+ try {
261
+ dir = resolveUploadTopic(dataDir, topic.trim());
262
+ }
263
+ catch (err) {
264
+ json(res, 400, { error: readableError(err) });
265
+ return;
266
+ }
267
+ try {
268
+ await mkdir(dir, { recursive: true });
269
+ const target = join(dir, safeName);
270
+ await writeFile(target, buf);
271
+ }
272
+ catch (err) {
273
+ json(res, 400, { error: readableError(err) });
274
+ return;
275
+ }
223
276
  json(res, 200, { ok: true, topic: topic.trim(), name: safeName.slice(0, -4), bytes: buf.length });
224
277
  return;
225
278
  }
@@ -236,15 +289,37 @@ export function registerRoutes(ctx, config) {
236
289
  json(res2, 409, { error: '文件被占用(可能正在阅读器或其他程序中打开),关闭后重试' });
237
290
  }
238
291
  else {
239
- json(res2, 400, { error: e instanceof Error ? e.message : String(err) });
292
+ json(res2, 400, { error: readableError(err) });
240
293
  }
241
294
  };
295
+ /** 非法/越界名字 → 400 可读文案(词法层;realpath 子路径层在 library primitive 内兜底)。
296
+ * 放在 busyPapersInTopic / resolvePaper 之前,越界输入不会碰任何文件系统。返回 true 表示已响应。 */
297
+ const badEntry = (res, raw) => {
298
+ const err = typeof raw === 'string' ? validateEntryName(raw) : '名字不能为空';
299
+ if (!err)
300
+ return false;
301
+ json(res, 400, { error: err });
302
+ return true;
303
+ };
304
+ /** 删除/改名-from 用:只做边界类校验(安全),不做命名规范类(长度/前导点)。
305
+ * 删除是对已存在文件的操作——upload 能创建的名字(前导点、超长、\x01 等)必须能删。 */
306
+ const badEntryBoundary = (res, raw) => {
307
+ const err = typeof raw === 'string' ? validateEntryBoundary(raw) : '名字不能为空';
308
+ if (!err)
309
+ return false;
310
+ json(res, 400, { error: err });
311
+ return true;
312
+ };
242
313
  if (sub === '/api/library/rename-topic' && req.method === 'POST') {
243
314
  const body = (await readBody(req));
244
- if (!body.topic?.trim()) {
315
+ if (typeof body.topic !== 'string' || !body.topic.trim()) {
245
316
  json(res, 400, { error: '缺少 topic' });
246
317
  return;
247
318
  }
319
+ if (badEntry(res, body.topic))
320
+ return;
321
+ if (badEntry(res, body.to ?? ''))
322
+ return;
248
323
  const busy = busyPapersInTopic(body.topic.trim());
249
324
  if (busy.length) {
250
325
  json(res, 409, { error: `以下文献正在翻译,稍后再试:${busy.join('、')}` });
@@ -263,10 +338,12 @@ export function registerRoutes(ctx, config) {
263
338
  }
264
339
  if (sub === '/api/library/delete-topic' && req.method === 'POST') {
265
340
  const body = (await readBody(req));
266
- if (!body.topic?.trim()) {
341
+ if (typeof body.topic !== 'string' || !body.topic.trim()) {
267
342
  json(res, 400, { error: '缺少 topic' });
268
343
  return;
269
344
  }
345
+ if (badEntry(res, body.topic))
346
+ return;
270
347
  const busy = busyPapersInTopic(body.topic.trim());
271
348
  if (busy.length) {
272
349
  json(res, 409, { error: `以下文献正在翻译,稍后再试:${busy.join('、')}` });
@@ -284,10 +361,16 @@ export function registerRoutes(ctx, config) {
284
361
  }
285
362
  if (sub === '/api/library/rename-paper' && req.method === 'POST') {
286
363
  const body = (await readBody(req));
287
- if (!body.topic?.trim() || !body.name?.trim()) {
364
+ if (typeof body.topic !== 'string' || !body.topic.trim() || typeof body.name !== 'string' || !body.name.trim()) {
288
365
  json(res, 400, { error: '缺少 topic / name' });
289
366
  return;
290
367
  }
368
+ if (badEntry(res, body.topic))
369
+ return;
370
+ if (badEntryBoundary(res, body.name))
371
+ return;
372
+ if (badEntry(res, body.to ?? ''))
373
+ return;
291
374
  let ref;
292
375
  try {
293
376
  ref = resolvePaper(dataDir, { topic: body.topic.trim(), name: body.name.trim() });
@@ -313,10 +396,14 @@ export function registerRoutes(ctx, config) {
313
396
  }
314
397
  if (sub === '/api/library/delete-paper' && req.method === 'POST') {
315
398
  const body = (await readBody(req));
316
- if (!body.topic?.trim() || !body.name?.trim()) {
399
+ if (typeof body.topic !== 'string' || !body.topic.trim() || typeof body.name !== 'string' || !body.name.trim()) {
317
400
  json(res, 400, { error: '缺少 topic / name' });
318
401
  return;
319
402
  }
403
+ if (badEntry(res, body.topic))
404
+ return;
405
+ if (badEntryBoundary(res, body.name))
406
+ return;
320
407
  let ref;
321
408
  try {
322
409
  ref = resolvePaper(dataDir, { topic: body.topic.trim(), name: body.name.trim() });
@@ -650,7 +737,7 @@ export function registerRoutes(ctx, config) {
650
737
  const url = new URL(req.url ?? '/', 'http://x');
651
738
  dispatch(req, res, url).catch((err) => {
652
739
  if (!res.headersSent)
653
- json(res, 500, { error: String(err instanceof Error ? err.message : err) });
740
+ json(res, 500, { error: readableError(err) });
654
741
  else
655
742
  res.end();
656
743
  });
package/dist/library.js CHANGED
@@ -6,8 +6,8 @@
6
6
  // <dataDir>/<专题>/<文献>.pages.json 页码偏移表(转录时生成,检索映射页码用)
7
7
  // pdf2zh 的产物(-en / -zh / -dual)不算用户文献。
8
8
  // (问答不落盘存档——会话持久化交给 dsh 自己;pdfqa 时代的 -qa/ 目录不再读写。)
9
- import { readdirSync, existsSync, mkdirSync, statSync, renameSync, rmSync } from 'node:fs';
10
- import { join, resolve, extname, basename } from 'node:path';
9
+ import { readdirSync, existsSync, mkdirSync, statSync, renameSync, rmSync, realpathSync, lstatSync } from 'node:fs';
10
+ import { join, resolve, extname, basename, sep } from 'node:path';
11
11
  import { homedir } from 'node:os';
12
12
  const PDF2ZH_SUFFIXES = ['-en', '-zh', '-dual'];
13
13
  /** 判定是否为用户文献:排除 pdf2zh 的中间/产出文件。 */
@@ -111,10 +111,103 @@ export function validateEntryName(raw) {
111
111
  return '名字过长(最多 120 个字符)';
112
112
  if (/[\\/]|\.\./.test(name))
113
113
  return '名字不能包含 / \\ 或 ..';
114
+ if (/[\u0000-\u001f\u007f]/.test(name))
115
+ return '名字不能包含控制字符';
114
116
  if (name.startsWith('.'))
115
117
  return '名字不能以 . 开头';
116
118
  return null;
117
119
  }
120
+ /**
121
+ * 边界类名字校验(删除/改名-from 用):只挡安全边界——空、分隔符/..、NUL。
122
+ * 与 validateEntryName 的分工:命名规范类(长度上限、前导点、非 NUL 控制字符)只在创建/改名时
123
+ * 强制(那是用户在起名字);删除是对已存在文件的操作——upload 能创建的名字(前导点、超长、
124
+ * \x01 等控制字符)必须能删,否则会成为无法清理的孤儿。NUL 必须挡:它会让 Node fs 抛
125
+ * ERR_INVALID_ARG_VALUE 并把绝对路径带进错误文案(错误回显卫生)。
126
+ */
127
+ export function validateEntryBoundary(raw) {
128
+ const name = raw.trim();
129
+ if (!name)
130
+ return '名字不能为空';
131
+ if (/[\\/]|\.\./.test(name))
132
+ return '名字不能包含 / \\ 或 ..';
133
+ if (name.includes('\u0000'))
134
+ return '名字不能包含控制字符';
135
+ return null;
136
+ }
137
+ // ================= 路径穿越防护(词法 + realpath 子路径双层) =================
138
+ // 这些 CRUD 原语以前只对 `to` 做过校验,`topic`/`name`/`from` 直接 `join(dataDir, x)`:
139
+ // `../sentinel` 这类输入会让 rmSync/renameSync 作用于 dataDir 之外的目录(可递归强制删除)。
140
+ // 这里补两层结构性防护:① 词法层(validateEntryName 拒绝 / \ .. 与隐藏名);② realpath
141
+ // 层(目标目录的 real 路径必须落在 dataDir 的 real 路径之内,挡住 `..` 之外的符号链接逃逸)。
142
+ /** dataDir 的 real 绝对路径(resolve + realpath,去符号链接与 `..`)。目录缺失时给无路径文案。 */
143
+ function realDataDir(dataDir) {
144
+ const abs = resolve(dataDir);
145
+ if (!existsSync(abs))
146
+ throw new Error('文献库数据目录不存在');
147
+ return realpathSync(abs);
148
+ }
149
+ /** target 必须落在 root 之内(严格子路径,不含 root 本身),否则视为越界。 */
150
+ function assertInside(root, target) {
151
+ const r = root.endsWith(sep) ? root : root + sep;
152
+ if (target !== root && !target.startsWith(r))
153
+ throw new Error('路径越界,已拒绝');
154
+ }
155
+ /**
156
+ * 安全解析专题目录:词法校验 + realpath 严格子路径包含。
157
+ * 返回可直接 rename/delete 的目录路径(join 后的路径,非 realpath——顶层符号链接对
158
+ * rmSync/renameSync 只操作链接本身,不跟随,语义保持不变)。任何调用方(不只路由)
159
+ * 传入越界 topic 都会被拒:`..`、绝对路径、`a/../b`、指向库外的符号链接等。
160
+ */
161
+ function resolveSafeTopic(dataDir, topic) {
162
+ const err = validateEntryName(topic);
163
+ if (err)
164
+ throw new Error(err);
165
+ const root = realDataDir(dataDir);
166
+ const dir = join(root, topic.trim());
167
+ if (!existsSync(dir))
168
+ throw new Error(`专题不存在: ${topic.trim()}`);
169
+ let real;
170
+ try {
171
+ real = realpathSync(dir);
172
+ }
173
+ catch {
174
+ throw new Error(`专题不存在: ${topic.trim()}`);
175
+ }
176
+ assertInside(root, real);
177
+ return dir;
178
+ }
179
+ /**
180
+ * 安全解析「待创建」的专题目录(upload 用):词法校验 + realpath 严格子路径包含。
181
+ * 与 resolveSafeTopic 不同:专题可以尚不存在(upload 会 mkdir 创建),但已存在的条目
182
+ * (目录/文件/符号链接)仍必须 realpath 落在 dataDir 之内——挡住「data/evil → 库外」这类
183
+ * 符号链接逃逸写。返回可直接 mkdir/writeFile 的目录路径(join 后的路径,非 realpath)。
184
+ */
185
+ export function resolveUploadTopic(dataDir, topic) {
186
+ const err = validateEntryName(topic);
187
+ if (err)
188
+ throw new Error(err);
189
+ const root = realDataDir(dataDir);
190
+ const dir = join(root, topic.trim());
191
+ let exists = false;
192
+ try {
193
+ lstatSync(dir); // lstat 不跟随符号链接:悬空链接也算「已存在」,交给 realpath 兜底
194
+ exists = true;
195
+ }
196
+ catch {
197
+ exists = false;
198
+ }
199
+ if (!exists)
200
+ return dir; // 尚不存在:mkdir(recursive) 会创建真实目录(词法层已保证无分隔符/../隐藏名)
201
+ let real;
202
+ try {
203
+ real = realpathSync(dir);
204
+ }
205
+ catch {
206
+ throw new Error(`专题不存在: ${topic.trim()}`);
207
+ }
208
+ assertInside(root, real);
209
+ return dir;
210
+ }
118
211
  /** 构造文献引用(不做存在性校验;给只按路径判定的调用方用,如翻译 busy 检查)。 */
119
212
  export function paperRefFor(dataDir, topic, name) {
120
213
  const pdfPath = join(dataDir, topic, name + '.pdf');
@@ -129,53 +222,59 @@ export function derivedFilesFor(pdfPath) {
129
222
  const stem = pdfPath.slice(0, -'.pdf'.length);
130
223
  return PAPER_SIDECARS.map((s) => stem + s);
131
224
  }
132
- /** 改名专题(目录 rename)。from 不存在 / to 已存在 / 名字非法 → throw 中文 Error。
225
+ /** 改名专题(目录 rename)。from/to 非法(含越界)→ throw;from 不存在 / to 已存在 → throw 中文 Error。
133
226
  * 已知局限:大小写不敏感文件系统(macOS/Windows)上纯大小写改名会被「目标已存在」
134
227
  * 拒绝——不做临时名两跳绕过,保持简单。 */
135
228
  export function renameTopic(dataDir, from, to) {
136
229
  const err = validateEntryName(to);
137
230
  if (err)
138
231
  throw new Error(err);
139
- if (to.trim() === from)
232
+ if (to.trim() === from.trim())
140
233
  return;
141
- const src = join(dataDir, from);
142
- if (!existsSync(src))
143
- throw new Error(`专题不存在: ${from}`);
144
- const dst = join(dataDir, to.trim());
234
+ const src = resolveSafeTopic(dataDir, from); // 词法 + realpath 子路径校验 from
235
+ const dst = join(realDataDir(dataDir), to.trim());
145
236
  if (existsSync(dst))
146
237
  throw new Error(`已存在同名专题: ${to.trim()}`);
147
238
  renameSync(src, dst);
148
239
  }
149
240
  /** 改名文献:连带派生文件(转录/页码索引/译文/学习档案)逐个跟随,不存在的跳过。
150
- * 返回实际改名的文件名清单(含 .pdf 本体)。 */
241
+ * 返回实际改名的文件名清单(含 .pdf 本体)。topic/from/to 非法(含越界)→ throw。 */
151
242
  export function renamePaper(dataDir, topic, from, to) {
152
- const err = validateEntryName(to);
153
- if (err)
154
- throw new Error(err);
155
- to = to.trim();
156
- if (to === from)
243
+ const errTo = validateEntryName(to);
244
+ if (errTo)
245
+ throw new Error(errTo);
246
+ const errFrom = validateEntryBoundary(from);
247
+ if (errFrom)
248
+ throw new Error(errFrom);
249
+ const toTrim = to.trim();
250
+ const fromTrim = from.trim();
251
+ if (toTrim === fromTrim)
157
252
  return [];
158
- const dir = join(dataDir, topic);
159
- if (!existsSync(join(dir, from + '.pdf')))
160
- throw new Error(`文献不存在: ${topic}/${from}`);
161
- if (existsSync(join(dir, to + '.pdf')))
162
- throw new Error(`已存在同名文献: ${to}`);
253
+ const dir = resolveSafeTopic(dataDir, topic); // 词法 + realpath 子路径校验 topic
254
+ if (!existsSync(join(dir, fromTrim + '.pdf')))
255
+ throw new Error(`文献不存在: ${topic.trim()}/${fromTrim}`);
256
+ if (existsSync(join(dir, toTrim + '.pdf')))
257
+ throw new Error(`已存在同名文献: ${toTrim}`);
163
258
  const renamed = [];
164
259
  for (const suf of PAPER_SIDECARS) {
165
- const src = join(dir, from + suf);
260
+ const src = join(dir, fromTrim + suf);
166
261
  if (!existsSync(src))
167
262
  continue;
168
- renameSync(src, join(dir, to + suf));
169
- renamed.push(from + suf);
263
+ renameSync(src, join(dir, toTrim + suf));
264
+ renamed.push(fromTrim + suf);
170
265
  }
171
266
  return renamed;
172
267
  }
173
- /** 删除文献及其全部派生文件。返回实际删除的文件名清单。 */
268
+ /** 删除文献及其全部派生文件。返回实际删除的文件名清单。topic/name 非法(含越界)→ throw。 */
174
269
  export function deletePaper(dataDir, topic, name) {
175
- const dir = join(dataDir, topic);
176
- const pdfPath = join(dir, name + '.pdf');
270
+ const errName = validateEntryBoundary(name);
271
+ if (errName)
272
+ throw new Error(errName);
273
+ const dir = resolveSafeTopic(dataDir, topic); // 词法 + realpath 子路径校验 topic
274
+ const nameTrim = name.trim();
275
+ const pdfPath = join(dir, nameTrim + '.pdf');
177
276
  if (!existsSync(pdfPath))
178
- throw new Error(`文献不存在: ${topic}/${name}`);
277
+ throw new Error(`文献不存在: ${topic.trim()}/${nameTrim}`);
179
278
  const deleted = [];
180
279
  for (const f of derivedFilesFor(pdfPath)) {
181
280
  if (!existsSync(f))
@@ -185,10 +284,8 @@ export function deletePaper(dataDir, topic, name) {
185
284
  }
186
285
  return deleted;
187
286
  }
188
- /** 删除专题(递归,含其下全部文献与派生文件)。 */
287
+ /** 删除专题(递归,含其下全部文献与派生文件)。topic 非法(含越界)→ throw(不触碰库外)。 */
189
288
  export function deleteTopic(dataDir, topic) {
190
- const dir = join(dataDir, topic);
191
- if (!existsSync(dir))
192
- throw new Error(`专题不存在: ${topic}`);
289
+ const dir = resolveSafeTopic(dataDir, topic);
193
290
  rmSync(dir, { recursive: true, force: true });
194
291
  }
package/lib/client.js CHANGED
@@ -631,12 +631,17 @@ window.__ModuleLoader__.load({
631
631
 
632
632
  // 激活会话:客户端会话目录(catalog)感知到新会话有时延,轮询到「已列出」再
633
633
  // openSession,并以 current === sessionId 为成功标准(否则原生对话区不切换)。
634
+ // ⚠️ 仅凭 catalog 的 current 判断不够(用户实测「点插件页后再点论文没反应」):
635
+ // 插件页等非对话视图占着主视图时,目录里 cur 仍是目标会话,但视图并不在对话——
636
+ // 提前返回会让主视图永远停在插件页、PDF 锚点不回来。补一个可见事实作成功标准:
637
+ // PDF 页签锚点回来(对话视图已恢复);没回来就持续 openSession 往回推。
634
638
  const activateSession = useCallback((sessionId) => {
635
639
  let tries = 20
636
640
  const tick = () => {
637
641
  try {
638
642
  const snap = uiWorkspace.sessions?.list?.getSnapshot?.()
639
- if (currentSessionOf(snap) === sessionId) return // 已是当前会话
643
+ const anchorBack = !!document.querySelector('[data-dsh-surface="rightbar-tab-anchor"]')
644
+ if (currentSessionOf(snap) === sessionId && anchorBack) return // 会话已切 + 视图已回
640
645
  const listed = !!(snap && (snap.byId?.[sessionId] || snap.ids?.includes(sessionId)))
641
646
  if (listed) uiWorkspace.openSession(sessionId)
642
647
  if (--tries > 0) setTimeout(tick, 300)
@@ -660,7 +665,14 @@ window.__ModuleLoader__.load({
660
665
  if (listed) {
661
666
  // 对目录里早已存在的会话这步能真正预开(store 已 adopt);
662
667
  // 对刚创建的会话恒 no-op —— 靠切换护栏兜底(见 beginSwitchGuard)。
663
- sidebarRight.openTabIn(sessionId, 'dpr-reader', { revealIfOpened: true })
668
+ // ⚠️ openTabIn 失败不能堵住 done()(用户实测「点插件页后再点论文没反应」):
669
+ // 插件页等非对话视图占着主视图时,目标会话在右栏没有 seat,openTabIn 每次都抛——
670
+ // 若当可重试错误轮询,done() 永远不执行 → activateSession(openSession 切回对话
671
+ // 视图)不被调用 → 主视图停在插件页、PDF 锚点永不回来。预开只是优化,失败就
672
+ // 直接放行,切换护栏 + openPdfTab 轮询兜底。
673
+ try {
674
+ sidebarRight.openTabIn(sessionId, 'dpr-reader', { revealIfOpened: true })
675
+ } catch { /* 无 seat(插件页/会话未收编)——切换后由 openPdfTab 补开 */ }
664
676
  done?.()
665
677
  return
666
678
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ggboy123/dsh-paper-reader",
3
- "version": "1.3.3",
3
+ "version": "1.3.5",
4
4
  "description": "DeepSeek Harness 插件:论文伴读工作台 — PDF 转录 / 检索 / 原生对话伴读 + 内置阅读器页面。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -45,6 +45,8 @@
45
45
  "scripts": {
46
46
  "build": "tsc -p tsconfig.json",
47
47
  "typecheck": "tsc -p tsconfig.json --noEmit",
48
+ "pretest": "npm run build",
49
+ "test": "node --test \"test/*.test.mjs\"",
48
50
  "smoke:engine": "node scripts/engine-smoke.mjs",
49
51
  "prepublishOnly": "tsc -p tsconfig.json"
50
52
  },