feat(html-embed): sandbox the embed block; split trusted trackers into an admin field
Convert the htmlEmbed node from same-origin raw-HTML execution to a sandboxed iframe (sandbox="allow-scripts allow-popups allow-forms", no allow-same-origin, srcdoc) with postMessage auto-resize (validated by event.source) and an optional manual height attr. The block now runs in an opaque origin and cannot reach the viewer's cookies/session/API, so it is safe for any member. Because the block is now harmless, remove the entire admin/role gating apparatus: drop htmlEmbedAllowed/canAuthorHtmlEmbed/stripDisallowedHtmlEmbedNodes/ collectHtmlEmbedSources and every role-based strip on the write paths (collab REST/MCP + socket, page create/duplicate, import x2, transclusion unsync), along with the now-unused WorkspaceRepo/UserRepo injections and the PageService.create callerRole param. Keep one strip: prepareContentForShare still removes htmlEmbed on the anonymous public-share read path when the workspace master toggle is OFF. The workspace settings.htmlEmbed toggle is now a plain feature switch (gates the slash-menu and share rendering); when ON the block is available to all members. Add settings.trackerHead: an admin-only raw HTML/JS analytics snippet injected verbatim into the <head> of public share pages only (ShareSeoController), for trackers that genuinely need same-origin. Admin-gated via the existing CASL Manage/Settings ability; never injected into the authenticated app shell. Closes security-review findings #1, #2, #4, #5, #10 (and #3 as a security issue). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,11 +2,18 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* The container the raw source is injected into. */
|
||||
/* Fallback container used only for the empty, non-editor case. */
|
||||
.htmlEmbedContent {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* The sandboxed iframe the embed source is rendered into. */
|
||||
.htmlEmbedFrame {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Edit affordance overlay, only shown while editing the document. */
|
||||
.htmlEmbedToolbar {
|
||||
position: absolute;
|
||||
|
||||
@@ -1,85 +1,114 @@
|
||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "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 useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import classes from "./html-embed-view.module.css";
|
||||
import {
|
||||
buildSandboxSrcdoc,
|
||||
canEdit as computeCanEdit,
|
||||
renderRawHtml,
|
||||
HTML_EMBED_HEIGHT_MESSAGE,
|
||||
shouldExecute as computeShouldExecute,
|
||||
} from "./render-raw-html.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;
|
||||
|
||||
export default function HtmlEmbedView(props: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { node, selected, updateAttributes, editor } = props;
|
||||
const { source } = node.attrs as { source: string };
|
||||
const { isAdmin } = useUserRole();
|
||||
const { source, height } = node.attrs as {
|
||||
source: string;
|
||||
height: number | null;
|
||||
};
|
||||
|
||||
// Defense in depth: only execute the raw HTML/JS when the workspace HTML embed
|
||||
// feature toggle is ON. When OFF (the default), we render a neutral disabled
|
||||
// placeholder and inject nothing — so turning the feature off neutralizes
|
||||
// existing embeds at render time as well as on the next server-side save.
|
||||
// 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;
|
||||
|
||||
// Execution policy split by editor mode:
|
||||
// - READ-ONLY / public-share view: the SERVER already decided whether to
|
||||
// include the embed (it strips htmlEmbed from shared content when the
|
||||
// workspace toggle is OFF). An anonymous viewer has no workspace and thus
|
||||
// reads `htmlEmbedEnabled` as false, so we must NOT gate execution on it
|
||||
// here — we execute exactly the `source` the server chose to serve.
|
||||
// - EDITABLE editor (admin authoring): keep gating on the per-workspace
|
||||
// toggle so an admin sees the inert placeholder when the feature is OFF.
|
||||
const shouldExecute = computeShouldExecute(
|
||||
editor.isEditable,
|
||||
htmlEmbedEnabled,
|
||||
);
|
||||
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [draft, setDraft] = useState<string>(source || "");
|
||||
const [draftHeight, setDraftHeight] = useState<number | "">(height ?? "");
|
||||
|
||||
// (Re)render the raw source whenever it changes. This runs in BOTH the
|
||||
// editable editor and the read-only / public-share editor (same NodeView),
|
||||
// so trackers fire for readers too — that is the intended behaviour. When the
|
||||
// feature toggle is OFF we clear the container and inject/execute nothing.
|
||||
// Auto-resize height tracked in state (used only when no fixed height is set).
|
||||
const [autoHeight, setAutoHeight] = useState<number>(
|
||||
height ?? 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. No-op when a
|
||||
// fixed height is configured.
|
||||
useEffect(() => {
|
||||
if (!contentRef.current) return;
|
||||
if (shouldExecute) {
|
||||
renderRawHtml(contentRef.current, source || "");
|
||||
} else {
|
||||
contentRef.current.innerHTML = "";
|
||||
if (typeof height === "number") return;
|
||||
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(
|
||||
Math.min(MAX_IFRAME_HEIGHT, Math.max(MIN_IFRAME_HEIGHT, next)),
|
||||
);
|
||||
}
|
||||
}, [source, shouldExecute]);
|
||||
window.addEventListener("message", onMessage);
|
||||
return () => window.removeEventListener("message", onMessage);
|
||||
}, [height]);
|
||||
|
||||
const effectiveHeight =
|
||||
typeof height === "number"
|
||||
? Math.min(MAX_IFRAME_HEIGHT, Math.max(MIN_IFRAME_HEIGHT, height))
|
||||
: autoHeight;
|
||||
|
||||
const openEditor = useCallback(() => {
|
||||
setDraft(source || "");
|
||||
setDraftHeight(height ?? "");
|
||||
setModalOpen(true);
|
||||
}, [source]);
|
||||
}, [source, height]);
|
||||
|
||||
const onSave = useCallback(() => {
|
||||
if (editor.isEditable) {
|
||||
updateAttributes({ source: draft });
|
||||
updateAttributes({
|
||||
source: draft,
|
||||
height: draftHeight === "" ? null : Number(draftHeight),
|
||||
});
|
||||
}
|
||||
setModalOpen(false);
|
||||
}, [draft, editor.isEditable, updateAttributes]);
|
||||
}, [draft, draftHeight, editor.isEditable, updateAttributes]);
|
||||
|
||||
// The edit affordance is only meaningful in edit mode, is restricted to admins
|
||||
// (the server strips the node for non-admins anyway), and is offered only when
|
||||
// the workspace feature toggle is ON.
|
||||
const canEdit = computeCanEdit(editor.isEditable, isAdmin, htmlEmbedEnabled);
|
||||
// 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
|
||||
@@ -103,10 +132,10 @@ export default function HtmlEmbedView(props: NodeViewProps) {
|
||||
|
||||
{!shouldExecute ? (
|
||||
// Feature disabled for this workspace AND we're in the editable editor:
|
||||
// never inject/execute the source. Show a neutral placeholder so an
|
||||
// existing embed is visibly inert for the authoring admin. Read-only /
|
||||
// share viewers never hit this branch (`shouldExecute` is always true
|
||||
// there) — they execute exactly the source the server chose to serve.
|
||||
// render a neutral placeholder so an existing embed is visibly inert for
|
||||
// the author. Read-only / share viewers never hit this branch
|
||||
// (`shouldExecute` is always true there) — they render exactly the
|
||||
// source the server chose to serve.
|
||||
<div className={classes.htmlEmbedPlaceholder}>
|
||||
<IconCode size={18} />
|
||||
<Text size="sm">
|
||||
@@ -114,9 +143,18 @@ export default function HtmlEmbedView(props: NodeViewProps) {
|
||||
</Text>
|
||||
</div>
|
||||
) : source ? (
|
||||
// Raw HTML/CSS/JS rendered into the wiki origin. Scripts are re-created
|
||||
// in renderRawHtml so they execute.
|
||||
<div ref={contentRef} className={classes.htmlEmbedContent} />
|
||||
// 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="HTML embed"
|
||||
referrerPolicy="no-referrer"
|
||||
style={{ width: "100%", border: "none", height: effectiveHeight }}
|
||||
/>
|
||||
) : canEdit ? (
|
||||
<div className={classes.htmlEmbedPlaceholder} onClick={openEditor}>
|
||||
<IconCode size={18} />
|
||||
@@ -124,7 +162,7 @@ export default function HtmlEmbedView(props: NodeViewProps) {
|
||||
</div>
|
||||
) : (
|
||||
// Empty source, non-editor: render nothing visible.
|
||||
<div ref={contentRef} className={classes.htmlEmbedContent} />
|
||||
<div className={classes.htmlEmbedContent} />
|
||||
)}
|
||||
|
||||
<Modal
|
||||
@@ -135,7 +173,7 @@ export default function HtmlEmbedView(props: NodeViewProps) {
|
||||
>
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{t(
|
||||
"This HTML/CSS/JS runs in the page origin for everyone who views it. Admins only.",
|
||||
"This HTML/CSS/JS runs in a sandboxed frame and cannot access the viewer's session, cookies, or API.",
|
||||
)}
|
||||
</Text>
|
||||
<Textarea
|
||||
@@ -148,6 +186,19 @@ export default function HtmlEmbedView(props: NodeViewProps) {
|
||||
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")}
|
||||
|
||||
@@ -1,112 +1,63 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { renderRawHtml, shouldExecute, canEdit } from "./render-raw-html";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildSandboxSrcdoc,
|
||||
canEdit,
|
||||
HTML_EMBED_HEIGHT_MESSAGE,
|
||||
shouldExecute,
|
||||
} from "./render-raw-html";
|
||||
|
||||
// jsdom does NOT execute <script> nodes unless its instance was created with
|
||||
// `runScripts: "dangerously"`. The whole point of renderRawHtml is to make
|
||||
// re-created scripts run, so the execution tests drive a dedicated script-
|
||||
// running JSDOM and pass it a container from THAT document (renderRawHtml uses
|
||||
// `container.ownerDocument`, so it creates the fresh scripts in the running
|
||||
// instance). The default vitest jsdom (no runScripts) is used for the
|
||||
// structural and policy assertions.
|
||||
describe("renderRawHtml (script execution against a runScripts jsdom)", () => {
|
||||
let dom: JSDOM;
|
||||
let container: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
runScripts: "dangerously",
|
||||
});
|
||||
container = dom.window.document.createElement("div");
|
||||
dom.window.document.body.appendChild(container);
|
||||
describe("buildSandboxSrcdoc", () => {
|
||||
it("embeds the user source verbatim", () => {
|
||||
const out = buildSandboxSrcdoc("<div id='x'>hello</div>");
|
||||
expect(out).toContain("<div id='x'>hello</div>");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dom.window.close();
|
||||
it("injects the height-postMessage bootstrap after the source", () => {
|
||||
const out = buildSandboxSrcdoc("<p>body</p>");
|
||||
// The bootstrap is appended AFTER the source.
|
||||
expect(out.indexOf("<p>body</p>")).toBeLessThan(
|
||||
out.indexOf(HTML_EMBED_HEIGHT_MESSAGE),
|
||||
);
|
||||
// It reports its height to the parent via postMessage with the agreed type.
|
||||
expect(out).toContain("parent.postMessage");
|
||||
expect(out).toContain(HTML_EMBED_HEIGHT_MESSAGE);
|
||||
// It observes resizes so the parent can keep the iframe sized to fit.
|
||||
expect(out).toContain("ResizeObserver");
|
||||
expect(out).toContain('addEventListener("load"');
|
||||
});
|
||||
|
||||
it("re-creates and executes an inline <script> (observable side effect)", () => {
|
||||
renderRawHtml(
|
||||
container,
|
||||
"<div>hello</div><script>window.__htmlEmbedFlag = true;</script>",
|
||||
);
|
||||
// The re-created inline script ran inside the jsdom window.
|
||||
expect((dom.window as unknown as Record<string, unknown>).__htmlEmbedFlag).toBe(
|
||||
true,
|
||||
);
|
||||
// The non-script markup is preserved.
|
||||
expect(container.querySelector("div")?.textContent).toBe("hello");
|
||||
});
|
||||
|
||||
it("copies src/async/defer onto a re-created external <script src>", () => {
|
||||
renderRawHtml(
|
||||
container,
|
||||
'<script src="https://example.com/t.js" async defer></script>',
|
||||
);
|
||||
const script = container.querySelector("script");
|
||||
expect(script).not.toBeNull();
|
||||
expect(script?.getAttribute("src")).toBe("https://example.com/t.js");
|
||||
expect(script?.hasAttribute("async")).toBe(true);
|
||||
expect(script?.hasAttribute("defer")).toBe(true);
|
||||
});
|
||||
|
||||
it("clears the container when the source is empty", () => {
|
||||
container.innerHTML = "<p>stale</p>";
|
||||
renderRawHtml(container, "");
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("clears prior content first on a re-render with new source", () => {
|
||||
const win = dom.window as unknown as Record<string, unknown>;
|
||||
renderRawHtml(
|
||||
container,
|
||||
"<span id='first'>one</span><script>window.__htmlEmbedCount = 1;</script>",
|
||||
);
|
||||
expect(win.__htmlEmbedCount).toBe(1);
|
||||
expect(container.querySelector("#first")).not.toBeNull();
|
||||
|
||||
renderRawHtml(
|
||||
container,
|
||||
"<span id='second'>two</span><script>window.__htmlEmbedCount = 2;</script>",
|
||||
);
|
||||
// Prior content is gone; only the new render remains.
|
||||
expect(container.querySelector("#first")).toBeNull();
|
||||
expect(container.querySelector("#second")).not.toBeNull();
|
||||
expect(win.__htmlEmbedCount).toBe(2);
|
||||
it("handles an empty source (still injects the bootstrap)", () => {
|
||||
const out = buildSandboxSrcdoc("");
|
||||
expect(out).toContain(HTML_EMBED_HEIGHT_MESSAGE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldExecute (execution policy)", () => {
|
||||
it("read-only executes regardless of the workspace toggle", () => {
|
||||
describe("shouldExecute (render policy)", () => {
|
||||
it("read-only renders regardless of the workspace toggle", () => {
|
||||
// isEditable=false → the server already gated the content.
|
||||
expect(shouldExecute(false, false)).toBe(true);
|
||||
expect(shouldExecute(false, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("editable + toggle OFF does NOT execute", () => {
|
||||
it("editable + toggle OFF does NOT render", () => {
|
||||
expect(shouldExecute(true, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("editable + toggle ON executes", () => {
|
||||
it("editable + toggle ON renders", () => {
|
||||
expect(shouldExecute(true, true)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canEdit (edit policy)", () => {
|
||||
it("a member (non-admin) can never edit", () => {
|
||||
expect(canEdit(true, false, true)).toBe(false);
|
||||
expect(canEdit(false, false, true)).toBe(false);
|
||||
it("any member can edit when editable and the toggle is ON (no admin gate)", () => {
|
||||
expect(canEdit(true, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("an admin with the toggle OFF cannot edit", () => {
|
||||
expect(canEdit(true, true, false)).toBe(false);
|
||||
it("cannot edit when the toggle is OFF", () => {
|
||||
expect(canEdit(true, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("an admin with the toggle ON in editable mode can edit", () => {
|
||||
expect(canEdit(true, true, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("an admin in read-only mode cannot edit (no edit affordance)", () => {
|
||||
expect(canEdit(false, true, true)).toBe(false);
|
||||
it("cannot edit in read-only mode (no edit affordance)", () => {
|
||||
expect(canEdit(false, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,56 +1,64 @@
|
||||
/**
|
||||
* Pure DOM helpers for the HTML embed node view. Kept out of the React
|
||||
* component so the script re-creation/execution mechanism and the execution/
|
||||
* edit policy can be unit-tested against a bare jsdom container with no
|
||||
* Tiptap/Mantine providers.
|
||||
* Pure helpers for the HTML embed node view. Kept out of the React component so
|
||||
* the sandbox srcdoc builder and the execution/edit policy can be unit-tested
|
||||
* against a bare environment with no Tiptap/Mantine providers.
|
||||
*/
|
||||
|
||||
/** postMessage type the sandboxed iframe uses to report its content height. */
|
||||
export const HTML_EMBED_HEIGHT_MESSAGE = "gitmost-html-embed-height";
|
||||
|
||||
/**
|
||||
* Inject raw HTML (including <script> tags) into `container`, executing any
|
||||
* scripts.
|
||||
* Build the `srcdoc` document for the sandboxed embed iframe.
|
||||
*
|
||||
* Setting `innerHTML` does NOT run inline or external <script> tags the browser
|
||||
* parses that way: the HTML spec marks scripts inserted via innerHTML as
|
||||
* "already started" so they never execute. To get the tracker/analytics
|
||||
* use-case working we walk the freshly-parsed scripts and replace each with a
|
||||
* brand-new <script> element copying its attributes and inline code. A
|
||||
* programmatically created+inserted <script> DOES execute, so this restores
|
||||
* normal script behaviour in the wiki origin (Variant C).
|
||||
* The user's `source` is placed verbatim, then a small bootstrap <script> is
|
||||
* appended at the end of the body. The iframe is rendered with a sandbox that
|
||||
* does NOT include `allow-same-origin`, so this content runs in an opaque
|
||||
* ("null") origin and cannot read the viewer's cookies/session/API — it is
|
||||
* harmless. The bootstrap measures the document height and reports it to the
|
||||
* parent via postMessage on load and whenever the content resizes, so the
|
||||
* parent can size the iframe to fit (auto-resize mode).
|
||||
*/
|
||||
export function renderRawHtml(container: HTMLElement, source: string): void {
|
||||
// Clear any previous render (re-render on source change).
|
||||
container.innerHTML = "";
|
||||
if (!source) return;
|
||||
|
||||
container.innerHTML = source;
|
||||
|
||||
// Use the container's own document so the helper works against any document
|
||||
// (the live page or a standalone jsdom instance in tests), not just the
|
||||
// ambient global `document`.
|
||||
const doc = container.ownerDocument;
|
||||
const scripts = Array.from(container.querySelectorAll("script"));
|
||||
for (const oldScript of scripts) {
|
||||
const newScript = doc.createElement("script");
|
||||
// Copy every attribute (src, type, async, defer, data-*, etc.).
|
||||
for (const attr of Array.from(oldScript.attributes)) {
|
||||
newScript.setAttribute(attr.name, attr.value);
|
||||
export function buildSandboxSrcdoc(source: string): string {
|
||||
const bootstrap = `
|
||||
<script>
|
||||
(function () {
|
||||
function reportHeight() {
|
||||
var doc = document.documentElement;
|
||||
var body = document.body;
|
||||
var height = Math.max(
|
||||
doc ? doc.scrollHeight : 0,
|
||||
body ? body.scrollHeight : 0
|
||||
);
|
||||
parent.postMessage(
|
||||
{ type: ${JSON.stringify(HTML_EMBED_HEIGHT_MESSAGE)}, height: height },
|
||||
"*"
|
||||
);
|
||||
}
|
||||
// Copy inline code.
|
||||
newScript.text = oldScript.textContent ?? "";
|
||||
// Replacing the node in place triggers execution.
|
||||
oldScript.parentNode?.replaceChild(newScript, oldScript);
|
||||
}
|
||||
window.addEventListener("load", reportHeight);
|
||||
// Report immediately too, in case load already fired.
|
||||
reportHeight();
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
try {
|
||||
var ro = new ResizeObserver(reportHeight);
|
||||
ro.observe(document.documentElement);
|
||||
} catch (e) {
|
||||
// ResizeObserver unavailable/failed: the load handler still reports once.
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>`;
|
||||
return `${source || ""}${bootstrap}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execution policy split by editor mode:
|
||||
* - READ-ONLY / public-share view: the SERVER already decided whether to
|
||||
* include the embed (it strips htmlEmbed from shared content when the
|
||||
* workspace toggle is OFF). An anonymous viewer has no workspace and thus
|
||||
* reads `featureEnabled` as false, so we must NOT gate execution on it here
|
||||
* — we execute exactly the `source` the server chose to serve.
|
||||
* - EDITABLE editor (admin authoring): keep gating on the per-workspace toggle
|
||||
* so an admin sees the inert placeholder when the feature is OFF.
|
||||
* workspace master toggle is OFF). An anonymous viewer has no workspace and
|
||||
* thus reads `featureEnabled` as false, so we must NOT gate rendering on it
|
||||
* here — we render exactly the `source` the server chose to serve.
|
||||
* - EDITABLE editor: gate on the per-workspace master toggle so an author sees
|
||||
* the inert placeholder when the feature is OFF.
|
||||
*/
|
||||
export function shouldExecute(
|
||||
isEditable: boolean,
|
||||
@@ -60,14 +68,11 @@ export function shouldExecute(
|
||||
}
|
||||
|
||||
/**
|
||||
* The edit affordance is only meaningful in edit mode, is restricted to admins
|
||||
* (the server strips the node for non-admins anyway), and is offered only when
|
||||
* the workspace feature toggle is ON.
|
||||
* The edit affordance is only meaningful in edit mode and is offered only when
|
||||
* the workspace master toggle is ON. The block renders in a sandboxed iframe
|
||||
* (no same-origin access), so authoring is allowed to ANY member — there is no
|
||||
* admin requirement.
|
||||
*/
|
||||
export function canEdit(
|
||||
isEditable: boolean,
|
||||
isAdmin: boolean,
|
||||
featureEnabled: boolean,
|
||||
): boolean {
|
||||
return isEditable && isAdmin && featureEnabled;
|
||||
export function canEdit(isEditable: boolean, featureEnabled: boolean): boolean {
|
||||
return isEditable && featureEnabled;
|
||||
}
|
||||
|
||||
@@ -623,10 +623,9 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
},
|
||||
{
|
||||
title: "HTML embed",
|
||||
description: "Embed raw HTML, CSS and JavaScript (admins only).",
|
||||
description: "Embed raw HTML, CSS and JavaScript (sandboxed).",
|
||||
searchTerms: ["html", "css", "js", "javascript", "script", "tracker", "analytics", "raw", "embed"],
|
||||
icon: IconCode,
|
||||
adminOnly: true,
|
||||
requiresHtmlEmbedFeature: true,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor
|
||||
@@ -795,30 +794,12 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Read whether the current user is a workspace admin/owner from the persisted
|
||||
* `currentUser` (the same payload `currentUserAtom` stores via localStorage).
|
||||
* Used to hide admin-only slash items (e.g. raw HTML embed). This is a UI gate
|
||||
* only; the server independently strips admin-only nodes from non-admin writes.
|
||||
*/
|
||||
function isCurrentUserAdmin(): boolean {
|
||||
try {
|
||||
const raw = localStorage.getItem("currentUser");
|
||||
if (!raw) return false;
|
||||
const parsed = JSON.parse(raw);
|
||||
const role = parsed?.user?.role;
|
||||
return role === "owner" || role === "admin";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the workspace-level HTML embed feature toggle from the persisted
|
||||
* Read the workspace-level HTML embed master toggle from the persisted
|
||||
* `currentUser` payload (the same localStorage entry `currentUserAtom` writes,
|
||||
* carrying `workspace.settings`). ABSENT/false => OFF (the default). The slash
|
||||
* `getSuggestionItems` is a plain function (no React/atom context), so we read
|
||||
* the persisted state the same way `isCurrentUserAdmin()` does. UI gate only;
|
||||
* the server independently strips htmlEmbed from every non-allowed write.
|
||||
* the persisted state directly. UI gate only; an anonymous public-share read is
|
||||
* served already-stripped content by the server when the toggle is OFF.
|
||||
*/
|
||||
function isHtmlEmbedFeatureEnabled(): boolean {
|
||||
try {
|
||||
@@ -840,7 +821,6 @@ export const getSuggestionItems = ({
|
||||
}): SlashMenuGroupedItemsType => {
|
||||
const search = query.toLowerCase();
|
||||
const filteredGroups: SlashMenuGroupedItemsType = {};
|
||||
const isAdmin = isCurrentUserAdmin();
|
||||
const htmlEmbedFeatureEnabled = isHtmlEmbedFeatureEnabled();
|
||||
|
||||
const fuzzyMatch = (query: string, target: string) => {
|
||||
@@ -856,9 +836,7 @@ export const getSuggestionItems = ({
|
||||
for (const [group, items] of Object.entries(CommandGroups)) {
|
||||
const filteredItems = items.filter((item) => {
|
||||
if (excludeItems?.has(item.title)) return false;
|
||||
// Hide admin-only items (raw HTML embed) from non-admins.
|
||||
if (item.adminOnly && !isAdmin) return false;
|
||||
// Hide HTML-embed-gated items unless the workspace feature toggle is ON.
|
||||
// Hide the HTML embed item unless the workspace master toggle is ON.
|
||||
if (item.requiresHtmlEmbedFeature && !htmlEmbedFeatureEnabled)
|
||||
return false;
|
||||
return (
|
||||
|
||||
@@ -21,13 +21,9 @@ export type SlashMenuItemType = {
|
||||
searchTerms: string[];
|
||||
command: (props: CommandProps) => void;
|
||||
disable?: (editor: ReturnType<typeof useEditor>) => boolean;
|
||||
// When true, the item is only offered to workspace admins/owners. This is a
|
||||
// UI convenience only — the real authoring gate is enforced server-side.
|
||||
adminOnly?: boolean;
|
||||
// When true, the item is hidden unless the workspace HTML embed feature toggle
|
||||
// is ON. Combined with adminOnly, the item shows only for admins in workspaces
|
||||
// where the feature is enabled. UI gate only — the server strips htmlEmbed on
|
||||
// every write where the toggle is OFF or the user is not an admin.
|
||||
// When true, the item is hidden unless the workspace HTML embed master toggle
|
||||
// is ON. UI gate only — for anonymous public-share reads the server serves
|
||||
// already-stripped content when the toggle is OFF.
|
||||
requiresHtmlEmbedFeature?: boolean;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user