dsh-multi2api 0.1.5 → 0.1.6

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 CHANGED
@@ -66,6 +66,25 @@ dsh plugin --profile web install dsh-multi2api
66
66
  - 如果之前手动跑过 `multi2api`,把它的 `auths/*.json` 复制到本插件的数据目录,
67
67
  或者在面板里用「导入账号文件」导入即可。
68
68
 
69
+ ## 更新日志
70
+
71
+ ### 0.1.6
72
+
73
+ 修了三个会让你用不下去的毛病:
74
+
75
+ - **桌面端(DSH Desktop / Electron)加不上账号**:管理接口的写操作守卫原来要求请求必须带
76
+ `Origin` 头,而桌面端经 Electron 网络层转发出来的请求不带这个头,于是加号、签到、删号、
77
+ 保存设置、重启**全部 403 `origin-required`**。现在去掉了这条硬性要求,改成
78
+ 「Host 必须是回环地址」+「Origin 一旦出现就必须也是回环」+「必须 `Content-Type: application/json`」,
79
+ 防跨站伪造和防 DNS rebinding 的能力不变。
80
+ - **点了「添加账号」两秒后自己中断**:上游在"还没登录完"时返回的是
81
+ `HTTP 200 + code=11217 (login ing...)`,旧代码把它当成致命错误抛出去,直接掐断了整个登录流程,
82
+ 账号永远加不上(那个可以手动复制的登录链接也跟着一起消失了)。现在未完成会被正确识别为
83
+ "继续等待"。
84
+ - **改成手动确认登录**:不再后台自动轮询(它容易判断错)。现在点「添加账号」会**立刻显示完整
85
+ 登录链接并提供「复制链接」按钮**——浏览器拉不起来也能自己粘到浏览器里打开。你在浏览器里
86
+ 登录完成后,回到面板点「我已登录完成」即可。没检测到会提示"再点一次",不会把面板关掉。
87
+
69
88
  ## 许可
70
89
 
71
90
  MIT
