import type { Config } from '../config.ts'; /** * Reads the text of a collaborative text editor (Etherpad) element. * * The board API is no help on its own: a `collaborativeTextEditor` element * comes back with `content: {}` — no pad id, no url, nothing. What a class * actually wrote in it is invisible to every other tool here. * * Two calls recover it, and neither needs Etherpad's own API key (which is a * server-side secret this process has no business holding): * * 1. `GET /api/v3/collaborative-text-editor/content-element/{id}` answers with * the pad url *and*, in a `Set-Cookie`, an Etherpad `sessionID` — the same * exchange the web client performs before it embeds the pad. * 2. Etherpad's own `/p/{padId}/export/txt` returns the pad as plain text to * whoever holds that session. * * The session cookie is only ever sent back to the instance's own host: step 1 * returns a url built from the server's `ETHERPAD__PAD_URI`, and a value * pointing anywhere else is refused rather than followed. * * Everything degrades to `undefined`. A pad that cannot be read costs its text, * never the board. */ export async function fetchPadText(config: Config, elementId: string): Promise { try { const handle = await fetchPadHandle(config, elementId); if (!handle) return undefined; const response = await fetch(`${handle.origin}/etherpad/p/${handle.padId}/export/txt`, { headers: { Cookie: handle.sessionCookie, Accept: 'text/plain' }, signal: AbortSignal.timeout(config.requestTimeoutMs), }); if (!response.ok) return undefined; const text = (await response.text()).trim(); // A pad nobody has typed in still exports the placeholder the instance // seeds new pads with; reporting that as content would be a lie. return text.length > 0 && text !== DEFAULT_PAD_TEXT ? text : undefined; } catch { return undefined; } } /** The instance's `DEFAULT_PAD_TEXT`; an untouched pad exports exactly this. */ const DEFAULT_PAD_TEXT = 'Schreib etwas!'; interface PadHandle { origin: string; padId: string; sessionCookie: string; } async function fetchPadHandle(config: Config, elementId: string): Promise { const response = await fetch( `${config.baseUrl}/api/v3/collaborative-text-editor/content-element/${encodeURIComponent(elementId)}`, { headers: { Authorization: `Bearer ${config.jwt}`, Accept: 'application/json' }, signal: AbortSignal.timeout(config.requestTimeoutMs), }, ); if (!response.ok) return undefined; const body = (await response.json()) as { url?: unknown }; if (typeof body.url !== 'string') return undefined; const padId = padIdFromUrl(body.url, config.baseUrl); if (!padId) return undefined; // `getSetCookie` keeps the header split correctly; a plain `get` would join // several cookies on the commas that appear inside the session list itself. const sessionCookie = response.headers .getSetCookie() .map((cookie) => /^(sessionID=[^;]*)/.exec(cookie)?.[1]) .find((value): value is string => Boolean(value)); if (!sessionCookie) return undefined; return { origin: new URL(config.baseUrl).origin, padId, sessionCookie }; } /** * The pad id out of the url the server hands back. * * Refuses a url on another host: that url is server-configured, and following * it blindly would send an Etherpad session cookie wherever it pointed. * Group pad ids contain `$`, so the segment is kept exactly as encoded. */ export function padIdFromUrl(url: string, baseUrl: string): string | undefined { let parsed: URL; try { parsed = new URL(url); } catch { return undefined; } if (parsed.host !== new URL(baseUrl).host) return undefined; const segment = /\/etherpad\/p\/([^/?#]+)/.exec(parsed.pathname)?.[1]; return segment && segment.length > 0 ? segment : undefined; } /** * The text of a pad linked from a *topic* ("Thema"), as opposed to a board. * * Topics reach Etherpad differently from column boards: there is no * `collaborative-text-editor` element to ask, only a stored pad url on the * lesson component. The session cookie comes from the topic page instead — * the legacy client requests an Etherpad session on every topic page whose * lesson has contents, whether or not a pad is present, so simply rendering * the page yields one. Everything here is a GET. * * The stored url is data and may point anywhere; `padIdFromUrl` refuses any * host but this instance's, so a pad recorded against another deployment is * reported as a link rather than fetched — which is the correct outcome, not * a failure. */ export async function fetchLessonPadText( config: Config, courseId: string, lessonId: string, padUrl: string, ): Promise { const padId = padIdFromUrl(padUrl, config.baseUrl); if (!padId) return undefined; try { const page = await fetch( `${config.baseUrl}/courses/${encodeURIComponent(courseId)}/topics/${encodeURIComponent(lessonId)}`, { headers: { Cookie: `jwt=${config.jwt}`, Accept: 'text/html' }, signal: AbortSignal.timeout(config.requestTimeoutMs), redirect: 'follow', }, ); if (!page.ok) return undefined; const sessionCookie = page.headers .getSetCookie() .map((cookie) => /^(sessionID=[^;]*)/.exec(cookie)?.[1]) .find((value): value is string => Boolean(value)); if (!sessionCookie) return undefined; const response = await fetch(`${new URL(config.baseUrl).origin}/etherpad/p/${padId}/export/txt`, { headers: { Cookie: sessionCookie, Accept: 'text/plain' }, signal: AbortSignal.timeout(config.requestTimeoutMs), }); if (!response.ok) return undefined; const text = (await response.text()).trim(); return text.length > 0 && text !== DEFAULT_PAD_TEXT ? text : undefined; } catch { return undefined; } }