@lvxianchao/dsh-web-guard 2.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/i18n.js CHANGED
@@ -3,271 +3,73 @@
3
3
  *
4
4
  * The harness stores the operator's language choice in the profile patch file
5
5
  * (`- id: locale` → `config.preference`), which is exactly what the settings UI
6
- * writes when someone switches language. Reading it means the login and admin
7
- * pages follow the harness instead of guessing from the browser.
6
+ * writes when someone switches language. Reading it means the login and
7
+ * sign-out pages follow the harness instead of guessing from the browser.
8
8
  *
9
9
  * When no explicit preference exists, the harness falls back to the browser
10
10
  * language — and so do we, via Accept-Language.
11
11
  */
12
- import { readFileSync, watch, statSync } from 'node:fs'
13
- import { basename, dirname } from 'node:path'
14
-
15
- const SUPPORTED = ['zh', 'en']
16
-
12
+ import { createPreferenceReader, readPreference } from './preferences.js';
13
+ const SUPPORTED = ['zh', 'en'];
17
14
  /**
18
15
  * Read the explicit locale preference from a profile patch file.
19
- * @param {string} patchPath - absolute path to cordis.patch.yml
20
- * @returns {'zh'|'en'|''} a supported locale id, or '' when unset/unreadable
16
+ * Returns a supported locale id, or '' when unset/unreadable.
17
+ * @param patchPath - profile patch file path.
21
18
  */
