Правки для синхронизации с основным порталом
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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=<git-hash>` или `?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=<git-hash>` или `?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 этой проблемы не имеет вообще, так как всегда живёт на домене портала.
|
||||
|
||||
|
||||
+18
-1
@@ -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 (
|
||||
<Theme preset={presetGpnDefault} style={{ height: '100%' }}>
|
||||
<DiagramEditor initialValue={doc} onChange={handleChange} />
|
||||
<DiagramEditor
|
||||
initialValue={doc}
|
||||
onChange={handleChange}
|
||||
lockedMode={lockedMode}
|
||||
/>
|
||||
</Theme>
|
||||
);
|
||||
}
|
||||
|
||||
+22
-11
@@ -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<Node[]>(() => toRfNodes(initialValue));
|
||||
const [edges, setEdges] = useState<Edge[]>(() => toRfEdges(initialValue));
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
||||
const [focusNodeId, setFocusNodeId] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState<"edit" | "view">("edit");
|
||||
const [selectedMode, setSelectedMode] = useState<"edit" | "view">("edit");
|
||||
const mode = lockedMode ?? selectedMode;
|
||||
const [alignmentGuides, setAlignmentGuides] = useState<AlignmentGuide[]>([]);
|
||||
const nodeCounter = useRef(0);
|
||||
const historyRef = useRef<{ nodes: Node[]; edges: Edge[] }[]>([]);
|
||||
@@ -834,15 +843,17 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
)}
|
||||
</Controls>
|
||||
{mode === "edit" && <MiniMap />}
|
||||
<Panel position="top-right" className="diagram-mode-toggle">
|
||||
<ChoiceGroup
|
||||
size="xs"
|
||||
name="diagram-mode"
|
||||
items={MODE_ITEMS}
|
||||
value={MODE_ITEMS.find((item) => item.mode === mode) ?? null}
|
||||
onChange={(item) => setMode(item.mode)}
|
||||
/>
|
||||
</Panel>
|
||||
{!lockedMode && (
|
||||
<Panel position="top-right" className="diagram-mode-toggle">
|
||||
<ChoiceGroup
|
||||
size="xs"
|
||||
name="diagram-mode"
|
||||
items={MODE_ITEMS}
|
||||
value={MODE_ITEMS.find((item) => item.mode === mode) ?? null}
|
||||
onChange={(item) => setSelectedMode(item.mode)}
|
||||
/>
|
||||
</Panel>
|
||||
)}
|
||||
{mode === "edit" && (
|
||||
<Panel position="top-left" className="diagram-toolbar">
|
||||
{NODE_KINDS.map(({ kind, label }) => (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user