Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,23 @@ user-completed and ephemeral. Read the full
[authentication guide](docs/AUTHENTICATION.md) before setting up headless or
automated use.

## Data sources and mirrors

The academic calendar and the public SUSTech Online layer are read from GitHub.
On networks that cannot reach `raw.githubusercontent.com`, point either source at
an HTTPS mirror:

```bash
export SUSTECH_CALENDAR_RAW_BASE="https://ghfast.top/https://raw.githubusercontent.com/dumixthestpd/sustech-calendar/main"
export SUSTECH_ONLINE_RAW_BASE="https://ghfast.top/https://raw.githubusercontent.com/SUSTech-CRA/sustech-online-ng/master"
```

Both values must be plain HTTPS URLs without embedded credentials, a query
string, or a fragment; when unset, the public repositories above are used.
Mirrors are third-party services and must serve the files directly; the online
layer still rejects redirects. See [SUSTech Online](docs/ONLINE.md) for the
online allowlist details.

## Updates

Interactive terminals check npm for a newer stable release at most once every
Expand Down
2 changes: 1 addition & 1 deletion docs/ONLINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ sustech online talks list --limit 5
The raw allowlist is derived from this base. Redirects are still rejected and
the final URL must still match the constructed target exactly, so mirrors that
answer with a 3xx redirect cannot be used. Values that are not HTTPS URLs
without a query string or fragment are rejected with
without embedded credentials, a query string, or a fragment are rejected with
`ONLINE_RAW_BASE_INVALID`.

