@nbtca/prompt 1.4.2 → 1.5.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.
Files changed (67) hide show
  1. package/README.md +27 -58
  2. package/SECURITY.md +16 -45
  3. package/dist/app/app.js +53 -55
  4. package/dist/app/chrome.js +67 -50
  5. package/dist/app/fields/list-field.js +12 -25
  6. package/dist/app/fields/text-field.js +3 -8
  7. package/dist/app/frame.js +2 -21
  8. package/dist/app/keys.js +10 -2
  9. package/dist/app/views/docs-render.js +31 -24
  10. package/dist/app/views/docs.js +211 -60
  11. package/dist/app/views/events-render.js +19 -26
  12. package/dist/app/views/events.js +44 -31
  13. package/dist/app/views/home.js +28 -30
  14. package/dist/app/views/schedule-grid-cursor.js +9 -18
  15. package/dist/app/views/schedule-render.js +47 -71
  16. package/dist/app/views/schedule.js +158 -81
  17. package/dist/app/views/settings-render.js +8 -19
  18. package/dist/app/views/settings.js +92 -17
  19. package/dist/auth/cookie-transport.js +31 -32
  20. package/dist/auth/errors.js +3 -1
  21. package/dist/auth/nbt-auth.js +42 -25
  22. package/dist/auth/session-store.js +17 -9
  23. package/dist/config/data.js +9 -11
  24. package/dist/config/preferences.js +14 -7
  25. package/dist/core/calendar-day.js +37 -0
  26. package/dist/core/capabilities.js +6 -3
  27. package/dist/core/components/confirm.js +9 -8
  28. package/dist/core/components/menu.js +41 -16
  29. package/dist/core/components/messages.js +12 -4
  30. package/dist/core/components/painter.js +3 -1
  31. package/dist/core/components/spinner.js +17 -6
  32. package/dist/core/components/text-input.js +24 -18
  33. package/dist/core/icons.js +2 -2
  34. package/dist/core/logo.js +23 -5
  35. package/dist/core/motion.js +25 -19
  36. package/dist/core/text.js +182 -69
  37. package/dist/core/theme.js +0 -28
  38. package/dist/core/transitions.js +2 -2
  39. package/dist/core/ui.js +15 -13
  40. package/dist/core/vim-keys.js +9 -15
  41. package/dist/features/about.js +23 -0
  42. package/dist/features/calendar-heatmap.js +16 -40
  43. package/dist/features/calendar-query.js +1 -2
  44. package/dist/features/calendar.js +12 -185
  45. package/dist/features/docs.js +436 -275
  46. package/dist/features/schedule-render.js +65 -101
  47. package/dist/features/schedule-store.js +51 -9
  48. package/dist/features/schedule-view.js +46 -213
  49. package/dist/features/status.js +44 -56
  50. package/dist/features/student-timetable.js +73 -95
  51. package/dist/features/theme.js +6 -2
  52. package/dist/features/timetable-sanitize.js +40 -0
  53. package/dist/features/update.js +9 -27
  54. package/dist/i18n/index.js +83 -19
  55. package/dist/i18n/locales/en.json +1 -1
  56. package/dist/i18n/locales/zh.json +1 -1
  57. package/dist/index.js +83 -58
  58. package/dist/logo/ca-dotmatrix.txt +16 -18
  59. package/dist/main.js +7 -48
  60. package/package.json +27 -18
  61. package/bin/nbtca-welcome.js +0 -2
  62. package/dist/core/components/screen.js +0 -18
  63. package/dist/core/menu.js +0 -68
  64. package/dist/features/links.js +0 -36
  65. package/dist/features/schedule-query.js +0 -47
  66. package/dist/features/settings.js +0 -127
  67. package/dist/logo/ca-logo.png +0 -0
@@ -3,21 +3,33 @@ import { renderSettings } from './settings-render.js';
3
3
  import { applyColorModePreference, loadPreferences, resetPreferences, setColorMode, setIconMode, } from '../../config/preferences.js';
4
4
  import { resetIconCache, pickIcon } from '../../core/icons.js';
5
5
  import { APP_INFO, URLS } from '../../config/data.js';
6
- import { t, getCurrentLanguage, saveLanguagePreference, clearTranslationCache } from '../../i18n/index.js';
6
+ import { t, getCurrentLanguage, saveLanguagePreference, clearTranslationCache, } from '../../i18n/index.js';
7
7
  import { padEndV } from '../../core/text.js';
