ethercalc
Version:
Multi-User Spreadsheet Server — TypeScript rewrite (Cloudflare fullstack)
362 lines (327 loc) • 13.3 kB
text/typescript
/**
* Browser-only side-effect entry. This is the actual input to Vite's bundle;
* it imports + boots everything and leaves no exports.
*
* Kept in its own file so Istanbul can exclude it via `vitest.config.ts`
* without dragging down the 100% coverage gate on `main.ts`/`ws-adapter.ts`
* /`socialcalc-callbacks.ts`.
*/
import DOMPurify from 'dompurify';
import { runMain, type MainHost } from './main.ts';
import { installGraph } from './graph.ts';
import { installSecurityPolicy } from './sanitize-html.ts';
export interface BootHost extends MainHost {
SocialCalc: MainHost['SocialCalc'];
doresize?: () => void;
document?: {
addEventListener: (
type: string,
listener: (event: { target?: EventTarget | null }) => void,
) => void;
};
/**
* Test-only override for the URL-opener. In production `host` IS
* `window`, and `host.open === window.open` (the browser builtin),
* so the default production path — a synthetic `<a>` click —
* must NOT delegate to `host.open` (doing so triggers the popup
* blocker). Instead we expose a dedicated override field that tests
* set explicitly; production code ignores it.
*/
__exportOpen?: (url: string) => unknown;
/** @deprecated legacy alias — keep `window.open` addressable for type. */
open?: (url: string) => unknown;
parent?: {
location: {
href?: string;
pathname: string;
};
};
vex?: {
defaultOptions?: { className?: string };
dialog?: {
open: (opts: {
message: string;
callback?: () => void;
buttons: Array<Record<string, unknown>>;
}) => unknown;
buttons?: {
YES?: Record<string, unknown>;
NO?: Record<string, unknown>;
};
};
};
__ETHERCALC_EXPORT_BOUND__?: boolean;
/**
* Test-only override for the logo-click opener. Production uses a
* plain anchor click (see `installSocialCalcLogoLink`).
*/
__logoOpen?: (url: string) => unknown;
__ETHERCALC_LOGO_BOUND__?: boolean;
}
const EXPORT_SELECTOR = '.te_download tr:nth-child(2) td:first-child';
const LOGO_SELECTOR = 'td[id$="_logo"]';
// CPAL Exhibit B requires the logo to link to the Attribution URL. The
// license hard-codes http://www.socialcalc.org, but that domain has
// lapsed into scam territory. LEGAL.txt in the actively maintained fork
// carries the verbatim Attribution Copyright Notice + Attribution
// Phrase, so pointing there presents the mandated notice to the user in
// a comparable manner.
const SOCIALCALC_ATTRIBUTION_URL =
'https://github.com/audreyt/socialcalc/blob/main/LEGAL.txt';
type ExportFormat = 'xlsx' | 'csv' | 'html' | 'ods';
interface ExportCellLike {
closest?: (selector: string) => unknown;
setAttribute?: (name: string, value: string) => void;
}
function findExportCell(target: EventTarget | null | undefined): ExportCellLike | null {
if (!target || typeof target !== 'object') return null;
const element = target as ExportCellLike;
if (typeof element.closest !== 'function') return null;
const cell = element.closest(EXPORT_SELECTOR);
return cell && typeof cell === 'object' ? (cell as ExportCellLike) : null;
}
export function buildLegacyExportUrl(
format: ExportFormat,
room: string,
opts: {
isMultiple: boolean;
parentHref?: string | undefined;
parentPathname: string;
},
): string {
const relBase = /\/.*\/(?:view|edit)$/.test(opts.parentPathname) ? '..' : '.';
if ((format === 'xlsx' || format === 'ods') && opts.isMultiple) {
const hrefMatch = opts.parentHref ? /(^.*\/=[^?/]+)/.exec(opts.parentHref) : null;
if (hrefMatch) return `${hrefMatch[1]}.${format}`;
const baseRoom = room.replace(/\.[1-9]\d*$/, '');
return `${relBase}/=${baseRoom}.${format}`;
}
return `${relBase}/${room}.${format}`;
}
export function openLegacyExportDialog(host: BootHost): void {
const room = host.SocialCalc._room ?? '';
if (!room) return;
const vex =
host.vex ??
(
typeof window !== 'undefined'
? (window as Window & { vex?: BootHost['vex'] }).vex
: undefined
);
if (!vex?.dialog?.open) return;
// `window.parent.location` throws a SecurityError in cross-origin
// iframe embeds (Sandstorm, notion integrations, etc.). Fall back to
// the current document's location when that happens — the export-URL
// builder handles both shapes.
const readParentLocation = (): { href?: string; pathname: string } | undefined => {
if (host.parent?.location) return host.parent.location;
if (typeof window === 'undefined') return undefined;
try {
return window.parent.location;
} catch {
return undefined;
}
};
const parentLocation =
readParentLocation() ?? { pathname: host.location.pathname };
const multiRows =
(host as BootHost & { __MULTI__?: { rows?: unknown[] } }).__MULTI__?.rows ??
(typeof window !== 'undefined'
? (window as Window & { __MULTI__?: { rows?: unknown[] } }).__MULTI__?.rows
: undefined);
// Empty `rows: []` is truthy — must check length (#232 viewer export).
const isMultiple = (multiRows?.length ?? 0) > 0 || /\.[1-9]\d*$/.test(room);
// Production path: synthetic `<a>` click. `window.open()` is
// popup-blocked in Chrome when called from inside the vex-dialog
// button handler (it's one async tick removed from the direct user
// click, so Chrome's user-activation window has lapsed). Anchor
// clicks don't hit the popup blocker, and they also honor the
// server's `Content-Disposition` header: CSV/XLSX/ODS download
// (server sets attachment), HTML opens inline (server doesn't).
//
// Tests override via `host.__exportOpen`; we deliberately do NOT
// fall back to `host.open` because in production `host === window`
// and `window.open` is the popup-blocked path we're avoiding.
// Production download path.
//
// Why `fetch → blob → object URL → anchor click` instead of a plain
// anchor pointing at the server URL: Chrome's "automatic downloads"
// security policy blocks same-origin navigation/download anchors
// that fire after any async hop (our vex dialog button -> callback
// -> openFormat is two ticks past the direct user click, so the
// user-activation gate has already closed). Pulling the bytes via
// fetch and then clicking an anchor that points at a blob: URL
// bypasses that gate — the click is treated as a direct save of
// local data, not a new navigation. This works uniformly for all
// four formats (xlsx/ods are binary, csv/html are text) and
// honors the server's Content-Disposition filename.
const deriveFilename = (url: string, cd: string | null): string => {
const match = cd && /filename\*?="?([^";]+)"?/i.exec(cd);
if (match?.[1]) return match[1];
const tail = url.split('?')[0]?.split('/').pop() ?? 'export';
return tail;
};
const open =
host.__exportOpen ??
(typeof document !== 'undefined' && typeof fetch !== 'undefined'
? (url: string) => {
void (async () => {
const res = await fetch(url);
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = objectUrl;
a.download = deriveFilename(url, res.headers.get('Content-Disposition'));
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Revoke on next tick so the click's navigation has
// already kicked off. Immediate revoke would race.
setTimeout(() => URL.revokeObjectURL(objectUrl), 1_000);
})();
return undefined;
}
: undefined);
if (!open) return;
host.SocialCalc.Keyboard ??= {};
host.SocialCalc.Keyboard.passThru = true;
if (vex.defaultOptions) vex.defaultOptions.className = 'vex-theme-flat-attack';
const openFormat = (format: ExportFormat): void => {
open(
buildLegacyExportUrl(format, room, {
isMultiple,
parentHref: parentLocation.href,
parentPathname: parentLocation.pathname,
}),
);
};
const yes = vex.dialog.buttons?.YES ?? {};
const no = vex.dialog.buttons?.NO ?? {};
vex.dialog.open({
message:
(host.SocialCalc.Constants.s_loc_export_format ?? 'Please choose an export format.') +
(isMultiple ? '<br><small>(ODS and EXCEL support multiple sheets.)</small>' : ''),
callback: () => {
if (host.SocialCalc.Keyboard) host.SocialCalc.Keyboard.passThru = false;
},
buttons: [
{ ...yes, text: 'Excel', click: () => openFormat('xlsx') },
{ ...yes, text: 'CSV', click: () => openFormat('csv') },
{ ...yes, text: 'HTML', click: () => openFormat('html') },
{ ...yes, text: 'ODS', click: () => openFormat('ods') },
{ ...no, text: host.SocialCalc.Constants.s_loc_cancel ?? 'Cancel' },
],
});
}
function findLogoCell(target: EventTarget | null | undefined): unknown {
if (!target || typeof target !== 'object') return null;
const element = target as { closest?: (selector: string) => unknown };
if (typeof element.closest !== 'function') return null;
return element.closest(LOGO_SELECTOR);
}
export function installSocialCalcLogoLink(host: BootHost): void {
if (host.__ETHERCALC_LOGO_BOUND__) return;
const doc = host.document ?? (typeof document !== 'undefined' ? document : undefined);
if (!doc) return;
host.__ETHERCALC_LOGO_BOUND__ = true;
doc.addEventListener('click', (event) => {
if (!findLogoCell(event.target)) return;
const open =
host.__logoOpen ??
(typeof document !== 'undefined'
? (url: string) => {
const a = document.createElement('a');
a.href = url;
a.target = '_blank';
a.rel = 'noopener noreferrer';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
return undefined;
}
: undefined);
open?.(SOCIALCALC_ATTRIBUTION_URL);
});
}
export function installLegacyExportBindings(host: BootHost): void {
if (host.__ETHERCALC_EXPORT_BOUND__) return;
const doc = host.document ?? (typeof document !== 'undefined' ? document : undefined);
if (!doc) return;
host.__ETHERCALC_EXPORT_BOUND__ = true;
doc.addEventListener('mouseover', (event) => {
const cell = findExportCell(event.target);
if (!cell || typeof cell.setAttribute !== 'function') return;
cell.setAttribute('title', host.SocialCalc.Constants.s_loc_export ?? 'Export');
});
doc.addEventListener('click', (event) => {
if (!findExportCell(event.target)) return;
openLegacyExportDialog(host);
});
}
export function initializeSpreadsheet(host: BootHost): void {
const w = host as BootHost & Window;
const { SocialCalc } = host;
w.doresize = () => {
w.spreadsheet?.DoOnResize?.();
};
const existing = SocialCalc.CurrentSpreadsheetControlObject;
const ss =
existing ??
(
SocialCalc._view || SocialCalc._app
? SocialCalc.SpreadsheetViewer
? new SocialCalc.SpreadsheetViewer()
: undefined
: SocialCalc.SpreadsheetControl
? new SocialCalc.SpreadsheetControl()
: undefined
);
if (!ss) return;
w.spreadsheet = ss;
if (SocialCalc.Cell) {
const loaderCell = new SocialCalc.Cell('A1') as { displaystring?: string };
loaderCell.displaystring = '<div class="loader"></div>';
ss.sheet.cells['A1'] = loaderCell;
}
ss.InitializeSpreadsheetViewer?.('tableeditor', 0, 0, 0);
ss.InitializeSpreadsheetControl?.('tableeditor', 0, 0, 0);
if (!SocialCalc._view && ss.formDataViewer) {
const room = `${SocialCalc._room ?? ''}_formdata`;
ss.formDataViewer.sheet._room = room;
ss.formDataViewer._room = room;
SocialCalc.Callbacks.broadcast?.('ask.log', { room });
} else {
SocialCalc.Callbacks.broadcast?.('ask.log');
}
ss.ExecuteCommand?.('redisplay', '');
ss.ExecuteCommand?.('set sheet defaulttextvalueformat text-wiki');
installLegacyExportBindings(host);
installSocialCalcLogoLink(host);
}
async function autoBoot(): Promise<void> {
if (typeof document === 'undefined' || typeof window === 'undefined') return;
const w = window as unknown as BootHost;
if (!w.SocialCalc) return;
// Close the stored-XSS hole in the live editor: the served SocialCalc
// runtime renders `text-html` cell values straight into the cell div's
// innerHTML. Enable SocialCalc 3.1.0's built-in untrustedContent security
// model and wire DOMPurify as the sanitiser callback before any sheet data
// is parsed (see packages/client/src/sanitize-html.ts).
installSecurityPolicy(w.SocialCalc, DOMPurify);
installGraph({
SocialCalc: w.SocialCalc,
win: window as unknown as Parameters<typeof installGraph>[0]['win'],
doc: document,
});
const handle = runMain({ host: w });
if (!handle) return;
initializeSpreadsheet(w);
}
if (typeof document !== 'undefined' && typeof window !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => void autoBoot());
} else {
void autoBoot();
}
}