Merge remote-tracking branch 'gitea/develop' into fix/review-batch-2

# Conflicts:
#	AGENTS.md
#	CHANGELOG.md
#	README.md
#	apps/server/src/collaboration/collaboration.handler.ts
#	apps/server/src/common/helpers/prosemirror/html-embed.spec.ts
#	apps/server/src/common/helpers/prosemirror/html-embed.util.ts
#	apps/server/src/core/ai-chat/public-share-chat.service.ts
#	apps/server/src/core/ai-chat/public-share-chat.spec.ts
#	apps/server/src/core/ai-chat/public-share-workspace-limiter.ts
#	apps/server/src/core/page/services/page.service.ts
#	apps/server/src/core/page/transclusion/transclusion.service.ts
#	apps/server/src/integrations/import/services/file-import-task.service.ts
#	apps/server/src/integrations/import/services/import.service.ts
This commit is contained in:
claude code agent 227
2026-06-21 05:32:44 +03:00
65 changed files with 1448 additions and 2927 deletions

View File

@@ -22,6 +22,11 @@ interface MessageItemProps {
* UUIDs/routes in the assistant's markdown don't leak as clickable links.
*/
neutralizeInternalLinks?: boolean;
/**
* Display name for the dimmed assistant label. Defaults to "AI agent" when
* absent; the public share passes the configured identity (agent role) name.
*/
assistantName?: string;
}
/**
@@ -40,6 +45,7 @@ export default function MessageItem({
message,
showCitations = true,
neutralizeInternalLinks = false,
assistantName,
}: MessageItemProps) {
const { t } = useTranslation();
const isUser = message.role === "user";
@@ -61,7 +67,7 @@ export default function MessageItem({
return (
<Box className={classes.messageRow}>
<Text size="xs" c="dimmed" mb={4}>
{t("AI agent")}
{assistantName?.trim() || t("AI agent")}
</Text>
{message.parts.map((part, index) => {
if (part.type === "text") {

View File

@@ -30,6 +30,12 @@ interface MessageListProps {
* UUIDs/routes don't leak as clickable links to anonymous readers.
*/
neutralizeInternalLinks?: boolean;
/**
* Display name for the assistant's dimmed row label and typing indicator.
* Defaults to "AI agent" when absent. The public share passes the configured
* identity (agent role) name; the internal chat omits it.
*/
assistantName?: string;
}
// Distance (px) from the bottom within which the viewport still counts as
@@ -67,6 +73,7 @@ export default function MessageList({
emptyState,
showCitations = true,
neutralizeInternalLinks = false,
assistantName,
}: MessageListProps) {
const { t } = useTranslation();
const viewportRef = useRef<HTMLDivElement>(null);
@@ -148,9 +155,10 @@ export default function MessageList({
message={message}
showCitations={showCitations}
neutralizeInternalLinks={neutralizeInternalLinks}
assistantName={assistantName}
/>
))}
{typing && <TypingIndicator />}
{typing && <TypingIndicator assistantName={assistantName} />}
</Stack>
</ScrollArea>
);

View File

