@robertying/thu-learn-lib 4.1.0 → 5.1.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/README.md +41 -7
- package/lib/auth.d.ts +1 -1
- package/lib/auth.d.ts.map +1 -1
- package/lib/auth.js +17 -5
- package/lib/auth.js.map +1 -1
- package/lib/calendar.d.ts +6 -4
- package/lib/calendar.d.ts.map +1 -1
- package/lib/calendar.js +21 -8
- package/lib/calendar.js.map +1 -1
- package/lib/contents.d.ts +4 -4
- package/lib/contents.d.ts.map +1 -1
- package/lib/contents.js +28 -20
- package/lib/contents.js.map +1 -1
- package/lib/courses.d.ts +5 -3
- package/lib/courses.d.ts.map +1 -1
- package/lib/courses.js +24 -12
- package/lib/courses.js.map +1 -1
- package/lib/discussions.js +2 -2
- package/lib/discussions.js.map +1 -1
- package/lib/favorites.d.ts.map +1 -1
- package/lib/favorites.js +12 -12
- package/lib/favorites.js.map +1 -1
- package/lib/files.d.ts +1 -1
- package/lib/files.d.ts.map +1 -1
- package/lib/files.js +10 -8
- package/lib/files.js.map +1 -1
- package/lib/homework.d.ts +11 -4
- package/lib/homework.d.ts.map +1 -1
- package/lib/homework.js +46 -43
- package/lib/homework.js.map +1 -1
- package/lib/index.d.ts +47 -13
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +63 -17
- package/lib/index.js.map +1 -1
- package/lib/notices.d.ts +11 -3
- package/lib/notices.d.ts.map +1 -1
- package/lib/notices.js +40 -36
- package/lib/notices.js.map +1 -1
- package/lib/questionnaires.d.ts +6 -3
- package/lib/questionnaires.d.ts.map +1 -1
- package/lib/questionnaires.js +20 -12
- package/lib/questionnaires.js.map +1 -1
- package/lib/request.d.ts +66 -10
- package/lib/request.d.ts.map +1 -1
- package/lib/request.js +234 -50
- package/lib/request.js.map +1 -1
- package/lib/types.d.ts +52 -3
- package/lib/types.d.ts.map +1 -1
- package/lib/types.js +1 -0
- package/lib/types.js.map +1 -1
- package/lib/user.js +1 -1
- package/lib/user.js.map +1 -1
- package/lib/utils.d.ts +3 -0
- package/lib/utils.d.ts.map +1 -1
- package/lib/utils.js +11 -3
- package/lib/utils.js.map +1 -1
- package/package.json +2 -2
- package/src/auth.ts +18 -5
- package/src/calendar.ts +28 -10
- package/src/contents.ts +30 -25
- package/src/courses.ts +31 -19
- package/src/discussions.ts +2 -2
- package/src/favorites.ts +22 -32
- package/src/files.ts +12 -12
- package/src/homework.ts +118 -123
- package/src/index.ts +94 -19
- package/src/notices.ts +58 -59
- package/src/questionnaires.ts +51 -37
- package/src/request.ts +260 -48
- package/src/types.ts +62 -2
- package/src/user.ts +1 -1
- package/src/utils.ts +14 -3
package/src/notices.ts
CHANGED
|
@@ -4,21 +4,31 @@ import { Base64 } from 'js-base64';
|
|
|
4
4
|
|
|
5
5
|
import type { Session } from './request.ts';
|
|
6
6
|
import { $, YES } from './request.ts';
|
|
7
|
-
import type { INotification,
|
|
7
|
+
import type { INotification, Notification, NotificationListOptions, RemoteFile } from './types.ts';
|
|
8
8
|
import { ContentType, CourseType, FailReason } from './types.ts';
|
|
9
9
|
import * as URLS from './urls.ts';
|
|
10
|
-
import { decodeHTML, trimAndDefine } from './utils.ts';
|
|
10
|
+
import { decodeHTML, isSelected, trimAndDefine } from './utils.ts';
|
|
11
11
|
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Get all notifications (课程公告) of the specified course: current and expired lists.
|
|
14
|
+
* Attachments only for the notices `options.attachments` selects.
|
|
15
|
+
*/
|
|
13
16
|
export async function getNotificationList(
|
|
14
17
|
s: Session,
|
|
15
18
|
courseID: string,
|
|
16
19
|
courseType: CourseType = CourseType.STUDENT,
|
|
20
|
+
options: NotificationListOptions = {},
|
|
17
21
|
): Promise<Notification[]> {
|
|
18
|
-
|
|
22
|
+
const lists = await Promise.all([
|
|
19
23
|
getNotificationListKind(s, courseID, courseType, false),
|
|
20
24
|
getNotificationListKind(s, courseID, courseType, true),
|
|
21
|
-
])
|
|
25
|
+
]);
|
|
26
|
+
return Promise.all(
|
|
27
|
+
lists.flat().map(async (n): Promise<Notification> => {
|
|
28
|
+
if (!n.attachmentName || !isSelected(options.attachments, n)) return n;
|
|
29
|
+
return { ...n, attachment: await getNotificationAttachment(s, n, courseType) };
|
|
30
|
+
}),
|
|
31
|
+
);
|
|
22
32
|
}
|
|
23
33
|
|
|
24
34
|
async function getNotificationListKind(
|
|
@@ -26,13 +36,11 @@ async function getNotificationListKind(
|
|
|
26
36
|
courseID: string,
|
|
27
37
|
courseType: CourseType,
|
|
28
38
|
expired: boolean,
|
|
29
|
-
): Promise<
|
|
30
|
-
const json = await (
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
})
|
|
35
|
-
).json();
|
|
39
|
+
): Promise<INotification[]> {
|
|
40
|
+
const json = await s.fetchJsonWithToken(URLS.LEARN_NOTIFICATION_LIST(courseType, expired), {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
body: URLS.LEARN_PAGE_LIST_FORM_DATA(courseID),
|
|
43
|
+
});
|
|
36
44
|
if (json.result !== 'success') {
|
|
37
45
|
return Promise.reject({
|
|
38
46
|
reason: FailReason.INVALID_RESPONSE,
|
|
@@ -42,69 +50,60 @@ async function getNotificationListKind(
|
|
|
42
50
|
|
|
43
51
|
const result = (json.object?.aaData ?? json.object?.resultsList ?? []) as any[];
|
|
44
52
|
|
|
45
|
-
return
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if (attachmentName) {
|
|
63
|
-
detail = await parseNotificationDetail(s, courseID, notification.id, courseType, attachmentName);
|
|
64
|
-
}
|
|
65
|
-
return { ...notification, ...detail } satisfies Notification;
|
|
66
|
-
}),
|
|
67
|
-
);
|
|
53
|
+
return result.map((n) => {
|
|
54
|
+
const attachmentName: unknown = courseType === CourseType.STUDENT ? n.fjmc : n.fjbt;
|
|
55
|
+
return {
|
|
56
|
+
id: n.ggid,
|
|
57
|
+
content: Base64.decode(n.ggnr ?? ''),
|
|
58
|
+
title: decodeHTML(n.bt),
|
|
59
|
+
url: URLS.LEARN_NOTIFICATION_DETAIL(courseID, n.ggid, courseType),
|
|
60
|
+
publisher: n.fbrxm,
|
|
61
|
+
hasRead: n.sfyd === YES,
|
|
62
|
+
markedImportant: Number(n.sfqd) === 1, // n.sfqd could be string '1' (teacher mode) or number 1 (student mode)
|
|
63
|
+
publishTime: n.fbsj && typeof n.fbsj === 'string' ? n.fbsj : n.fbsjStr,
|
|
64
|
+
expireTime: n.jzsj ?? undefined,
|
|
65
|
+
isFavorite: n.sfsc === YES,
|
|
66
|
+
comment: n.bznr ?? undefined,
|
|
67
|
+
attachmentName: typeof attachmentName === 'string' && attachmentName ? attachmentName : undefined,
|
|
68
|
+
} satisfies INotification;
|
|
69
|
+
});
|
|
68
70
|
}
|
|
69
71
|
|
|
70
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Get the attachment of a notice from its detail page (1 request), or `undefined` without a request if
|
|
74
|
+
* `attachmentName` says it has none, or if the page has no attachment link.
|
|
75
|
+
*/
|
|
76
|
+
export async function getNotificationAttachment(
|
|
71
77
|
s: Session,
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
attachmentName
|
|
76
|
-
): Promise<INotificationDetail> {
|
|
78
|
+
notification: Pick<INotification, 'url' | 'attachmentName'>,
|
|
79
|
+
courseType: CourseType = CourseType.STUDENT,
|
|
80
|
+
): Promise<RemoteFile | undefined> {
|
|
81
|
+
if (!notification.attachmentName) return undefined;
|
|
77
82
|
/// from JSON (backup, currently not used)
|
|
78
83
|
// const postParams = new FormData();
|
|
79
84
|
// postParams.append('id', id);
|
|
80
85
|
// postParams.append('wlkcid', courseID);
|
|
81
|
-
// const metadata = await
|
|
86
|
+
// const metadata = await s.fetchJsonWithToken(URL.LEARN_NOTIFICATION_EDIT(courseType), {
|
|
82
87
|
// 'method': 'POST',
|
|
83
88
|
// 'body': postParams,
|
|
84
|
-
// })
|
|
89
|
+
// });
|
|
85
90
|
// const attachmentId = metadata.ggfjid as string;
|
|
86
91
|
/// parsed from HTML
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if (
|
|
91
|
-
|
|
92
|
-
} else {
|
|
93
|
-
path = result('#wjid').attr('href')!;
|
|
94
|
-
}
|
|
95
|
-
const size = trimAndDefine(result('div#attachment > div.fl > span[class^="color"]').first().text())!;
|
|
92
|
+
const result = $(await s.fetchTextWithToken(notification.url));
|
|
93
|
+
let path = (courseType === CourseType.STUDENT ? result('.ml-10') : result('#wjid')).attr('href');
|
|
94
|
+
// the page has no attachment link (removed since the list was fetched, or the page changed)
|
|
95
|
+
if (!path) return undefined;
|
|
96
|
+
const size = trimAndDefine(result('div#attachment > div.fl > span[class^="color"]').first().text()) ?? '';
|
|
96
97
|
const params = new URLSearchParams(path.split('?').slice(-1)[0]);
|
|
97
98
|
const attachmentId = params.get('wjid')!;
|
|
98
99
|
if (!path.startsWith(URLS.LEARN_PREFIX)) {
|
|
99
100
|
path = URLS.LEARN_PREFIX + path;
|
|
100
101
|
}
|
|
101
102
|
return {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
size,
|
|
108
|
-
},
|
|
103
|
+
name: notification.attachmentName,
|
|
104
|
+
id: attachmentId,
|
|
105
|
+
downloadUrl: path,
|
|
106
|
+
previewUrl: URLS.LEARN_FILE_PREVIEW(ContentType.NOTIFICATION, attachmentId, courseType, s.previewFirstPage),
|
|
107
|
+
size,
|
|
109
108
|
};
|
|
110
109
|
}
|
package/src/questionnaires.ts
CHANGED
|
@@ -2,25 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
import type { Session } from './request.ts';
|
|
4
4
|
import { YES } from './request.ts';
|
|
5
|
-
import type { Questionnaire, QuestionnaireDetail } from './types.ts';
|
|
5
|
+
import type { ApiError, Questionnaire, QuestionnaireDetail, QuestionnaireListOptions } from './types.ts';
|
|
6
6
|
import { FailReason, QuestionnaireType } from './types.ts';
|
|
7
7
|
import * as URLS from './urls.ts';
|
|
8
|
-
import { QNR_TYPE_MAP, decodeHTML } from './utils.ts';
|
|
8
|
+
import { QNR_TYPE_MAP, decodeHTML, isSelected } from './utils.ts';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* Get all questionnaires (课程问卷/QNR) of the specified course.
|
|
11
|
+
* Get all questionnaires (课程问卷/QNR) of the specified course: ongoing and ended lists.
|
|
12
|
+
* Questions (`detail`, 1 request each) only for the questionnaires `options.details` selects (default `'all'`).
|
|
12
13
|
*/
|
|
13
|
-
export async function getQuestionnaireList(
|
|
14
|
-
|
|
14
|
+
export async function getQuestionnaireList(
|
|
15
|
+
s: Session,
|
|
16
|
+
courseID: string,
|
|
17
|
+
options: QuestionnaireListOptions = {},
|
|
18
|
+
): Promise<Questionnaire[]> {
|
|
19
|
+
const details = options.details ?? 'all';
|
|
20
|
+
const lists = await Promise.all([
|
|
15
21
|
getQuestionnaireListAtUrl(s, courseID, URLS.LEARN_QNR_LIST_ONGOING),
|
|
16
22
|
getQuestionnaireListAtUrl(s, courseID, URLS.LEARN_QNR_LIST_ENDED),
|
|
17
|
-
])
|
|
23
|
+
]);
|
|
24
|
+
return Promise.all(
|
|
25
|
+
lists
|
|
26
|
+
.flat()
|
|
27
|
+
.map(async (q) =>
|
|
28
|
+
isSelected(details, q) ? { ...q, detail: await getQuestionnaireDetail(s, courseID, q.id) } : q,
|
|
29
|
+
),
|
|
30
|
+
);
|
|
18
31
|
}
|
|
19
32
|
|
|
20
33
|
async function getQuestionnaireListAtUrl(s: Session, courseID: string, url: string): Promise<Questionnaire[]> {
|
|
21
|
-
const json = await (
|
|
22
|
-
await s.fetchWithToken(url, { method: 'POST', body: URLS.LEARN_PAGE_LIST_FORM_DATA(courseID) })
|
|
23
|
-
).json();
|
|
34
|
+
const json = await s.fetchJsonWithToken(url, { method: 'POST', body: URLS.LEARN_PAGE_LIST_FORM_DATA(courseID) });
|
|
24
35
|
if (json.result !== 'success') {
|
|
25
36
|
return Promise.reject({
|
|
26
37
|
reason: FailReason.INVALID_RESPONSE,
|
|
@@ -28,36 +39,39 @@ async function getQuestionnaireListAtUrl(s: Session, courseID: string, url: stri
|
|
|
28
39
|
});
|
|
29
40
|
}
|
|
30
41
|
const result = (json.object?.aaData ?? []) as any[];
|
|
31
|
-
return
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
} satisfies Questionnaire;
|
|
49
|
-
}),
|
|
50
|
-
);
|
|
42
|
+
return result.map((e) => {
|
|
43
|
+
const type = QNR_TYPE_MAP.get(e.wjlx) ?? QuestionnaireType.SURVEY;
|
|
44
|
+
return {
|
|
45
|
+
id: e.wjid,
|
|
46
|
+
type,
|
|
47
|
+
title: decodeHTML(e.wjbt),
|
|
48
|
+
startTime: e.kssj,
|
|
49
|
+
endTime: e.jssj,
|
|
50
|
+
uploadTime: e.scsj,
|
|
51
|
+
uploaderId: e.scr,
|
|
52
|
+
uploaderName: e.scrxm,
|
|
53
|
+
submitTime: e.tjsj ? e.tjsj : undefined,
|
|
54
|
+
isFavorite: e.sfsc === YES,
|
|
55
|
+
comment: e.bznr ?? undefined,
|
|
56
|
+
url: URLS.LEARN_QNR_SUBMIT_PAGE(e.wlkcid, e.wjid, type),
|
|
57
|
+
} satisfies Questionnaire;
|
|
58
|
+
});
|
|
51
59
|
}
|
|
52
60
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
+
/** Get the questions of one questionnaire (1 request). */
|
|
62
|
+
export async function getQuestionnaireDetail(
|
|
63
|
+
s: Session,
|
|
64
|
+
courseID: string,
|
|
65
|
+
qnrID: string,
|
|
66
|
+
): Promise<QuestionnaireDetail[]> {
|
|
67
|
+
const json = await s.fetchJsonWithToken(URLS.LEARN_QNR_DETAIL, {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
body: URLS.LEARN_QNR_DETAIL_FORM(courseID, qnrID),
|
|
70
|
+
});
|
|
71
|
+
if (!Array.isArray(json)) {
|
|
72
|
+
throw { reason: FailReason.INVALID_RESPONSE, extra: json } as ApiError;
|
|
73
|
+
}
|
|
74
|
+
return json.map(
|
|
61
75
|
(e) =>
|
|
62
76
|
({
|
|
63
77
|
id: e.wtid,
|
package/src/request.ts
CHANGED
|
@@ -4,7 +4,7 @@ import fetchCookie from 'fetch-cookie';
|
|
|
4
4
|
import { fetch } from 'node-fetch-native';
|
|
5
5
|
import { CookieJar } from 'tough-cookie';
|
|
6
6
|
|
|
7
|
-
import type { CredentialProvider, Fetch, HelperConfig } from './types.ts';
|
|
7
|
+
import type { ApiError, CredentialProvider, Fetch, HelperConfig } from './types.ts';
|
|
8
8
|
import { FailReason, Language } from './types.ts';
|
|
9
9
|
|
|
10
10
|
const CHEERIO_CONFIG: cheerio.CheerioOptions = { xml: true };
|
|
@@ -17,6 +17,11 @@ export const noLogin = (res: Response) => res.url.includes('login_timeout') || r
|
|
|
17
17
|
|
|
18
18
|
export const YES = '是';
|
|
19
19
|
|
|
20
|
+
/** default for `HelperConfig.maxConcurrentRequests` */
|
|
21
|
+
export const DEFAULT_MAX_CONCURRENT_REQUESTS = 6;
|
|
22
|
+
/** default for `HelperConfig.requestTimeout`, in milliseconds */
|
|
23
|
+
export const DEFAULT_REQUEST_TIMEOUT = 60_000;
|
|
24
|
+
|
|
20
25
|
/** add CSRF token to any request URL as parameters */
|
|
21
26
|
export const addCSRFTokenToUrl = (url: string, token: string): string => {
|
|
22
27
|
const newUrl = new URL(url);
|
|
@@ -28,32 +33,110 @@ function makeFetch(jar?: CookieJar): typeof fetch {
|
|
|
28
33
|
return fetchCookie(fetch, jar);
|
|
29
34
|
}
|
|
30
35
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
36
|
+
const urlOf = (input: Parameters<Fetch>[0]): string =>
|
|
37
|
+
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Let at most `max` calls of `inner` run at once; the rest wait in FIFO order. A slot is freed when the response
|
|
41
|
+
* headers arrive (or the call fails), so reading a body does not hold one.
|
|
42
|
+
*/
|
|
43
|
+
export function limitConcurrency<F extends (...args: any[]) => Promise<Response>>(inner: F, max: number): F {
|
|
44
|
+
if (!(max >= 1) || max === Infinity) return inner;
|
|
45
|
+
let active = 0;
|
|
46
|
+
const waiting: (() => void)[] = [];
|
|
47
|
+
const acquire = (): Promise<void> => {
|
|
48
|
+
if (active < max) {
|
|
49
|
+
active += 1;
|
|
50
|
+
return Promise.resolve();
|
|
51
|
+
}
|
|
52
|
+
return new Promise((resolve) => waiting.push(resolve));
|
|
53
|
+
};
|
|
54
|
+
const release = () => {
|
|
55
|
+
const next = waiting.shift();
|
|
56
|
+
// hand the slot straight to the next caller, so `active` stays the same
|
|
57
|
+
if (next) next();
|
|
58
|
+
else active -= 1;
|
|
59
|
+
};
|
|
60
|
+
return (async (...args: Parameters<F>) => {
|
|
61
|
+
await acquire();
|
|
62
|
+
try {
|
|
63
|
+
return await inner(...args);
|
|
64
|
+
} finally {
|
|
65
|
+
release();
|
|
66
|
+
}
|
|
67
|
+
}) as F;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** `RequestInit` plus a per-request `timeout` (milliseconds; 0 or `Infinity` for none) */
|
|
71
|
+
export type RequestOptions = RequestInit & { timeout?: number };
|
|
72
|
+
|
|
73
|
+
/** a `Fetch` whose `init` can also carry a per-request `timeout` */
|
|
74
|
+
export type TimedFetch = (input: Parameters<Fetch>[0], init?: RequestOptions) => Promise<Response>;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Abort a call of `inner` that has no response headers after `ms` milliseconds (or `init.timeout`, which overrides it
|
|
78
|
+
* for one request) and reject it with `FailReason.REQUEST_TIMEOUT`. A caller's own `signal` still works, also while
|
|
79
|
+
* the body is read. Uses only `AbortController` and `setTimeout`, which React Native has (unlike
|
|
80
|
+
* `AbortSignal.timeout` / `AbortSignal.any`). Note that React Native's fetch resolves only after the whole body has
|
|
81
|
+
* arrived, so there the timeout covers the download too.
|
|
82
|
+
*/
|
|
83
|
+
export function withTimeout(inner: Fetch, ms: number): TimedFetch {
|
|
84
|
+
return async (input, options) => {
|
|
85
|
+
const { timeout = ms, ...init } = options ?? {};
|
|
86
|
+
if (!(timeout > 0) || timeout === Infinity) return inner(input, init);
|
|
87
|
+
const controller = new AbortController();
|
|
88
|
+
const outer = init.signal ?? undefined;
|
|
89
|
+
const forward = () => controller.abort(outer?.reason);
|
|
90
|
+
if (outer?.aborted) forward();
|
|
91
|
+
// kept after the headers arrive, so the caller can still cancel reading the body
|
|
92
|
+
else outer?.addEventListener('abort', forward, { once: true });
|
|
93
|
+
let timedOut = false;
|
|
94
|
+
const timer = setTimeout(() => {
|
|
95
|
+
timedOut = true;
|
|
96
|
+
controller.abort();
|
|
97
|
+
}, timeout);
|
|
98
|
+
try {
|
|
99
|
+
return await inner(input, { ...init, signal: controller.signal });
|
|
100
|
+
} catch (err) {
|
|
101
|
+
outer?.removeEventListener('abort', forward);
|
|
102
|
+
if (timedOut) {
|
|
103
|
+
throw {
|
|
104
|
+
reason: FailReason.REQUEST_TIMEOUT,
|
|
105
|
+
extra: { url: urlOf(input), timeout },
|
|
106
|
+
} as ApiError;
|
|
107
|
+
}
|
|
108
|
+
throw err;
|
|
109
|
+
} finally {
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
}
|
|
54
112
|
};
|
|
55
113
|
}
|
|
56
114
|
|
|
115
|
+
/** free the connection of a response we won't read */
|
|
116
|
+
const discard = (res: Response) => {
|
|
117
|
+
res.body?.cancel().catch(() => {});
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** returned by a read step when the response turned out to be from an expired session */
|
|
121
|
+
const EXPIRED = Symbol('expired');
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Whether a 200 body that should have been JSON is an HTML page instead. Web Learning answers some requests of an
|
|
125
|
+
* expired session with a login or redirect page and status 200, not with a 403 or a `login_timeout` redirect.
|
|
126
|
+
*/
|
|
127
|
+
const looksLikeHtml = (text: string) => text.trimStart().startsWith('<');
|
|
128
|
+
|
|
129
|
+
/** pages longer than this are taken as real pages, never as the short login / redirect page of an expired session */
|
|
130
|
+
const MAX_LOGIN_PAGE_LENGTH = 4096;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Whether a 200 page is the short login or redirect page of an expired session rather than the page asked for. A
|
|
134
|
+
* heuristic (not verified against the real server): short, and pointing at the login timeout page or the SSO login.
|
|
135
|
+
*/
|
|
136
|
+
export const looksLikeLoginPage = (text: string) =>
|
|
137
|
+
text.length < MAX_LOGIN_PAGE_LENGTH &&
|
|
138
|
+
/login_timeout|id\.tsinghua\.edu\.cn\/do\/off\/ui\/auth\/login|j_spring_security_thauth_roaming_entry/.test(text);
|
|
139
|
+
|
|
57
140
|
/**
|
|
58
141
|
* State shared by every request a `Learn2018Helper` makes: cookies, CSRF token, language and the fetch wrappers.
|
|
59
142
|
* Internal; the helper keeps it in a private field.
|
|
@@ -61,41 +144,170 @@ function withReAuth(rawFetch: Fetch, login: () => Promise<void>): Fetch {
|
|
|
61
144
|
export class Session {
|
|
62
145
|
readonly provider?: CredentialProvider;
|
|
63
146
|
readonly cookieJar: CookieJar;
|
|
64
|
-
/**
|
|
65
|
-
readonly rawFetch:
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
147
|
+
/** fetch with cookies, the concurrency cap and the timeout, but no login check */
|
|
148
|
+
readonly rawFetch: TimedFetch;
|
|
149
|
+
#csrfToken = '';
|
|
150
|
+
/** bumped whenever the session changes (login, `csrfToken` set), so late failures from an old session don't log in again */
|
|
151
|
+
#generation = 0;
|
|
152
|
+
#pendingLogin?: Promise<void>;
|
|
69
153
|
lang = Language.ZH;
|
|
70
154
|
previewFirstPage: boolean;
|
|
71
155
|
|
|
72
|
-
/** `
|
|
156
|
+
/** `loginWithProvider` signs in through SSO with the `CredentialProvider` */
|
|
73
157
|
constructor(
|
|
74
158
|
config: HelperConfig | undefined,
|
|
75
|
-
readonly
|
|
159
|
+
readonly loginWithProvider: () => Promise<void>,
|
|
76
160
|
) {
|
|
77
161
|
this.previewFirstPage = config?.generatePreviewUrlForFirstPage ?? true;
|
|
78
162
|
this.provider = config?.provider;
|
|
79
163
|
this.cookieJar = config?.cookieJar ?? new CookieJar();
|
|
80
|
-
this.rawFetch =
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
});
|
|
89
|
-
return result;
|
|
90
|
-
};
|
|
164
|
+
this.rawFetch = limitConcurrency(
|
|
165
|
+
withTimeout(config?.fetch ?? makeFetch(this.cookieJar), config?.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT),
|
|
166
|
+
config?.maxConcurrentRequests ?? DEFAULT_MAX_CONCURRENT_REQUESTS,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
get csrfToken(): string {
|
|
171
|
+
return this.#csrfToken;
|
|
91
172
|
}
|
|
92
173
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
174
|
+
set csrfToken(token: string) {
|
|
175
|
+
this.#csrfToken = token;
|
|
176
|
+
this.#generation += 1;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Run one login (`login`) at a time. It starts after a login already in progress has settled, and requests that
|
|
181
|
+
* find the session expired meanwhile wait for it instead of starting their own (two SSO logins at once reset each
|
|
182
|
+
* other's id.tsinghua.edu.cn cookie).
|
|
183
|
+
*/
|
|
184
|
+
runLogin(login: () => Promise<void>): Promise<void> {
|
|
185
|
+
const previous = this.#pendingLogin?.catch(() => {}) ?? Promise.resolve();
|
|
186
|
+
const current: Promise<void> = previous.then(login).finally(() => {
|
|
187
|
+
if (this.#pendingLogin === current) this.#pendingLogin = undefined;
|
|
188
|
+
});
|
|
189
|
+
this.#pendingLogin = current;
|
|
190
|
+
return current;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Log in with the `CredentialProvider`, or wait for the login already in progress (also one with explicit
|
|
195
|
+
* credentials) instead of starting another.
|
|
196
|
+
*/
|
|
197
|
+
relogin(): Promise<void> {
|
|
198
|
+
return this.#pendingLogin ?? this.runLogin(this.loginWithProvider);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Send a request built by `makeUrl` (called again for the retry, so it can pick up a new CSRF token) and read it
|
|
203
|
+
* with `read`, which returns `EXPIRED` for a body that shows the session has expired.
|
|
204
|
+
* When the session has expired, it rejects with `NOT_LOGGED_IN` if there is no `CredentialProvider`; otherwise it
|
|
205
|
+
* logs in again (sharing a login already in progress, or skipping it if another request already renewed the
|
|
206
|
+
* session) and retries once.
|
|
207
|
+
*/
|
|
208
|
+
async #send<T>(
|
|
209
|
+
makeUrl: () => string,
|
|
210
|
+
init: RequestOptions | undefined,
|
|
211
|
+
read: (res: Response, retry: boolean) => Promise<T | typeof EXPIRED>,
|
|
212
|
+
): Promise<T> {
|
|
213
|
+
const attempt = async (retry: boolean): Promise<T | typeof EXPIRED> => {
|
|
214
|
+
const res = await this.rawFetch(makeUrl(), init);
|
|
215
|
+
if (noLogin(res)) {
|
|
216
|
+
discard(res);
|
|
217
|
+
return EXPIRED;
|
|
218
|
+
}
|
|
219
|
+
return read(res, retry);
|
|
220
|
+
};
|
|
221
|
+
const generation = this.#generation;
|
|
222
|
+
const first = await attempt(false);
|
|
223
|
+
if (first !== EXPIRED) return first;
|
|
224
|
+
if (!this.provider) {
|
|
225
|
+
throw { reason: FailReason.NOT_LOGGED_IN } as ApiError;
|
|
226
|
+
}
|
|
227
|
+
if (this.#generation === generation) {
|
|
228
|
+
await this.relogin();
|
|
229
|
+
}
|
|
230
|
+
const second = await attempt(true);
|
|
231
|
+
if (second === EXPIRED) {
|
|
232
|
+
throw { reason: FailReason.NOT_LOGGED_IN } as ApiError;
|
|
233
|
+
}
|
|
234
|
+
return second;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** the response, but after a re-login only one with status 200 */
|
|
238
|
+
static readonly #response = async (res: Response, retry: boolean): Promise<Response> => {
|
|
239
|
+
if (retry && res.status !== 200) {
|
|
240
|
+
discard(res);
|
|
241
|
+
throw {
|
|
242
|
+
reason: FailReason.UNEXPECTED_STATUS,
|
|
243
|
+
extra: { code: res.status, text: res.statusText },
|
|
244
|
+
} as ApiError;
|
|
245
|
+
}
|
|
246
|
+
return res;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
/** the parsed JSON body; an HTML page with status 200 means the session has expired */
|
|
250
|
+
static readonly #json = async (res: Response): Promise<any> => {
|
|
251
|
+
const text = await res.text();
|
|
252
|
+
try {
|
|
253
|
+
return JSON.parse(text);
|
|
254
|
+
} catch {
|
|
255
|
+
if (res.status === 200 && looksLikeHtml(text)) return EXPIRED;
|
|
256
|
+
throw {
|
|
257
|
+
reason: res.status === 200 ? FailReason.INVALID_RESPONSE : FailReason.UNEXPECTED_STATUS,
|
|
258
|
+
extra: { code: res.status, text: text.slice(0, 200) },
|
|
259
|
+
} as ApiError;
|
|
97
260
|
}
|
|
98
|
-
const [url, ...remaining] = args;
|
|
99
|
-
return this.fetch(addCSRFTokenToUrl(url as string, this.csrfToken), ...remaining);
|
|
100
261
|
};
|
|
262
|
+
|
|
263
|
+
/** the text body; a short login / redirect page with status 200 means the session has expired */
|
|
264
|
+
static readonly #text = async (res: Response, retry: boolean): Promise<string | typeof EXPIRED> => {
|
|
265
|
+
await Session.#response(res, retry);
|
|
266
|
+
const text = await res.text();
|
|
267
|
+
return res.status === 200 && looksLikeLoginPage(text) ? EXPIRED : text;
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
#withToken = async (input: Parameters<Fetch>[0]): Promise<() => string> => {
|
|
271
|
+
if (this.#csrfToken === '') {
|
|
272
|
+
await this.relogin();
|
|
273
|
+
}
|
|
274
|
+
const url = urlOf(input);
|
|
275
|
+
return () => addCSRFTokenToUrl(url, this.#csrfToken);
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
/** fetch with a login check (see `#send`) */
|
|
279
|
+
readonly fetch: TimedFetch = (input, init) => this.#send(() => urlOf(input), init, Session.#response);
|
|
280
|
+
|
|
281
|
+
/** like `fetch`, but logs in first if there is no CSRF token yet and adds the current token to the URL */
|
|
282
|
+
readonly fetchWithToken: TimedFetch = async (input, init) =>
|
|
283
|
+
this.#send(await this.#withToken(input), init, Session.#response);
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Like `fetchWithToken`, but returns the parsed JSON body. A 200 HTML page instead of JSON counts as an expired
|
|
287
|
+
* session (log in again and retry once, as for a 403).
|
|
288
|
+
*/
|
|
289
|
+
readonly fetchJsonWithToken = async (input: Parameters<Fetch>[0], init?: RequestOptions): Promise<any> =>
|
|
290
|
+
this.#send(await this.#withToken(input), init, Session.#json);
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Like `fetchWithToken`, but returns the text body (an HTML page). A short login or redirect page with status 200
|
|
294
|
+
* counts as an expired session (log in again and retry once, as for a 403).
|
|
295
|
+
*/
|
|
296
|
+
readonly fetchTextWithToken = async (input: Parameters<Fetch>[0], init?: RequestOptions): Promise<string> =>
|
|
297
|
+
this.#send(await this.#withToken(input), init, Session.#text);
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Make sure the session works: if there is a CSRF token, fetch the current semester (`probeUrl`) and log in only if
|
|
301
|
+
* the session has expired; otherwise log in.
|
|
302
|
+
*/
|
|
303
|
+
async ensureSession(probeUrl: string): Promise<void> {
|
|
304
|
+
if (this.#csrfToken === '') {
|
|
305
|
+
await this.relogin();
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const json = await this.fetchJsonWithToken(probeUrl);
|
|
309
|
+
if (json?.message !== 'success') {
|
|
310
|
+
throw { reason: FailReason.INVALID_RESPONSE, extra: json } as ApiError;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
101
313
|
}
|