From 3bedfad01ff204f2d4348703104604117f2f86ad Mon Sep 17 00:00:00 2001 From: Yasin Date: Mon, 24 Aug 2026 11:26:50 +0200 Subject: [PATCH 1/2] fix(training): fetch TeSS listings at build time All three listings on /training were driven by the TeSS browser widget, and all three had been failing silently. The widget issues its XHRs without an Accept header, so TeSS answers with an HTML 403 that carries no CORS headers, and the browser reports the result as a CORS violation. It also tries to set a User-Agent, which browsers forbid. Nothing catches any of this, so the page threw three uncaught TypeErrors and rendered no listings at all. The static placeholder underneath made it worse. "No upcoming training events at the moment." was our own markup waiting to be replaced, so the page claimed there was nothing on instead of admitting it had failed to ask. TeSS was serving ten Norwegian materials and ten past events the whole time. The fetch now happens during the build, where an Accept header is enough to get JSON back and CORS never comes into it. The third-party script and stylesheet are gone, and so are the 123 lines of overrides that existed to restyle markup we now emit ourselves. TeSS is unreliable, around one request in three came back 5xx while I was testing, so this retries and then degrades. It also separates an empty listing from an unreachable one. Repeating the old bug's habit of showing a plausible empty state over a dead feed would have defeated the point, so a failed fetch says so and links out to TeSS. Drops the unused BASE constant while the file is open. --- src/components/tess-unavailable.astro | 21 ++ src/lib/tess.ts | 120 ++++++++++ src/pages/training/index.astro | 319 ++++++++++---------------- 3 files changed, 264 insertions(+), 196 deletions(-) create mode 100644 src/components/tess-unavailable.astro create mode 100644 src/lib/tess.ts diff --git a/src/components/tess-unavailable.astro b/src/components/tess-unavailable.astro new file mode 100644 index 00000000..cfea13ec --- /dev/null +++ b/src/components/tess-unavailable.astro @@ -0,0 +1,21 @@ +--- +import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; + +interface Props { + /** Where to send people instead, on TeSS. */ + href?: string; +} + +const { href = "https://tess.elixir-europe.org/" } = Astro.props; +--- + +
+
diff --git a/src/lib/tess.ts b/src/lib/tess.ts new file mode 100644 index 00000000..e4c5c6f9 --- /dev/null +++ b/src/lib/tess.ts @@ -0,0 +1,120 @@ +const API = "https://tess.elixir-europe.org"; + +const ATTEMPTS: number = 5; +const TIMEOUT_MS = 20_000; + +export interface TessEvent { + id: number; + title: string; + url: string; + slug?: string; + description?: string; + start?: string; + end?: string; + venue?: string; + city?: string; + country?: string; + organizer?: string; +} + +export interface TessMaterial { + id: number; + title: string; + url: string; + description?: string; + doi?: string; +} + +type Params = Record; + +/** + * `reachable` separates "TESS has nothing to list" from "TESS never answered". + * Both leave `items` empty, and rendering them the same way is what let the old + * widget show a plausible-looking empty state over a dead feed for months. + */ +export interface TessResult { + items: T[]; + reachable: boolean; +} + +function query(params: Params): string { + const q = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + // TESS expects repeated bracketed keys for arrays: country[]=Norway + if (Array.isArray(value)) value.forEach(v => q.append(`${key}[]`, v)); + else q.append(key, String(value)); + } + return q.toString(); +} + +// Without an explicit JSON Accept header TESS serves an HTML 403 instead of the +// API response, which is what broke the browser widget this replaced: the error +// page carries no CORS headers, so the failure surfaced as a CORS violation. +// TESS also returns 5xx intermittently, hence the retries. +async function fetchList(path: string, params: Params): Promise> { + const url = `${API}/${path}?${query(params)}`; + + for (let attempt = 1; attempt <= ATTEMPTS; attempt++) { + try { + const res = await fetch(url, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + + const body = await res.json(); + if (!Array.isArray(body)) throw new Error("expected a JSON array"); + return { items: body as T[], reachable: true }; + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + if (attempt === ATTEMPTS) { + const tries = ATTEMPTS === 1 ? "1 attempt" : `${ATTEMPTS} attempts`; + console.warn(`[tess] ${path} failed after ${tries} (${reason}); the section will say so`); + return { items: [], reachable: false }; + } + await new Promise(r => setTimeout(r, attempt * 1500)); + } + } + return { items: [], reachable: false }; +} + +export const upcomingEvents = (pageSize = 5) => + fetchList("events", { page_size: pageSize, country: ["Norway"] }); + +export const pastEvents = (pageSize = 10) => + fetchList("events", { + page_size: pageSize, + sort: "new", + country: ["Norway"], + include_expired: true, + include_disabled: false, + }); + +export const materials = (pageSize = 10) => + fetchList("materials", { page_size: pageSize, node: ["Norway"] }); + +/** Link to the event's own registration page, falling back to its TESS entry. */ +export function eventLink(event: TessEvent): string { + return event.url || (event.slug ? `${API}/events/${event.slug}` : API); +} + +/** "12 Mar 2026", or a range when the event spans more than one day. */ +export function eventDates(event: TessEvent): string { + const fmt = (iso: string) => + new Date(iso).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" }); + + if (!event.start) return ""; + const start = fmt(event.start); + if (!event.end) return start; + + const end = fmt(event.end); + return start === end ? start : `${start} to ${end}`; +} + +// Venue is left out on purpose. TESS stores a full street address there and +// often a doubled one ("Moltke Moes vei, Moltke Moes vei"), which is noise in a +// one-line summary; the linked event page carries the address. +/** "Oslo, Norway", skipping whichever part TESS left blank. */ +export function eventPlace(event: TessEvent): string { + return [event.city, event.country].filter(Boolean).join(", "); +} diff --git a/src/pages/training/index.astro b/src/pages/training/index.astro index d165df01..edcaf566 100644 --- a/src/pages/training/index.astro +++ b/src/pages/training/index.astro @@ -10,8 +10,17 @@ import { MagnifyingGlassIcon, WrenchScrewdriverIcon, } from "@heroicons/react/24/outline"; +import Unavailable from "../../components/tess-unavailable.astro"; +import { upcomingEvents, pastEvents, materials, eventLink, eventDates, eventPlace } from "../../lib/tess"; -const BASE = import.meta.env.BASE_URL.replace(/\/$/, ''); +// Fetched at build time rather than from the browser: TESS rejects a request +// that does not ask for JSON, and the error page it returns carries no CORS +// headers. See src/lib/tess.ts. +const [upcoming, past, trainingMaterials] = await Promise.all([ + upcomingEvents(5), + pastEvents(10), + materials(10), +]); --- - - -

Training

@@ -137,216 +143,137 @@ const BASE = import.meta.env.BASE_URL.replace(/\/$/, '');
- +
-
- - - Live - +

Upcoming training

+ + Browse all on TeSS +
-
-
- +
+ {upcoming.items.length > 0 ? ( + + ) : upcoming.reachable ? ( +

+

+ ) : ( + + )}
- +
-

Training materials

-
-
+
+

Training materials

+ + Browse all on TeSS + +
+
+ {trainingMaterials.items.length > 0 ? ( + + +
+
+ + +
+ ) : trainingMaterials.reachable ? ( +

+

+ ) : ( + + )}
- +

Past training events

-
-
+
+ {past.items.length > 0 ? ( + + ) : past.reachable ? ( +

+

+ ) : ( + + )}
- - - - -
From 84b486244d72b4e5fe17e2385332fddca73fb20b Mon Sep 17 00:00:00 2001 From: Yasin Date: Mon, 24 Aug 2026 11:40:16 +0200 Subject: [PATCH 2/2] fix(training): reject non-http(s) URLs from the TeSS feed Event and material URLs come from a feed we do not control, and a build-time render writes them into static HTML, so a javascript: URL would ship and then sit there until somebody rebuilt. eventLink and the new materialLink now parse the URL and fall back to a TeSS address we construct ourselves whenever the scheme is anything but http or https. test-pages.mjs grew a matching check over the built output, because the helper is only one of the routes a URL can take into an href. It allows http, https, mailto and tel, which covers everything the site emits today, and fails the run on anything else. Confirmed by injecting a javascript: href into dist and watching the check catch it. Raised in review on #332. --- scripts/test-pages.mjs | 30 +++++++++++++++++++++++++++++- src/lib/tess.ts | 24 +++++++++++++++++++++++- src/pages/training/index.astro | 4 ++-- 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/scripts/test-pages.mjs b/scripts/test-pages.mjs index ef010507..97b47036 100644 --- a/scripts/test-pages.mjs +++ b/scripts/test-pages.mjs @@ -117,6 +117,20 @@ function parseSitemap(xml) { // File-extension pattern — used to distinguish real assets from nav links. const HAS_EXT = /\.[a-z0-9]{1,6}$/i; +// Some hrefs come from feeds we do not control (TeSS on /training) and get baked +// into static HTML at build time, so a `javascript:` URL would ship and stay +// shipped. Anything outside this set is a defect, not a style preference. +const SAFE_SCHEMES = new Set(['http', 'https', 'mailto', 'tel']); +const SCHEMED_HREF = /(?:href|src)="([a-zA-Z][a-zA-Z0-9+.-]*):/g; + +function extractUnsafeSchemes(html) { + const found = new Set(); + for (const [, scheme] of html.matchAll(SCHEMED_HREF)) { + if (!SAFE_SCHEMES.has(scheme.toLowerCase())) found.add(scheme.toLowerCase()); + } + return [...found]; +} + const ASSET_ATTRS = [ // src attributes (scripts, images — always files) /\bsrc=["']([^"']+)["']/g, @@ -195,6 +209,7 @@ async function main() { const pageErrors = []; // { url, status, error } const ssrPages = []; // SSR pages in sitemap (no static HTML file) const assetErrors = []; // { asset, foundOn, status, error } + const schemeErrors = []; // { url, scheme } const assetCache = new Map(); // url → status // Parse sitemap @@ -242,6 +257,10 @@ async function main() { pageErrors.push({ url: siteUrl, status: null, error: err.message }); } + for (const scheme of pageHtml ? extractUnsafeSchemes(pageHtml) : []) { + schemeErrors.push({ url: siteUrl, scheme }); + } + // Check assets found in page HTML if (pageHtml) { const assets = extractLocalAssets(pageHtml, localUrl); @@ -277,6 +296,7 @@ async function main() { console.log(' ┌─────────────────────────────────────────────┐'); console.log(` │ Pages ${String(pageOk).padStart(4)}/${String(staticTotal).padEnd(4)} ok ${pageErrors.length > 0 ? c.red(`${pageErrors.length} failed`) : c.green('all passed')} │`); console.log(` │ Assets ${String(assetOk).padStart(4)}/${String(totalAssets).padEnd(4)} ok ${assetErrors.length > 0 ? c.red(`${assetErrors.length} failed`) : c.green('all passed')} │`); + console.log(` │ Links ${schemeErrors.length > 0 ? c.red(`${String(schemeErrors.length).padStart(4)} unsafe scheme(s)`) : c.green(' 0 unsafe schemes ')} │`); if (ssrPages.length > 0) console.log(` │ SSR ${String(ssrPages.length).padStart(4)} pages skipped (no static HTML) │`); console.log(' └─────────────────────────────────────────────┘\n'); @@ -290,6 +310,14 @@ async function main() { console.log(); } + if (schemeErrors.length > 0) { + console.log(c.red(c.bold(' Unsafe URL schemes:'))); + for (const { url, scheme } of schemeErrors) { + console.log(` ${c.red(scheme + ':')} ${url}`); + } + console.log(); + } + if (assetErrors.length > 0) { console.log(c.red(c.bold(' Broken assets:'))); for (const { asset, foundOn, status, error } of assetErrors) { @@ -302,7 +330,7 @@ async function main() { server.close(); - const failed = pageErrors.length + assetErrors.length; + const failed = pageErrors.length + assetErrors.length + schemeErrors.length; if (failed > 0) { console.log(c.red(c.bold(` ${failed} issue(s) found. Fix before deploying.\n`))); process.exit(1); diff --git a/src/lib/tess.ts b/src/lib/tess.ts index e4c5c6f9..81df9606 100644 --- a/src/lib/tess.ts +++ b/src/lib/tess.ts @@ -93,9 +93,31 @@ export const pastEvents = (pageSize = 10) => export const materials = (pageSize = 10) => fetchList("materials", { page_size: pageSize, node: ["Norway"] }); +// These URLs come from a feed we do not control and get baked into static HTML, +// so a bad one would stay on the page until the next build. Anything that is not +// http(s), a `javascript:` URL being the case that matters, is dropped for a +// TESS address we build ourselves. +function safeUrl(raw: string | undefined, fallback: string): string { + if (!raw) return fallback; + try { + const { protocol } = new URL(raw); + return protocol === "http:" || protocol === "https:" ? raw : fallback; + } catch { + return fallback; + } +} + /** Link to the event's own registration page, falling back to its TESS entry. */ export function eventLink(event: TessEvent): string { - return event.url || (event.slug ? `${API}/events/${event.slug}` : API); + const onTess = event.slug + ? `${API}/events/${encodeURIComponent(event.slug)}` + : `${API}/events`; + return safeUrl(event.url, onTess); +} + +/** Link to the material on TESS, falling back to the Norwegian listing. */ +export function materialLink(material: TessMaterial): string { + return safeUrl(material.url, `${API}/materials?node[]=Norway`); } /** "12 Mar 2026", or a range when the event spans more than one day. */ diff --git a/src/pages/training/index.astro b/src/pages/training/index.astro index edcaf566..10af68c7 100644 --- a/src/pages/training/index.astro +++ b/src/pages/training/index.astro @@ -11,7 +11,7 @@ import { WrenchScrewdriverIcon, } from "@heroicons/react/24/outline"; import Unavailable from "../../components/tess-unavailable.astro"; -import { upcomingEvents, pastEvents, materials, eventLink, eventDates, eventPlace } from "../../lib/tess"; +import { upcomingEvents, pastEvents, materials, eventLink, materialLink, eventDates, eventPlace } from "../../lib/tess"; // Fetched at build time rather than from the browser: TESS rejects a request // that does not ask for JSON, and the error page it returns carries no CORS @@ -202,7 +202,7 @@ const [upcoming, past, trainingMaterials] = await Promise.all([