From 6e420470b3de72cf409c3e16bf5123ff0631a530 Mon Sep 17 00:00:00 2001 From: NikitaKalitov Date: Mon, 24 Aug 2026 17:03:05 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D1=81=D0=B8=D0=BD=D1=85=D1=80=D0=BE=D0=BD=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20=D1=81=20=D0=BE=D1=81=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=BD=D1=8B=D0=BC=20=D0=BF=D0=BE=D1=80=D1=82=D0=B0?= =?UTF-8?q?=D0=BB=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/backend-quickstart.md | 6 ++++-- docs/bitrix-integration.md | 26 +++++++++++++++++++++++--- src/App.tsx | 19 ++++++++++++++++++- src/DiagramEditor.tsx | 33 ++++++++++++++++++++++----------- src/host/bitrixAdapter.ts | 23 ++++++++++++++++------- 5 files changed, 83 insertions(+), 24 deletions(-) diff --git a/docs/backend-quickstart.md b/docs/backend-quickstart.md index 6a8ec5d..6486c9f 100644 --- a/docs/backend-quickstart.md +++ b/docs/backend-quickstart.md @@ -8,13 +8,15 @@ 1. Собрать в репозитории `portal-chart-builder`: ``` - VITE_BITRIX_ORIGIN=https://ВАШ-ДОМЕН-ПОРТАЛА VITE_BASE_PATH=/local/diagram-app/ npm run build + VITE_BASE_PATH=/local/diagram-app/ npm run build ``` - - `VITE_BITRIX_ORIGIN` — origin портала (без пути). Обязателен — без него приложение намеренно не запустится (защита от случайного `postMessage('*')`). - `VITE_BASE_PATH` — подпуть, под которым будет отдаваться приложение. Должен совпадать с тем, куда положите `dist/` на шаге 2. + - `VITE_BITRIX_ORIGIN` здесь **не нужен**: приложение и портал на одном домене, origin хоста приложение берёт из `window.location.origin` в рантайме. Задавать переменную нужно только если хост на другом origin — например, локальная разработка (dev-сервер хоста и `vite preview` этого приложения на разных портах) или деплой отдельным контейнером (см. `bitrix-integration.md`, раздел 7, вариант B). 2. Скопировать содержимое `dist/` в `local/diagram-app/` внутри каталога сайта на сервере (тот же каталог, где лежит остальной код портала — `bitrix_www`/аналог на проде). 3. Готово: приложение доступно по `https://ВАШ-ДОМЕН-ПОРТАЛА/local/diagram-app/index.html`. +Если схему нужно только показать, без правки, — добавьте к `src` iframe `?mode=view`: редактор откроется в режиме просмотра, переключатель режимов будет скрыт. + **При каждом повторном деплое** (обновили React-приложение, положили новый `dist/` поверх старого) — добавляйте к `src` iframe cache-busting параметр (`?v=<дата/хэш билда>`). У `.html`-файлов на большинстве Bitrix-стендов стоит `Cache-Control` на 30 дней — без версии в URL браузер администратора может продолжать открывать старый `index.html` со старыми хэшами JS/CSS ещё месяц. ## 2. Как сформировать HTML diff --git a/docs/bitrix-integration.md b/docs/bitrix-integration.md index ddcdf16..28a9664 100644 --- a/docs/bitrix-integration.md +++ b/docs/bitrix-integration.md @@ -120,6 +120,18 @@ class DiagramProperty `diagram:init` с `payload: null` (или пустой строкой) — валидный случай: новый элемент без диаграммы, приложение стартует с чистого холста. +### Режим только для просмотра + +Хост может открыть приложение на чтение, добавив `?mode=view` к URL iframe: + +``` +/local/diagram-app/index.html?mode=view +``` + +Тогда редактор стартует в режиме «Просмотр» и переключатель «Просмотр/Редактирование» скрыт — фигуры нельзя двигать, создавать и удалять. Без параметра поведение прежнее: редактирование с переключателем. Параметр читается один раз при загрузке (`src/App.tsx`), в контракте `postMessage` ничего не меняется — `diagram:init`/`diagram:change` работают одинаково в обоих режимах. + +Зачем: встраивающая сторона, которая не слушает `diagram:change` (например, страница портала, показывающая утверждённую схему), иначе предлагала бы правки, которые молча никуда не сохраняются. + ## 5. Требования безопасности (обязательны, не опциональны) - Значение всегда попадает на страницу только через `value`-атрибут hidden input (`htmlspecialcharsbx($rawValue)`, см. раздел 3), не через встраивание в инлайн-скрипт — так JSON-содержимое никогда не оказывается внутри JS-исходника, и `JSON_HEX_*`-экранирование не требуется вообще. Мост читает `input.value` напрямую в JS. Если по каким-то причинам всё же понадобится встроить значение в инлайн-скрипт (например, отдельный `initialValue` вместо чтения из DOM) — тогда обязателен `JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT` при `json_encode`. @@ -199,14 +211,20 @@ Backend не обязан ничего валидировать глубже « - `local/` уже существует как каталог для кастомного кода (`ai`, `components`, `modules`, `php_interface`, `templates` — по факту на дев-стенде). Новый подкаталог, например `local/diagram-app/`, вписывается в эту конвенцию и не пересекается с `local/modules`/`local/php_interface` — они у nginx явно помечены `internal` (заблокированы для прямого внешнего доступа), новый подкаталог под этот блок не попадает. - Нюанс: `index` на уровне сервера — `index.php`, не `index.html`. Открыть голый `/local/diagram-app/` (без имени файла) не выйдет — nginx попытается найти там `index.php` и не найдёт. Не проблема: PHP-класс (раздел 3) сам подставляет `src` у iframe, поэтому в `$appUrl` достаточно явно указывать файл — `/local/diagram-app/index.html`. - Так как приложение и портал теперь на одном домене, `VITE_BITRIX_ORIGIN`/`appOrigin` — это просто origin самого портала, а не адрес отдельного сервиса. -- **Важный нюанс, найден живым тестом на дев-стенде:** тот же regex-location в nginx, что раздаёт статику, ставит `.html`-файлам `Cache-Control: public` с `expires 30d`. Значит после **повторного** деплоя (обновили React-приложение, положили новый `dist/` поверх старого) браузеры, уже открывавшие `/local/diagram-app/index.html` раньше, могут до 30 дней получать из своего кэша старый `index.html` — а в нём старые хэши JS/CSS-файлов, которых уже нет на диске (404) или которые не соответствуют новой версии приложения. При первом же открытии URL это не проявляется (кэшировать ещё нечего), но при каждом обновлении приложения — да. Решение: в `$appUrl`/`src` iframe добавлять cache-busting query-параметр, привязанный к версии сборки (например, `?v=` или `?v=<дата билда>`), а не просто `/local/diagram-app/index.html` без параметров — тогда каждая новая версия получает свой URL и обходит браузерный кэш. То же самое верно и для самой страницы со свойством (если её тоже будете кэшировать). +- **Важный нюанс, найден живым тестом на дев-стенде:** тот же regex-location в nginx, что раздаёт статику, ставит `.html`-, `.js`- и `.css`-файлам одинаковый `Cache-Control: public` с `expires 30d`. Это касается **двух независимых уровней**, и решать их нужно по отдельности — закрыть только один недостаточно: + + 1. **Кэш самого `index.html`.** После повторного деплоя (обновили React-приложение, положили новый `dist/` поверх старого) браузер, уже открывавший `$appUrl` раньше, может до 30 дней получать из кэша старый `index.html` вместо нового. Решение: тот, кто формирует `src` у iframe (PHP-класс из раздела 3, либо любой другой хост), должен сам добавлять к нему cache-busting query-параметр, привязанный к версии сборки (например, `?v=` или `?v=<дата билда>`), а не отдавать голый `/local/diagram-app/index.html` без параметров. + 2. **Кэш `index.js`/`index.css`, на которые ссылается сам `index.html`.** Тут пункт 1 не помогает: даже если браузер честно перезапросит свежий `index.html` (URL с новым `?v=` не совпал с закэшированным), внутри этого свежего файла ссылки на `index.js`/`index.css` — те же самые, неизменные имена (в приложении нет хеша в имени файла, см. `vite.config.ts` — сделано намеренно, чтобы имена были человекочитаемыми). Если эти конкретные URL уже лежат в кэше браузера с прошлого визита, он отдаст их с диска, даже не сходив на сервер, — HTML окажется свежим, а исполняемый код внутри него старым. + + Пока это **не автоматизировано** и остаётся на усмотрение того, кто деплоит: либо вручную версировать `index.js`/`index.css` внутри задеплоенного `dist/index.html` при каждом обновлении, либо настроить на стороне nginx `Cache-Control` для этих файлов иначе (например, `no-cache`/`must-revalidate`). Если проблема будет всплывать на практике — можно вернуться к автоматизации на стороне сборки. Порядок действий: 1. В репозитории `portal-chart-builder` собрать статику с двумя build-time переменными: ``` - VITE_BITRIX_ORIGIN=https://ВАШ-ДОМЕН-ПОРТАЛА VITE_BASE_PATH=/local/diagram-app/ npm run build + VITE_BASE_PATH=/local/diagram-app/ npm run build ``` + `VITE_BITRIX_ORIGIN` в этом варианте не задаётся — см. примечание в конце раздела. `VITE_BASE_PATH` — путь, под которым будет отдаваться приложение (нужен, чтобы ссылки на JS/CSS внутри `dist/index.html` были корректными под этим подпутём; см. `vite.config.ts`). По умолчанию, если переменная не задана, `base` — `/` (не влияет на локальную разработку, `npm run dev`). 2. Скопировать содержимое `dist/` в `local/diagram-app/` внутри host-каталога, который смонтирован как `bitrix_www` (на текущем дев-стенде это `/home/nikit/bitrix/www/local/diagram-app/` — точный путь для конкретного стенда уточняется у того, кто его поднимал/администрирует). 3. В PHP-классе (раздел 3): `$appUrl = $appOrigin . '/local/diagram-app/index.html';`, `$appOrigin` — origin портала (без пути). @@ -245,7 +263,9 @@ services: Собрал и проверил локально (`docker build` + `docker run -p 5178:80`) — контейнер поднимается и корректно отдаёт приложение по `http://localhost:5178`. -**Важно (общее для обоих вариантов):** `VITE_BITRIX_ORIGIN` — build-time переменная, вшивается в JS-бандл на этапе `npm run build`/`docker build`, не читается заново при каждом запуске/обновлении. Без неё приложение намеренно упадёт при загрузке (см. раздел 5) — чтобы нельзя было собрать билд с `postMessage('*')`. +**Важно (общее для обоих вариантов):** `VITE_BITRIX_ORIGIN` — build-time переменная, вшивается в JS-бандл на этапе `npm run build`/`docker build`, не читается заново при каждом запуске/обновлении. + +В варианте A она **не нужна**: приложение и портал на одном домене, и origin хоста приложение берёт из `window.location.origin`. Более того, заданная переменная там вредна — она перебьёт рантайм-значение, и на любом домене, кроме зашитого, обмен молча не состоится. Нужна она только в варианте B (или при локальной разработке), где хост и приложение на разных origin. Требование «никогда `postMessage('*')`» соблюдается в обоих случаях: targetOrigin всегда конкретный origin. Схема с `localhost` (вариант B) работает, пока браузер администратора и Docker-хост — одна машина (текущий дев-стенд). Если позже появится реальный удалённый прод с админами не с этой машины — понадобится либо настоящий домен, либо reverse-proxy; это отдельная, более поздняя задача. Вариант A этой проблемы не имеет вообще, так как всегда живёт на домене портала. diff --git a/src/App.tsx b/src/App.tsx index 04ef689..f1713ba 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -64,6 +64,19 @@ const SAMPLE_DOCUMENT: DiagramDocument = { ], }; +/** + * Хост может открыть приложение только на просмотр, добавив `?mode=view` + * к URL iframe (портал встраивает схему как картинку и `diagram:change` + * не слушает). Без параметра — прежнее поведение, редактирование. + */ +function getLockedMode(): "view" | undefined { + return new URLSearchParams(window.location.search).get("mode") === "view" ? + "view" + : undefined; +} + +const lockedMode = getLockedMode(); + const isProd = import.meta.env.PROD; const adapter = isProd ? createBitrixAdapter() : createDevAdapter(); // In Bitrix, no value means a brand-new element — start blank, not with dev demo content. @@ -86,7 +99,11 @@ function App() { return ( - + ); } diff --git a/src/DiagramEditor.tsx b/src/DiagramEditor.tsx index babba0f..729bf12 100644 --- a/src/DiagramEditor.tsx +++ b/src/DiagramEditor.tsx @@ -44,6 +44,14 @@ import "./diagram.css"; interface DiagramEditorProps { initialValue: DiagramDocument | null; onChange: (doc: DiagramDocument) => void; + /** + * Режим, зафиксированный хостом (см. `?mode=view` в `src/App.tsx`). + * Когда задан, переключатель «Просмотр/Редактирование» скрыт: хост, + * встроивший приложение только на чтение, всё равно игнорирует + * `diagram:change`, и предлагать правки, которые никуда не сохранятся, — + * обман пользователя. + */ + lockedMode?: "edit" | "view"; } const DEFAULT_EDGE_TYPE = "smoothstep"; @@ -196,13 +204,14 @@ function isEditableTarget(target: EventTarget | null): boolean { ); } -function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { +function DiagramCanvas({ initialValue, onChange, lockedMode }: DiagramEditorProps) { const [nodes, setNodes] = useState(() => toRfNodes(initialValue)); const [edges, setEdges] = useState(() => toRfEdges(initialValue)); const [selectedNodeId, setSelectedNodeId] = useState(null); const [selectedEdgeId, setSelectedEdgeId] = useState(null); const [focusNodeId, setFocusNodeId] = useState(null); - const [mode, setMode] = useState<"edit" | "view">("edit"); + const [selectedMode, setSelectedMode] = useState<"edit" | "view">("edit"); + const mode = lockedMode ?? selectedMode; const [alignmentGuides, setAlignmentGuides] = useState([]); const nodeCounter = useRef(0); const historyRef = useRef<{ nodes: Node[]; edges: Edge[] }[]>([]); @@ -834,15 +843,17 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { )} {mode === "edit" && } - - item.mode === mode) ?? null} - onChange={(item) => setMode(item.mode)} - /> - + {!lockedMode && ( + + item.mode === mode) ?? null} + onChange={(item) => setSelectedMode(item.mode)} + /> + + )} {mode === "edit" && ( {NODE_KINDS.map(({ kind, label }) => ( diff --git a/src/host/bitrixAdapter.ts b/src/host/bitrixAdapter.ts index bb439fe..01d344a 100644 --- a/src/host/bitrixAdapter.ts +++ b/src/host/bitrixAdapter.ts @@ -11,14 +11,23 @@ const READY_MESSAGE = "diagram:ready"; const INIT_MESSAGE = "diagram:init"; const CHANGE_MESSAGE = "diagram:change"; +/** + * Origin хоста, встроившего приложение. + * + * Обычный случай — приложение и хост на одном домене (статика лежит в дереве + * портала, см. docs/bitrix-integration.md §7, вариант A): тогда origin + * известен в рантайме и задавать его на этапе сборки не нужно. + * VITE_BITRIX_ORIGIN нужен только когда хост на другом origin — локальная + * разработка (dev-сервер хоста и `vite preview` этого приложения на разных + * портах) или деплой отдельным контейнером (§7, вариант B). + * + * Требование «никогда не `postMessage('*')`» соблюдено в обоих случаях: + * значение всегда конкретный origin. Если хост окажется на другом домене, + * а переменная не задана, обмен просто не состоится — это безопасный отказ, + * а не утечка сообщений куда попало. + */ function getBitrixOrigin(): string { - const origin = import.meta.env.VITE_BITRIX_ORIGIN; - if (!origin) { - throw new Error( - 'VITE_BITRIX_ORIGIN is not set. The bridge needs a concrete parent origin — postMessage must never target "*".', - ); - } - return origin; + return import.meta.env.VITE_BITRIX_ORIGIN || window.location.origin; } export function createBitrixAdapter(): HostAdapter {