Address the non-test code-review findings on the htmlEmbed sandbox change (test-coverage gaps are tracked in issue #99): - html-embed-view: track the iframe's reported content height even while a fixed height is set, so clearing the height (fixed -> auto) without editing the source no longer leaves the frame pinned to the stale value. Derive the fixed-height predicate once; seed autoHeight to the default. - html-embed-view: drop width/border from the iframe inline style (the .htmlEmbedFrame CSS class already provides them). - html-embed-sandbox: coalesce height reports via requestAnimationFrame and skip <=1px deltas to damp the self-measure feedback loop; fix the misleading bootstrap comment. - tracker-settings: add an aria-label to the snippet Textarea (a11y). - CHANGELOG: note the removal of server-side role-based HTML-embed stripping.
216 lines
7.5 KiB
TypeScript
216 lines
7.5 KiB
TypeScript
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
|
import React, {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import clsx from "clsx";
|
|
import {
|
|
ActionIcon,
|
|
Button,
|
|
Group,
|
|
Modal,
|
|
NumberInput,
|
|
Text,
|
|
Textarea,
|
|
} from "@mantine/core";
|
|
import { IconCode, IconEdit } from "@tabler/icons-react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useAtomValue } from "jotai";
|
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
|
import classes from "./html-embed-view.module.css";
|
|
import {
|
|
buildSandboxSrcdoc,
|
|
canEdit as computeCanEdit,
|
|
HTML_EMBED_HEIGHT_MESSAGE,
|
|
shouldRender as computeShouldRender,
|
|
} from "./html-embed-sandbox.ts";
|
|
|
|
// Sane bounds for the auto-resized iframe so a runaway embed cannot blow up the
|
|
// page layout, and a sensible default before the first height message arrives.
|
|
const MIN_IFRAME_HEIGHT = 40;
|
|
const MAX_IFRAME_HEIGHT = 4000;
|
|
const DEFAULT_IFRAME_HEIGHT = 150;
|
|
|
|
// Clamp a reported/configured height into the sane iframe bounds.
|
|
const clampHeight = (h: number) =>
|
|
Math.min(MAX_IFRAME_HEIGHT, Math.max(MIN_IFRAME_HEIGHT, h));
|
|
|
|
export default function HtmlEmbedView(props: NodeViewProps) {
|
|
const { t } = useTranslation();
|
|
const { node, selected, updateAttributes, editor } = props;
|
|
const { source, height } = node.attrs as {
|
|
source: string;
|
|
height: number | null;
|
|
};
|
|
|
|
// The HTML embed renders inside a SANDBOXED iframe (no same-origin access), so
|
|
// the workspace toggle is a feature switch, not a security gate. When OFF (the
|
|
// default) we render a neutral placeholder in the editor and nothing else.
|
|
const workspace = useAtomValue(workspaceAtom);
|
|
const htmlEmbedEnabled = workspace?.settings?.htmlEmbed === true;
|
|
|
|
const shouldRender = computeShouldRender(
|
|
editor.isEditable,
|
|
htmlEmbedEnabled,
|
|
);
|
|
|
|
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [draft, setDraft] = useState<string>(source || "");
|
|
const [draftHeight, setDraftHeight] = useState<number | "">(height ?? "");
|
|
|
|
// True when the author pinned an explicit height; otherwise we auto-resize to
|
|
// the iframe's reported content height.
|
|
const hasFixedHeight = typeof height === "number" && Number.isFinite(height);
|
|
|
|
// Auto-resize height tracked in state. Seeded to the default and updated from
|
|
// the iframe's postMessage reports (see effect below) regardless of mode, so
|
|
// switching a fixed-height embed back to auto immediately reflects the last
|
|
// reported content height instead of staying pinned to the old fixed value.
|
|
const [autoHeight, setAutoHeight] = useState<number>(DEFAULT_IFRAME_HEIGHT);
|
|
|
|
const srcdoc = useMemo(() => buildSandboxSrcdoc(source || ""), [source]);
|
|
|
|
// Auto-resize: accept height messages ONLY from this iframe's own content
|
|
// window. The sandboxed srcdoc has an opaque ("null") origin, so we cannot
|
|
// match by event.origin — we match by event.source instead. We track the
|
|
// reported height even while a fixed height is in effect, so toggling back to
|
|
// auto shows the current content height with no iframe reload.
|
|
useEffect(() => {
|
|
function onMessage(event: MessageEvent) {
|
|
if (event.source !== iframeRef.current?.contentWindow) return;
|
|
const data = event.data as { type?: string; height?: number };
|
|
if (data?.type !== HTML_EMBED_HEIGHT_MESSAGE) return;
|
|
const next = Number(data.height);
|
|
if (!Number.isFinite(next)) return;
|
|
setAutoHeight(clampHeight(next));
|
|
}
|
|
window.addEventListener("message", onMessage);
|
|
return () => window.removeEventListener("message", onMessage);
|
|
}, []);
|
|
|
|
const effectiveHeight = hasFixedHeight ? clampHeight(height) : autoHeight;
|
|
|
|
const openEditor = useCallback(() => {
|
|
setDraft(source || "");
|
|
setDraftHeight(height ?? "");
|
|
setModalOpen(true);
|
|
}, [source, height]);
|
|
|
|
const onSave = useCallback(() => {
|
|
if (editor.isEditable) {
|
|
updateAttributes({
|
|
source: draft,
|
|
height: draftHeight === "" ? null : Number(draftHeight),
|
|
});
|
|
}
|
|
setModalOpen(false);
|
|
}, [draft, draftHeight, editor.isEditable, updateAttributes]);
|
|
|
|
// The edit affordance is only meaningful in edit mode and is offered only when
|
|
// the workspace master toggle is ON. Any member can edit (sandboxed = safe).
|
|
const canEdit = computeCanEdit(editor.isEditable, htmlEmbedEnabled);
|
|
|
|
return (
|
|
<NodeViewWrapper
|
|
data-drag-handle
|
|
className={clsx(classes.htmlEmbedNodeView, {
|
|
[classes.htmlEmbedSelected]: selected,
|
|
})}
|
|
>
|
|
{canEdit && (
|
|
<div className={classes.htmlEmbedToolbar}>
|
|
<ActionIcon
|
|
variant="default"
|
|
size="sm"
|
|
aria-label={t("Edit HTML embed")}
|
|
onClick={openEditor}
|
|
>
|
|
<IconEdit size={16} />
|
|
</ActionIcon>
|
|
</div>
|
|
)}
|
|
|
|
{!shouldRender ? (
|
|
// Feature disabled for this workspace AND we're in the editable editor:
|
|
// render a neutral placeholder so an existing embed is visibly inert for
|
|
// the author. Read-only / share viewers never hit this branch
|
|
// (`shouldRender` is always true there) — they render exactly the
|
|
// source the server chose to serve.
|
|
<div className={classes.htmlEmbedPlaceholder}>
|
|
<IconCode size={18} />
|
|
<Text size="sm">
|
|
{t("HTML embed is disabled in this workspace")}
|
|
</Text>
|
|
</div>
|
|
) : source ? (
|
|
// Raw HTML/CSS/JS rendered inside a sandboxed iframe (no same-origin):
|
|
// scripts run in an opaque origin and cannot touch the viewer's
|
|
// session/cookies/API.
|
|
<iframe
|
|
ref={iframeRef}
|
|
className={classes.htmlEmbedFrame}
|
|
sandbox="allow-scripts allow-popups allow-forms"
|
|
srcDoc={srcdoc}
|
|
title={t("HTML embed")}
|
|
referrerPolicy="no-referrer"
|
|
style={{ height: effectiveHeight }}
|
|
/>
|
|
) : canEdit ? (
|
|
<div className={classes.htmlEmbedPlaceholder} onClick={openEditor}>
|
|
<IconCode size={18} />
|
|
<Text size="sm">{t("Click to add HTML / CSS / JS")}</Text>
|
|
</div>
|
|
) : (
|
|
// Empty source, non-editor: render nothing visible.
|
|
<div className={classes.htmlEmbedContent} />
|
|
)}
|
|
|
|
<Modal
|
|
opened={modalOpen}
|
|
onClose={() => setModalOpen(false)}
|
|
title={t("Edit HTML embed")}
|
|
size="lg"
|
|
>
|
|
<Text size="xs" c="dimmed" mb="xs">
|
|
{t(
|
|
"This HTML/CSS/JS runs in a sandboxed frame and cannot access the viewer's session, cookies, or API.",
|
|
)}
|
|
</Text>
|
|
<Textarea
|
|
autosize
|
|
minRows={10}
|
|
maxRows={24}
|
|
value={draft}
|
|
onChange={(e) => setDraft(e.currentTarget.value)}
|
|
placeholder={t("<script>...</script>")}
|
|
styles={{ input: { fontFamily: "monospace" } }}
|
|
data-autofocus
|
|
/>
|
|
<NumberInput
|
|
mt="md"
|
|
label={t("Height (px, blank = auto)")}
|
|
value={draftHeight}
|
|
onChange={(value) =>
|
|
setDraftHeight(
|
|
value === "" || value === null ? "" : Number(value),
|
|
)
|
|
}
|
|
min={MIN_IFRAME_HEIGHT}
|
|
max={MAX_IFRAME_HEIGHT}
|
|
allowDecimal={false}
|
|
/>
|
|
<Group justify="flex-end" mt="md">
|
|
<Button variant="default" onClick={() => setModalOpen(false)}>
|
|
{t("Cancel")}
|
|
</Button>
|
|
<Button onClick={onSave}>{t("Save")}</Button>
|
|
</Group>
|
|
</Modal>
|
|
</NodeViewWrapper>
|
|
);
|
|
}
|