diff --git a/.env.example b/.env.example index 4cebe788..1cfcb43f 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,11 @@ PORT=3000 # `127.0.0.1, 10.0.0.0/8` # TRUST_PROXY= +# APP_SECRET has a DUAL role: it signs JWTs AND derives the AES-256-GCM key that +# encrypts stored AI-provider credentials (API keys) at rest. CONSEQUENCE: if you +# change APP_SECRET after setup, every stored AI API key becomes undecryptable — +# you must re-enter them in AI settings — and all existing sessions/JWTs are +# invalidated. Choose it ONCE, keep it stable, and back it up alongside your DB. # minimum of 32 characters. Generate one with: openssl rand -hex 32 APP_SECRET=REPLACE_WITH_LONG_SECRET @@ -139,7 +144,12 @@ MCP_DOCMOST_PASSWORD= # # Backstop: a cluster-wide, sliding-window cap per workspace (IP-independent, # keyed by the server-resolved workspace id) bounds the owner's bill even if the -# per-IP limit is fully evaded. It is a COST backstop, not an access control, -# and FAILS OPEN if Redis is unavailable. Override the hourly cap below +# per-IP limit is fully evaded. It is a COST backstop, not an access control, and +# FAILS CLOSED if Redis is unavailable (an optional assistant briefly going +# offline is safer than an unbounded bill). Override the hourly cap below # (default: 300 calls per workspace per rolling hour). # SHARE_AI_WORKSPACE_MAX_PER_HOUR=300 +# +# Per-request output-token ceiling for the anonymous assistant (default: 512). +# Worst-case output per accepted call = agent steps (5) × this value. +# SHARE_AI_MAX_OUTPUT_TOKENS=512 diff --git a/.gitignore b/.gitignore index 6af27e98..e814fb29 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ lerna-debug.log* .nx/installation .nx/cache .claude/worktrees/ + +# TypeScript incremental build artifacts +*.tsbuildinfo diff --git a/AGENTS.md b/AGENTS.md index b17edd89..a56358a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -280,4 +280,4 @@ The git tag is the source of truth for the displayed version (UI reads `git desc ## Planning docs -`docs/*.md` hold design plans for in-progress / planned features (mobile app, offline sync, RAG improvements, streaming dictation). Arbitrary HTML embed has **shipped** (admin-gated by the `htmlEmbed` workspace toggle in Workspace settings) and is no longer a planning doc. `docs/backlog/*.md` track known issues / follow-ups (e.g. AI-chat review follow-ups). Consult the relevant plan before working on one of those areas. +`docs/*.md` hold design plans for in-progress / planned features (mobile app, offline sync, RAG improvements, voice dictation). Arbitrary HTML embed has **shipped** — it renders inside a sandboxed iframe and, when the `htmlEmbed` workspace toggle is on, is insertable by any member (no longer admin-only); turning the toggle off hides/stops serving existing embeds on public share pages. `docs/backlog/*.md` track known issues / follow-ups (e.g. AI-chat review follow-ups). Consult the relevant plan before working on one of those areas. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bdec8c4..815c05a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Admin-only "Analytics / tracker" workspace setting: a raw HTML/JS snippet + injected into the `` of public share pages only (for analytics such as + Google Analytics or Yandex.Metrika). + +### Changed + +- HTML embed blocks now render inside a sandboxed iframe (separate origin) and, + when the workspace HTML-embed toggle is on, can be inserted by any member + (previously admin-only). Turning the toggle off hides existing embeds and + stops serving them on public share pages. +- Remove the server-side role-based stripping of HTML-embed blocks from the + write paths (collab/REST/MCP, page create/duplicate, import, transclusion + unsync); sandboxing makes per-write gating unnecessary. The only remaining + server-side strip is the public-share read path, which still honors the + workspace HTML-embed toggle. + ### Breaking Changes - **MCP shared-token auth moved to its own header.** The `/mcp` shared guard diff --git a/README.md b/README.md index 0a96253b..b63b76f5 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,8 @@ community feature, with no enterprise license. Open it from the page header; the - ✅ **AI chat** — built-in AI agent chat over your wiki content (read + write, RAG search, configurable provider, optional web access via external MCP). - ✅ **Voice dictation** — microphone button in the AI agent chat and the page editor; audio is transcribed server-side (Whisper / OpenAI-compatible STT) via the workspace AI provider, with an admin toggle to show/hide it. - ✅ **Page templates** — flag a page as a template and embed its whole content live into other pages; edits to the template propagate to every place it is inserted (whole-page transclusion on top of the existing synced blocks). +- ✅ **Public-share AI assistant** — anonymous visitors of a shared page can ask the AI agent, scoped strictly to that share's page tree (read-only, share-scoped search), behind a workspace toggle. +- ✅ **Footnotes** — academic-style footnotes: a numbered superscript reference inline (read it in place via a hover popover), with the note text living as a real, editable block at the bottom of the page; auto-numbered, collaboration-safe, and round-trips through Markdown export/import and the AI agent / MCP. ### In progress @@ -110,12 +112,10 @@ community feature, with no enterprise license. Open it from the page header; the ### Planned - 🔭 **Viewer comments** — let read-only viewers leave comments. -- 🔭 **Public-share AI assistant** — let anonymous visitors of a shared page ask the AI agent, scoped strictly to that share's page tree (read-only, share-scoped search), behind a workspace toggle. See [docs/public-share-assistant-plan.md](docs/public-share-assistant-plan.md). - 🔭 **Password-protected pages** — protect individual pages / shares with a password. - 🔭 **Windows / Linux app** — native desktop app for Windows and Linux. - 🔭 **Mobile app** — mobile apps (iOS first, Android to follow), reusing the existing responsive web UI and editor via a Capacitor wrapper, with offline planned for later. See [docs/mobile-app-plan.md](docs/mobile-app-plan.md). - 🔭 **Offline mode** — offline sync & PWA support. -- 🔭 **Footnotes** — academic-style footnotes: a numbered superscript reference inline (read it in place via a hover popover), with the note text living as a real, editable block at the bottom of the page; auto-numbered, collaboration-safe, and round-trips through Markdown export/import and the AI agent / MCP. See [docs/footnotes-plan.md](docs/footnotes-plan.md). - 🔭 **Editor & UX improvements** — blocks inside tables (lists, to-do items), column layout, additional heading levels, highlight blocks, custom emoji in callouts, floating images, anchor links for page mentions, toggles (shared-page width, aside/sidebar, spellcheck, ligatures), sanitized space-tree export, and mentions in breadcrumbs. ## Getting started @@ -158,6 +158,11 @@ the existing data directory is reused as-is: start the new migrations apply on top of your existing schema (`CREATE EXTENSION vector` plus the `page_embeddings` and AI tables); watch the logs for `Migration "..." executed successfully`. +> ⚠️ **Never change `APP_SECRET` after setup.** It does double duty: it signs JWTs *and* derives the +> AES-256-GCM key that encrypts stored AI-provider credentials (API keys). Rotating it makes every +> saved AI API key undecryptable (you'd have to re-enter them in AI settings) and invalidates all +> existing sessions. Pick it once, keep it stable, and back it up together with your database. + ### Notes - **Back up first.** Take a `pg_dump` before swapping — migrations apply in place, and the diff --git a/README.ru.md b/README.ru.md index 0bd9a5de..cb0d12ad 100644 --- a/README.ru.md +++ b/README.ru.md @@ -102,6 +102,9 @@ real-time-коллаборации Docmost, поэтому запись нико - ✅ **Приложение для macOS** — нативное приложение для macOS ([gitmost-app](https://github.com/vvzvlad/gitmost-app)), встраивающее UI с вкладками для нескольких серверов. - ✅ **AI-чат** — встроенный чат с AI-агентом по содержимому вики (чтение + запись, RAG-поиск, настраиваемый провайдер, опциональный доступ в интернет через внешние MCP). - ✅ **Голосовая диктовка** — кнопка-микрофон в чате AI-агента и в редакторе страниц; аудио распознаётся на сервере (Whisper / OpenAI-совместимый STT) через AI-провайдер воркспейса, с тумблером админа для показа/скрытия. +- ✅ **Шаблоны страниц** — пометить страницу шаблоном и вставлять её содержимое живой ссылкой в другие страницы; правки шаблона распространяются на все места вставки (whole-page-транслюзия поверх существующих synced-блоков). +- ✅ **AI-ассистент на публичных шарах** — анонимный зритель расшаренной страницы может спросить AI-агента, который ищет строго по дереву этой шары (read-only, share-scoped поиск), за тумблером воркспейса. +- ✅ **Сноски** — сноски академического вида: нумерованная ссылка-надстрочник прямо в тексте (читается на месте во всплывающем окне по наведению), а текст сноски живёт реальным редактируемым блоком внизу страницы; авто-нумерация, безопасна для совместного редактирования, переживает экспорт/импорт Markdown и доступна AI-агенту / MCP. ### В процессе @@ -109,14 +112,11 @@ real-time-коллаборации Docmost, поэтому запись нико ### В планах -- 🔭 **Шаблоны страниц** — пометить страницу шаблоном и вставлять её содержимое живой ссылкой в другие страницы; правки шаблона распространяются на все места вставки (whole-page-транслюзия поверх существующих synced-блоков). См. [docs/page-templates-plan.md](docs/page-templates-plan.md). - 🔭 **Комментарии зрителей** — возможность комментировать для пользователей с доступом только на чтение. -- 🔭 **AI-ассистент на публичных шарах** — возможность анонимному зрителю расшаренной страницы спросить AI-агента, который ищет строго по дереву этой шары (read-only, share-scoped поиск), за тумблером воркспейса. См. [docs/public-share-assistant-plan.md](docs/public-share-assistant-plan.md). - 🔭 **Защищённые паролем страницы** — защита отдельных страниц / шар паролем. - 🔭 **Приложение для Windows / Linux** — нативное десктоп-приложение для Windows и Linux. - 🔭 **Мобильное приложение** — мобильные приложения (iOS обязательно, Android как пойдёт) на базе существующей адаптивной веб-версии и редактора через обёртку Capacitor; оффлайн запланирован на будущее. См. [docs/mobile-app-plan.md](docs/mobile-app-plan.md). - 🔭 **Офлайн-режим** — офлайн-синхронизация и поддержка PWA. -- 🔭 **Сноски** — сноски академического вида: нумерованная ссылка-надстрочник прямо в тексте (читается на месте во всплывающем окне по наведению), а текст сноски живёт реальным редактируемым блоком внизу страницы; авто-нумерация, безопасна для совместного редактирования, переживает экспорт/импорт Markdown и доступна AI-агенту / MCP. См. [docs/footnotes-plan.md](docs/footnotes-plan.md). - 🔭 **Улучшения редактора и UX** — блоки внутри таблиц (списки, чек-листы), колоночная вёрстка, дополнительные уровни заголовков, highlight-блоки, кастомные эмодзи в callout-ах, плавающие изображения, anchor-ссылки на упоминания страниц, тоглы (ширина шары, aside/сайдбар, spellcheck, лигатуры), санитизация экспорта дерева спейса и mentions в хлебных крошках. ## С чего начать @@ -159,6 +159,12 @@ dump/restore, существующий каталог данных переис новые миграции применяются поверх вашей схемы (`CREATE EXTENSION vector` плюс таблицы `page_embeddings` и AI-таблицы); следите в логах за строками `Migration "..." executed successfully`. +> ⚠️ **Никогда не меняйте `APP_SECRET` после установки.** Он выполняет двойную роль: подписывает JWT +> *и* служит материалом для ключа AES-256-GCM, которым шифруются сохранённые ключи AI-провайдеров +> (API-ключи). Смена секрета сделает все сохранённые AI-ключи нерасшифровываемыми (придётся вводить +> их заново в настройках AI) и инвалидирует все текущие сессии. Задайте его один раз, держите +> неизменным и бэкапьте вместе с базой данных. + ## Возможности diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 0f6a1a9f..70353fee 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1145,6 +1145,7 @@ "Current context size": "Current context size", "AI agent": "AI agent", "AI agent is typing…": "AI agent is typing…", + "{{name}} is typing…": "{{name}} is typing…", "Send": "Send", "Stop": "Stop", "Chat menu": "Chat menu", @@ -1239,5 +1240,20 @@ "Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.": "Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.", "No roles configured": "No roles configured", "Delete role": "Delete role", - "Are you sure you want to delete this role?": "Are you sure you want to delete this role?" + "Are you sure you want to delete this role?": "Are you sure you want to delete this role?", + "HTML embed": "HTML embed", + "Edit HTML embed": "Edit HTML embed", + "HTML embed is disabled in this workspace": "HTML embed is disabled in this workspace", + "Click to add HTML / CSS / JS": "Click to add HTML / CSS / JS", + "This HTML/CSS/JS runs in a sandboxed frame and cannot access the viewer's session, cookies, or API.": "This HTML/CSS/JS runs in a sandboxed frame and cannot access the viewer's session, cookies, or API.", + "": "", + "Height (px, blank = auto)": "Height (px, blank = auto)", + "advanced": "advanced", + "Enable HTML embed": "Enable HTML embed", + "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.": "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.", + "When enabled, any member can insert an HTML embed block. The toggle just enables or disables the block type workspace-wide.": "When enabled, any member can insert an HTML embed block. The toggle just enables or disables the block type workspace-wide.", + "Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.": "Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.", + "Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.": "Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.", + "Analytics / tracker": "Analytics / tracker", + "Injected verbatim into the of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.": "Injected verbatim into the of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only." } diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index 1e2b6bb2..8c91dd5a 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -668,6 +668,7 @@ "AI search": "Поиск ИИ", "AI Answer": "Ответ ИИ", "Ask AI": "Спросить ИИ", + "{{name}} is typing…": "{{name}} печатает…", "AI is thinking...": "ИИ обрабатывает запрос...", "Thinking": "Думаю", "Ask a question...": "Задайте вопрос...", diff --git a/apps/client/src/features/ai-chat/components/message-item.tsx b/apps/client/src/features/ai-chat/components/message-item.tsx index e8709d5c..a0c37089 100644 --- a/apps/client/src/features/ai-chat/components/message-item.tsx +++ b/apps/client/src/features/ai-chat/components/message-item.tsx @@ -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 ( - {t("AI agent")} + {assistantName?.trim() || t("AI agent")} {message.parts.map((part, index) => { if (part.type === "text") { diff --git a/apps/client/src/features/ai-chat/components/message-list.tsx b/apps/client/src/features/ai-chat/components/message-list.tsx index 3d9c5024..b4b4101d 100644 --- a/apps/client/src/features/ai-chat/components/message-list.tsx +++ b/apps/client/src/features/ai-chat/components/message-list.tsx @@ -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(null); @@ -148,9 +155,10 @@ export default function MessageList({ message={message} showCitations={showCitations} neutralizeInternalLinks={neutralizeInternalLinks} + assistantName={assistantName} /> ))} - {typing && } + {typing && } ); diff --git a/apps/client/src/features/ai-chat/components/typing-indicator.tsx b/apps/client/src/features/ai-chat/components/typing-indicator.tsx index 443fe1e1..ba6c6db0 100644 --- a/apps/client/src/features/ai-chat/components/typing-indicator.tsx +++ b/apps/client/src/features/ai-chat/components/typing-indicator.tsx @@ -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 ( - {t("AI agent")} + {name || t("AI agent")} diff --git a/apps/client/src/features/editor/components/html-embed/html-embed-sandbox.test.ts b/apps/client/src/features/editor/components/html-embed/html-embed-sandbox.test.ts new file mode 100644 index 00000000..70534be3 --- /dev/null +++ b/apps/client/src/features/editor/components/html-embed/html-embed-sandbox.test.ts @@ -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("
hello
"); + expect(out).toContain("
hello
"); + }); + + it("injects the height-postMessage bootstrap after the source", () => { + const out = buildSandboxSrcdoc("