Rendered-page metadata is optional; raw Markdown is required. Tests use frozen
Expand Down
7 changes: 6 additions & 1 deletion src/calendar/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { CliError } from "../core/errors.js";
import { fetchJson } from "../core/http.js";
import { resolveHttpsBase } from "../core/raw-base.js";
import type { Semester } from "../core/semester.js";
import type {
CalendarDayInfo,
Expand All @@ -15,6 +16,10 @@ import type {
} from "./types.js";

const DEFAULT_REPO_BASE = "https://raw.githubusercontent.com/dumixthestpd/sustech-calendar/main";

export function calendarRepoBase(env: NodeJS.ProcessEnv = process.env): string {
return resolveHttpsBase("SUSTECH_CALENDAR_RAW_BASE", DEFAULT_REPO_BASE, "CALENDAR_RAW_BASE_INVALID", env);
}
const WEEKDAYS: WeekdayName[] = [
"Monday",
"Tuesday",
Expand Down Expand Up @@ -206,7 +211,7 @@ export class CalendarClient {
level: CalendarLevel = "undergraduate",
options: { baseUrl?: string; timeoutMs?: number } = {},
): Promise<AcademicCalendar> {
const baseUrl = yearBaseUrl(options.baseUrl ?? DEFAULT_REPO_BASE, year);
const baseUrl = yearBaseUrl(options.baseUrl ?? calendarRepoBase(), year);
const [undergraduate, graduate, general] = await Promise.all([
fetchJson(`${baseUrl}/undergraduate.json`, options),
fetchJson(`${baseUrl}/graduate.json`, options),
Expand Down
33 changes: 33 additions & 0 deletions src/core/raw-base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { CliError } from "./errors.js";

export function normaliseHttpsBase(value: string | undefined): string | undefined {
const candidate = value?.trim().replace(/\/+$/, "");
if (!candidate) return undefined;
try {
const url = new URL(candidate);
if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) return undefined;
} catch {
return undefined;
}
return candidate;
}

export function resolveHttpsBase(
variable: string,
defaultValue: string,
invalidCode: string,
env: NodeJS.ProcessEnv = process.env,
): string {
const configured = env[variable]?.trim();
if (!configured) return defaultValue;
const normalised = normaliseHttpsBase(configured);
if (!normalised) {
throw new CliError(
`${variable} must be a plain HTTPS URL without credentials, a query string, or a fragment.`,
invalidCode,
2,
{ variable },
);
}
return normalised;
}
28 changes: 3 additions & 25 deletions src/online/shared.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { CliError } from "../core/errors.js";
import { normaliseHttpsBase, resolveHttpsBase } from "../core/raw-base.js";
import { USER_AGENT } from "../core/version.js";
import {
collapseWhitespace,
Expand All @@ -15,34 +16,11 @@ export const ONLINE_REPO_NAME = "sustech-online-ng";
export const ONLINE_REPO_BRANCH = "master";
const DEFAULT_ONLINE_RAW_BASE = `https://raw.githubusercontent.com/${ONLINE_REPO_OWNER}/${ONLINE_REPO_NAME}/${ONLINE_REPO_BRANCH}`;

function normaliseOnlineRawBase(value: string): string | undefined {
const candidate = value.trim().replace(/\/+$/, "");
if (!candidate) return undefined;
try {
const url = new URL(candidate);
if (url.protocol !== "https:" || url.search || url.hash) return undefined;
} catch {
return undefined;
}
return candidate;
}

export function onlineRawBase(env: NodeJS.ProcessEnv = process.env): string {
const configured = env.SUSTECH_ONLINE_RAW_BASE?.trim();
if (!configured) return DEFAULT_ONLINE_RAW_BASE;
const normalised = normaliseOnlineRawBase(configured);
if (!normalised) {
throw new CliError(
"SUSTECH_ONLINE_RAW_BASE must be a plain HTTPS URL without a query string or fragment.",
"ONLINE_RAW_BASE_INVALID",
2,
{ variable: "SUSTECH_ONLINE_RAW_BASE" },
);
}
return normalised;
return resolveHttpsBase("SUSTECH_ONLINE_RAW_BASE", DEFAULT_ONLINE_RAW_BASE, "ONLINE_RAW_BASE_INVALID", env);
}

export const ONLINE_RAW_BASE = normaliseOnlineRawBase(process.env.SUSTECH_ONLINE_RAW_BASE ?? "") ?? DEFAULT_ONLINE_RAW_BASE;
export const ONLINE_RAW_BASE = normaliseHttpsBase(process.env.SUSTECH_ONLINE_RAW_BASE) ?? DEFAULT_ONLINE_RAW_BASE;
const ONLINE_RAW_BASE_URL = new URL(ONLINE_RAW_BASE);
export const ONLINE_RAW_ORIGIN = ONLINE_RAW_BASE_URL.origin;
export const ONLINE_RAW_BASE_PATH = `${ONLINE_RAW_BASE_URL.pathname.replace(/\/+$/, "")}/`;
Expand Down
55 changes: 55 additions & 0 deletions src/test/calendar-raw-base.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import assert from "node:assert/strict";
import test from "node:test";
import { CalendarClient, calendarRepoBase } from "../calendar/client.js";
import { CliError } from "../core/errors.js";

const DEFAULT_BASE = "https://raw.githubusercontent.com/dumixthestpd/sustech-calendar/main";
const MIRROR_BASE = "https://ghfast.top/https://raw.githubusercontent.com/dumixthestpd/sustech-calendar/main";

test("defaults to the public calendar repository", () => {
assert.equal(calendarRepoBase({}), DEFAULT_BASE);
});

test("accepts an HTTPS mirror override and trims surrounding slashes and spaces", () => {
assert.equal(calendarRepoBase({ SUSTECH_CALENDAR_RAW_BASE: MIRROR_BASE }), MIRROR_BASE);
assert.equal(calendarRepoBase({ SUSTECH_CALENDAR_RAW_BASE: `${MIRROR_BASE}/` }), MIRROR_BASE);
assert.equal(calendarRepoBase({ SUSTECH_CALENDAR_RAW_BASE: ` ${MIRROR_BASE} ` }), MIRROR_BASE);
});

test("rejects unsafe or malformed overrides", () => {
const values = [
"http://mirror.example/repo",
"https://user:secret@mirror.example/repo",
"https://mirror.example/repo?token=1",
"https://mirror.example/repo#fragment",
"not a url",
];
for (const value of values) {
assert.throws(
() => calendarRepoBase({ SUSTECH_CALENDAR_RAW_BASE: value }),
(error: unknown) => error instanceof CliError && error.code === "CALENDAR_RAW_BASE_INVALID",
);
}
});

test("loadYear reads the configured mirror base", async () => {
const originalBase = process.env.SUSTECH_CALENDAR_RAW_BASE;
const originalFetch = globalThis.fetch;
const requested: string[] = [];
process.env.SUSTECH_CALENDAR_RAW_BASE = MIRROR_BASE;
globalThis.fetch = (async (input: string | URL | Request) => {
requested.push(String(input));
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } });
}) as typeof fetch;
try {
await assert.rejects(() => new CalendarClient().loadYear(2026));
assert.equal(requested.length, 3);
for (const url of requested) {
assert.ok(url.startsWith(`${MIRROR_BASE}/2026/`), url);
}
} finally {
globalThis.fetch = originalFetch;
if (originalBase === undefined) delete process.env.SUSTECH_CALENDAR_RAW_BASE;
else process.env.SUSTECH_CALENDAR_RAW_BASE = originalBase;
}
});
9 changes: 8 additions & 1 deletion src/test/online-raw-base.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,13 @@ const CONTACT_PATH = "docs/contact/README.md";

test("defaults to the public GitHub raw base", () => {
assert.equal(onlineRawBase({}), DEFAULT_BASE);
assert.equal(onlineRawUrl(CONTACT_PATH), `${DEFAULT_BASE}/${CONTACT_PATH}`);
const configured = process.env.SUSTECH_ONLINE_RAW_BASE;
delete process.env.SUSTECH_ONLINE_RAW_BASE;
try {
assert.equal(onlineRawUrl(CONTACT_PATH), `${DEFAULT_BASE}/${CONTACT_PATH}`);
} finally {
if (configured !== undefined) process.env.SUSTECH_ONLINE_RAW_BASE = configured;
}
});

test("accepts an HTTPS mirror override and trims surrounding slashes and spaces", () => {
Expand All @@ -30,6 +36,7 @@ test("accepts an HTTPS mirror override and trims surrounding slashes and spaces"
test("rejects unsafe or malformed overrides", () => {
const values = [
"http://mirror.example/repo",
"https://user:secret@mirror.example/repo",
"https://mirror.example/repo?token=1",
"https://mirror.example/repo#fragment",
"not a url",
Expand Down
Loading