@@ -2,22 +2,33 @@ import { Box, Group, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
interface TypingIndicatorProps {
/**
* Display name for the dimmed label and the "… is typing…" line. Defaults to
* "AI agent" when absent; the public share passes the configured identity
* (agent role) name.
*/
assistantName?: string;
}
/**
* Live "AI agent is typing…" placeholder shown while a turn is in flight but the
* latest assistant message has no visible content yet (no rendered text/tool
* parts). It covers the gap between sending and the first streamed token, and is
* replaced by the real assistant message once content starts arriving.
* Live " is typing…" placeholder shown while a turn is in flight but the latest
* assistant message has no visible content yet (no rendered text/tool parts). It
* covers the gap between sending and the first streamed token, and is replaced by
* the real assistant message once content starts arriving.
*
* Mirrors the assistant row layout in MessageItem (the dimmed "AI agent" label),
* so it reads as the assistant's bubble taking shape.
* Mirrors the assistant row layout in MessageItem (the dimmed label), so it reads
* as the assistant's bubble taking shape. The label and typing line use the
* configured identity name when provided, otherwise the generic "AI agent".
*/
export default function TypingIndicator() {
export default function TypingIndicator({ assistantName }: TypingIndicatorProps) {
const { t } = useTranslation();
const name = assistantName?.trim();
return (
<Box className={classes.messageRow}>
<Text size="xs" c="dimmed" mb={4}>
{t("AI agent")}
{name || t("AI agent")}
</Text>
<Group gap={8} align="center">
<span className={classes.typingDots} aria-hidden="true">
@@ -26,7 +37,7 @@ export default function TypingIndicator() {
<span />
</span>
<Text size="sm" c="dimmed">
{t("AI agent is typing…")}
{name ? t("{{name}} is typing…", { name }) : t("AI agent is typing…")}
</Text>
</Group>
</Box>

View File

@@ -0,0 +1,63 @@
import { describe, it, expect } from "vitest";
import {
buildSandboxSrcdoc,
canEdit,
HTML_EMBED_HEIGHT_MESSAGE,
shouldRender,
} from "./html-embed-sandbox";
describe("buildSandboxSrcdoc", () => {
it("embeds the user source verbatim", () => {
const out = buildSandboxSrcdoc("<div id='x'>hello</div>");
expect(out).toContain("<div id='x'>hello</div>");
});
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("handles an empty source (still injects the bootstrap)", () => {
const out = buildSandboxSrcdoc("");
expect(out).toContain(HTML_EMBED_HEIGHT_MESSAGE);
});
});
describe("shouldRender (render policy)", () => {
it("read-only renders regardless of the workspace toggle", () => {
// isEditable=false → the server already gated the content.
expect(shouldRender(false, false)).toBe(true);
expect(shouldRender(false, true)).toBe(true);
});
it("editable + toggle OFF does NOT render", () => {
expect(shouldRender(true, false)).toBe(false);
});
it("editable + toggle ON renders", () => {
expect(shouldRender(true, true)).toBe(true);
});
});
describe("canEdit (edit policy)", () => {
it("any member can edit when editable and the toggle is ON (no admin gate)", () => {
expect(canEdit(true, true)).toBe(true);
});
it("cannot edit when the toggle is OFF", () => {
expect(canEdit(true, false)).toBe(false);
});
it("cannot edit in read-only mode (no edit affordance)", () => {
expect(canEdit(false, true)).toBe(false);
});
});

View File

@@ -0,0 +1,100 @@
/**
* Pure helpers for the HTML embed node view. Kept out of the React component so
* the sandbox srcdoc builder and the render/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";
/**
* Build the `srcdoc` document for the sandboxed embed iframe.
*
* 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 buildSandboxSrcdoc(source: string): string {
const bootstrap = `
<script>
(function () {
var lastSent = -1;
var scheduled = false;
function measure() {
var doc = document.documentElement;
var body = document.body;
return Math.max(
doc ? doc.scrollHeight : 0,
body ? body.scrollHeight : 0
);
}
function flush() {
scheduled = false;
var height = measure();
// Only report when the height actually changed by more than 1px. This
// damps the iframe self-measure feedback loop: content sized to the iframe
// viewport would otherwise oscillate as the parent resizes the frame in
// response to each report.
if (Math.abs(height - lastSent) <= 1) return;
lastSent = height;
parent.postMessage(
{ type: ${JSON.stringify(HTML_EMBED_HEIGHT_MESSAGE)}, height: height },
"*"
);
}
function reportHeight() {
if (scheduled) return;
scheduled = true;
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(flush);
} else {
flush();
}
}
window.addEventListener("load", reportHeight);
// Report an initial height now (runs during parse, before load/images
// settle); the load handler and ResizeObserver refine it as content changes.
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}`;
}
/**
* Render 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 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 shouldRender(
isEditable: boolean,
featureEnabled: boolean,
): boolean {
return !isEditable || featureEnabled;
}
/**
* 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, featureEnabled: boolean): boolean {
return isEditable && featureEnabled;
}

View File

@@ -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;

View File

@@ -1,85 +1,118 @@
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,
shouldExecute as computeShouldExecute,
} from "./render-raw-html.ts";
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 } = 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(
const shouldRender = computeShouldRender(
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.
// 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(() => {
if (!contentRef.current) return;
if (shouldExecute) {
renderRawHtml(contentRef.current, source || "");
} else {
contentRef.current.innerHTML = "";
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));
}
}, [source, shouldExecute]);
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]);
}, [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
@@ -101,12 +134,12 @@ export default function HtmlEmbedView(props: NodeViewProps) {
</div>
)}
{!shouldExecute ? (
{!shouldRender ? (
// 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
// (`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">
@@ -114,9 +147,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={t("HTML embed")}
referrerPolicy="no-referrer"
style={{ height: effectiveHeight }}
/>
) : canEdit ? (
<div className={classes.htmlEmbedPlaceholder} onClick={openEditor}>
<IconCode size={18} />
@@ -124,7 +166,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 +177,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 +190,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")}

View File

@@ -1,112 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { JSDOM } from "jsdom";
import { renderRawHtml, shouldExecute, canEdit } 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);
});
afterEach(() => {
dom.window.close();
});
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);
});
});
describe("shouldExecute (execution policy)", () => {
it("read-only executes 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", () => {
expect(shouldExecute(true, false)).toBe(false);
});
it("editable + toggle ON executes", () => {
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("an admin with the toggle OFF cannot edit", () => {
expect(canEdit(true, 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);
});
});

View File

@@ -1,73 +0,0 @@
/**
* 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.
*/
/**
* Inject raw HTML (including <script> tags) into `container`, executing any
* scripts.
*
* 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).
*/
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);
}
// Copy inline code.
newScript.text = oldScript.textContent ?? "";
// Replacing the node in place triggers execution.
oldScript.parentNode?.replaceChild(newScript, oldScript);
}
}
/**
* 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.
*/
export function shouldExecute(
isEditable: boolean,
featureEnabled: boolean,
): boolean {
return !isEditable || featureEnabled;
}
/**
* 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.
*/
export function canEdit(
isEditable: boolean,
isAdmin: boolean,
featureEnabled: boolean,
): boolean {
return isEditable && isAdmin && featureEnabled;
}

View File

@@ -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 (

View File

@@ -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;
};

View File

@@ -28,6 +28,8 @@ interface ShareAiWidgetProps {
shareId: string;
/** The page the reader currently has open (context for "this page"). */
pageId: string;
/** Display name of the configured assistant identity; falls back to 'AI agent' when absent. */
assistantName?: string;
}
/**
@@ -48,7 +50,11 @@ interface ShareAiWidgetProps {
* links (so internal UUIDs/auth-gated routes in the answer don't leak as
* clickable links), and a documentation-focused empty state.
*/
export default function ShareAiWidget({ shareId, pageId }: ShareAiWidgetProps) {
export default function ShareAiWidget({
shareId,
pageId,
assistantName,
}: ShareAiWidgetProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [input, setInput] = useState("");
@@ -153,6 +159,7 @@ export default function ShareAiWidget({ shareId, pageId }: ShareAiWidgetProps) {
<MessageList
messages={messages}
isStreaming={isStreaming}
assistantName={assistantName}
showCitations={false}
// Anonymous reader: neutralize internal/relative links in the
// assistant's markdown so internal UUIDs/auth-gated routes don't

View File

@@ -45,6 +45,10 @@ export interface ISharedPage extends IShare {
// Whether the anonymous public-share AI assistant is enabled for the
// workspace (server-resolved). Gates the "Ask AI" widget.
aiAssistant?: boolean;
// Display name of the configured assistant identity (agent role name), used
// to label the public-share chat. Null/absent when no identity is set →
// the widget falls back to the generic "AI agent" label.
aiAssistantName?: string | null;
}
export interface IShareForPage extends IShare {

View File

@@ -1,57 +1,32 @@
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useAtom } from "jotai";
import { useState } from "react";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { useWorkspaceSetting } from "@/features/workspace/hooks/use-workspace-setting.ts";
import { Switch, Stack, Paper, Group, Text, List } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next";
/**
* Admin toggle for the workspace HTML embed feature.
* Workspace master toggle that enables/disables the HTML embed block type.
*
* SECURITY: when ON, workspace admins/owners can embed raw HTML/CSS/JS that
* EXECUTES in the wiki page origin for every reader (a deliberate stored-XSS
* surface, e.g. for analytics trackers). OFF by default. The server strips
* htmlEmbed nodes on every write where the toggle is OFF or the saver is not an
* admin, so this switch fully enables/disables the feature workspace-wide.
* The block renders inside a SANDBOXED iframe (no same-origin access), so it
* cannot touch the viewer's session/cookies/API — it is a feature switch, not a
* security gate. When ON, ANY member can insert the block. OFF by default; for
* anonymous public-share reads the server serves already-stripped content when
* the toggle is OFF. The toggle itself is managed by workspace admins.
*/
export default function HtmlEmbedSettings() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const { workspace, isLoading, save } = useWorkspaceSetting("htmlEmbed");
const { isAdmin } = useUserRole();
const [checked, setChecked] = useState<boolean>(
workspace?.settings?.htmlEmbed ?? false,
);
const [isLoading, setIsLoading] = useState(false);
async function handleToggle(value: boolean) {
setIsLoading(true);
const previous = checked;
setChecked(value); // optimistic update
try {
const updated = await updateWorkspace({ htmlEmbed: value });
// Force settings.htmlEmbed to the new value so the atom is consistent even
// if the response shape omits it.
setWorkspace({
...updated,
settings: {
...updated.settings,
htmlEmbed: value,
},
});
notifications.show({ message: t("Updated successfully") });
} catch (err) {
console.log(err);
setChecked(previous); // revert on failure
notifications.show({
message: t("Failed to update data"),
color: "red",
});
} finally {
setIsLoading(false);
}
const ok = await save(value);
if (!ok) setChecked(previous); // revert on failure
}
return (
@@ -69,7 +44,7 @@ export default function HtmlEmbedSettings() {
<Switch
label={t("Enable HTML embed")}
description={t(
"Allow workspace admins to insert raw HTML/CSS/JavaScript that EXECUTES in the wiki page origin for everyone who views the page (a deliberate stored-XSS surface, e.g. for analytics trackers). Off by default.",
"Allow members to insert raw HTML/CSS/JavaScript blocks. The block renders in a sandboxed frame and cannot access the viewer's session, cookies, or API. Off by default.",
)}
checked={checked}
disabled={!isAdmin || isLoading}
@@ -79,17 +54,17 @@ export default function HtmlEmbedSettings() {
<List size="xs" c="dimmed" mt="md" spacing={4}>
<List.Item>
{t(
"Only workspace admins/owners can insert HTML embeds. Members never can: the editor option is hidden for them and the server strips the embed on save at every write path.",
"When enabled, any member can insert an HTML embed block. The toggle just enables or disables the block type workspace-wide.",
)}
</List.Item>
<List.Item>
{t(
"If a non-admin edits and saves a page that contains an admin's embed, that save strips the embed (fail-closed). An admin must re-add it.",
"Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.",
)}
</List.Item>
<List.Item>
{t(
"Turning this off strips existing embeds on their next save and immediately disables execution (existing embeds render as a disabled placeholder).",
"Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.",
)}
</List.Item>
</List>

View File

@@ -0,0 +1,76 @@
import { useState } from "react";
import { useWorkspaceSetting } from "@/features/workspace/hooks/use-workspace-setting.ts";
import {
Button,
Group,
Paper,
Stack,
Text,
Textarea,
} from "@mantine/core";
import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next";
/**
* Admin-only analytics/tracker snippet for public share pages.
*
* The value is injected VERBATIM into the <head> of PUBLIC SHARE pages only,
* in the page's own (same-origin) context. It is the deliberate same-origin
* surface for analytics snippets (Google Analytics, Yandex.Metrika, etc.).
* Admin only — the workspace settings write is admin-gated server-side, and the
* Save button is disabled for non-admins.
*/
export default function TrackerSettings() {
const { t } = useTranslation();
const { workspace, isLoading, save } = useWorkspaceSetting("trackerHead");
const { isAdmin } = useUserRole();
const [value, setValue] = useState<string>(
workspace?.settings?.trackerHead ?? "",
);
async function handleSave() {
await save(value);
}
return (
<Stack mt="sm">
<Group justify="space-between" align="center">
<Text fw={700} size="lg">
{t("Analytics / tracker")}
</Text>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{t("advanced")}
</Text>
</Group>
<Paper withBorder radius="md" p="lg">
<Text size="xs" c="dimmed" mb="xs">
{t(
"Injected verbatim into the <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.",
)}
</Text>
<Textarea
autosize
minRows={6}
maxRows={20}
aria-label={t("Analytics / tracker")}
value={value}
onChange={(e) => setValue(e.currentTarget.value)}
placeholder={t("<script>...</script>")}
styles={{ input: { fontFamily: "monospace" } }}
disabled={!isAdmin || isLoading}
/>
<Group justify="flex-end" mt="md">
<Button
onClick={handleSave}
loading={isLoading}
disabled={!isAdmin}
>
{t("Save")}
</Button>
</Group>
</Paper>
</Stack>
);
}

View File

@@ -0,0 +1,65 @@
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useAtom } from "jotai";
import { useCallback, useState } from "react";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
/**
* Workspace setting keys that this hook can persist. Each key is both a
* write-only field on the update payload and a read field under
* `workspace.settings`, so the value type is derived from the settings shape.
*/
type WorkspaceSettingKey = "htmlEmbed" | "trackerHead";
type WorkspaceSettingValue<K extends WorkspaceSettingKey> =
NonNullable<IWorkspace["settings"][K]>;
/**
* Shared "save a workspace setting" plumbing extracted from the individual
* settings components. Owns the `isLoading` state and the persist-then-merge
* flow (call `updateWorkspace`, merge the response back into the workspace atom
* while forcing `settings[key]` to the saved value, and surface a success/error
* notification). Callers keep their own interaction model (optimistic toggle,
* edit-then-save, etc.) on top of this.
*/
export function useWorkspaceSetting<K extends WorkspaceSettingKey>(key: K) {
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const save = useCallback(
async (value: WorkspaceSettingValue<K>): Promise<boolean> => {
setIsLoading(true);
try {
const updated = await updateWorkspace({
[key]: value,
} as Partial<IWorkspace>);
// Force settings[key] to the new value so the atom is consistent even
// if the response shape omits it.
setWorkspace({
...updated,
settings: {
...updated.settings,
[key]: value,
},
});
notifications.show({ message: t("Updated successfully") });
return true;
} catch (err) {
console.error(`Failed to update workspace setting "${key}"`, err);
notifications.show({
message:
(err as any)?.response?.data?.message ?? t("Failed to update data"),
color: "red",
});
return false;
} finally {
setIsLoading(false);
}
},
[key, setWorkspace, t],
);
return { workspace, isLoading, save };
}

View File

@@ -33,6 +33,9 @@ export interface IWorkspace {
// Write-only field for updateWorkspace({ htmlEmbed }). Read state lives at
// settings.htmlEmbed.
htmlEmbed?: boolean;
// Write-only field for updateWorkspace({ trackerHead }). Read state lives at
// settings.trackerHead.
trackerHead?: string;
}
export interface IWorkspaceSettings {
@@ -40,8 +43,13 @@ export interface IWorkspaceSettings {
sharing?: IWorkspaceSharingSettings;
api?: IWorkspaceApiSettings;
templates?: IWorkspaceTemplateSettings;
// Admin-only HTML embed feature toggle. ABSENT/false => OFF (default).
// HTML embed master toggle (enables/disables the block type). The block
// renders in a sandboxed iframe, so this is a feature switch, not a security
// gate. ABSENT/false => OFF (default).
htmlEmbed?: boolean;
// Admin-only analytics/tracker snippet injected into the <head> of public
// share pages (same-origin). ABSENT/empty => none.
trackerHead?: string;
}
export interface IWorkspaceApiSettings {