/** * Export HTTP routes (Phase 8 — AGENTS.md §6.1 and §8). * * Covers the read-only export surface from the inventory: * * GET /_/:room/html, /:room.html * GET /_/:room/csv, /:room.csv + Content-Disposition * GET /_/:room/csv.json, /:room.csv.json * GET /_/:room/xlsx, /:room.xlsx + Content-Disposition (binary) * GET /_/:room/ods, /:room.ods + Content-Disposition (binary) * GET /_/:room/fods, /:room.fods + Content-Disposition (binary) * GET /_/:room/md, /:room.md * * Each route dispatches to the target DO's `/_do/` endpoint and * streams the body back. Text bodies go through `Response.text()` (fine — * each DO response fits in memory by construction); binary bodies are read * as `ArrayBuffer` so we can re-emit them verbatim with the correct headers. * * ─── Multi-sheet export (`GET /_/=:room/xlsx` etc.) ───────────────────── * * Legacy behavior (src/main.ls:363-392): iterates the TOC sheet rows, * fetches each sub-sheet by its `.` key, and merges them into a * single workbook with one named sheet per row. This requires DO-to-DO * fetches plus some TOC-parsing glue that depends on Phase 6 exec routes * and the multi-sheet routing in §1. Scoped out of Phase 8 per the phase * spec — routes return `501 Phase 8.1` with a brief explanation body. * * Route registration is via `registerExports(app)`, separate from * `registerRoomRoutes(app)` to avoid collisions with Phase 6's planned * POST /_/:room additions on the same file. The registration order in * `src/index.ts` is documented there. */ /* istanbul ignore file */ import type { Hono } from 'hono'; import { MAX_MULTI_SHEETS, MAX_MULTI_SHEET_TITLE_LENGTH, isSafeMultiSheetLink, } from '@ethercalc/shared'; import { doFetch } from '../lib/do-dispatch.ts'; import { readBoundedResponseText } from '../lib/cross-sheet.ts'; import { getSessionPrincipal } from '../lib/session-middleware.ts'; import type { SessionPrincipal } from '../lib/session.ts'; import { BINARY_CONTENT_TYPES, type BinaryFormat, buildMultiSheetWorkbookFromSheets, } from '../lib/xlsx-build.ts'; import { ALLOWED_ELEMENTS, DANGEROUS_ELEMENTS, attributeAction, } from '../lib/html-sanitize.ts'; import { MAX_IMPORT_CELLS } from '../lib/xlsx-import.ts'; import type { Env, EtherCalcHonoEnv } from '../env.ts'; const TEXT_CT = 'text/plain; charset=utf-8'; const MAX_MULTI_EXPORT_TOC_BYTES = 1024 * 1024; const MAX_MULTI_EXPORT_SHEET_BYTES = 8 * 1024 * 1024; const MAX_MULTI_EXPORT_TOTAL_CHARS = 12 * 1024 * 1024; function multiExportTooLarge(): Response { return new Response('multi-sheet export exceeds resource limits', { status: 413, headers: { 'Content-Type': TEXT_CT }, }); } /** * Mirror a DO 401/403 auth verdict to the client verbatim (status + * text/plain body). Returns null for any other status so callers fall * through to their normal handling — export headers (attachment, * CSP, content type) never dress up a denial. */ async function authVerdict(res: Response): Promise { if (res.status !== 401 && res.status !== 403) return null; return new Response(await res.text(), { status: res.status, headers: { 'Content-Type': TEXT_CT }, }); } /** * Fetch the TOC sheet (at `:room`, sans `=` prefix) and its sub-rooms, then * build a single workbook with one worksheet per TOC row. TOC shape matches * `packages/client-multi/src/Foldr.ts`: first row is headers; subsequent * rows are `[link, title]` where link is `/` and title is the * user-chosen sheet tab name. * * Returns `null` when the TOC is missing or empty — caller emits 404. * Returns a `Response` when the DO denied a read (401/403) — caller * propagates the verdict instead of masking it as a 404. */ async function fetchMultiSheetBundle( env: Env, room: string, principal: SessionPrincipal | null, ): Promise | Response | null> { const tocRes = await doFetch(env, room, '/_do/csv.json', {}, principal); const tocDenied = await authVerdict(tocRes); if (tocDenied) return tocDenied; if (tocRes.status !== 200) return null; const tocText = await readBoundedResponseText( tocRes, MAX_MULTI_EXPORT_TOC_BYTES, ); if (tocText === null) return multiExportTooLarge(); let rows: unknown; try { rows = JSON.parse(tocText); } catch { return null; } if (!Array.isArray(rows) || rows.length < 2) return null; if (rows.length - 1 > MAX_MULTI_SHEETS) return multiExportTooLarge(); // Skip the header row. Import and export share the same persisted TOC // limits, so a hand-edited TOC cannot fan one request out across an // unbounded number of Durable Objects. const tocRows = rows.slice(1) as unknown[]; const bundle: Array<{ name: string; view: unknown }> = []; let totalChars = 0; let totalCells = 0; for (const raw of tocRows) { if (!Array.isArray(raw)) continue; const link = typeof raw[0] === 'string' ? raw[0] : ''; if (!isSafeMultiSheetLink(link)) continue; const subroom = link.slice(1); const rawTitle = typeof raw[1] === 'string' ? raw[1] : ''; const title = rawTitle.length > 0 && rawTitle.length <= MAX_MULTI_SHEET_TITLE_LENGTH ? rawTitle : `Sheet${bundle.length + 1}`; const sheetRes = await doFetch(env, subroom, '/_do/sheet-data', {}, principal); const sheetDenied = await authVerdict(sheetRes); if (sheetDenied) return sheetDenied; if (sheetRes.status !== 200) continue; const sheetText = await readBoundedResponseText( sheetRes, MAX_MULTI_EXPORT_SHEET_BYTES, ); if ( sheetText === null || totalChars + sheetText.length > MAX_MULTI_EXPORT_TOTAL_CHARS ) { return multiExportTooLarge(); } let view: unknown; try { view = JSON.parse(sheetText); } catch { continue; } const cells = view !== null && typeof view === 'object' && 'cells' in view && view.cells !== null && typeof view.cells === 'object' && !Array.isArray(view.cells) ? Object.keys(view.cells).length : 0; if (totalCells + cells > MAX_IMPORT_CELLS) { return multiExportTooLarge(); } totalChars += sheetText.length; totalCells += cells; bundle.push({ name: title, view }); } return bundle.length > 0 ? bundle : null; } async function dispatchMultiSheetExport( env: Env, room: string, format: BinaryFormat, principal: SessionPrincipal | null, ): Promise { const bundle = await fetchMultiSheetBundle(env, room, principal); if (bundle instanceof Response) return bundle; if (!bundle) { return new Response('', { status: 404, headers: { 'Content-Type': TEXT_CT } }); } // `buildMultiSheetWorkbookFromSheets` expects `SocialCalcSheetView` — // the JSON we got from the DO is structurally identical. const bytes = buildMultiSheetWorkbookFromSheets( bundle as ReadonlyArray<{ name: string; view: Parameters[0][number]['view'] }>, format, ); // `bytes as unknown as BodyInit`: workerd accepts a `Uint8Array` body at // runtime; the cast satisfies the stricter `Uint8Array` // lib typing that doesn't structurally match `BodyInit`. return new Response(bytes as unknown as BodyInit, { status: 200, headers: { 'Content-Type': BINARY_CONTENT_TYPES[format], 'Content-Disposition': attachmentHeader(room, format), 'X-Content-Type-Options': 'nosniff', }, }); } /** * Content-Security-Policy for the HTML export. SocialCalc emits cells whose * `valueformat===text-html` are NOT escaped (socialcalc.bundled.ts), so an * attacker who can write a room (anonymous write is by design) could plant * `` / `