body

"); + // The bootstrap is appended AFTER the source. + expect(out.indexOf("

body

")).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); + }); +}); diff --git a/apps/client/src/features/editor/components/html-embed/html-embed-sandbox.ts b/apps/client/src/features/editor/components/html-embed/html-embed-sandbox.ts new file mode 100644 index 00000000..d4ea79f4 --- /dev/null +++ b/apps/client/src/features/editor/components/html-embed/html-embed-sandbox.ts @@ -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 `; + 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; +} diff --git a/apps/client/src/features/editor/components/html-embed/html-embed-view.module.css b/apps/client/src/features/editor/components/html-embed/html-embed-view.module.css index 75304685..2ff32e3a 100644 --- a/apps/client/src/features/editor/components/html-embed/html-embed-view.module.css +++ b/apps/client/src/features/editor/components/html-embed/html-embed-view.module.css @@ -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; diff --git a/apps/client/src/features/editor/components/html-embed/html-embed-view.tsx b/apps/client/src/features/editor/components/html-embed/html-embed-view.tsx index 273fbaff..4a9beb3a 100644 --- a/apps/client/src/features/editor/components/html-embed/html-embed-view.tsx +++ b/apps/client/src/features/editor/components/html-embed/html-embed-view.tsx @@ -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(null); + const iframeRef = useRef(null); const [modalOpen, setModalOpen] = useState(false); const [draft, setDraft] = useState(source || ""); + const [draftHeight, setDraftHeight] = useState(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(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 ( )} - {!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.
@@ -114,9 +147,18 @@ export default function HtmlEmbedView(props: NodeViewProps) {
) : source ? ( - // Raw HTML/CSS/JS rendered into the wiki origin. Scripts are re-created - // in renderRawHtml so they execute. -
+ // 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. +