8
+ import { resetCapabilities } from '../../core/capabilities.js';
8
9
  let state = { mode: 'menu' };
10
+ function currentHint(current, hint) {
11
+ return current ? { hint } : {};
12
+ }
9
13
  function buildMenuField(statusMessage) {
10
14
  const trans = t();
11
15
  const prefs = loadPreferences();
12
16
  const currentLang = getCurrentLanguage();
13
17
  const options = [
14
- { value: 'language', label: trans.language.selectLanguage, hint: currentLang === 'zh' ? trans.language.zh : trans.language.en },
18
+ {
19
+ value: 'language',
20
+ label: trans.language.selectLanguage,
21
+ hint: currentLang === 'zh' ? trans.language.zh : trans.language.en,
22
+ },
15
23
  { value: 'icon', label: trans.theme.iconMode, hint: prefs.iconMode },
16
24
  { value: 'color', label: trans.theme.colorMode, hint: prefs.colorMode },
17
25
  { value: 'reset', label: trans.theme.resetLabel },
18
26
  { value: 'about', label: trans.about.title },
19
27
  ];
20
- return { mode: 'menu', statusMessage, menuField: new ListField({ title: trans.theme.chooseAction, options }) };
28
+ return {
29
+ mode: 'menu',
30
+ ...(statusMessage === undefined ? {} : { statusMessage }),
31
+ menuField: new ListField({ title: trans.theme.chooseAction, options }),
32
+ };
21
33
  }