22
- export function readPreference(patchPath) {
23
- if (!patchPath) return ''
24
- let text
25
- try {
26
- text = readFileSync(patchPath, 'utf8')
27
- } catch {
28
- return ''
29
- }
30
- const lines = text.split('\n')
31
- let inLocaleRow = false
32
- let rowIndent = 0
33
- for (const line of lines) {
34
- const trimmed = line.trim()
35
- if (!trimmed || trimmed.startsWith('#')) continue
36
- const indent = line.length - line.trimStart().length
37
-
38
- // A new top-level-ish row ends the section we were scanning.
39
- if (inLocaleRow && /^- /.test(trimmed) && indent <= rowIndent) break
40
-
41
- if (/^-\s+id:\s*['"]?locale['"]?\s*$/.test(trimmed)) {
42
- inLocaleRow = true
43
- rowIndent = indent
44
- continue
45
- }
46
- if (inLocaleRow) {
47
- const m = trimmed.match(/^preference:\s*['"]?([A-Za-z-]+)['"]?/)
48
- if (m) return normalize(m[1])
49
- }
50
- }
51
- return ''
19
+ export function readLocalePreference(patchPath) {
20
+ return normalize(readPreference(patchPath, 'locale'));
52
21
  }
53
-
54
22
  /** Map an arbitrary tag (`zh-CN`, `en-US`) onto a supported id. */
55
23
  export function normalize(tag) {
56
- const base = String(tag ?? '').toLowerCase().split(/[-_]/)[0]
57
- return SUPPORTED.includes(base) ? base : ''
24
+ const base = String(tag ?? '').toLowerCase().split(/[-_]/)[0];
25
+ return SUPPORTED.includes(base) ? base : '';
58
26
  }
59
-
60
- /**
61
- * Resolve the page locale: explicit preference first, then Accept-Language.
62
- * @param {string} patchPath - profile patch file
63
- * @param {import('node:http').IncomingMessage} [req] - request carrying Accept-Language
64
- */
65
- export function detectLocale(patchPath, req) {
66
- const explicit = readPreference(patchPath)
67
- if (explicit) return explicit
68
- const header = String(req?.headers?.['accept-language'] ?? '')
69
- const first = header.split(',')[0]?.trim()
70
- return normalize(first) || 'en'
71
- }
72
-
73
- /** Relative time in the active language. */
74
- export function ago(ms, locale) {
75
- if (!ms) return '—'
76
- const s = Math.max(0, Math.round((Date.now() - ms) / 1000))
77
- if (locale === 'zh') {
78
- if (s < 60) return `${s} 秒前`
79
- if (s < 3600) return `${Math.round(s / 60)} 分钟前`
80
- if (s < 86400) return `${Math.round(s / 3600)} 小时前`
81
- return `${Math.round(s / 86400)} 天前`
82
- }
83
- if (s < 60) return `${s}s ago`
84
- if (s < 3600) return `${Math.round(s / 60)} min ago`
85
- if (s < 86400) return `${Math.round(s / 3600)} h ago`
86
- return `${Math.round(s / 86400)} d ago`
87
- }
88
-
89
- const ZH = {
90
- locale: 'zh',
91
- adminTitle: 'DSH Web Guard',
92
- pageTitleAdmin: 'DSH Web Guard',
93
- backToDsh: '返回 DSH',
94
- close: '关闭',
95
- signOut: '退出登录',
96
- secPassword: '登录密码',
97
- hintPassword: '留空则不修改。修改后其他设备上的会话仍然有效,需要的话用下面的「退出所有设备」。',
98
- currentPassword: '当前密码',
99
- currentPasswordPh: '修改密码时必填',
100
- newPassword: '新密码',
101
- newPasswordPh: '至少 6 位',
102
- confirmPassword: '确认新密码',
103
- confirmPasswordPh: '再输一次',
104
- secSession: '会话与安全',
105
- hintSession: '会话有效期从最后一次活动开始计算;改为 0 表示关掉浏览器即失效。',
106
- sessionDays: '会话有效期(天)',
107
- lockMinutes: '锁定时间(分钟)',
108
- maxAttempts: '连续失败次数上限',
109
- secListen: '监听',
110
- hintListen: '修改后需要重启 DSH 才生效;填错会导致管理页和外网入口都连不上,确认前先记下原值。',
111
- listenHost: '监听地址',
112
- listenPort: '监听端口',
113
- save: '保存',
114
- signOutAll: '退出所有设备',
115
- confirmSignOutAll: '将退出所有设备上的登录,包括当前这个。继续?',
116
- secSessions: '活跃会话',
117
- hintSessions: '每个浏览器一条记录。会话存在内存里,重启 DSH 会全部失效。',
118
- colSource: '来源',
119
- colBrowser: '浏览器',
120
- colLastSeen: '最后活动',
121
- current: '当前',
122
- kick: '踢出',
123
- emptySessions: '当前没有其他活跃会话',
124
- saved: '已保存',
125
- savedPassword: '已保存,密码已更新',
126
- restartNote: '监听地址/端口已记录,重启 DSH 后才会生效。',
127
- errCurrent: '当前密码不正确',
128
- errShort: '新密码至少 6 位',
129
- errMismatch: '两次输入的新密码不一致',
130
- errOrigin: '请求来源不合法',
131
- kicked: '已踢出该会话',
132
- kickMissing: '该会话已不存在',
133
- // login page
134
- loginSubtitle: '此服务需要密码才能访问',
135
- passwordLabel: '访问密码',
136
- passwordPh: '输入密码',
137
- submit: '进入',
138
- errWrong: '密码不正确',
139
- errLocked: '尝试次数过多,请稍后再试',
140
- loggedOut: '已退出登录',
141
- signInAgain: '重新登录',
142
- }
143
-
144
27
  const EN = {
145
- locale: 'en',
146
- adminTitle: 'DSH Web Guard',
147
- pageTitleAdmin: 'DSH Web Guard',
148
- backToDsh: 'Back to DSH',
149
- close: 'Close',
150
- signOut: 'Sign out',
151
- secPassword: 'Password',
152
- hintPassword: 'Leave blank to keep the current one. Other devices stay signed in — use "Sign out everywhere" below if you want them gone.',
153
- currentPassword: 'Current password',
154
- currentPasswordPh: 'Required to change it',
155
- newPassword: 'New password',
156
- newPasswordPh: 'At least 6 characters',
157
- confirmPassword: 'Confirm new password',
158
- confirmPasswordPh: 'Type it again',
159
- secSession: 'Sessions and lockout',
160
- hintSession: 'Lifetime counts from the last activity; 0 means the session ends when the browser closes.',
161
- sessionDays: 'Session lifetime (days)',
162
- lockMinutes: 'Lockout duration (minutes)',
163
- maxAttempts: 'Failed attempts before lockout',
164
- secListen: 'Listener',
165
- hintListen: 'Takes effect after a DSH restart. A wrong value locks you out of both this page and the public entry — note the current values first.',
166
- listenHost: 'Listen address',
167
- listenPort: 'Listen port',
168
- save: 'Save',
169
- signOutAll: 'Sign out everywhere',
170
- confirmSignOutAll: 'This signs out every device, including this one. Continue?',
171
- secSessions: 'Active sessions',
172
- hintSessions: 'One row per browser. Sessions live in memory and are cleared when DSH restarts.',
173
- colSource: 'Source',
174
- colBrowser: 'Browser',
175
- colLastSeen: 'Last seen',
176
- current: 'this device',
177
- kick: 'Kick',
178
- emptySessions: 'No other active sessions',
179
- saved: 'Saved',
180
- savedPassword: 'Saved — password updated',
181
- restartNote: 'Listener change recorded; it takes effect after a DSH restart.',
182
- errCurrent: 'Current password is incorrect',
183
- errShort: 'New password must be at least 6 characters',
184
- errMismatch: 'The new passwords do not match',
185
- errOrigin: 'Request origin rejected',
186
- kicked: 'Session kicked',
187
- kickMissing: 'That session no longer exists',
188
- // login page
189
- loginSubtitle: 'This service requires a password',
190
- passwordLabel: 'Password',
191
- passwordPh: 'Enter password',
192
- submit: 'Continue',
193
- errWrong: 'Incorrect password',
194
- errLocked: 'Too many attempts — try again later',
195
- loggedOut: 'Signed out',
196
- signInAgain: 'Sign in again',
197
- }
198
-
199
- const CATALOG = { zh: ZH, en: EN }
200
-
28
+ locale: 'en',
29
+ // login page
30
+ loginSubtitle: 'This service requires a password',
31
+ passwordLabel: 'Password',
32
+ passwordPh: 'Enter password',
33
+ submit: 'Continue',
34
+ errWrong: 'Incorrect password',
35
+ errLocked: 'Too many attempts — try again later',
36
+ // sign-out page
37
+ loggedOut: 'Signed out',
38
+ signInAgain: 'Sign in again',
39
+ // shared
40
+ errOrigin: 'Request origin rejected',
41
+ };
42
+ const ZH = {
43
+ locale: 'zh',
44
+ // login page
45
+ loginSubtitle: '此服务需要密码才能访问',
46
+ passwordLabel: '访问密码',
47
+ passwordPh: '输入密码',
48
+ submit: '进入',
49
+ errWrong: '密码不正确',
50
+ errLocked: '尝试次数过多,请稍后再试',
51
+ // sign-out page
52
+ loggedOut: '已退出登录',
53
+ signInAgain: '重新登录',
54
+ // shared
55
+ errOrigin: '请求来源不合法',
56
+ };
57
+ const CATALOG = { zh: ZH, en: EN };
201
58
  /** Copy bundle for a locale, falling back to English. */
202
59
  export function t(locale) {
203
- return CATALOG[locale] ?? EN
60
+ return CATALOG[locale] ?? EN;
204
61
  }
205
-
206
62
  /**
207
63
  * Keep the patch file's locale preference in memory.
208
64
  *
209
- * Reading the file per request would block the harness event loop (this plugin
210
- * shares its process), and a fixed poll interval is a guess in both directions.
211
- * Instead: read once at startup, then re-read only when the directory reports a
212
- * change — the harness writes atomically via rename, so watching the directory
213
- * is what actually catches the replacement. If the platform has no usable
214
- * watcher, fall back to a slow mtime check on a timer.
215
- *
216
- * @param {string} patchPath - absolute path to cordis.patch.yml
217
- * @returns {{get: () => string, dispose: () => void}}
65
+ * The watch/poll mechanics live in `preferences.ts`; this wrapper only maps the
66
+ * raw value onto a supported locale id.
67
+ * @param patchPath - profile patch file path.
218
68
  */
219
69
  export function createLocaleReader(patchPath) {
220
- let value = readPreference(patchPath)
221
- let watcher
222
- let poller
223
- let mtime = 0
224
- let debounce
225
-
226
- const stamp = () => {
227
- try {
228
- return statSync(patchPath).mtimeMs
229
- } catch {
230
- return 0
231
- }
232
- }
233
- mtime = stamp()
234
-
235
- const refresh = () => {
236
- const next = stamp()
237
- if (next === mtime) return
238
- mtime = next
239
- value = readPreference(patchPath)
240
- }
241
- const schedule = () => {
242
- if (debounce) clearTimeout(debounce)
243
- debounce = setTimeout(refresh, 40)
244
- debounce.unref?.()
245
- }
246
-
247
- try {
248
- watcher = watch(dirname(patchPath), { persistent: false }, (_event, filename) => {
249
- if (!filename || String(filename) === basename(patchPath)) schedule()
250
- })
251
- watcher.on('error', () => {
252
- watcher?.close()
253
- watcher = undefined
254
- })
255
- } catch {
256
- watcher = undefined
257
- }
258
-
259
- if (!watcher) {
260
- // No usable watcher: an inexpensive mtime check every 30s is the safety net.
261
- poller = setInterval(refresh, 30_000)
262
- poller.unref?.()
263
- }
264
-
265
- return {
266
- get: () => value,
267
- dispose: () => {
268
- if (debounce) clearTimeout(debounce)
269
- if (poller) clearInterval(poller)
270
- watcher?.close()
271
- },
272
- }
70
+ const reader = createPreferenceReader(patchPath, 'locale');
71
+ return {
72
+ get: () => normalize(reader.get()),
73
+ dispose: () => reader.dispose(),
74
+ };
273
75
  }