diff --git a/README.md b/README.md index 7594aa0..8bbd452 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/ONLINE.md b/docs/ONLINE.md index 15d5984..0a05675 100644 --- a/docs/ONLINE.md +++ b/docs/ONLINE.md @@ -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 diff --git a/src/calendar/client.ts b/src/calendar/client.ts index 4ff6f46..24be031 100644 --- a/src/calendar/client.ts +++ b/src/calendar/client.ts @@ -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, @@ -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", @@ -206,7 +211,7 @@ export class CalendarClient { level: CalendarLevel = "undergraduate", options: { baseUrl?: string; timeoutMs?: number } = {}, ): Promise { - 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), diff --git a/src/core/raw-base.ts b/src/core/raw-base.ts new file mode 100644 index 0000000..c5fe547 --- /dev/null +++ b/src/core/raw-base.ts @@ -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; +} diff --git a/src/online/shared.ts b/src/online/shared.ts index 5811ab6..c2e415c 100644 --- a/src/online/shared.ts +++ b/src/online/shared.ts @@ -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, @@ -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(/\/+$/, "")}/`; diff --git a/src/test/calendar-raw-base.test.ts b/src/test/calendar-raw-base.test.ts new file mode 100644 index 0000000..c76e68e --- /dev/null +++ b/src/test/calendar-raw-base.test.ts @@ -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; + } +}); diff --git a/src/test/online-raw-base.test.ts b/src/test/online-raw-base.test.ts index 20bc970..c7a2e00 100644 --- a/src/test/online-raw-base.test.ts +++ b/src/test/online-raw-base.test.ts @@ -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", () => { @@ -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",