22
34
  function goToMenu(statusMessage) {
23
35
  state = buildMenuField(statusMessage);
@@ -25,8 +37,9 @@ function goToMenu(statusMessage) {
25
37
  export const settingsView = {
26
38
  id: 'settings',
27
39
  title: t().menu.settings,
28
- async load(_ctx) {
40
+ load() {
29
41
  goToMenu();
42
+ return Promise.resolve();
30
43
  },
31
44
  render(ctx) {
32
45
  return renderSettings(state, ctx.bodyRows, ctx.size.cols);
@@ -34,6 +47,9 @@ export const settingsView = {
34
47
  capturesInput() {
35
48
  return false;
36
49
  },
50
+ capturesPageKeys() {
51
+ return state.mode !== 'about';
52
+ },
37
53
  handleBack() {
38
54
  if (state.mode !== 'menu') {
39
55
  goToMenu();
@@ -41,7 +57,7 @@ export const settingsView = {
41
57
  }
42
58
  return false;
43
59
  },
44
- handleKey(key, _ctx) {
60
+ handleKey(key) {
45
61
  const trans = t();
46
62
  switch (state.mode) {
47
63
  case 'menu': {
@@ -51,38 +67,92 @@ export const settingsView = {
51
67
  if (result.selected === 'language') {
52
68
  const currentLang = getCurrentLanguage();
53
69
  const options = [
54
- { value: 'zh', label: trans.language.zh, hint: currentLang === 'zh' ? trans.common.current : undefined },
55
- { value: 'en', label: trans.language.en, hint: currentLang === 'en' ? trans.common.current : undefined },
70
+ {
71
+ value: 'zh',
72
+ label: trans.language.zh,
73
+ ...currentHint(currentLang === 'zh', trans.common.current),
74
+ },
75
+ {
76
+ value: 'en',
77
+ label: trans.language.en,
78
+ ...currentHint(currentLang === 'en', trans.common.current),
79
+ },
56
80
  ];
57
- state = { mode: 'language', subField: new ListField({ title: trans.language.selectLanguage, options, initialIndex: currentLang === 'en' ? 1 : 0 }) };
81
+ state = {
82
+ mode: 'language',
83
+ subField: new ListField({
84
+ title: trans.language.selectLanguage,
85
+ options,
86
+ initialIndex: currentLang === 'en' ? 1 : 0,
87
+ }),
88
+ };
58
89
  return;
59
90
  }
60
91
  if (result.selected === 'icon') {
61
92
  const prefs = loadPreferences();
62
93
  const options = [
63
- { value: 'auto', label: trans.theme.modeAuto, hint: prefs.iconMode === 'auto' ? trans.common.current : undefined },
64
- { value: 'ascii', label: trans.theme.modeAscii, hint: prefs.iconMode === 'ascii' ? trans.common.current : undefined },
65
- { value: 'unicode', label: trans.theme.modeUnicode, hint: prefs.iconMode === 'unicode' ? trans.common.current : undefined },
94
+ {
95
+ value: 'auto',
96
+ label: trans.theme.modeAuto,
97
+ ...currentHint(prefs.iconMode === 'auto', trans.common.current),
98
+ },
99
+ {
100
+ value: 'ascii',
101
+ label: trans.theme.modeAscii,
102
+ ...currentHint(prefs.iconMode === 'ascii', trans.common.current),
103
+ },
104
+ {
105
+ value: 'unicode',
106
+ label: trans.theme.modeUnicode,
107
+ ...currentHint(prefs.iconMode === 'unicode', trans.common.current),
108
+ },
66
109
  ];
67
110
  const idx = Math.max(0, options.findIndex((o) => o.value === prefs.iconMode));
68
- state = { mode: 'icon', subField: new ListField({ title: trans.theme.chooseIconMode, options, initialIndex: idx }) };
111
+ state = {
112
+ mode: 'icon',
113
+ subField: new ListField({
114
+ title: trans.theme.chooseIconMode,
115
+ options,
116
+ initialIndex: idx,
117
+ }),
118
+ };
69
119
  return;
70
120
  }
71
121
  if (result.selected === 'color') {
72
122
  const prefs = loadPreferences();
73
123
  const options = [
74
- { value: 'auto', label: trans.theme.modeAuto, hint: prefs.colorMode === 'auto' ? trans.common.current : undefined },
75
- { value: 'on', label: trans.theme.modeOn, hint: prefs.colorMode === 'on' ? trans.common.current : undefined },
76
- { value: 'off', label: trans.theme.modeOff, hint: prefs.colorMode === 'off' ? trans.common.current : undefined },
124
+ {
125
+ value: 'auto',
126
+ label: trans.theme.modeAuto,
127
+ ...currentHint(prefs.colorMode === 'auto', trans.common.current),
128
+ },
129
+ {
130
+ value: 'on',
131
+ label: trans.theme.modeOn,
132
+ ...currentHint(prefs.colorMode === 'on', trans.common.current),
133
+ },
134
+ {
135
+ value: 'off',
136
+ label: trans.theme.modeOff,
137
+ ...currentHint(prefs.colorMode === 'off', trans.common.current),
138
+ },
77
139
  ];
78
140
  const idx = Math.max(0, options.findIndex((o) => o.value === prefs.colorMode));
79
- state = { mode: 'color', subField: new ListField({ title: trans.theme.chooseColorMode, options, initialIndex: idx }) };
141
+ state = {
142
+ mode: 'color',
143
+ subField: new ListField({
144
+ title: trans.theme.chooseColorMode,
145
+ options,
146
+ initialIndex: idx,
147
+ }),
148
+ };
80
149
  return;
81
150
  }
82
151
  if (result.selected === 'reset') {
83
152
  const saved = resetPreferences();
84
153
  resetIconCache();
85
154
  applyColorModePreference(false);
155
+ resetCapabilities();
86
156
  goToMenu(saved ? trans.theme.reset : trans.theme.resetSessionOnly);
87
157
  return;
88
158
  }
@@ -102,7 +172,10 @@ export const settingsView = {
102
172
  '',
103
173
  row(trans.about.license, `MIT ${pickIcon('·', '-')} ${trans.about.author}: m1ngsama`),
104
174
  ],
105
- backField: new ListField({ title: trans.about.title, options: [{ value: '__back__', label: trans.common.back }] }),
175
+ backField: new ListField({
176
+ title: trans.about.title,
177
+ options: [{ value: '__back__', label: trans.common.back }],
178
+ }),
106
179
  };
107
180
  }
108
181
  return;
@@ -128,6 +201,7 @@ export const settingsView = {
128
201
  return;
129
202
  const saved = setIconMode(result.selected);
130
203
  resetIconCache();
204
+ resetCapabilities();
131
205
  goToMenu(saved ? trans.theme.updated : trans.theme.updatedSessionOnly);
132
206
  return;
133
207
  }
@@ -137,6 +211,7 @@ export const settingsView = {
137
211
  return;
138
212
  const saved = setColorMode(result.selected);
139
213
  applyColorModePreference(false);
214
+ resetCapabilities();
140
215
  goToMenu(saved ? trans.theme.updated : trans.theme.updatedSessionOnly);
141
216
  return;
142
217
  }
@@ -11,10 +11,7 @@ const WEBVPN_PATHS = new Set([
11
11
  '/users/auth/cas/callback',
12
12
  '/vpn_key/update',
13
13
  ]);
14
- const AUTH_PATHS = new Set([
15
- '/authserver/login',
16
- '/authserver/checkNeedCaptcha.htl',
17
- ]);
14
+ const AUTH_PATHS = new Set(['/authserver/login', '/authserver/checkNeedCaptcha.htl']);
18
15
  const JWXT_EXACT_PATHS = new Set([
19
16
  '/sso/jziotlogin',
20
17
  '/jwglxt/ticketlogin',
@@ -35,19 +32,17 @@ function assertAllowedVpnOrigin(value) {
35
32
  catch {
36
33
  throw new AuthError('UNTRUSTED_URL', 'session', 'The campus service returned an untrusted redirect.');
37
34
  }
38
- if (nested.protocol !== 'https:'
39
- || (nested.port !== '' && nested.port !== '443')
40
- || nested.username !== ''
41
- || nested.password !== '') {
35
+ if (nested.protocol !== 'https:' ||
36
+ (nested.port !== '' && nested.port !== '443') ||
37
+ nested.username !== '' ||
38
+ nested.password !== '') {
42
39
  throw new AuthError('UNTRUSTED_URL', 'session', 'The campus service returned an untrusted redirect.');
43
40
  }
44
41
  const host = nested.hostname.toLowerCase();
45
- const allowed = (host === JWXT_HOST && isAllowedJwxtPath(nested.pathname)) || (host === AUTH_HOST && nested.pathname === '/authserver/login') || (host === WEBVPN_HOST && [
46
- '/',
47
- '/users/sign_in',
48
- '/users/auth/cas',
49
- '/users/auth/cas/callback',
50
- ].includes(nested.pathname));
42
+ const allowed = (host === JWXT_HOST && isAllowedJwxtPath(nested.pathname)) ||
43
+ (host === AUTH_HOST && nested.pathname === '/authserver/login') ||
44
+ (host === WEBVPN_HOST &&
45
+ ['/', '/users/sign_in', '/users/auth/cas', '/users/auth/cas/callback'].includes(nested.pathname));
51
46
  if (!allowed) {
52
47
  throw new AuthError('UNTRUSTED_URL', 'session', 'The campus service returned an untrusted redirect.');
53
48
  }
@@ -60,23 +55,23 @@ function assertAllowedCasService(value) {
60
55
  catch {
61
56
  throw new AuthError('UNTRUSTED_URL', 'session', 'The campus service returned an untrusted redirect.');
62
57
  }
63
- const allowed = service.protocol === 'https:'
64
- && (service.port === '' || service.port === '443')
65
- && service.username === ''
66
- && service.password === ''
67
- && ((service.hostname.toLowerCase() === WEBVPN_HOST
68
- && service.pathname === '/users/auth/cas/callback') || (service.hostname.toLowerCase() === JWXT_HOST
69
- && service.pathname === '/sso/jziotlogin'));
58
+ const allowed = service.protocol === 'https:' &&
59
+ (service.port === '' || service.port === '443') &&
60
+ service.username === '' &&
61
+ service.password === '' &&
62
+ ((service.hostname.toLowerCase() === WEBVPN_HOST &&
63
+ service.pathname === '/users/auth/cas/callback') ||
64
+ (service.hostname.toLowerCase() === JWXT_HOST && service.pathname === '/sso/jziotlogin'));
70
65
  if (!allowed) {
71
66
  throw new AuthError('UNTRUSTED_URL', 'session', 'The campus service returned an untrusted redirect.');
72
67
  }
73
68
  }
74
69
  export function assertAllowedCampusUrl(url) {
75
70
  const hostname = url.hostname.toLowerCase();
76
- if (url.protocol !== 'https:'
77
- || (url.port !== '' && url.port !== '443')
78
- || url.username !== ''
79
- || url.password !== '') {
71
+ if (url.protocol !== 'https:' ||
72
+ (url.port !== '' && url.port !== '443') ||
73
+ url.username !== '' ||
74
+ url.password !== '') {
80
75
  throw new AuthError('UNTRUSTED_URL', 'session', 'The campus service URL is not allowed.');
81
76
  }
82
77
  if (hostname === WEBVPN_HOST && WEBVPN_PATHS.has(url.pathname)) {
@@ -111,7 +106,9 @@ function safeHeaders(headers) {
111
106
  function abortSignal(signal, timeoutMs) {
112
107
  const controller = new AbortController();
113
108
  let didTimeout = false;
114
- const onAbort = () => controller.abort(signal?.reason);
109
+ const onAbort = () => {
110
+ controller.abort(signal?.reason);
111
+ };
115
112
  signal?.addEventListener('abort', onAbort, { once: true });
116
113
  if (signal?.aborted)
117
114
  onAbort();
@@ -119,7 +116,7 @@ function abortSignal(signal, timeoutMs) {
119
116
  didTimeout = true;
120
117
  controller.abort();
121
118
  }, timeoutMs);
122
- timer.unref?.();
119
+ timer.unref();
123
120
  return {
124
121
  signal: controller.signal,
125
122
  cleanup() {
@@ -133,7 +130,9 @@ function safeFetchError(error, stage, didTimeout) {
133
130
  if (error instanceof AuthError)
134
131
  return error;
135
132
  if (didTimeout)
136
- return new AuthError('TIMEOUT', stage, 'The campus service request timed out.', { retryable: true });
133
+ return new AuthError('TIMEOUT', stage, 'The campus service request timed out.', {
134
+ retryable: true,
135
+ });
137
136
  if (typeof error === 'object' && error !== null && Reflect.get(error, 'name') === 'AbortError') {
138
137
  return new DOMException('The campus service request was aborted.', 'AbortError');
139
138
  }
@@ -188,10 +187,10 @@ export function createCampusCookieSession(options = {}) {
188
187
  const response = await request(url, init, 'session');
189
188
  try {
190
189
  const finalUrl = new URL(response.url);
191
- if (finalUrl.hostname.toLowerCase() !== JWXT_HOST
192
- || finalUrl.pathname.includes('/authserver/login')
193
- || finalUrl.pathname.includes('/users/sign_in')
194
- || finalUrl.pathname === '/vpn_key/update')
190
+ if (finalUrl.hostname.toLowerCase() !== JWXT_HOST ||
191
+ finalUrl.pathname.includes('/authserver/login') ||
192
+ finalUrl.pathname.includes('/users/sign_in') ||
193
+ finalUrl.pathname === '/vpn_key/update')
195
194
  throw new SessionExpiredError();
196
195
  }
197
196
  catch (error) {
@@ -12,7 +12,9 @@ export class AuthError extends Error {
12
12
  }
13
13
  export class SessionExpiredError extends AuthError {
14
14
  constructor() {
15
- super('SESSION_EXPIRED', 'session', 'The campus login session has expired.', { retryable: true });
15
+ super('SESSION_EXPIRED', 'session', 'The campus login session has expired.', {
16
+ retryable: true,
17
+ });
16
18
  this.name = 'SessionExpiredError';
17
19
  }
18
20
  }
@@ -1,4 +1,4 @@
1
- import { createCipheriv, randomBytes } from 'node:crypto';
1
+ import { createCipheriv, randomFillSync } from 'node:crypto';
2
2
  import { load } from 'cheerio';
3
3
  import { AuthError } from './errors.js';
4
4
  import { JWXT_HOST, cookieSessionFromSerialized, createCampusCookieSession, } from './cookie-transport.js';
@@ -9,29 +9,38 @@ const TIMETABLE_INDEX = new URL(`https://${JWXT_HOST}/jwglxt/kbcx/xskbcx_cxXskbc
9
9
  const RANDOM_ALPHABET = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678';
10
10
  const MAX_AUTH_HTML_BYTES = 2 * 1024 * 1024;
11
11
  const SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
12
+ const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
13
+ function signalOption(signal) {
14
+ return signal === undefined ? {} : { signal };
15
+ }
16
+ const secureRandomBytes = (size) => randomFillSync(new Uint8Array(size));
12
17
  function randomCharacters(length, source) {
13
18
  const bytes = source(length);
14
19
  if (bytes.length < length)
15
20
  throw new AuthError('LOGIN_PAGE_CHANGED', 'credentials', 'Secure login initialization failed.');
16
21
  let result = '';
17
22
  for (let index = 0; index < length; index += 1) {
18
- result += RANDOM_ALPHABET[bytes[index] % RANDOM_ALPHABET.length];
23
+ const byte = bytes[index];
24
+ if (byte === undefined) {
25
+ throw new AuthError('LOGIN_PAGE_CHANGED', 'credentials', 'Secure login initialization failed.');
26
+ }
27
+ result += RANDOM_ALPHABET.charAt(byte % RANDOM_ALPHABET.length);
19
28
  }
20
29
  return result;
21
30
  }
22
- export function encryptCampusPassword(password, salt, source = randomBytes) {
23
- const key = Buffer.from(salt, 'utf8');
31
+ export function encryptCampusPassword(password, salt, source = secureRandomBytes) {
32
+ const key = Uint8Array.from(Buffer.from(salt, 'utf8'));
24
33
  if (key.length !== 16) {
25
34
  key.fill(0);
26
35
  throw new AuthError('LOGIN_PAGE_CHANGED', 'credentials', 'The campus login page changed unexpectedly.');
27
36
  }
28
37
  const ivText = randomCharacters(16, source);
29
38
  const prefix = randomCharacters(64, source);
30
- const iv = Buffer.from(ivText, 'utf8');
31
- const plaintext = Buffer.from(prefix + password, 'utf8');
39
+ const iv = Uint8Array.from(Buffer.from(ivText, 'utf8'));
40
+ const plaintext = Uint8Array.from(Buffer.from(prefix + password, 'utf8'));
32
41
  try {
33
42
  const cipher = createCipheriv('aes-128-cbc', key, iv);
34
- return Buffer.concat([cipher.update(plaintext), cipher.final()]).toString('base64');
43
+ return cipher.update(plaintext, undefined, 'base64') + cipher.final('base64');
35
44
  }
36
45
  finally {
37
46
  key.fill(0);
@@ -72,7 +81,10 @@ function classifyRejectedLogin(html) {
72
81
  $('#showWarnTip').text(),
73
82
  $('#errorMsg').text(),
74
83
  $('.alert-danger').text(),
75
- ].join(' ').replace(/\s+/g, ' ').trim();
84
+ ]
85
+ .join(' ')
86
+ .replace(/\s+/g, ' ')
87
+ .trim();
76
88
  if (/锁定|冻结|次数过多|稍后再试/.test(visibleError)) {
77
89
  return new AuthError('ACCOUNT_LOCKED', 'credentials', 'The campus account is temporarily locked.');
78
90
  }
@@ -89,7 +101,9 @@ function classifyRejectedLogin(html) {
89
101
  }
90
102
  async function readText(response, stage) {
91
103
  if (response.status < 200 || response.status >= 300) {
92
- throw new AuthError('HTTP_ERROR', stage, 'The campus service returned an error.', { retryable: response.status >= 500 });
104
+ throw new AuthError('HTTP_ERROR', stage, 'The campus service returned an error.', {
105
+ retryable: response.status >= 500,
106
+ });
93
107
  }
94
108
  const length = Number.parseInt(response.headers.get('content-length') ?? '', 10);
95
109
  if (Number.isFinite(length) && length > MAX_AUTH_HTML_BYTES) {
@@ -107,7 +121,7 @@ async function challengeRequired(cookies, loginUrl, username, signal) {
107
121
  const response = await cookies.request(endpoint, {
108
122
  method: 'GET',
109
123
  headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
110
- signal,
124
+ ...signalOption(signal),
111
125
  }, 'challenge-check');
112
126
  const text = await readText(response, 'challenge-check');
113
127
  let parsed;
@@ -120,7 +134,8 @@ async function challengeRequired(cookies, loginUrl, username, signal) {
120
134
  if (typeof parsed === 'boolean')
121
135
  return parsed;
122
136
  if (typeof parsed === 'object' && parsed !== null) {
123
- const value = Reflect.get(parsed, 'isNeed') ?? Reflect.get(parsed, 'needCaptcha');
137
+ const challenge = parsed;
138
+ const value = challenge['isNeed'] ?? challenge['needCaptcha'];
124
139
  if (value === true || value === 'true' || value === 1 || value === '1')
125
140
  return true;
126
141
  if (value === false || value === 'false' || value === 0 || value === '0')
@@ -129,7 +144,7 @@ async function challengeRequired(cookies, loginUrl, username, signal) {
129
144
  throw new AuthError('LOGIN_PAGE_CHANGED', 'challenge-check', 'The campus challenge response changed unexpectedly.');
130
145
  }
131
146
  function maskAccountId(username) {
132
- const characters = [...username];
147
+ const characters = Array.from(GRAPHEME_SEGMENTER.segment(username), ({ segment }) => segment);
133
148
  if (characters.length <= 2)
134
149
  return '•'.repeat(characters.length);
135
150
  return `${'•'.repeat(characters.length - 2)}${characters.slice(-2).join('')}`;
@@ -144,7 +159,7 @@ function createAuthenticatedSession(cookies, metadata) {
144
159
  version: 1,
145
160
  provider: 'nbt-webvpn',
146
161
  jar: await cookies.serialize(),
147
- accountHint: metadata.accountHint,
162
+ ...(metadata.accountHint === undefined ? {} : { accountHint: metadata.accountHint }),
148
163
  authenticatedAt: metadata.authenticatedAt,
149
164
  validatedAt: validatedAtText,
150
165
  expiresAt: new Date(validatedAt.getTime() + SESSION_MAX_AGE_MS).toISOString(),
@@ -154,9 +169,9 @@ function createAuthenticatedSession(cookies, metadata) {
154
169
  };
155
170
  }
156
171
  async function verifyJwxtSession(cookies, signal) {
157
- await readText(await cookies.request(SSO_ENTRY, { method: 'GET', signal }, 'sso'), 'sso');
158
- await readText(await cookies.request(JWXT_MENU, { method: 'GET', signal }, 'sso'), 'sso');
159
- const response = await cookies.request(TIMETABLE_INDEX, { method: 'GET', signal }, 'sso');
172
+ await readText(await cookies.request(SSO_ENTRY, { method: 'GET', ...signalOption(signal) }, 'sso'), 'sso');
173
+ await readText(await cookies.request(JWXT_MENU, { method: 'GET', ...signalOption(signal) }, 'sso'), 'sso');
174
+ const response = await cookies.request(TIMETABLE_INDEX, { method: 'GET', ...signalOption(signal) }, 'sso');
160
175
  const html = await readText(response, 'sso');
161
176
  let finalUrl;
162
177
  try {
@@ -165,10 +180,10 @@ async function verifyJwxtSession(cookies, signal) {
165
180
  catch {
166
181
  throw new AuthError('UNEXPECTED_RESPONSE', 'sso', 'Campus login could not be confirmed.');
167
182
  }
168
- if (finalUrl.hostname.toLowerCase() !== JWXT_HOST
169
- || hasLoginFingerprint(html)
170
- || !/<select\b[^>]*(?:id|name)=["']xnm["']/i.test(html)
171
- || !/<select\b[^>]*(?:id|name)=["']xqm["']/i.test(html)) {
183
+ if (finalUrl.hostname.toLowerCase() !== JWXT_HOST ||
184
+ hasLoginFingerprint(html) ||
185
+ !/<select\b[^>]*(?:id|name)=["']xnm["']/i.test(html) ||
186
+ !/<select\b[^>]*(?:id|name)=["']xqm["']/i.test(html)) {
172
187
  throw new AuthError('UNEXPECTED_RESPONSE', 'sso', 'Campus login could not be confirmed.');
173
188
  }
174
189
  }
@@ -185,14 +200,14 @@ export async function loginWithStudentPassword(username, password, options = {})
185
200
  Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
186
201
  'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
187
202
  },
188
- signal: options.signal,
203
+ ...signalOption(options.signal),
189
204
  }, 'login-page');
190
205
  const loginHtml = await readText(loginResponse, 'login-page');
191
206
  const form = parseLoginForm(loginHtml, loginResponse.url);
192
207
  if (await challengeRequired(cookies, form.action, normalizedUsername, options.signal)) {
193
208
  throw new AuthError('INTERACTIVE_CHALLENGE', 'challenge-check', 'The campus login requires an interactive browser challenge.');
194
209
  }
195
- const encryptedPassword = encryptCampusPassword(password, form.salt, options.randomBytes ?? randomBytes);
210
+ const encryptedPassword = encryptCampusPassword(password, form.salt, options.randomBytes ?? secureRandomBytes);
196
211
  const body = new URLSearchParams({
197
212
  username: normalizedUsername,
198
213
  password: encryptedPassword,
@@ -209,7 +224,7 @@ export async function loginWithStudentPassword(username, password, options = {})
209
224
  Referer: loginResponse.url,
210
225
  },
211
226
  body: body.toString(),
212
- signal: options.signal,
227
+ ...signalOption(options.signal),
213
228
  }, 'credentials');
214
229
  const credentialHtml = await readText(credentialResponse, 'credentials');
215
230
  if (hasLoginFingerprint(credentialHtml))
@@ -227,13 +242,15 @@ export async function loginWithStudentPassword(username, password, options = {})
227
242
  throw error;
228
243
  if (typeof error === 'object' && error !== null && Reflect.get(error, 'name') === 'AbortError')
229
244
  throw error;
230
- throw new AuthError('NETWORK', 'credentials', 'The campus login request failed.', { retryable: true });
245
+ throw new AuthError('NETWORK', 'credentials', 'The campus login request failed.', {
246
+ retryable: true,
247
+ });
231
248
  }
232
249
  }
233
250
  export async function restoreNbtSession(persisted, options = {}) {
234
251
  const cookies = await cookieSessionFromSerialized(persisted.jar, options);
235
252
  return createAuthenticatedSession(cookies, {
236
- accountHint: persisted.accountHint,
253
+ ...(persisted.accountHint === undefined ? {} : { accountHint: persisted.accountHint }),
237
254
  authenticatedAt: persisted.authenticatedAt,
238
255
  });
239
256
  }
@@ -23,19 +23,21 @@ function parseSession(value) {
23
23
  return null;
24
24
  if (value['expiresAt'] !== undefined && !isIsoDate(value['expiresAt']))
25
25
  return null;
26
- if (value['accountHint'] !== undefined
27
- && (typeof value['accountHint'] !== 'string'
28
- || value['accountHint'].length > 64
29
- || /[\u0000-\u001f\u007f]/.test(value['accountHint'])))
26
+ if (value['accountHint'] !== undefined &&
27
+ (typeof value['accountHint'] !== 'string' ||
28
+ value['accountHint'].length > 64 ||
29
+ /[\u0000-\u001f\u007f]/.test(value['accountHint'])))
30
30
  return null;
31
+ const accountHint = value['accountHint'];
32
+ const expiresAt = value['expiresAt'];
31
33
  return {
32
34
  version: SESSION_SCHEMA_VERSION,
33
35
  provider: 'nbt-webvpn',
34
36
  jar: value['jar'],
35
- accountHint: value['accountHint'],
37
+ ...(typeof accountHint === 'string' ? { accountHint } : {}),
36
38
  authenticatedAt: value['authenticatedAt'],
37
39
  validatedAt: value['validatedAt'],
38
- expiresAt: value['expiresAt'],
40
+ ...(typeof expiresAt === 'string' ? { expiresAt } : {}),
39
41
  };
40
42
  }
41
43
  function removeFile(filePath) {
@@ -93,7 +95,9 @@ export function createSessionStore(options = {}) {
93
95
  try {
94
96
  fs.chmodSync(directory, 0o700);
95
97
  }
96
- catch { /* Best effort on non-POSIX filesystems. */ }
98
+ catch {
99
+ /* Best effort on non-POSIX filesystems. */
100
+ }
97
101
  const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
98
102
  try {
99
103
  fs.writeFileSync(temporaryPath, `${JSON.stringify(validated)}\n`, {
@@ -105,13 +109,17 @@ export function createSessionStore(options = {}) {
105
109
  try {
106
110
  fs.chmodSync(filePath, 0o600);
107
111
  }
108
- catch { /* Best effort on non-POSIX filesystems. */ }
112
+ catch {
113
+ /* Best effort on non-POSIX filesystems. */
114
+ }
109
115
  }
110
116
  finally {
111
117
  try {
112
118
  removeFile(temporaryPath);
113
119
  }
114
- catch { /* The main write result is authoritative. */ }
120
+ catch {
121
+ /* The main write result is authoritative. */
122
+ }
115
123
  }
116
124
  }
117
125
  return { filePath, load, save, clear };
@@ -1,13 +1,16 @@
1
- import { readFileSync } from 'fs';
2
- import { fileURLToPath } from 'url';
3
- import { dirname, join } from 'path';
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
4
  const __filename = fileURLToPath(import.meta.url);
5
5
  const __dirname = dirname(__filename);
6
6
  function readPackageVersion() {
7
7
  try {
8
8
  const pkgPath = join(__dirname, '..', '..', 'package.json');
9
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
10
- return pkg.version ?? '0.0.0';
9
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
10
+ if (typeof pkg !== 'object' || pkg === null)
11
+ return '0.0.0';
12
+ const version = pkg['version'];
13
+ return typeof version === 'string' ? version : '0.0.0';
11
14
  }
12
15
  catch {
13
16
  return '0.0.0';
@@ -24,16 +27,11 @@ export const URLS = {
24
27
  cloud: 'https://cloud.nbtca.space',
25
28
  mirror: 'https://i.nbtca.space',
26
29
  };
27
- export const GITHUB_REPO = {
28
- owner: 'nbtca',
29
- repo: 'documents',
30
- branch: 'main',
31
- };
32
30
  export const APP_INFO = {
33
31
  name: 'Prompt',
34
32
  version: readPackageVersion(),
35
33
  description: 'NBTCA community',
36
34
  author: 'm1ngsama <contact@m1ng.space>',
37
35
  license: 'MIT',
38
- repository: 'https://github.com/nbtca/prompt'
36
+ repository: 'https://github.com/nbtca/Prompt',
39
37
  };