dsh-mishu 0.5.4 → 0.6.2
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 +18 -6
- package/lib/client.js +542 -284
- package/lib/core/github/device.js +129 -0
- package/lib/core/github/token.js +2 -0
- package/lib/core/index.js +5 -4
- package/lib/core/mail/account.js +61 -5
- package/lib/core/mail/sync.js +34 -5
- package/lib/core/restart.js +35 -5
- package/lib/core/secrets.js +102 -2
- package/lib/core/settings.js +2 -2
- package/lib/dsh/api.js +34 -7
- package/lib/dsh/config.js +2 -1
- package/lib/dsh/runtime.js +7 -4
- package/lib/types/core/github/device.d.ts +64 -0
- package/lib/types/core/github/token.d.ts +2 -0
- package/lib/types/core/index.d.ts +6 -4
- package/lib/types/core/mail/account.d.ts +25 -1
- package/lib/types/core/mail/sync.d.ts +8 -2
- package/lib/types/core/restart.d.ts +22 -7
- package/lib/types/core/secrets.d.ts +50 -4
- package/lib/types/dsh/config.d.ts +2 -0
- package/lib/types/dsh/runtime.d.ts +4 -4
- package/package.json +1 -1
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Sign in with GitHub" without pasting a token: GitHub's device flow.
|
|
3
|
+
*
|
|
4
|
+
* The secretary asks GitHub for a short code, you enter it at
|
|
5
|
+
* github.com/login/device (on any computer) and approve, and the secretary
|
|
6
|
+
* receives a token, checks it like a pasted one and keeps it in the
|
|
7
|
+
* keychain (or the secrets file). It needs the client id of a GitHub OAuth
|
|
8
|
+
* App with device flow enabled; the id is public, there is no secret.
|
|
9
|
+
*
|
|
10
|
+
* https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow
|
|
11
|
+
*/
|
|
12
|
+
import { bi, MishuError } from "../i18n.js";
|
|
13
|
+
import { saveGithubToken } from "./token.js";
|
|
14
|
+
/** Notifications, and repo so CI runs, pull requests and issues of private repositories can be looked up. */
|
|
15
|
+
export const DEVICE_SCOPES = 'notifications repo';
|
|
16
|
+
export class GithubDeviceLogin {
|
|
17
|
+
options;
|
|
18
|
+
current = { state: 'idle' };
|
|
19
|
+
/** Bumped by every start and cancel, so an older poll loop stops quietly. */
|
|
20
|
+
generation = 0;
|
|
21
|
+
constructor(options) {
|
|
22
|
+
this.options = options;
|
|
23
|
+
}
|
|
24
|
+
status() {
|
|
25
|
+
if (this.current.state === 'waiting' && this.now() > this.current.expiresAt) {
|
|
26
|
+
this.current = { state: 'failed', message: EXPIRED };
|
|
27
|
+
}
|
|
28
|
+
return this.current;
|
|
29
|
+
}
|
|
30
|
+
cancel() {
|
|
31
|
+
this.generation++;
|
|
32
|
+
this.current = { state: 'idle' };
|
|
33
|
+
}
|
|
34
|
+
/** Ask GitHub for a code; polling for your approval runs in the background. */
|
|
35
|
+
async start() {
|
|
36
|
+
if (this.options.clientId === '') {
|
|
37
|
+
throw new MishuError(bi('还没有配置 GitHub 应用,没法用 GitHub 登录。可以改用下面的"粘贴 token"。', 'No GitHub app is configured, so signing in with GitHub is not available. Paste a token below instead.'));
|
|
38
|
+
}
|
|
39
|
+
const generation = ++this.generation;
|
|
40
|
+
const answer = await this.form('https://github.com/login/device/code', { client_id: this.options.clientId, scope: DEVICE_SCOPES });
|
|
41
|
+
const deviceCode = String(answer.device_code ?? '');
|
|
42
|
+
const userCode = String(answer.user_code ?? '');
|
|
43
|
+
if (deviceCode === '' || userCode === '')
|
|
44
|
+
throw new MishuError(failure(answer));
|
|
45
|
+
const expiresAt = this.now() + Number(answer.expires_in ?? 900) * 1000;
|
|
46
|
+
this.current = { state: 'waiting', userCode, verificationUri: String(answer.verification_uri ?? 'https://github.com/login/device'), expiresAt };
|
|
47
|
+
void this.poll(generation, deviceCode, Number(answer.interval ?? 5), expiresAt);
|
|
48
|
+
return this.current;
|
|
49
|
+
}
|
|
50
|
+
async poll(generation, deviceCode, interval, expiresAt) {
|
|
51
|
+
const stale = () => generation !== this.generation;
|
|
52
|
+
try {
|
|
53
|
+
while (!stale()) {
|
|
54
|
+
await this.sleep(interval * 1000);
|
|
55
|
+
if (stale())
|
|
56
|
+
return;
|
|
57
|
+
if (this.now() > expiresAt) {
|
|
58
|
+
this.current = { state: 'failed', message: EXPIRED };
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const answer = await this.form('https://github.com/login/oauth/access_token', {
|
|
62
|
+
client_id: this.options.clientId,
|
|
63
|
+
device_code: deviceCode,
|
|
64
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
65
|
+
});
|
|
66
|
+
if (stale())
|
|
67
|
+
return;
|
|
68
|
+
if (typeof answer.access_token === 'string' && answer.access_token !== '') {
|
|
69
|
+
const { login } = await saveGithubToken(this.options.secrets, answer.access_token, this.options.fetch);
|
|
70
|
+
if (!stale())
|
|
71
|
+
this.current = { state: 'done', login };
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
switch (answer.error) {
|
|
75
|
+
case 'authorization_pending':
|
|
76
|
+
break;
|
|
77
|
+
case 'slow_down':
|
|
78
|
+
interval = Number(answer.interval ?? interval + 5);
|
|
79
|
+
break;
|
|
80
|
+
case 'expired_token':
|
|
81
|
+
this.current = { state: 'failed', message: EXPIRED };
|
|
82
|
+
return;
|
|
83
|
+
case 'access_denied':
|
|
84
|
+
this.current = { state: 'failed', message: bi('你在 GitHub 上取消了授权。', 'You cancelled the authorization on GitHub.') };
|
|
85
|
+
return;
|
|
86
|
+
default:
|
|
87
|
+
this.current = { state: 'failed', message: failure(answer) };
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (!stale())
|
|
94
|
+
this.current = { state: 'failed', message: error instanceof MishuError ? error.text : bi(`连不上 GitHub:${error.message}`, `Could not reach GitHub: ${error.message}`) };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async form(url, fields) {
|
|
98
|
+
const post = this.options.post ?? globalThis.fetch;
|
|
99
|
+
const response = await post(url, {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded', 'user-agent': 'dsh-mishu' },
|
|
102
|
+
body: new URLSearchParams(fields).toString(),
|
|
103
|
+
});
|
|
104
|
+
const text = await response.text();
|
|
105
|
+
try {
|
|
106
|
+
return JSON.parse(text);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return { error: `http_${response.status}`, error_description: text.slice(0, 200) };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
now() {
|
|
113
|
+
return (this.options.now ?? Date.now)();
|
|
114
|
+
}
|
|
115
|
+
sleep(ms) {
|
|
116
|
+
return (this.options.sleep ?? (wait => new Promise(resolve => setTimeout(resolve, wait))))(ms);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const EXPIRED = bi('登录码过期了,再点一次"用 GitHub 登录"。', 'The sign-in code expired; click "Sign in with GitHub" again.');
|
|
120
|
+
function failure(answer) {
|
|
121
|
+
const what = String(answer.error_description ?? answer.error ?? 'unknown error');
|
|
122
|
+
if (answer.error === 'http_404' || /^not found$/i.test(String(answer.error ?? answer.message ?? ''))) {
|
|
123
|
+
return bi('GitHub 找不到这个应用:Client ID 不对。', 'GitHub does not know this app: the client id is wrong.');
|
|
124
|
+
}
|
|
125
|
+
if (answer.error === 'device_flow_disabled') {
|
|
126
|
+
return bi('这个 GitHub 应用没有打开"设备登录"(Enable Device Flow)。', 'This GitHub app does not have device flow enabled.');
|
|
127
|
+
}
|
|
128
|
+
return bi(`GitHub 登录没成功:${what}`, `Signing in with GitHub failed: ${what}`);
|
|
129
|
+
}
|
package/lib/core/github/token.js
CHANGED
|
@@ -12,6 +12,8 @@ import { bi, MishuError } from "../i18n.js";
|
|
|
12
12
|
import { GitHubClient, GitHubError } from "./client.js";
|
|
13
13
|
/** The keychain entry for a token pasted on the settings page. */
|
|
14
14
|
export const GITHUB_SECRET = 'github';
|
|
15
|
+
/** The OAuth App behind "Sign in with GitHub" (device flow, "DSH 小秘书"); public, not a secret. */
|
|
16
|
+
export const GITHUB_CLIENT_ID = 'Ov23liejlNOyARQKgy5x';
|
|
15
17
|
/** Opens GitHub's "new classic token" page with the two scopes ticked. */
|
|
16
18
|
export const NEW_TOKEN_URL = 'https://github.com/settings/tokens/new?scopes=notifications,repo&description=DSH%20mishu';
|
|
17
19
|
/** `ghPath` is for tests: a fixed path, or `null` for "not installed". */
|
package/lib/core/index.js
CHANGED
|
@@ -11,20 +11,21 @@ export { GitHubClient, GitHubError } from "./github/client.js";
|
|
|
11
11
|
export { gatherFacts, Lookups, parseCheckSuiteTitle } from "./github/facts.js";
|
|
12
12
|
export { CLOSING_REASONS, judge } from "./github/rules.js";
|
|
13
13
|
export { syncGithub } from "./github/sync.js";
|
|
14
|
-
export {
|
|
14
|
+
export { DEVICE_SCOPES, GithubDeviceLogin } from "./github/device.js";
|
|
15
|
+
export { deleteGithubToken, GITHUB_CLIENT_ID, GITHUB_SECRET, NEW_TOKEN_URL, resolveGithubToken, saveGithubToken } from "./github/token.js";
|
|
15
16
|
export { bi, decode, detectLang, errorText, LANGS, MishuError, pick } from "./i18n.js";
|
|
16
17
|
export { addSeries, describeActiveSeries, describeSeries, resolveSeries, stopSeries, tickManual, upcoming, } from "./manual.js";
|
|
17
18
|
export { DEFAULT_DEMOTION, isActive, isDemoted, setStatusByUser, snoozeByUser } from "./matters.js";
|
|
18
19
|
export { CATEGORIES, CATEGORY_LABELS, DEFAULT_LEAD_DAYS, REPEATS } from "./recur.js";
|
|
19
20
|
export { MishuStore } from "./store.js";
|
|
20
21
|
export { addDays, dayOf, formatMoment } from "./time.js";
|
|
21
|
-
export { MAIL_PRESETS, presetById, secretAccount } from "./mail/account.js";
|
|
22
|
+
export { MAIL_PRESETS, normalizePassword, presetById, presetForAddress, providerMismatch, secretAccount } from "./mail/account.js";
|
|
22
23
|
export { ImapFetcher } from "./mail/imap.js";
|
|
23
24
|
export { judgeMail } from "./mail/rules.js";
|
|
24
25
|
export { describeMailError, MAIL_SOURCE, mailReportKey, syncMail } from "./mail/sync.js";
|
|
25
26
|
export { findProgram } from "./exec.js";
|
|
26
27
|
export { restartPlan, scheduleRestart } from "./restart.js";
|
|
27
|
-
export { MemorySecrets, secretEnvName, secretFromEnv, systemSecrets } from "./secrets.js";
|
|
28
|
+
export { FileSecrets, KeychainOrFile, MemorySecrets, secretEnvName, secretFromEnv, systemSecrets } from "./secrets.js";
|
|
28
29
|
export { deleteMailAccount, effectiveSettings, mailAccounts, saveMailAccount, saveSettings } from "./settings.js";
|
|
29
30
|
export const DEFAULT_HOME = '~/.mishu';
|
|
30
31
|
/** `~` and `~/…` expand to the home directory on every platform. */
|
|
@@ -37,5 +38,5 @@ export function expandHome(path) {
|
|
|
37
38
|
}
|
|
38
39
|
export function mishuPaths(home) {
|
|
39
40
|
const root = expandHome(home);
|
|
40
|
-
return { root, db: join(root, 'mishu.db') };
|
|
41
|
+
return { root, db: join(root, 'mishu.db'), secrets: join(root, 'secrets.json') };
|
|
41
42
|
}
|
package/lib/core/mail/account.js
CHANGED
|
@@ -6,37 +6,93 @@
|
|
|
6
6
|
* system keychain under `mail:<id>`, never in the notebook.
|
|
7
7
|
*/
|
|
8
8
|
import { bi, MishuError } from "../i18n.js";
|
|
9
|
+
/** Steps, and the same steps as one numbered paragraph. */
|
|
10
|
+
function guide(steps) {
|
|
11
|
+
return {
|
|
12
|
+
steps,
|
|
13
|
+
howTo: bi(steps.map((step, i) => `${i + 1}. ${step.text.zh}`).join(' '), steps.map((step, i) => `${i + 1}. ${step.text.en}`).join(' ')),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
const NETEASE = (name, url) => guide([
|
|
17
|
+
{ text: bi(`打开 ${name} 邮箱网页版并登录。`, `Open ${name} Mail on the web and sign in.`), link: { url, label: bi(`打开 ${name} 邮箱`, `Open ${name} Mail`) } },
|
|
18
|
+
{ text: bi('点上方的「设置」→「POP3/SMTP/IMAP」,开启「IMAP/SMTP 服务」。', 'Click Settings → POP3/SMTP/IMAP at the top and turn on "IMAP/SMTP".') },
|
|
19
|
+
{ text: bi('按提示用手机验证,页面会给你一个「客户端授权码」,复制后粘贴到下面的密码框(不是邮箱登录密码)。', 'Verify with your phone as asked; the page shows a client authorization code. Copy it into the password box below (not your mail password).') },
|
|
20
|
+
]);
|
|
9
21
|
export const MAIL_PRESETS = [
|
|
10
22
|
{
|
|
11
23
|
id: 'qq', label: bi('QQ 邮箱', 'QQ Mail'), host: 'imap.qq.com', port: 993, secure: true, supported: true,
|
|
12
|
-
|
|
24
|
+
domains: ['qq.com', 'vip.qq.com', 'foxmail.com'], passwordLength: 16,
|
|
25
|
+
...guide([
|
|
26
|
+
{ text: bi('打开 QQ 邮箱网页版并登录。', 'Open QQ Mail on the web and sign in.'), link: { url: 'https://mail.qq.com/', label: bi('打开 QQ 邮箱', 'Open QQ Mail') } },
|
|
27
|
+
{ text: bi('点「设置」→「账号与安全」→「安全设置」(旧版界面是「设置」→「账户」),找到「IMAP/SMTP 服务」,点「开启」。', 'Click Settings → Account and Security → Security (Settings → Account in the older layout), find "IMAP/SMTP" and turn it on.') },
|
|
28
|
+
{ text: bi('按提示用手机验证,页面会显示一个 16 位授权码,复制后粘贴到下面的密码框(不是 QQ 密码)。改过 QQ 密码后,旧授权码会失效。', 'Verify with your phone as asked; the page shows a 16-character authorization code. Copy it into the password box below (not your QQ password). Changing your QQ password expires old codes.') },
|
|
29
|
+
]),
|
|
13
30
|
},
|
|
14
31
|
{
|
|
15
32
|
id: '163', label: bi('网易 163 邮箱', 'NetEase 163'), host: 'imap.163.com', port: 993, secure: true, supported: true,
|
|
16
|
-
|
|
33
|
+
domains: ['163.com'], ...NETEASE('163', 'https://mail.163.com/'),
|
|
17
34
|
},
|
|
18
35
|
{
|
|
19
36
|
id: '126', label: bi('网易 126 邮箱', 'NetEase 126'), host: 'imap.126.com', port: 993, secure: true, supported: true,
|
|
20
|
-
|
|
37
|
+
domains: ['126.com'], ...NETEASE('126', 'https://mail.126.com/'),
|
|
21
38
|
},
|
|
22
39
|
{
|
|
23
40
|
id: 'gmail', label: bi('Gmail', 'Gmail'), host: 'imap.gmail.com', port: 993, secure: true, supported: true,
|
|
24
|
-
|
|
41
|
+
domains: ['gmail.com', 'googlemail.com'], passwordLength: 16,
|
|
42
|
+
...guide([
|
|
43
|
+
{
|
|
44
|
+
text: bi('开启 Google 账号的两步验证(已经开了就跳过这步)。', 'Turn on 2-Step Verification for your Google account (skip this if it is on).'),
|
|
45
|
+
link: { url: 'https://myaccount.google.com/signinoptions/twosv', label: bi('打开两步验证', 'Open 2-Step Verification') },
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
text: bi('打开「应用专用密码」页面,名字随便填(比如"小秘书"),点「创建」。如果页面说"设置不可用",说明第 1 步还没做。', 'Open the App passwords page, type any name ("secretary") and click Create. If the page says the setting is not available, step 1 is not done yet.'),
|
|
49
|
+
link: { url: 'https://myaccount.google.com/apppasswords', label: bi('打开应用专用密码', 'Open App passwords') },
|
|
50
|
+
},
|
|
51
|
+
{ text: bi('把弹出来的 16 位密码复制,粘贴到下面的密码框(带不带空格都行)。它只显示这一次。', 'Copy the 16-character password it shows into the password box below (spaces or not). It is shown only once.') },
|
|
52
|
+
]),
|
|
25
53
|
findMessage: id => `https://mail.google.com/mail/u/0/#search/rfc822msgid:${encodeURIComponent(id)}`,
|
|
26
54
|
},
|
|
27
55
|
{
|
|
28
56
|
id: 'icloud', label: bi('iCloud 邮件', 'iCloud Mail'), host: 'imap.mail.me.com', port: 993, secure: true, supported: true,
|
|
29
|
-
|
|
57
|
+
domains: ['icloud.com', 'me.com', 'mac.com'],
|
|
58
|
+
...guide([
|
|
59
|
+
{ text: bi('打开 Apple 账户页面并登录。', 'Open your Apple Account page and sign in.'), link: { url: 'https://account.apple.com/account/manage', label: bi('打开 Apple 账户', 'Open Apple Account') } },
|
|
60
|
+
{ text: bi('点「登录与安全」→「App 专用密码」,点「+」,名字随便填。', 'Click Sign-In and Security → App-Specific Passwords, click +, and type any name.') },
|
|
61
|
+
{ text: bi('把显示的密码(xxxx-xxxx-xxxx-xxxx)复制,粘贴到下面的密码框。', 'Copy the password it shows (xxxx-xxxx-xxxx-xxxx) into the password box below.') },
|
|
62
|
+
]),
|
|
30
63
|
},
|
|
31
64
|
{
|
|
32
65
|
id: 'outlook', label: bi('Outlook / Hotmail', 'Outlook / Hotmail'), host: 'outlook.office365.com', port: 993, secure: true, supported: false,
|
|
66
|
+
domains: ['outlook.com', 'hotmail.com', 'live.com', 'msn.com'], steps: [],
|
|
33
67
|
howTo: bi('微软已经不允许用密码登录 IMAP,需要 OAuth 授权,小秘书暂时还不支持。', 'Microsoft no longer allows password logins over IMAP; it needs OAuth, which the secretary does not support yet.'),
|
|
34
68
|
},
|
|
35
69
|
{
|
|
36
70
|
id: 'custom', label: bi('其他(自己填服务器)', 'Other (enter the server)'), host: '', port: 993, secure: true, supported: true,
|
|
71
|
+
domains: [], steps: [],
|
|
37
72
|
howTo: bi('填你的邮件服务商给的 IMAP 服务器、端口和密码(很多服务商要求用专门的客户端授权码)。', 'Enter the IMAP server, port and password your provider gives you (many require a separate app password).'),
|
|
38
73
|
},
|
|
39
74
|
];
|
|
75
|
+
/** The provider an address belongs to, from its domain; `undefined` when we do not know it. */
|
|
76
|
+
export function presetForAddress(address) {
|
|
77
|
+
const domain = address.trim().toLowerCase().split('@')[1] ?? '';
|
|
78
|
+
return MAIL_PRESETS.find(p => p.domains.includes(domain));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The password as the server wants it. App passwords and codes are shown in
|
|
82
|
+
* groups ("abcd efgh ijkl mnop") and copied with the spaces; they never
|
|
83
|
+
* contain one, so spaces go when the provider fixes the length.
|
|
84
|
+
*/
|
|
85
|
+
export function normalizePassword(presetId, password) {
|
|
86
|
+
return presetById(presetId).passwordLength === undefined ? password : password.replace(/\s+/g, '');
|
|
87
|
+
}
|
|
88
|
+
/** An account whose address belongs to another provider than its server ("…@gmail.com" on imap.qq.com). */
|
|
89
|
+
export function providerMismatch(account) {
|
|
90
|
+
const expected = presetForAddress(account.address);
|
|
91
|
+
if (expected === undefined || expected.id === account.preset || account.preset === 'custom')
|
|
92
|
+
return undefined;
|
|
93
|
+
const domain = account.address.split('@')[1];
|
|
94
|
+
return bi(`地址是 ${domain},服务器却是 ${presetById(account.preset).label.zh}的 ${account.host}。点「编辑」把服务商改成「${expected.label.zh}」。`, `The address is ${domain} but the server is ${account.host} (${presetById(account.preset).label.en}). Click Edit and choose "${expected.label.en}".`);
|
|
95
|
+
}
|
|
40
96
|
export function presetById(id) {
|
|
41
97
|
return MAIL_PRESETS.find(p => p.id === id) ?? MAIL_PRESETS[MAIL_PRESETS.length - 1];
|
|
42
98
|
}
|
package/lib/core/mail/sync.js
CHANGED
|
@@ -11,15 +11,44 @@ import { bi } from "../i18n.js";
|
|
|
11
11
|
import { presetById } from "./account.js";
|
|
12
12
|
import { judgeMail } from "./rules.js";
|
|
13
13
|
export const MAIL_SOURCE = 'mail';
|
|
14
|
-
/**
|
|
15
|
-
|
|
14
|
+
/** What some providers' refusals mean, in words you can act on. */
|
|
15
|
+
const KNOWN_REFUSALS = [
|
|
16
|
+
{
|
|
17
|
+
presets: ['gmail'], match: /application-specific password required/i,
|
|
18
|
+
text: bi('Google 不接受账号的登录密码:这里要填「应用专用密码」(16 位)。按步骤第 1、2 步生成一个,大约一分钟。', 'Google does not take your account password here: it needs an app password (16 characters). Steps 1 and 2 make one in about a minute.'),
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
presets: ['gmail'], match: /invalid credentials/i,
|
|
22
|
+
text: bi('Google 不认这个应用专用密码:可能抄错了,或者已经在 Google 账号里删掉了。按步骤第 2 步重新生成一个再试。', 'Google does not accept this app password: it may be mistyped or deleted from your Google account. Make a new one with step 2 and try again.'),
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
presets: ['gmail'], match: /web login required|log in via your web browser/i,
|
|
26
|
+
text: bi('Google 拦下了这次登录:先在浏览器里登录一次 Gmail,再回来试。', 'Google blocked this sign-in: sign in to Gmail in a browser once, then try again.'),
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
presets: ['qq'], match: /login fail/i,
|
|
30
|
+
text: bi('QQ 邮箱不让登录:最常见是还没开启 IMAP 服务,或者填的是 QQ 密码而不是授权码;改过 QQ 密码后,旧授权码也会失效。', 'QQ Mail refused the sign-in: usually IMAP is not turned on, or this is your QQ password instead of the authorization code; changing your QQ password also expires old codes.'),
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
presets: ['163', '126'], match: /unsafe login/i,
|
|
34
|
+
text: bi('网易邮箱拦下了这次登录:确认设置里已开启 IMAP 服务,并且填的是客户端授权码。', 'NetEase Mail blocked this sign-in: check that IMAP is turned on in its settings and that this is the client authorization code.'),
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
/**
|
|
38
|
+
* A connection or login failure, said so a person knows what to fix. With
|
|
39
|
+
* `steps` (the default) a login failure also repeats where the provider's
|
|
40
|
+
* app password comes from; the add-account dialog shows those steps already.
|
|
41
|
+
*/
|
|
42
|
+
export function describeMailError(error, account, options = {}) {
|
|
16
43
|
const raw = error.responseText ?? error.message ?? String(error);
|
|
17
|
-
const
|
|
44
|
+
const known = KNOWN_REFUSALS.find(k => k.presets.includes(account.preset) && k.match.test(raw));
|
|
45
|
+
const auth = known !== undefined || /auth|login|password|credential|AUTHENTICATIONFAILED|LOGIN failed/i.test(raw);
|
|
18
46
|
if (!auth)
|
|
19
47
|
return bi(`连接 ${account.host}:${account.port} 失败:${raw}`, `Could not reach ${account.host}:${account.port}: ${raw}`);
|
|
20
48
|
const preset = presetById(account.preset);
|
|
21
|
-
const
|
|
22
|
-
|
|
49
|
+
const steps = options.steps !== false && preset.id !== 'custom' ? preset.howTo : undefined;
|
|
50
|
+
const what = known?.text ?? bi('登录失败:服务器不接受这个用户名或密码。多数邮箱要先开启 IMAP,再填客户端授权码或应用专用密码,不是网页登录密码。', 'Login failed: the server rejected the user name or password. Most providers need IMAP turned on and an app password, not your web password.');
|
|
51
|
+
return bi(`${what.zh}(服务器原话:${raw})${steps === undefined ? '' : `\n${preset.label.zh}:${steps.zh}`}`, `${what.en} (The server said: ${raw})${steps === undefined ? '' : `\n${preset.label.en}: ${steps.en}`}`);
|
|
23
52
|
}
|
|
24
53
|
export function mailReportKey(accountId) {
|
|
25
54
|
return `mail.lastSync.${accountId}`;
|
package/lib/core/restart.js
CHANGED
|
@@ -15,14 +15,19 @@
|
|
|
15
15
|
* Windows `taskkill /PID` without /F (a close request), then the app again
|
|
16
16
|
* Linux SIGTERM to the app, then the app again
|
|
17
17
|
*
|
|
18
|
+
* On a server set up with dsh-vps, DSH is a child of the dsh-gate login
|
|
19
|
+
* gateway (`$GATE_HOME/gate/server.js`, run by systemd), which starts DSH
|
|
20
|
+
* again whenever it exits and picks up the new sign-in token itself. There
|
|
21
|
+
* a restart is simply DSH exiting cleanly; the page reconnects.
|
|
22
|
+
*
|
|
18
23
|
* DSH started with the `dsh` command is not restarted from here: its web
|
|
19
24
|
* address (with a new sign-in token) is printed in that terminal, which a
|
|
20
25
|
* detached replacement could not reach. The page says what to do instead.
|
|
21
26
|
*/
|
|
22
27
|
import { execFileSync, spawn } from 'node:child_process';
|
|
23
|
-
import { readlinkSync } from 'node:fs';
|
|
28
|
+
import { readFileSync, readlinkSync } from 'node:fs';
|
|
24
29
|
import { tmpdir } from 'node:os';
|
|
25
|
-
import { join } from 'node:path';
|
|
30
|
+
import { join, posix } from 'node:path';
|
|
26
31
|
import { findProgram } from "./exec.js";
|
|
27
32
|
import { bi } from "./i18n.js";
|
|
28
33
|
/** `<x>.app` holding a macOS executable, if any. */
|
|
@@ -52,8 +57,21 @@ function bundleIdOf(app) {
|
|
|
52
57
|
return undefined;
|
|
53
58
|
}
|
|
54
59
|
}
|
|
60
|
+
function parentArguments(ppid) {
|
|
61
|
+
try {
|
|
62
|
+
return readFileSync(`/proc/${ppid}/cmdline`, 'utf8').split('\0').filter(Boolean);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
55
68
|
function here() {
|
|
56
|
-
const facts = {
|
|
69
|
+
const facts = {
|
|
70
|
+
env: process.env, platform: process.platform, execPath: process.execPath, ppid: process.ppid, electron: process.versions.electron,
|
|
71
|
+
parentExe: undefined, bundleId: undefined, parentArgs: undefined,
|
|
72
|
+
};
|
|
73
|
+
if (facts.platform === 'linux' && facts.env.GATE_HOME !== undefined)
|
|
74
|
+
facts.parentArgs = parentArguments(facts.ppid);
|
|
57
75
|
if (facts.electron === undefined || facts.env.ELECTRON_RUN_AS_NODE !== '1' || facts.ppid <= 1)
|
|
58
76
|
return facts;
|
|
59
77
|
facts.parentExe = parentExecutable(facts.ppid, facts.platform);
|
|
@@ -68,6 +86,13 @@ function samePath(a, b, platform) {
|
|
|
68
86
|
const FROM_TERMINAL = bi('这个 DSH 是用 dsh 命令启动的,小秘书没法替它重启:回到运行 dsh 的那个终端按 Ctrl+C,再运行同一条命令。', 'This DSH was started with the dsh command, so the secretary cannot restart it: press Ctrl+C in the terminal running dsh, then run the same command again.');
|
|
69
87
|
/** How DSH can be restarted from here, or why it cannot. */
|
|
70
88
|
export function restartPlan(facts = here()) {
|
|
89
|
+
// dsh-vps: the parent must be that very gateway, not just a shell that has GATE_HOME set.
|
|
90
|
+
const gateHome = facts.env.GATE_HOME;
|
|
91
|
+
if (gateHome !== undefined && gateHome !== '') {
|
|
92
|
+
const gate = posix.join(gateHome, 'gate', 'server.js');
|
|
93
|
+
if (facts.parentArgs?.includes(gate) === true)
|
|
94
|
+
return { kind: 'gate', gate };
|
|
95
|
+
}
|
|
71
96
|
const desktop = facts.electron !== undefined && facts.env.ELECTRON_RUN_AS_NODE === '1' && facts.ppid > 1 && samePath(facts.parentExe, facts.execPath, facts.platform);
|
|
72
97
|
if (!desktop)
|
|
73
98
|
return { kind: 'none', why: FROM_TERMINAL };
|
|
@@ -140,8 +165,13 @@ export function restartHelperSource(plan, log, tools = MAC_TOOLS) {
|
|
|
140
165
|
"main().catch(e => note('helper failed: ' + (e && e.stack || e)))",
|
|
141
166
|
].join('\n');
|
|
142
167
|
}
|
|
143
|
-
/** Start the helper and
|
|
144
|
-
export function scheduleRestart(plan) {
|
|
168
|
+
/** Start the restart: under dsh-gate, exit; in the desktop app, start the helper and say where it logs. Runs with this process's Node (Electron as Node in the app). */
|
|
169
|
+
export function scheduleRestart(plan, exit = () => process.kill(process.pid, 'SIGTERM')) {
|
|
170
|
+
if (plan.kind === 'gate') {
|
|
171
|
+
// Let the page receive its answer, then leave the normal way; the gateway starts DSH again.
|
|
172
|
+
setTimeout(exit, 400);
|
|
173
|
+
return { reconnect: true };
|
|
174
|
+
}
|
|
145
175
|
const log = join(tmpdir(), `dsh-mishu-restart-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.log`);
|
|
146
176
|
const helper = spawn(process.execPath, ['-e', restartHelperSource(plan, log)], { detached: true, stdio: 'ignore', env: process.env, windowsHide: true });
|
|
147
177
|
helper.unref();
|
package/lib/core/secrets.js
CHANGED
|
@@ -7,9 +7,17 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Secrets travel to the helper on stdin only, never as command-line
|
|
9
9
|
* arguments, which other processes on the machine could read. Everything is
|
|
10
|
-
* stored under the service name "dsh-mishu".
|
|
11
|
-
*
|
|
10
|
+
* stored under the service name "dsh-mishu".
|
|
11
|
+
*
|
|
12
|
+
* A computer without a usable keychain (a Linux server with no desktop,
|
|
13
|
+
* say) keeps them in `secrets.json` in the notebook directory instead:
|
|
14
|
+
* mode 600, so only your own system account can read it. That is how gh,
|
|
15
|
+
* git and Thunderbird manage on such machines, and it is said plainly on
|
|
16
|
+
* the settings page: protected by file permissions, not encrypted.
|
|
17
|
+
* `secretFromEnv` remains a last resort.
|
|
12
18
|
*/
|
|
19
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { dirname } from 'node:path';
|
|
13
21
|
import { findProgram, runProgram } from "./exec.js";
|
|
14
22
|
import { bi } from "./i18n.js";
|
|
15
23
|
export const SERVICE = 'dsh-mishu';
|
|
@@ -168,3 +176,95 @@ export function secretFromEnv(account) {
|
|
|
168
176
|
const value = process.env[secretEnvName(account)];
|
|
169
177
|
return value === undefined || value === '' ? undefined : value;
|
|
170
178
|
}
|
|
179
|
+
/**
|
|
180
|
+
* Secrets in one JSON file, readable by your system account only (600).
|
|
181
|
+
* Written to a temporary file first and renamed, so a crash never leaves
|
|
182
|
+
* half a file; a file that cannot be read is never overwritten.
|
|
183
|
+
*/
|
|
184
|
+
export class FileSecrets {
|
|
185
|
+
kind = 'file';
|
|
186
|
+
path;
|
|
187
|
+
constructor(path) { this.path = path; }
|
|
188
|
+
async status() { return { available: true }; }
|
|
189
|
+
read() {
|
|
190
|
+
if (!existsSync(this.path))
|
|
191
|
+
return {};
|
|
192
|
+
// Put the permissions back if something loosened them.
|
|
193
|
+
if (process.platform !== 'win32' && (statSync(this.path).mode & 0o077) !== 0)
|
|
194
|
+
chmodSync(this.path, 0o600);
|
|
195
|
+
let parsed;
|
|
196
|
+
try {
|
|
197
|
+
parsed = JSON.parse(readFileSync(this.path, 'utf8'));
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
throw new Error(`${this.path} cannot be read (${error.message}); fix or remove it`);
|
|
201
|
+
}
|
|
202
|
+
const secrets = parsed.secrets;
|
|
203
|
+
return typeof secrets === 'object' && secrets !== null ? { ...secrets } : {};
|
|
204
|
+
}
|
|
205
|
+
write(secrets) {
|
|
206
|
+
mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
207
|
+
const temp = `${this.path}.${process.pid}.tmp`;
|
|
208
|
+
writeFileSync(temp, `${JSON.stringify({ version: 1, secrets }, null, 2)}\n`, { mode: 0o600 });
|
|
209
|
+
renameSync(temp, this.path);
|
|
210
|
+
}
|
|
211
|
+
async get(account) { return this.read()[account]; }
|
|
212
|
+
async set(account, secret) {
|
|
213
|
+
checkAccount(account);
|
|
214
|
+
checkSecret(secret);
|
|
215
|
+
this.write({ ...this.read(), [account]: secret });
|
|
216
|
+
}
|
|
217
|
+
async delete(account) {
|
|
218
|
+
const secrets = this.read();
|
|
219
|
+
if (!(account in secrets))
|
|
220
|
+
return;
|
|
221
|
+
delete secrets[account];
|
|
222
|
+
this.write(secrets);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* The keychain when it works, else the file. Reads look in both, so a
|
|
227
|
+
* secret saved to the file before a keychain was set up is still found;
|
|
228
|
+
* saving to the keychain removes the file's copy.
|
|
229
|
+
*/
|
|
230
|
+
export class KeychainOrFile {
|
|
231
|
+
keychain;
|
|
232
|
+
file;
|
|
233
|
+
usable;
|
|
234
|
+
constructor(keychain, file) {
|
|
235
|
+
this.keychain = keychain;
|
|
236
|
+
this.file = file;
|
|
237
|
+
}
|
|
238
|
+
get kind() { return this.keychain.kind; }
|
|
239
|
+
keychainStatus() {
|
|
240
|
+
// Asked once per run: a missing helper does not appear while DSH runs.
|
|
241
|
+
this.usable ??= this.keychain.status().catch((error) => ({ available: false, why: bi(error.message, error.message) }));
|
|
242
|
+
return this.usable;
|
|
243
|
+
}
|
|
244
|
+
async status() {
|
|
245
|
+
const keychain = await this.keychainStatus();
|
|
246
|
+
return keychain.available ? keychain : { available: true, file: { path: this.file.path, why: keychain.why } };
|
|
247
|
+
}
|
|
248
|
+
async get(account) {
|
|
249
|
+
if ((await this.keychainStatus()).available) {
|
|
250
|
+
const stored = await this.keychain.get(account);
|
|
251
|
+
if (stored !== undefined)
|
|
252
|
+
return stored;
|
|
253
|
+
}
|
|
254
|
+
return this.file.get(account);
|
|
255
|
+
}
|
|
256
|
+
async set(account, secret) {
|
|
257
|
+
if ((await this.keychainStatus()).available) {
|
|
258
|
+
await this.keychain.set(account, secret);
|
|
259
|
+
await this.file.delete(account);
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
await this.file.set(account, secret);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
async delete(account) {
|
|
266
|
+
if ((await this.keychainStatus()).available)
|
|
267
|
+
await this.keychain.delete(account);
|
|
268
|
+
await this.file.delete(account);
|
|
269
|
+
}
|
|
270
|
+
}
|
package/lib/core/settings.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* password goes to the system keychain (see secrets.ts), never here.
|
|
7
7
|
*/
|
|
8
8
|
import { MishuError, bi } from "./i18n.js";
|
|
9
|
-
import { resolveAccount, secretAccount } from "./mail/account.js";
|
|
9
|
+
import { normalizePassword, resolveAccount, secretAccount } from "./mail/account.js";
|
|
10
10
|
import { MAIL_SOURCE } from "./mail/sync.js";
|
|
11
11
|
import { CATEGORIES } from "./recur.js";
|
|
12
12
|
import { secretFromEnv } from "./secrets.js";
|
|
@@ -70,7 +70,7 @@ export async function saveMailAccount(store, secrets, input, password, now) {
|
|
|
70
70
|
const account = resolveAccount({ ...existing, ...input }, id);
|
|
71
71
|
const keychain = (await secrets.status()).available;
|
|
72
72
|
if (keychain && password !== undefined && password !== '')
|
|
73
|
-
await secrets.set(secretAccount(id), password);
|
|
73
|
+
await secrets.set(secretAccount(id), normalizePassword(account.preset, password));
|
|
74
74
|
else if (keychain && existing === undefined && await secrets.get(secretAccount(id)) === undefined && secretFromEnv(secretAccount(id)) === undefined) {
|
|
75
75
|
throw new MishuError(bi('新邮箱要填密码(授权码)', 'a new account needs its password (app password)'));
|
|
76
76
|
}
|
package/lib/dsh/api.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* travel inward: they go straight to the keychain, and no endpoint ever
|
|
10
10
|
* returns one.
|
|
11
11
|
*/
|
|
12
|
-
import { addDays, addSeries, bi, buildAsk, buildBoard, buildDetail, dayOf, deleteGithubToken, deleteMailAccount, describeMailError, MishuError, describeSeries, detectLang, errorText, GitHubClient, MAIL_PRESETS, mailReportKey, NEW_TOKEN_URL, resolveSeries, saveGithubToken, saveMailAccount, saveSettings, secretAccount, secretEnvName, setStatusByUser, snoozeByUser, stopSeries, tickManual, } from "../core/index.js";
|
|
12
|
+
import { addDays, addSeries, bi, buildAsk, buildBoard, buildDetail, dayOf, deleteGithubToken, deleteMailAccount, describeMailError, MishuError, describeSeries, detectLang, errorText, GitHubClient, MAIL_PRESETS, mailReportKey, NEW_TOKEN_URL, normalizePassword, providerMismatch, resolveSeries, saveGithubToken, saveMailAccount, saveSettings, secretAccount, secretEnvName, setStatusByUser, snoozeByUser, stopSeries, tickManual, } from "../core/index.js";
|
|
13
13
|
import { resolveAccount } from "../core/mail/account.js";
|
|
14
14
|
export const API_BASE = '/api/dsh-mishu';
|
|
15
15
|
class ApiError extends Error {
|
|
@@ -88,6 +88,7 @@ export function apiHandlers(runtime, clock = Date.now) {
|
|
|
88
88
|
...a,
|
|
89
89
|
hasPassword: (await runtime.mailPassword(a.id)) !== undefined,
|
|
90
90
|
envName: secretEnvName(secretAccount(a.id)),
|
|
91
|
+
mismatch: providerMismatch(a)?.[lang] ?? null,
|
|
91
92
|
report: store.get(mailReportKey(a.id)) ?? null,
|
|
92
93
|
})));
|
|
93
94
|
return {
|
|
@@ -97,11 +98,21 @@ export function apiHandlers(runtime, clock = Date.now) {
|
|
|
97
98
|
tokenEnv: runtime.config.github.tokenEnv,
|
|
98
99
|
token: 'error' in token ? { found: false, text: token.error[lang] } : { found: true, from: token.from },
|
|
99
100
|
newTokenUrl: NEW_TOKEN_URL,
|
|
101
|
+
deviceLogin: runtime.config.github.clientId !== '',
|
|
100
102
|
lastSync: store.get('github.lastSync') ?? null,
|
|
101
103
|
},
|
|
102
|
-
secrets: {
|
|
104
|
+
secrets: {
|
|
105
|
+
kind: runtime.secrets.kind,
|
|
106
|
+
available: secrets.available,
|
|
107
|
+
...secrets.available ? {} : { why: secrets.why[lang] },
|
|
108
|
+
...secrets.available && secrets.file !== undefined ? { file: { path: secrets.file.path, why: secrets.file.why[lang] } } : {},
|
|
109
|
+
},
|
|
103
110
|
accounts,
|
|
104
|
-
presets: MAIL_PRESETS.map(p => ({
|
|
111
|
+
presets: MAIL_PRESETS.map(p => ({
|
|
112
|
+
id: p.id, label: p.label[lang], host: p.host, port: p.port, secure: p.secure, supported: p.supported, howTo: p.howTo[lang],
|
|
113
|
+
domains: p.domains, passwordLength: p.passwordLength ?? null,
|
|
114
|
+
steps: p.steps.map(step => ({ text: step.text[lang], ...step.link === undefined ? {} : { link: { url: step.link.url, label: step.link.label[lang] } } })),
|
|
115
|
+
})),
|
|
105
116
|
};
|
|
106
117
|
};
|
|
107
118
|
return {
|
|
@@ -208,6 +219,17 @@ export function apiHandlers(runtime, clock = Date.now) {
|
|
|
208
219
|
const { login } = await saveGithubToken(runtime.secrets, typeof body.token === 'string' ? body.token : '');
|
|
209
220
|
return { login, view: await settingsView(lang) };
|
|
210
221
|
},
|
|
222
|
+
/** "Sign in with GitHub": a code to enter on github.com; the host waits for your approval. */
|
|
223
|
+
'/github/device/start': async (body) => ({ login: deviceView(await runtime.githubLogin.start(), langOf(body)) }),
|
|
224
|
+
'/github/device/status': async (body) => {
|
|
225
|
+
const lang = langOf(body);
|
|
226
|
+
const state = runtime.githubLogin.status();
|
|
227
|
+
return { login: deviceView(state, lang), ...state.state === 'done' ? { view: await settingsView(lang) } : {} };
|
|
228
|
+
},
|
|
229
|
+
'/github/device/cancel': async (body) => {
|
|
230
|
+
runtime.githubLogin.cancel();
|
|
231
|
+
return { login: deviceView(runtime.githubLogin.status(), langOf(body)) };
|
|
232
|
+
},
|
|
211
233
|
'/github/token/delete': async (body) => {
|
|
212
234
|
await deleteGithubToken(runtime.secrets);
|
|
213
235
|
return { view: await settingsView(langOf(body)) };
|
|
@@ -222,7 +244,8 @@ export function apiHandlers(runtime, clock = Date.now) {
|
|
|
222
244
|
const lang = langOf(body);
|
|
223
245
|
const input = accountInput(body.account);
|
|
224
246
|
const draft = resolveAccount(input, input.id ?? 0);
|
|
225
|
-
const
|
|
247
|
+
const typed = password(body);
|
|
248
|
+
const secret = typed !== undefined ? normalizePassword(draft.preset, typed) : input.id === undefined ? undefined : await runtime.mailPassword(input.id);
|
|
226
249
|
if (secret === undefined)
|
|
227
250
|
throw new ApiError(lang === 'zh' ? '先填密码(授权码)再测试' : 'Enter the password (app password) first');
|
|
228
251
|
try {
|
|
@@ -230,7 +253,8 @@ export function apiHandlers(runtime, clock = Date.now) {
|
|
|
230
253
|
return { unread: messages.length };
|
|
231
254
|
}
|
|
232
255
|
catch (error) {
|
|
233
|
-
|
|
256
|
+
// The dialog shows the provider's steps already; the error only says what went wrong.
|
|
257
|
+
throw new MishuError(describeMailError(error, draft, { steps: false }));
|
|
234
258
|
}
|
|
235
259
|
},
|
|
236
260
|
'/mail/delete': async (body) => {
|
|
@@ -249,8 +273,7 @@ export function apiHandlers(runtime, clock = Date.now) {
|
|
|
249
273
|
const plan = runtime.restarter.plan();
|
|
250
274
|
if (plan.kind === 'none')
|
|
251
275
|
throw new ApiError(plan.why[langOf(body)]);
|
|
252
|
-
|
|
253
|
-
return { restarting: true, log };
|
|
276
|
+
return { restarting: true, ...runtime.restarter.schedule(plan) };
|
|
254
277
|
},
|
|
255
278
|
'/mail/sync': async (body) => {
|
|
256
279
|
const lang = langOf(body);
|
|
@@ -262,6 +285,10 @@ export function apiHandlers(runtime, clock = Date.now) {
|
|
|
262
285
|
},
|
|
263
286
|
};
|
|
264
287
|
}
|
|
288
|
+
/** The page's view of a sign-in: messages in its language. */
|
|
289
|
+
function deviceView(state, lang) {
|
|
290
|
+
return state.state === 'failed' ? { state: 'failed', message: state.message[lang] } : { ...state };
|
|
291
|
+
}
|
|
265
292
|
/** Run one handler; failures become `{ ok: false, message }` in the caller's language. */
|
|
266
293
|
export async function handle(handler, request, fixedLang) {
|
|
267
294
|
let body = {};
|