@ranzenjs/client 0.2.1 → 0.4.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 +40 -2
- package/dist/apps/lessons/parse.js +23 -26
- package/dist/apps/lessons/types.d.ts +5 -2
- package/dist/apps/substitutions/parse.js +25 -1
- package/dist/apps/substitutions/types.d.ts +11 -2
- package/dist/html.d.ts +2 -0
- package/dist/html.js +13 -0
- package/dist/kurswahl/connection.d.ts +34 -0
- package/dist/kurswahl/connection.js +126 -0
- package/dist/kurswahl/index.d.ts +30 -0
- package/dist/kurswahl/index.js +55 -0
- package/dist/kurswahl/parse.d.ts +13 -0
- package/dist/kurswahl/parse.js +149 -0
- package/dist/kurswahl/session.d.ts +29 -0
- package/dist/kurswahl/session.js +125 -0
- package/dist/kurswahl/sign-in.d.ts +19 -0
- package/dist/kurswahl/sign-in.js +134 -0
- package/dist/kurswahl/state.d.ts +12 -0
- package/dist/kurswahl/state.js +43 -0
- package/dist/kurswahl/types.d.ts +68 -0
- package/dist/kurswahl/types.js +1 -0
- package/dist/rotation.d.ts +2 -0
- package/dist/rotation.js +1 -0
- package/dist/state.d.ts +2 -0
- package/dist/state.js +8 -4
- package/dist/transport/cookies.js +15 -1
- package/dist/transport/http.d.ts +3 -0
- package/dist/transport/http.js +2 -2
- package/package.json +6 -1
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { layoutChanged } from '../errors.js';
|
|
2
|
+
import { all, attr, cells, hasClass, one, ownText, parseHtml, stripTags, text } from '../html.js';
|
|
3
|
+
import { isoDate } from '../util.js';
|
|
4
|
+
const DAYS = ['montag', 'dienstag', 'mittwoch', 'donnerstag', 'freitag', 'samstag', 'sonntag'];
|
|
5
|
+
// "<b>Leiste 1: LK 1: MATHEMATIK bei Muster, Frau<br>Raum: 101, Woche: A</b><br>Realkurs: 7"
|
|
6
|
+
const TOOLTIP = /^<b>\s*Leiste\s+(\d+):\s*(.*?)(?:\s+bei\s+(.*?))?\s*<br\s*\/?>\s*Raum:\s*(.*?)(?:,\s*Woche:\s*([AB]))?\s*<\/b>\s*<br\s*\/?>\s*Realkurs:\s*(\d+)\s*$/is;
|
|
7
|
+
// "2: LK 2: PH-LK (Mu)" or "18: TUT (Bsp)".
|
|
8
|
+
const LABEL = /^(\d+):\s*(?:LK\s*\d+:\s*)?(.+?)(?:\s*\(([^()]+)\))?$/;
|
|
9
|
+
// "27.08.26 12:12:51"
|
|
10
|
+
const STAMP = /(\d{2})\.(\d{2})\.(\d{2}|\d{4})\s+(\d{2}):(\d{2}):(\d{2})/g;
|
|
11
|
+
/** The anti-CSRF token every Kurswahl page carries. */
|
|
12
|
+
export function csrfToken(html) {
|
|
13
|
+
const token = attr(one('meta[name="anti_csrf_token"]', parseHtml(html)), 'content');
|
|
14
|
+
return token && isToken(token) ? token : null;
|
|
15
|
+
}
|
|
16
|
+
/** The timetable database the page's week form posts, e.g. "musterschule_stundenplan_1". */
|
|
17
|
+
export function timetableDatabase(doc) {
|
|
18
|
+
const database = attr(one('input[name="db_name"]', doc), 'value');
|
|
19
|
+
if (!database || !isDatabase(database))
|
|
20
|
+
throw layoutChanged('the timetable database field');
|
|
21
|
+
return database;
|
|
22
|
+
}
|
|
23
|
+
/** Kurswahl marks "allgemeiner Stundenplan" as selected only when it shows the general plan. */
|
|
24
|
+
export function isGeneralPlan(html) {
|
|
25
|
+
return all('select[name="woche"] option[selected]', parseHtml(html)).some((option) => attr(option, 'value') === '0');
|
|
26
|
+
}
|
|
27
|
+
/** Visible ASCII only, since the token travels in a header; shared with state validation. */
|
|
28
|
+
export function isToken(value) {
|
|
29
|
+
return /^[\x21-\x7e]{1,512}$/.test(value);
|
|
30
|
+
}
|
|
31
|
+
export function isDatabase(value) {
|
|
32
|
+
return /^[\w-]{1,200}$/.test(value);
|
|
33
|
+
}
|
|
34
|
+
export function parseTimetable(html, drift) {
|
|
35
|
+
const doc = parseHtml(html);
|
|
36
|
+
const database = timetableDatabase(doc);
|
|
37
|
+
const table = one('table.stundenplan', doc);
|
|
38
|
+
if (!table)
|
|
39
|
+
throw layoutChanged('the timetable table');
|
|
40
|
+
const [head, ...body] = all('tr', table);
|
|
41
|
+
const days = all('th', head ?? table)
|
|
42
|
+
.slice(1)
|
|
43
|
+
.map((th) => DAYS.indexOf(text(th).toLowerCase()) + 1);
|
|
44
|
+
if (!days.length || !days.every(isWeekday))
|
|
45
|
+
throw layoutChanged('weekday headers in the timetable');
|
|
46
|
+
const periods = [];
|
|
47
|
+
const courses = new Map();
|
|
48
|
+
const lessons = [];
|
|
49
|
+
for (const row of body) {
|
|
50
|
+
const [first, ...slots] = cells(row);
|
|
51
|
+
const match = /^(\d+)\.\s*(?:(\d{1,2}:\d{2})\s*-\s*(\d{1,2}:\d{2}))?/.exec(text(first));
|
|
52
|
+
if (!match || slots.length !== days.length)
|
|
53
|
+
throw layoutChanged('timetable rows per period');
|
|
54
|
+
const period = Number(match[1]);
|
|
55
|
+
periods.push({ period, start: match[2] ?? null, end: match[3] ?? null });
|
|
56
|
+
slots.forEach((slot, index) => {
|
|
57
|
+
const entries = all('div[tooltip]', slot);
|
|
58
|
+
if (!entries.length && text(slot))
|
|
59
|
+
throw layoutChanged('course entries in timetable cells');
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
const { course, room, rotation } = parseEntry(entry);
|
|
62
|
+
const known = courses.get(course.id);
|
|
63
|
+
if (!known)
|
|
64
|
+
courses.set(course.id, course);
|
|
65
|
+
else if (JSON.stringify(known) !== JSON.stringify(course)) {
|
|
66
|
+
drift('A course shows different details in different cells.');
|
|
67
|
+
}
|
|
68
|
+
lessons.push({ course: course.id, day: days[index], period, room, rotation });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
const options = all('select[name="woche"] option', doc).map((option) => {
|
|
73
|
+
const value = attr(option, 'value') ?? '';
|
|
74
|
+
const week = parseWeek(value, text(option));
|
|
75
|
+
if (!week && value !== '0')
|
|
76
|
+
drift('A week option has an unknown format.');
|
|
77
|
+
return { value, week, selected: 'selected' in option.attribs };
|
|
78
|
+
});
|
|
79
|
+
// The general plan selects no option (or "0"); an unreadable dated week must not pass for it.
|
|
80
|
+
const chosen = options.find((option) => option.selected && option.value !== '0');
|
|
81
|
+
if (chosen && !chosen.week)
|
|
82
|
+
throw layoutChanged('the selected week');
|
|
83
|
+
return {
|
|
84
|
+
database,
|
|
85
|
+
week: chosen?.week ?? null,
|
|
86
|
+
weeks: options.flatMap((option) => (option.week ? [option.week] : [])),
|
|
87
|
+
periods,
|
|
88
|
+
courses: [...courses.values()],
|
|
89
|
+
lessons,
|
|
90
|
+
...changeTimes(doc)
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function parseEntry(entry) {
|
|
94
|
+
const tooltip = TOOLTIP.exec((attr(entry, 'tooltip') ?? '').trim());
|
|
95
|
+
const label = LABEL.exec(ownText(entry));
|
|
96
|
+
if (!tooltip || !label)
|
|
97
|
+
throw layoutChanged('a known course entry in the timetable');
|
|
98
|
+
const [, block = '', heading = '', teacher = '', room = '', week, id = ''] = tooltip;
|
|
99
|
+
const code = label[2] ?? '';
|
|
100
|
+
const level = /^(LK|GK)\b\s*(?:\d+:)?\s*(.*)$/.exec(stripTags(heading));
|
|
101
|
+
// The heading names the level; failing that, a "-LK"/"-GK" code or the cell's lk class does.
|
|
102
|
+
const kind = level?.[1] ?? /-(LK|GK)$/.exec(code)?.[1] ?? (hasClass(entry, 'lk') ? 'LK' : null);
|
|
103
|
+
const person = /^(.*?)(?:,\s*(Frau|Herr))?$/.exec(stripTags(teacher));
|
|
104
|
+
const shown = text(one('span', entry));
|
|
105
|
+
return {
|
|
106
|
+
course: {
|
|
107
|
+
id,
|
|
108
|
+
block: Number(block),
|
|
109
|
+
code,
|
|
110
|
+
title: (level ? level[2] : stripTags(heading)) || null,
|
|
111
|
+
level: kind,
|
|
112
|
+
teacher: {
|
|
113
|
+
code: label[3]?.trim() || null,
|
|
114
|
+
lastName: person?.[1] || null,
|
|
115
|
+
salutation: person?.[2] ?? null
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
room: stripTags(room) || shown.replace(/\[[AB]\]/, '').trim() || null,
|
|
119
|
+
rotation: (week ?? /\[([AB])\]/.exec(shown)?.[1] ?? null)
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
function isWeekday(day) {
|
|
123
|
+
return day >= 1 && day <= 7;
|
|
124
|
+
}
|
|
125
|
+
function parseWeek(value, label) {
|
|
126
|
+
const [start, end] = label.split(/\s+-\s+/).map((part) => isoDate(part));
|
|
127
|
+
return /^\d{2}_\d{2}_\d{4}$/.test(value) && start && end ? { id: value, start, end } : null;
|
|
128
|
+
}
|
|
129
|
+
// "Letzte Änderung Stundenplan (Kurszuteilung): 27.08.26 12:12:51 (25.08.26 09:02:04)"
|
|
130
|
+
function changeTimes(doc) {
|
|
131
|
+
let updatedAt = null;
|
|
132
|
+
let assignedAt = null;
|
|
133
|
+
let examsUpdatedAt = null;
|
|
134
|
+
for (const row of all('tr', doc)) {
|
|
135
|
+
const [label = '', value = ''] = cells(row).map((cell) => text(cell));
|
|
136
|
+
if (!label.startsWith('Letzte Änderung'))
|
|
137
|
+
continue;
|
|
138
|
+
const [first = null, second = null] = [...value.matchAll(STAMP)].map(localTime);
|
|
139
|
+
if (label.includes('Klausurplan'))
|
|
140
|
+
examsUpdatedAt = first;
|
|
141
|
+
else if (label.includes('Stundenplan'))
|
|
142
|
+
[updatedAt, assignedAt] = [first, second];
|
|
143
|
+
}
|
|
144
|
+
return { updatedAt, assignedAt, examsUpdatedAt };
|
|
145
|
+
}
|
|
146
|
+
function localTime([, day, month, year = '', hours, minutes, seconds]) {
|
|
147
|
+
const date = isoDate(`${day}.${month}.${year.length === 2 ? `20${year}` : year}`);
|
|
148
|
+
return date && `${date}T${hours}:${minutes}:${seconds}`;
|
|
149
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Reply, Transport } from '../transport/http.js';
|
|
2
|
+
export interface KurswahlAccount {
|
|
3
|
+
/** The school's subdomain, e.g. "musterschule". */
|
|
4
|
+
readonly school: string;
|
|
5
|
+
readonly username: string;
|
|
6
|
+
}
|
|
7
|
+
export declare const WWW = "https://www.kurswahl-online.de";
|
|
8
|
+
export declare function schoolUrl(school: string): string;
|
|
9
|
+
export declare class KurswahlSession {
|
|
10
|
+
#private;
|
|
11
|
+
readonly account: KurswahlAccount;
|
|
12
|
+
readonly transport: Transport;
|
|
13
|
+
token: string | null;
|
|
14
|
+
database: string | null;
|
|
15
|
+
constructor(account: KurswahlAccount, transport: Transport, token: string | null, database?: string | null);
|
|
16
|
+
get disposed(): boolean;
|
|
17
|
+
ensureOpen(): void;
|
|
18
|
+
dispose(): void;
|
|
19
|
+
/** A page the way post_link() opens it: a multipart POST carrying the current token. */
|
|
20
|
+
page(path: string, fields: Record<string, string>, signal?: AbortSignal): Promise<Reply>;
|
|
21
|
+
/** An XHR call; the token travels as a header, and a new one may come back the same way. */
|
|
22
|
+
ajax(path: string, form: Record<string, string>, signal?: AbortSignal): Promise<Reply>;
|
|
23
|
+
/** Leaves through the logout link, after any request still running. */
|
|
24
|
+
leave(signal?: AbortSignal): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
/** A token handed back in the XHR header, kept only if exported state would accept it. */
|
|
27
|
+
export declare function nextToken(reply: Reply, current: string | null): string | null;
|
|
28
|
+
/** The sign-in form, which Kurswahl serves in place of any page once the session is gone. */
|
|
29
|
+
export declare function isLoginPage(html: string): boolean;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { ensureOk } from '../app.js';
|
|
2
|
+
import { fail, isRanzenError, layoutChanged } from '../errors.js';
|
|
3
|
+
import { Limiter } from '../transport/limiter.js';
|
|
4
|
+
import { csrfToken, isToken } from './parse.js';
|
|
5
|
+
export const WWW = 'https://www.kurswahl-online.de';
|
|
6
|
+
export function schoolUrl(school) {
|
|
7
|
+
return `https://${school}.kurswahl-online.de`;
|
|
8
|
+
}
|
|
9
|
+
// Package-internal: one request at a time, since every reply hands out the next CSRF token.
|
|
10
|
+
export class KurswahlSession {
|
|
11
|
+
account;
|
|
12
|
+
transport;
|
|
13
|
+
token;
|
|
14
|
+
database;
|
|
15
|
+
#life = new AbortController();
|
|
16
|
+
#queue = new Limiter(1);
|
|
17
|
+
constructor(account, transport, token, database = null) {
|
|
18
|
+
this.account = account;
|
|
19
|
+
this.transport = transport;
|
|
20
|
+
this.token = token;
|
|
21
|
+
this.database = database;
|
|
22
|
+
}
|
|
23
|
+
get disposed() {
|
|
24
|
+
return this.#life.signal.aborted;
|
|
25
|
+
}
|
|
26
|
+
ensureOpen() {
|
|
27
|
+
if (this.disposed)
|
|
28
|
+
throw fail('DISPOSED', 'The connection was disposed.');
|
|
29
|
+
}
|
|
30
|
+
dispose() {
|
|
31
|
+
this.#life.abort();
|
|
32
|
+
this.transport.jar.clear();
|
|
33
|
+
}
|
|
34
|
+
/** A page the way post_link() opens it: a multipart POST carrying the current token. */
|
|
35
|
+
page(path, fields, signal) {
|
|
36
|
+
return this.#run(signal, async (bound) => {
|
|
37
|
+
const form = new FormData();
|
|
38
|
+
for (const [name, value] of Object.entries(fields))
|
|
39
|
+
form.set(name, value);
|
|
40
|
+
form.set('_scrollLeft', '0');
|
|
41
|
+
form.set('_scrollTop', '0');
|
|
42
|
+
form.set('_scrollLocation', `/${path}`);
|
|
43
|
+
if (this.token)
|
|
44
|
+
form.set('_anti_csrf_token', this.token);
|
|
45
|
+
const reply = await this.transport.send({
|
|
46
|
+
url: `${schoolUrl(this.account.school)}/${path}`,
|
|
47
|
+
method: 'POST',
|
|
48
|
+
multipart: form,
|
|
49
|
+
redirects: 'manual',
|
|
50
|
+
signal: bound
|
|
51
|
+
});
|
|
52
|
+
this.#check(reply, path);
|
|
53
|
+
// Every page hands out the token for the next request; going on with a stale one would fail later.
|
|
54
|
+
const token = csrfToken(reply.text);
|
|
55
|
+
if (!token)
|
|
56
|
+
throw layoutChanged('an anti-CSRF token on the page');
|
|
57
|
+
this.token = token;
|
|
58
|
+
return reply;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/** An XHR call; the token travels as a header, and a new one may come back the same way. */
|
|
62
|
+
ajax(path, form, signal) {
|
|
63
|
+
return this.#run(signal, async (bound) => {
|
|
64
|
+
const headers = { 'x-requested-with': 'XMLHttpRequest' };
|
|
65
|
+
if (this.token)
|
|
66
|
+
headers['x-csrf-token'] = this.token;
|
|
67
|
+
const reply = await this.transport.send({
|
|
68
|
+
url: `${schoolUrl(this.account.school)}/${path}`,
|
|
69
|
+
method: 'POST',
|
|
70
|
+
form,
|
|
71
|
+
headers,
|
|
72
|
+
redirects: 'manual',
|
|
73
|
+
signal: bound
|
|
74
|
+
});
|
|
75
|
+
this.#check(reply, path);
|
|
76
|
+
this.token = nextToken(reply, this.token);
|
|
77
|
+
return reply;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/** Leaves through the logout link, after any request still running. */
|
|
81
|
+
leave(signal) {
|
|
82
|
+
return this.#run(signal, async (bound) => {
|
|
83
|
+
const url = `${schoolUrl(this.account.school)}/login.php`;
|
|
84
|
+
const reply = await this.transport.send({ url, redirects: 'manual', signal: bound });
|
|
85
|
+
// A refused logout may leave the session alive, so it must not pass for success.
|
|
86
|
+
if (!reply.location)
|
|
87
|
+
ensureOk(reply.status, 'login.php');
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
// Kurswahl flags an ended session with X-LOGIN-FORM on XHR and a redirect to login.php on pages.
|
|
91
|
+
#check(reply, path) {
|
|
92
|
+
const toLogin = reply.location && /\/login\.php$|^\/?$/.test(reply.location.pathname);
|
|
93
|
+
if (reply.headers.get('x-login-form') === 'true' || toLogin || isLoginPage(reply.text)) {
|
|
94
|
+
throw fail('SESSION_EXPIRED', 'The Kurswahl session has ended.');
|
|
95
|
+
}
|
|
96
|
+
if (reply.location)
|
|
97
|
+
throw layoutChanged(`${path} to answer without a redirect`);
|
|
98
|
+
ensureOk(reply.status, path);
|
|
99
|
+
}
|
|
100
|
+
async #run(signal, work) {
|
|
101
|
+
this.ensureOpen();
|
|
102
|
+
const life = this.#life.signal;
|
|
103
|
+
const bound = signal ? AbortSignal.any([signal, life]) : life;
|
|
104
|
+
try {
|
|
105
|
+
return await this.#queue.run(() => work(bound), bound);
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
if (isRanzenError(error) || !bound.aborted)
|
|
109
|
+
throw error;
|
|
110
|
+
throw fail('ABORTED', 'Request aborted.', error);
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
this.ensureOpen();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** A token handed back in the XHR header, kept only if exported state would accept it. */
|
|
118
|
+
export function nextToken(reply, current) {
|
|
119
|
+
const token = reply.headers.get('x-csrf-token');
|
|
120
|
+
return token && isToken(token) ? token : current;
|
|
121
|
+
}
|
|
122
|
+
/** The sign-in form, which Kurswahl serves in place of any page once the session is gone. */
|
|
123
|
+
export function isLoginPage(html) {
|
|
124
|
+
return /<input\b[^>]*\bname\s*=\s*["']?realpasswort\b/i.test(html);
|
|
125
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Transport } from '../transport/http.js';
|
|
2
|
+
import { KurswahlSession } from './session.js';
|
|
3
|
+
export interface KurswahlCredentials {
|
|
4
|
+
/** The school's subdomain, e.g. "musterschule"; see schools(). */
|
|
5
|
+
school: string;
|
|
6
|
+
username: string;
|
|
7
|
+
password: string;
|
|
8
|
+
}
|
|
9
|
+
interface Argon2Parameters {
|
|
10
|
+
type: 1 | 2;
|
|
11
|
+
time: number;
|
|
12
|
+
mem: number;
|
|
13
|
+
parallelism: number;
|
|
14
|
+
hashLen: number;
|
|
15
|
+
}
|
|
16
|
+
/** Signs in like the login page's script does, hashing the password with Argon2 on this side. */
|
|
17
|
+
export declare function signIn(transport: Transport, { school, username, password }: KurswahlCredentials, signal: AbortSignal | undefined): Promise<KurswahlSession>;
|
|
18
|
+
export declare function argon2(password: string, salt: string, parameters: Argon2Parameters | null | undefined): Promise<string>;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { argon2i, argon2id } from 'hash-wasm';
|
|
2
|
+
import { fail, layoutChanged } from '../errors.js';
|
|
3
|
+
import { isRecord, parseJson } from '../util.js';
|
|
4
|
+
import { csrfToken } from './parse.js';
|
|
5
|
+
import { isLoginPage, KurswahlSession, nextToken, schoolUrl } from './session.js';
|
|
6
|
+
/** Signs in like the login page's script does, hashing the password with Argon2 on this side. */
|
|
7
|
+
export async function signIn(transport, { school, username, password }, signal) {
|
|
8
|
+
const base = schoolUrl(school);
|
|
9
|
+
const login = await transport.send({ url: `${base}/login.php`, redirects: 'manual', signal });
|
|
10
|
+
// An unknown subdomain bounces to the main site.
|
|
11
|
+
if (login.location)
|
|
12
|
+
throw fail('NOT_FOUND', 'Kurswahl Online does not know this school.');
|
|
13
|
+
const versions = argon2Parameters(login.text);
|
|
14
|
+
let token = csrfToken(login.text);
|
|
15
|
+
// The login page's XHR carries the token as a header and may get the next one back.
|
|
16
|
+
const headers = { 'x-requested-with': 'XMLHttpRequest' };
|
|
17
|
+
if (token)
|
|
18
|
+
headers['x-csrf-token'] = token;
|
|
19
|
+
const answer = await transport.send({
|
|
20
|
+
url: `${base}/login.php`,
|
|
21
|
+
method: 'POST',
|
|
22
|
+
form: { ajax: 'argon2', username },
|
|
23
|
+
headers,
|
|
24
|
+
redirects: 'manual',
|
|
25
|
+
signal
|
|
26
|
+
});
|
|
27
|
+
token = nextToken(answer, token);
|
|
28
|
+
const { salts, rotating } = readSalts(parseJson(answer.text, 'the Argon2 salts'));
|
|
29
|
+
const latest = versions.length - 1;
|
|
30
|
+
const hash = (salt, version) => argon2(password, salt, versions[version]);
|
|
31
|
+
const hashes = await Promise.all(salts.map(async ({ version, raw, salt, upgradeSalt }) => {
|
|
32
|
+
const entry = {
|
|
33
|
+
version: raw,
|
|
34
|
+
hash: version === 0 ? password : await hash(salt, version),
|
|
35
|
+
rotating: false
|
|
36
|
+
};
|
|
37
|
+
if (upgradeSalt)
|
|
38
|
+
entry.upgrade_hash = await hash(upgradeSalt, latest);
|
|
39
|
+
return entry;
|
|
40
|
+
}));
|
|
41
|
+
if (rotating) {
|
|
42
|
+
hashes.push({
|
|
43
|
+
version: latest,
|
|
44
|
+
hash: await hash(rotating, latest),
|
|
45
|
+
upgrade_hash: null,
|
|
46
|
+
rotating: true
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
const form = new FormData();
|
|
50
|
+
form.set('username', username);
|
|
51
|
+
form.set('passwort', '');
|
|
52
|
+
form.set('keep_active', 'on');
|
|
53
|
+
form.set('resolution', '1920x1080');
|
|
54
|
+
form.set('realpasswort', JSON.stringify(hashes));
|
|
55
|
+
form.set('_scrollLeft', '0');
|
|
56
|
+
form.set('_scrollTop', '0');
|
|
57
|
+
form.set('_scrollLocation', '/login.php');
|
|
58
|
+
if (token)
|
|
59
|
+
form.set('_anti_csrf_token', token);
|
|
60
|
+
const result = await transport.send({
|
|
61
|
+
url: `${base}/login.php`,
|
|
62
|
+
method: 'POST',
|
|
63
|
+
multipart: form,
|
|
64
|
+
redirects: 'manual',
|
|
65
|
+
signal
|
|
66
|
+
});
|
|
67
|
+
if (isLoginPage(result.text))
|
|
68
|
+
throw fail('AUTH_FAILED', 'Kurswahl Online rejected the login.');
|
|
69
|
+
// The answer is an interstitial whose script continues with post_link("index.php").
|
|
70
|
+
if (result.status !== 200 || !/post_link\(\s*["']index\.php["']/.test(result.text)) {
|
|
71
|
+
throw layoutChanged('the page that follows a Kurswahl login');
|
|
72
|
+
}
|
|
73
|
+
const account = { school, username };
|
|
74
|
+
const session = new KurswahlSession(account, transport, csrfToken(result.text) ?? token);
|
|
75
|
+
await session.page('index.php', {}, signal);
|
|
76
|
+
return session;
|
|
77
|
+
}
|
|
78
|
+
// kwo.settings("argon2_client_parameters", ["dummy for old schema", {type, time, mem, ...}, ...])
|
|
79
|
+
function argon2Parameters(html) {
|
|
80
|
+
const json = /kwo\.settings\(\s*"argon2_client_parameters"\s*,\s*(\[.*?\])\s*\)\s*;/s.exec(html)?.[1];
|
|
81
|
+
const list = json === undefined ? null : parseJson(json, 'the Argon2 parameters');
|
|
82
|
+
if (!Array.isArray(list) || list.length < 2)
|
|
83
|
+
throw layoutChanged('the Argon2 parameters');
|
|
84
|
+
return list.map((entry) => (isParameters(entry) ? entry : null));
|
|
85
|
+
}
|
|
86
|
+
// Far above today's cost (time 14, 56 MiB), so a broken page cannot exhaust the host.
|
|
87
|
+
const LIMITS = { time: 100, mem: 512 * 1024, parallelism: 16, hashLen: 256 };
|
|
88
|
+
function isParameters(value) {
|
|
89
|
+
if (!isRecord(value) || (value.type !== 1 && value.type !== 2))
|
|
90
|
+
return false;
|
|
91
|
+
return ['time', 'mem', 'parallelism', 'hashLen'].every((key) => {
|
|
92
|
+
const number = value[key];
|
|
93
|
+
return Number.isSafeInteger(number) && Number(number) > 0 && Number(number) <= LIMITS[key];
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
function readSalts(data) {
|
|
97
|
+
if (!isRecord(data) || !Array.isArray(data.salts) || !data.salts.length) {
|
|
98
|
+
throw layoutChanged('Argon2 salts for the login');
|
|
99
|
+
}
|
|
100
|
+
const salts = data.salts.map((entry) => {
|
|
101
|
+
const version = isRecord(entry) ? Number(entry.version) : NaN;
|
|
102
|
+
if (!isRecord(entry) || !Number.isSafeInteger(version) || version < 0) {
|
|
103
|
+
throw layoutChanged('Argon2 salts for the login');
|
|
104
|
+
}
|
|
105
|
+
const salt = typeof entry.salt === 'string' ? entry.salt : '';
|
|
106
|
+
if (version !== 0 && !salt)
|
|
107
|
+
throw layoutChanged('Argon2 salts for the login');
|
|
108
|
+
return {
|
|
109
|
+
version,
|
|
110
|
+
raw: entry.version,
|
|
111
|
+
salt,
|
|
112
|
+
upgradeSalt: typeof entry.upgrade_salt === 'string' ? entry.upgrade_salt : null
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
return { salts, rotating: typeof data.rotating_salt === 'string' ? data.rotating_salt : null };
|
|
116
|
+
}
|
|
117
|
+
// The browser worker passes UTF-8 bytes but the JS string length, so longer UTF-8 input is cut short.
|
|
118
|
+
export function argon2(password, salt, parameters) {
|
|
119
|
+
if (!parameters)
|
|
120
|
+
throw layoutChanged('Argon2 parameters for every salt version');
|
|
121
|
+
const bytes = (value) => new TextEncoder().encode(value).slice(0, value.length);
|
|
122
|
+
return (parameters.type === 2 ? argon2id : argon2i)({
|
|
123
|
+
password: bytes(password),
|
|
124
|
+
salt: bytes(salt),
|
|
125
|
+
iterations: parameters.time,
|
|
126
|
+
memorySize: parameters.mem,
|
|
127
|
+
parallelism: parameters.parallelism,
|
|
128
|
+
hashLength: parameters.hashLen,
|
|
129
|
+
outputType: 'hex'
|
|
130
|
+
}).catch((error) => {
|
|
131
|
+
// hash-wasm refuses combinations such as too little memory per lane or a short salt.
|
|
132
|
+
throw layoutChanged('usable Argon2 parameters and salts', error);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { StoredCookie } from '../transport/cookies.js';
|
|
2
|
+
import type { KurswahlAccount } from './session.js';
|
|
3
|
+
export interface KurswahlState {
|
|
4
|
+
account: KurswahlAccount;
|
|
5
|
+
token: string | null;
|
|
6
|
+
database: string | null;
|
|
7
|
+
cookies: StoredCookie[];
|
|
8
|
+
}
|
|
9
|
+
export declare function writeKurswahlState({ account, token, database, cookies }: KurswahlState): string;
|
|
10
|
+
export declare function readKurswahlState(text: string): KurswahlState;
|
|
11
|
+
/** A Kurswahl subdomain, so a school key can never point a request at another host. */
|
|
12
|
+
export declare function isSchool(value: string): boolean;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { fail } from '../errors.js';
|
|
2
|
+
import { copyCookie, isCookie } from '../state.js';
|
|
3
|
+
import { isRecord } from '../util.js';
|
|
4
|
+
import { isDatabase, isToken } from './parse.js';
|
|
5
|
+
const FORMAT = 'ranzen/kurswahl@1';
|
|
6
|
+
export function writeKurswahlState({ account, token, database, cookies }) {
|
|
7
|
+
const { school, username } = account;
|
|
8
|
+
return JSON.stringify({ format: FORMAT, school, username, token, database, cookies });
|
|
9
|
+
}
|
|
10
|
+
export function readKurswahlState(text) {
|
|
11
|
+
let data;
|
|
12
|
+
try {
|
|
13
|
+
data = JSON.parse(text);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
throw fail('INVALID_STATE', 'Connection state is not valid JSON.');
|
|
17
|
+
}
|
|
18
|
+
if (!isRecord(data) || data.format !== FORMAT) {
|
|
19
|
+
throw fail('INVALID_STATE', 'Connection state has an unknown format.');
|
|
20
|
+
}
|
|
21
|
+
const { school, username, token, database, cookies } = data;
|
|
22
|
+
if (typeof school !== 'string' || !isSchool(school)) {
|
|
23
|
+
throw fail('INVALID_STATE', 'Connection state has an invalid school.');
|
|
24
|
+
}
|
|
25
|
+
if (typeof username !== 'string' || !username) {
|
|
26
|
+
throw fail('INVALID_STATE', 'Connection state has an invalid username.');
|
|
27
|
+
}
|
|
28
|
+
if (!isNullOr(token, isToken) || !isNullOr(database, isDatabase)) {
|
|
29
|
+
throw fail('INVALID_STATE', 'Connection state has an invalid token or database.');
|
|
30
|
+
}
|
|
31
|
+
if (!Array.isArray(cookies) || !cookies.every(isCookie)) {
|
|
32
|
+
throw fail('INVALID_STATE', 'Connection state has invalid cookies.');
|
|
33
|
+
}
|
|
34
|
+
return { account: { school, username }, token, database, cookies: cookies.map(copyCookie) };
|
|
35
|
+
}
|
|
36
|
+
/** A Kurswahl subdomain, so a school key can never point a request at another host. */
|
|
37
|
+
export function isSchool(value) {
|
|
38
|
+
return /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(value) && value !== 'www';
|
|
39
|
+
}
|
|
40
|
+
// The same checks the parser applies, so every exported state restores.
|
|
41
|
+
function isNullOr(value, valid) {
|
|
42
|
+
return value === null || (typeof value === 'string' && valid(value));
|
|
43
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Rotation } from '../rotation.js';
|
|
2
|
+
export type { Rotation };
|
|
3
|
+
/** ISO weekday, 1 = Monday. */
|
|
4
|
+
export type Weekday = 1 | 2 | 3 | 4 | 5 | 6 | 7;
|
|
5
|
+
export interface School {
|
|
6
|
+
/** The subdomain, e.g. "musterschule" for musterschule.kurswahl-online.de. */
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
}
|
|
10
|
+
export interface KurswahlTeacher {
|
|
11
|
+
/** The school's short code, the same one SPH uses, e.g. "Mu". */
|
|
12
|
+
code: string | null;
|
|
13
|
+
/** As written, e.g. "McLeod"; named like the SPH teacher field. */
|
|
14
|
+
lastName: string | null;
|
|
15
|
+
salutation: 'Frau' | 'Herr' | null;
|
|
16
|
+
}
|
|
17
|
+
export interface KurswahlCourse {
|
|
18
|
+
/** Kurswahl's "Realkurs" number; unrelated to SPH course ids. */
|
|
19
|
+
id: string;
|
|
20
|
+
/** The "Leiste" the course runs in. */
|
|
21
|
+
block: number | null;
|
|
22
|
+
/** The short name shown in the timetable, e.g. "M-LK" or "TUT". */
|
|
23
|
+
code: string;
|
|
24
|
+
/** The long name as written, e.g. "MATHEMATIK" or "Politik&Wirtschaft". */
|
|
25
|
+
title: string | null;
|
|
26
|
+
level: 'LK' | 'GK' | null;
|
|
27
|
+
teacher: KurswahlTeacher;
|
|
28
|
+
}
|
|
29
|
+
export interface KurswahlLesson {
|
|
30
|
+
/** A KurswahlCourse id. */
|
|
31
|
+
course: string;
|
|
32
|
+
day: Weekday;
|
|
33
|
+
/** The period number, as SPH counts them. */
|
|
34
|
+
period: number;
|
|
35
|
+
room: string | null;
|
|
36
|
+
/** Null when the lesson takes place every week. */
|
|
37
|
+
rotation: Rotation | null;
|
|
38
|
+
}
|
|
39
|
+
export interface PeriodTime {
|
|
40
|
+
period: number;
|
|
41
|
+
/** Local time, e.g. "08:00". */
|
|
42
|
+
start: string | null;
|
|
43
|
+
end: string | null;
|
|
44
|
+
}
|
|
45
|
+
export interface Week {
|
|
46
|
+
/** The portal's value, e.g. "25_08_2025". */
|
|
47
|
+
id: string;
|
|
48
|
+
/** ISO calendar dates. */
|
|
49
|
+
start: string;
|
|
50
|
+
end: string;
|
|
51
|
+
}
|
|
52
|
+
export interface Timetable {
|
|
53
|
+
/** The timetable database, e.g. "musterschule_stundenplan_1". */
|
|
54
|
+
database: string;
|
|
55
|
+
/** The week shown; null for the general plan. */
|
|
56
|
+
week: Week | null;
|
|
57
|
+
/** Weeks the portal offers; schools sometimes leave last year's dates here, so prefer rotation. */
|
|
58
|
+
weeks: Week[];
|
|
59
|
+
periods: PeriodTime[];
|
|
60
|
+
courses: KurswahlCourse[];
|
|
61
|
+
lessons: KurswahlLesson[];
|
|
62
|
+
/** Local portal time of the last timetable change, e.g. "2026-08-27T12:12:51". */
|
|
63
|
+
updatedAt: string | null;
|
|
64
|
+
/** Local portal time of the last course assignment change. */
|
|
65
|
+
assignedAt: string | null;
|
|
66
|
+
/** Local portal time of the last exam plan change. */
|
|
67
|
+
examsUpdatedAt: string | null;
|
|
68
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/rotation.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/state.d.ts
CHANGED
|
@@ -7,3 +7,5 @@ export interface ConnectionState {
|
|
|
7
7
|
}
|
|
8
8
|
export declare function writeState({ account, secret, cookies }: ConnectionState): string;
|
|
9
9
|
export declare function readState(text: string): ConnectionState;
|
|
10
|
+
export declare function copyCookie({ name, value, domain, hostOnly, path, secure, expires }: StoredCookie): StoredCookie;
|
|
11
|
+
export declare function isCookie(value: unknown): value is StoredCookie;
|
package/dist/state.js
CHANGED
|
@@ -38,15 +38,19 @@ export function readState(text) {
|
|
|
38
38
|
return {
|
|
39
39
|
account: { schoolId, username },
|
|
40
40
|
secret: key,
|
|
41
|
-
cookies: cookies.map(
|
|
42
|
-
? { name, value, domain, hostOnly, path, secure }
|
|
43
|
-
: { name, value, domain, hostOnly, path, secure, expires })
|
|
41
|
+
cookies: cookies.map(copyCookie)
|
|
44
42
|
};
|
|
45
43
|
}
|
|
44
|
+
// Only the known fields, so stray keys in stored state never reach the jar.
|
|
45
|
+
export function copyCookie({ name, value, domain, hostOnly, path, secure, expires }) {
|
|
46
|
+
return expires === undefined
|
|
47
|
+
? { name, value, domain, hostOnly, path, secure }
|
|
48
|
+
: { name, value, domain, hostOnly, path, secure, expires };
|
|
49
|
+
}
|
|
46
50
|
function isRecord(value) {
|
|
47
51
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
48
52
|
}
|
|
49
|
-
function isCookie(value) {
|
|
53
|
+
export function isCookie(value) {
|
|
50
54
|
return (isRecord(value) &&
|
|
51
55
|
typeof value.name === 'string' &&
|
|
52
56
|
typeof value.value === 'string' &&
|
|
@@ -25,6 +25,7 @@ export class CookieJar {
|
|
|
25
25
|
secure: false
|
|
26
26
|
};
|
|
27
27
|
let maxAge;
|
|
28
|
+
let pathSet = false;
|
|
28
29
|
for (const attribute of attributes) {
|
|
29
30
|
const index = attribute.indexOf('=');
|
|
30
31
|
const key = (index < 0 ? attribute : attribute.slice(0, index)).trim().toLowerCase();
|
|
@@ -36,8 +37,10 @@ export class CookieJar {
|
|
|
36
37
|
cookie.domain = domain;
|
|
37
38
|
cookie.hostOnly = false;
|
|
38
39
|
}
|
|
39
|
-
else if (key === 'path' && value.startsWith('/'))
|
|
40
|
+
else if (key === 'path' && value.startsWith('/')) {
|
|
40
41
|
cookie.path = value;
|
|
42
|
+
pathSet = true;
|
|
43
|
+
}
|
|
41
44
|
else if (key === 'secure')
|
|
42
45
|
cookie.secure = true;
|
|
43
46
|
else if (key === 'max-age' && /^-?\d+$/.test(value))
|
|
@@ -50,6 +53,8 @@ export class CookieJar {
|
|
|
50
53
|
}
|
|
51
54
|
if (maxAge !== undefined)
|
|
52
55
|
cookie.expires = this.now() + maxAge * 1000;
|
|
56
|
+
if (!prefixAllows(cookie, url, pathSet))
|
|
57
|
+
return;
|
|
53
58
|
const key = `${cookie.domain};${cookie.path};${cookie.name}`;
|
|
54
59
|
if (cookie.expires !== undefined && cookie.expires <= this.now())
|
|
55
60
|
this.cookies.delete(key);
|
|
@@ -90,6 +95,15 @@ export class CookieJar {
|
|
|
90
95
|
.sort((a, b) => b.path.length - a.path.length);
|
|
91
96
|
}
|
|
92
97
|
}
|
|
98
|
+
// RFC 6265bis §4.1.3: __Secure- needs Secure over https; __Host- also needs no Domain and an explicit Path=/.
|
|
99
|
+
function prefixAllows(cookie, url, pathSet) {
|
|
100
|
+
const name = cookie.name.toLowerCase();
|
|
101
|
+
const secure = cookie.secure && url.protocol === 'https:';
|
|
102
|
+
if (name.startsWith('__host-')) {
|
|
103
|
+
return secure && cookie.hostOnly && pathSet && cookie.path === '/';
|
|
104
|
+
}
|
|
105
|
+
return !name.startsWith('__secure-') || secure;
|
|
106
|
+
}
|
|
93
107
|
function domainMatches(host, domain) {
|
|
94
108
|
return host === domain || host.endsWith(`.${domain}`);
|
|
95
109
|
}
|