@nbtca/prompt 1.4.2 → 1.5.1
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 +27 -58
- package/SECURITY.md +16 -45
- package/dist/app/app.js +167 -64
- package/dist/app/chrome.js +67 -50
- package/dist/app/fields/list-field.js +12 -25
- package/dist/app/fields/text-field.js +3 -8
- package/dist/app/frame.js +16 -23
- package/dist/app/keys.js +110 -2
- package/dist/app/views/docs-render.js +34 -26
- package/dist/app/views/docs.js +280 -68
- package/dist/app/views/events-render.js +21 -27
- package/dist/app/views/events.js +57 -33
- package/dist/app/views/home.js +67 -47
- package/dist/app/views/schedule-grid-cursor.js +9 -18
- package/dist/app/views/schedule-render.js +51 -74
- package/dist/app/views/schedule.js +246 -101
- package/dist/app/views/settings-render.js +8 -19
- package/dist/app/views/settings.js +92 -17
- package/dist/auth/cookie-transport.js +31 -32
- package/dist/auth/errors.js +3 -1
- package/dist/auth/nbt-auth.js +42 -25
- package/dist/auth/session-store.js +17 -9
- package/dist/cli.js +570 -0
- package/dist/config/data.js +9 -11
- package/dist/config/preferences.js +21 -7
- package/dist/core/calendar-day.js +37 -0
- package/dist/core/canvas.js +1 -0
- package/dist/core/capabilities.js +6 -3
- package/dist/core/components/confirm.js +9 -8
- package/dist/core/components/menu.js +64 -38
- package/dist/core/components/messages.js +12 -4
- package/dist/core/components/painter.js +3 -1
- package/dist/core/components/spinner.js +34 -7
- package/dist/core/components/text-input.js +24 -18
- package/dist/core/icons.js +2 -2
- package/dist/core/logo.js +23 -5
- package/dist/core/motion.js +25 -19
- package/dist/core/text.js +186 -69
- package/dist/core/theme.js +0 -28
- package/dist/core/transitions.js +2 -2
- package/dist/core/ui.js +15 -13
- package/dist/core/vim-keys.js +156 -19
- package/dist/features/about.js +23 -0
- package/dist/features/calendar-heatmap.js +16 -40
- package/dist/features/calendar-query.js +1 -2
- package/dist/features/calendar-store.js +27 -0
- package/dist/features/calendar.js +66 -190
- package/dist/features/docs-client.js +225 -0
- package/dist/features/docs.js +615 -298
- package/dist/features/links.js +44 -29
- package/dist/features/schedule-render.js +65 -101
- package/dist/features/schedule-store.js +51 -9
- package/dist/features/schedule-view.js +46 -213
- package/dist/features/status.js +117 -60
- package/dist/features/student-timetable.js +74 -97
- package/dist/features/theme.js +9 -5
- package/dist/features/timetable-sanitize.js +40 -0
- package/dist/features/update.js +12 -29
- package/dist/i18n/index.js +83 -19
- package/dist/i18n/locales/en.json +8 -3
- package/dist/i18n/locales/zh.json +8 -3
- package/dist/index.js +6 -474
- package/dist/logo/ca-dotmatrix.txt +16 -18
- package/dist/main.js +7 -48
- package/package.json +28 -18
- package/bin/nbtca-welcome.js +0 -2
- package/dist/core/components/screen.js +0 -18
- package/dist/core/menu.js +0 -68
- package/dist/features/schedule-query.js +0 -47
- package/dist/features/settings.js +0 -127
- 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
|
-
{
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
{
|
|
55
|
-
|
|
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 = {
|
|
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
|
-
{
|
|
64
|
-
|
|
65
|
-
|
|
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 = {
|
|
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
|
-
{
|
|
75
|
-
|
|
76
|
-
|
|
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 = {
|
|
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({
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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)) ||
|
|
46
|
-
'/'
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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 = () =>
|
|
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.', {
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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) {
|
package/dist/auth/errors.js
CHANGED
|
@@ -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.', {
|
|
15
|
+
super('SESSION_EXPIRED', 'session', 'The campus login session has expired.', {
|
|
16
|
+
retryable: true,
|
|
17
|
+
});
|
|
16
18
|
this.name = 'SessionExpiredError';
|
|
17
19
|
}
|
|
18
20
|
}
|
package/dist/auth/nbt-auth.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createCipheriv,
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
]
|
|
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.', {
|
|
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
|
|
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 =
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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 ??
|
|
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
|
-
|
|
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.', {
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
37
|
+
...(typeof accountHint === 'string' ? { accountHint } : {}),
|
|
36
38
|
authenticatedAt: value['authenticatedAt'],
|
|
37
39
|
validatedAt: value['validatedAt'],
|
|
38
|
-
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 {
|
|
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 {
|
|
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 {
|
|
120
|
+
catch {
|
|
121
|
+
/* The main write result is authoritative. */
|
|
122
|
+
}
|
|
115
123
|
}
|
|
116
124
|
}
|
|
117
125
|
return { filePath, load, save, clear };
|