package/lib/client.js CHANGED
@@ -37,11 +37,18 @@ window.__ModuleLoader__.load({
37
37
  confirmDelete: "Delete this account? The service will restart.",
38
38
  model: "Model",
39
39
  login: "Add account",
40
- loginHint: "Scan or open the link below in a browser to sign in. This panel will finish automatically.",
40
+ loginHint: "Open the link below in a browser to sign in. If the browser did not open, copy the link below and open it yourself.",
41
41
  openLogin: "Open sign-in page",
42
42
  waitingLogin: "Waiting for sign-in…",
43
43
  loginDone: "Account added.",
44
44
  cancel: "Cancel",
45
+ copyLink: "Copy link",
46
+ copied: "Copied",
47
+ openFailed: "Browser did not open? Copy the link below and open it yourself.",
48
+ confirmLogin: "I have signed in",
49
+ confirmHint: "Finish signing in in the browser, then come back here and click the button below.",
50
+ confirming: "Checking…",
51
+ loginNotYet: "Not confirmed yet. Make sure you finished signing in, wait a few seconds and click again.",
45
52
  importAccount: "Import account files",
46
53
  importHint:
47
54
  "Pick one or several credential JSON files exported from another machine, or paste the JSON below. Each file adds one account.",
@@ -116,11 +123,18 @@ window.__ModuleLoader__.load({
116
123
  confirmDelete: "确定删除这个账号吗?服务会重启。",
117
124
  model: "模型",
118
125
  login: "添加账号",
119
- loginHint: "用浏览器打开下面的登录链接完成登录,本面板会自动完成后续步骤。",
126
+ loginHint: "用浏览器打开下面的登录链接完成登录。浏览器没自动打开,就复制下面的链接自己粘到浏览器里打开。",
120
127
  openLogin: "打开登录页",
121
128
  waitingLogin: "等待登录中…",
122
129
  loginDone: "账号已添加。",
123
130
  cancel: "取消",
131
+ copyLink: "复制链接",
132
+ copied: "已复制",
133
+ openFailed: "浏览器没能自动打开?复制下面的链接,自己粘到浏览器地址栏里打开就能登录。",
134
+ confirmLogin: "我已登录完成",
135
+ confirmHint: "在浏览器里登录成功后,回到这里点下面的按钮。",
136
+ confirming: "正在确认…",
137
+ loginNotYet: "还没检测到登录完成。确认一下浏览器里已经登录成功,等几秒再点一次。",
124
138
  importAccount: "导入账号文件",
125
139
  importHint:
126
140
  "选一个或多个从别的机器导出的凭证 JSON 文件,每个文件添加一个账号;也可以直接把内容粘在下面。",
@@ -186,6 +200,33 @@ window.__ModuleLoader__.load({
186
200
  });
187
201
  }
188
202
 
203
+ // 复制到剪贴板:优先用 Clipboard API,失败就退回 textarea + execCommand
204
+ // (桌面端 WebView 里 Clipboard API 有时没有权限)。
205
+ async function copyText(text) {
206
+ try {
207
+ if (navigator?.clipboard?.writeText) {
208
+ await navigator.clipboard.writeText(text);
209
+ return true;
210
+ }
211
+ } catch {
212
+ // 落到下面的兜底
213
+ }
214
+ try {
215
+ const area = document.createElement("textarea");
216
+ area.value = text;
217
+ area.setAttribute("readonly", "");
218
+ area.style.position = "fixed";
219
+ area.style.top = "-1000px";
220
+ document.body.appendChild(area);
221
+ area.select();
222
+ const ok = document.execCommand("copy");
223
+ document.body.removeChild(area);
224
+ return ok;
225
+ } catch {
226
+ return false;
227
+ }
228
+ }
229
+
189
230
  function formatCredits(value) {
190
231
  const number = Number(value);
191
232
  if (!Number.isFinite(number)) return "—";
@@ -317,6 +358,8 @@ window.__ModuleLoader__.load({
317
358
  const [busy, setBusy] = React.useState("");
318
359
  const [notice, setNotice] = React.useState("");
319
360
  const [login, setLogin] = React.useState(undefined);
361
+ const [copied, setCopied] = React.useState(false);
362
+ const [openBlocked, setOpenBlocked] = React.useState(false);
320
363
  const [importOpen, setImportOpen] = React.useState(false);
321
364
  const [importText, setImportText] = React.useState("");
322
365
  const [pasteOpen, setPasteOpen] = React.useState(false);
@@ -355,36 +398,37 @@ window.__ModuleLoader__.load({
355
398
  };
356
399
  }, [load]);
357
400
 
358
- // 登录轮询:拿到 state 后每 2 秒问一次。
359
- React.useEffect(() => {
360
- if (login === undefined || login.state === undefined) return undefined;
361
- let stopped = false;
362
- const tick = async () => {
363
- try {
364
- const body = await post("/login/poll", { state: login.state });
365
- if (stopped) return;
366
- if (body?.done === true) {
367
- if (body.ok === true) {
368
- setLogin(undefined);
369
- setNotice(t("loginDone"));
370
- await load();
371
- } else {
372
- setLogin(undefined);
373
- setError(String(body?.error ?? t("saveFailed")));
374
- }
401
+ // 登录不做后台自动轮询。用户在浏览器里登录成功后自己点「我已登录完成」,
402
+ // 由这里主动请求一次确认(服务端内部只做一轮很短的有限重试)。
403
+ // 好处:什么时候算"好了"由用户说了算,不会出现"它自己检测不明白"的情况。
404
+ const confirmLogin = async () => {
405
+ if (login === undefined || login.state === undefined) return;
406
+ setBusy("confirm");
407
+ setError("");
408
+ setNotice("");
409
+ try {
410
+ const body = await post("/login/confirm", { state: login.state });
411
+ if (body?.done === true) {
412
+ setLogin(undefined);
413
+ if (body.ok === true) {
414
+ setNotice(t("loginDone"));
415
+ await load();
416
+ } else {
417
+ setError(String(body?.error ?? t("saveFailed")));
375
418
  }
376
- } catch (pollError) {
377
- if (!stopped) setError(String(pollError.message ?? pollError));
419
+ return;
378
420
  }
379
- };
380
- const timer = window.setInterval(() => {
381
- void tick();
382
- }, 2000);
383
- return () => {
384
- stopped = true;
385
- window.clearInterval(timer);
386
- };
387
- }, [login, load, t]);
421
+ if (body?.ok !== true) {
422
+ setError(
423
+ body?.retry === true ? t("loginNotYet") : String(body?.error ?? t("saveFailed")),
424
+ );
425
+ }
426
+ } catch (confirmError) {
427
+ setError(String(confirmError.message ?? confirmError));
428
+ } finally {
429
+ setBusy("");
430
+ }
431
+ };
388
432
 
389
433
  const run = async (name, action) => {
390
434
  setBusy(name);
@@ -507,7 +551,15 @@ window.__ModuleLoader__.load({
507
551
  const body = await post("/login/start", {});
508
552
  if (body?.ok !== true) throw new Error(body?.error ?? t("saveFailed"));
509
553
  setLogin({ state: body.state, authUrl: body.authUrl });
510
- window.open(body.authUrl, "_blank", "noopener,noreferrer");
554
+ setCopied(false);
555
+ // 自动拉起浏览器;拉不起来就把网址留在下面让用户自己复制。
556
+ let opened;
557
+ try {
558
+ opened = window.open(body.authUrl, "_blank", "noopener,noreferrer");
559
+ } catch {
560
+ opened = undefined;
561
+ }
562
+ setOpenBlocked(!opened);
511
563
  });
512
564
  },
513
565
  },
@@ -539,15 +591,58 @@ window.__ModuleLoader__.load({
539
591
  },
540
592
  t("openLogin"),
541
593
  ),
542
- React.createElement("p", { className: "m2a-hint" }, t("waitingLogin")),
594
+ openBlocked
595
+ ? React.createElement("p", { className: "m2a-hint" }, t("openFailed"))
596
+ : null,
597
+ React.createElement("textarea", {
598
+ className: "m2a-url",
599
+ readOnly: true,
600
+ rows: 3,
601
+ value: login.authUrl,
602
+ onFocus: (event) => {
603
+ event.target.select();
604
+ },
605
+ }),
543
606
  React.createElement(
544
607
  Button,
545
608
  {
546
609
  onClick: () => {
547
- setLogin(undefined);
610
+ void (async () => {
611
+ const done = await copyText(login.authUrl);
612
+ setCopied(done);
613
+ window.setTimeout(() => setCopied(false), 2000);
614
+ })();
548
615
  },
549
616
  },
550
- t("cancel"),
617
+ copied ? t("copied") : t("copyLink"),
618
+ ),
619
+ React.createElement("p", { className: "m2a-hint" }, t("confirmHint")),
620
+ React.createElement(
621
+ "div",
622
+ { className: "m2a-card-actions" },
623
+ React.createElement(
624
+ Button,
625
+ {
626
+ variant: "primary",
627
+ disabled: busy === "confirm",
628
+ onClick: () => {
629
+ void confirmLogin();
630
+ },
631
+ },
632
+ busy === "confirm" ? t("confirming") : t("confirmLogin"),
633
+ ),
634
+ React.createElement(
635
+ Button,
636
+ {
637
+ disabled: busy === "confirm",
638
+ onClick: () => {
639
+ setLogin(undefined);
640
+ setOpenBlocked(false);
641
+ setError("");
642
+ },
643
+ },
644
+ t("cancel"),
645
+ ),
551
646
  ),
552
647
  )
553
648
  : null,
@@ -897,6 +992,7 @@ window.__ModuleLoader__.load({
897
992
  ".m2a-hint{color:var(--dsw-alias-label-tertiary);font-size:13px;margin:0}",
898
993
  ".m2a-login{display:flex;flex-direction:column;gap:8px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:10px}",
899
994
  ".m2a-link{color:var(--dsw-alias-brand-primary);font-size:13px}",
995
+ ".m2a-url{width:100%;box-sizing:border-box;font-family:var(--dsw-font-mono,ui-monospace,monospace);font-size:11px;line-height:1.5;resize:vertical;background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l1);border-radius:6px;padding:6px 8px;word-break:break-all}",
900
996
  ".m2a-section{display:flex;flex-direction:column;gap:8px;border-top:1px solid var(--dsw-alias-border-l1);padding-top:10px}",
901
997
  ".m2a-subtitle{margin:0;font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary)}",
902
998
  ".m2a-empty{color:var(--dsw-alias-label-tertiary);font-size:13px;margin:0}",
package/lib/index.js CHANGED
@@ -68,6 +68,12 @@ const WB = {
68
68
  pollIntervalMs: 2000,
69
69
  };
70
70
 
71
+ // 一次「添加账号」从点击到完成的总时限。
72
+ const LOGIN_TTL_MS = 10 * 60 * 1000;
73
+ // 用户点「我已登录完成」后,服务端做一轮**有界**重试的次数(间隔用 WB.pollIntervalMs)。
74
+ // 刻意不做无限自动轮询:后台瞎猜不如让用户点一下。
75
+ const LOGIN_CONFIRM_ATTEMPTS = 6;
76
+
71
77
  const NO_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
72
78
 
73
79
  const COMPAT = {
@@ -192,16 +198,21 @@ async function readBody(req, limit = MODELS_BODY_LIMIT) {
192
198
  return JSON.parse(text);
193
199
  }
194
200
 
195
- // 写操作必须来自本机页面且带 Origin、Content-Type 为 JSON(防 DNS rebinding / 跨站表单)。
201
+ // 写操作防护分两层,都在这里把关:
202
+ // 1. 防 DNS rebinding —— Host 必须是回环地址(见 loopbackRequest);
203
+ // 2. 防跨站伪造 —— Origin 一旦出现,就必须也是回环地址。
204
+ //
205
+ // 注意:**不能**要求 Origin 必须存在。DSH 桌面端(Electron)把渲染进程的请求经由
206
+ // Electron 网络层转发,转发时不带 Origin 头,一旦这里强校验,桌面端整个管理面板
207
+ // 的写操作(加号 / 签到 / 删号 / 保存设置 / 重启)会全部 403 origin-required。
208
+ // 少了 Origin 校验并不等于失去 CSRF 防护:本接口要求 Content-Type: application/json,
209
+ // 跨源 POST 会触发 CORS 预检而被浏览器拦下;跨站表单只能发 text/plain、
210
+ // application/x-www-form-urlencoded 或 multipart/form-data,会被下面的 415 挡掉。
196
211
  function checkLoopbackPost(req, res) {
197
212
  if (!loopbackRequest(req)) {
198
213
  json(res, 403, { error: "request-not-trusted" });
199
214
  return false;
200
215
  }
201
- if (typeof req.headers.origin !== "string") {
202
- json(res, 403, { error: "origin-required" });
203
- return false;
204
- }
205
216
  const contentType = String(req.headers["content-type"] ?? "");
206
217
  if (!contentType.toLowerCase().startsWith("application/json")) {
207
218
  json(res, 415, { error: "content-type must be application/json" });
@@ -328,27 +339,31 @@ async function workbuddyLoginStart() {
328
339
  return { state, authUrl };
329
340
  }
330
341
 
331
- // 第 2 步:轮询换 token。返回 {accessToken, refreshToken, expiresIn, domain},未完成返回 undefined。
342
+ // 第 2 步:轮询换 token。返回 {accessToken, refreshToken, expiresIn, domain};
343
+ // **登录尚未完成时返回 undefined**(上游此时回 HTTP 200 + code=11217 "login ing...",
344
+ // 或 4xx),这属于"还没好",必须继续轮询,绝不能被当成失败。
345
+ // 只有网络错误和 5xx 才算致命 —— 与 Go 侧 cmd/login/workbuddy.go 的 wbPollToken 判定一致。
332
346
  async function workbuddyLoginPoll(state) {
333
347
  const account = { domain: "www.codebuddy.cn", accessToken: "", uid: "", enterpriseId: "" };
334
348
  const url = `${WB.chatBaseCN}/v2/plugin/auth/token?state=${encodeURIComponent(state)}`;
335
- const response = await fetch(url, { headers: commonHeaders(account), signal: timeoutSignal(30000) });
336
- const text = await response.text();
349
+ let response;
350
+ try {
351
+ response = await fetch(url, { headers: commonHeaders(account), signal: timeoutSignal(30000) });
352
+ } catch (error) {
353
+ throw new Error(`token endpoint unreachable: ${messageOf(error)}`);
354
+ }
337
355
  if (response.status === 0 || response.status >= 500) {
338
356
  throw new Error(`token endpoint error: upstream ${response.status}`);
339
357
  }
340
- if (response.status >= 400) {
341
- throw new Error(`http_error: upstream ${response.status}: ${text.slice(0, 200)}`);
342
- }
358
+ const text = await response.text();
359
+ if (response.status >= 400) return undefined;
343
360
  let envelope;
344
361
  try {
345
362
  envelope = text.length > 0 ? JSON.parse(text) : {};
346
363
  } catch {
347
- throw new Error("invalid upstream response");
348
- }
349
- if (envelope?.code !== undefined && Number(envelope.code) !== 0) {
350
- throw new Error(`code=${envelope.code} msg=${envelope.msg ?? ""}`);
364
+ return undefined;
351
365
  }
366
+ if (envelope?.code !== undefined && Number(envelope.code) !== 0) return undefined;
352
367
  const accessToken = String(envelope?.data?.accessToken ?? "");
353
368
  if (accessToken.length === 0) return undefined;
354
369
  return {
@@ -1446,9 +1461,9 @@ function registerRoutes(ctx, runtime) {
1446
1461
  try {
1447
1462
  const started = await workbuddyLoginStart();
1448
1463
  runtime.pendingLogins.set(started.state, Date.now());
1449
- // 清理超过 10 分钟还没完成的登录。
1464
+ // 清理超过时限还没完成的登录。
1450
1465
  for (const [state, at] of runtime.pendingLogins) {
1451
- if (Date.now() - at > 10 * 60 * 1000) runtime.pendingLogins.delete(state);
1466
+ if (Date.now() - at > LOGIN_TTL_MS) runtime.pendingLogins.delete(state);
1452
1467
  }
1453
1468
  json(res, 200, { ok: true, authUrl: started.authUrl, state: started.state });
1454
1469
  } catch (error) {
@@ -1456,8 +1471,10 @@ function registerRoutes(ctx, runtime) {
1456
1471
  }
1457
1472
  });
1458
1473
 
1459
- // POST /login/poll body: {state}
1460
- add("/login/poll", async (req, res) => {
1474
+ // POST /login/confirm body: {state}
1475
+ // 由用户主动触发:他在浏览器里登录完成后,回到面板点「我已登录完成」。
1476
+ // 服务端只做一轮**有界**重试,不做无限自动轮询 —— 后台瞎猜不如让用户说一句。
1477
+ add("/login/confirm", async (req, res) => {
1461
1478
  if (req.method !== "POST") {
1462
1479
  json(res, 405, { error: "method not allowed" });
1463
1480
  return;
@@ -1470,9 +1487,37 @@ function registerRoutes(ctx, runtime) {
1470
1487
  json(res, 400, { ok: false, error: 'expected {"state": string}' });
1471
1488
  return;
1472
1489
  }
1473
- const token = await workbuddyLoginPoll(state);
1490
+ const startedAt = runtime.pendingLogins.get(state);
1491
+ if (startedAt === undefined) {
1492
+ // 会话里没有这个 state:多半是插件被重载过,或 state 已被回收。
1493
+ json(res, 200, {
1494
+ ok: false,
1495
+ done: true,
1496
+ error: "登录会话已失效,请重新点击「添加账号」",
1497
+ });
1498
+ return;
1499
+ }
1500
+ if (Date.now() - startedAt > LOGIN_TTL_MS) {
1501
+ runtime.pendingLogins.delete(state);
1502
+ json(res, 200, {
1503
+ ok: false,
1504
+ done: true,
1505
+ error: "登录超时(超过 10 分钟),请重新点击「添加账号」",
1506
+ });
1507
+ return;
1508
+ }
1509
+ // 有界重试:最多试 LOGIN_CONFIRM_ATTEMPTS 次,每次间隔 WB.pollIntervalMs。
1510
+ let token;
1511
+ for (let attempt = 1; attempt <= LOGIN_CONFIRM_ATTEMPTS; attempt += 1) {
1512
+ token = await workbuddyLoginPoll(state);
1513
+ if (token !== undefined) break;
1514
+ if (attempt < LOGIN_CONFIRM_ATTEMPTS) {
1515
+ await new Promise((resolve) => setTimeout(resolve, WB.pollIntervalMs));
1516
+ }
1517
+ }
1474
1518
  if (token === undefined) {
1475
- json(res, 200, { ok: true, done: false });
1519
+ // 还没好:保留登录会话原样返回,让用户稍等几秒再点一次。
1520
+ json(res, 200, { ok: false, done: false, retry: true });
1476
1521
  return;
1477
1522
  }
1478
1523
  let account = { uid: "", enterpriseId: "", nickname: "" };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-multi2api",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Multi2API 账号池:把多个 WorkBuddy 账号变成 DSH 里一个个互不干扰的模型。",
5
5
  "type": "module",
6
6
  "license": "MIT",