# CODE-REVIEW — issue #51 «Custom decor images» · заход r1 - Issue: https://github.com/Matysh/houseplan-card/issues/51 - Материал: `git log origin/dev..HEAD`, `git diff origin/dev...HEAD` - SHA материала: `38205d87a6e65bf670e214050223a9e22a9d2a42` (сверено с `git rev-parse HEAD` непосредственно перед выводом) - База: `origin/dev` = `e438c25ebe5ffbd4214da473d62a305016a3f3df` - Ветка приведена к `dev` конвейером до ревью (8 коммитов dev поверх исходного `c4e45aa9`, итог — единственный ребейзнутый коммит `38205d87`). Это другой код (§7.2) — разбор ниже полный, не по дельте. Второго раунда до этого не было, поэтому раздел «Унаследовано из r» не применяется. - Трейлеры коммита `38205d87`: `Issue: #51`, `User-Visible: yes` — оба changelog правлены в этом же коммите (проверено, см. «Гейты»/AC16). ## Скоуп изменения Новый decor kind `image`: приватное content-addressed хранилище PNG/JPEG/WebP/SVG (backend `decor_assets.py`, `http_api.py`, `websocket_api.py`, `validation.py`, `import_export.py`, `const.py`), одна кнопка «Изображение» в Background editor, furniture-parity transforms без wall magnet, palette с явным удалением неиспользуемого файла, отображение в full View и `houseplan-space-card`, missing-asset repair-placeholder, export v2/import v1 совместимость, i18n RU/EN/DE/FR, документация. 63 файла, ~4200/1200 строк. Спека `docs/specs/051-custom-decor-images.md` (16 AC) прошла ревью ТЗ зелёным (`docs/reviews/SPEC-REVIEW-51-r1.md`, единственная находка — Low, косметика). ## Как проверялось Ревью выполнено чтением всего диффа (`git diff origin/dev...HEAD`) плюс двумя параллельными агентами: один разобрал backend security (`decor_assets.py`, `http_api.py`, `websocket_api.py`, `validation.py`, `import_export.py`, `tests_backend/*`), второй — frontend (`decor-assets.ts`, `houseplan-editor-runtime.ts`, `space-render.ts`, `space-geometry.ts`, `houseplan-card.ts`, `backdrop-pick.ts`, `test/decor-assets.test.mjs`, i18n). Их находки перепроверены мной лично там, где это осмысленно (см. ниже): самое серьёзное — воспроизведено собственноручно, не принято на слово. ### Гейты — что прогнано и результат | Гейт | Команда | Результат | |---|---|---| | Typecheck | `npx tsc --noEmit` | зелёный | | Unit | `npm test` | 1775 passed, 1 skipped, 0 failed (1776 подтестов) | | Build | `npm run build` | зелёный | | Bundle sync | `npm run bundle:sync` | зелёный, `git status` после — чисто (три копии совпадают) | | Bundle budget | `npm run bundle:budget` | initial View 291026 B / 300000 B — **прошёл**, но запас 8974 Б < порога 15000 Б (предупреждение скрипта, ссылка на #367). Не блокирует, но следующая фича упрётся в стену бюджета — стоит упомянуть автору | | Backend pure | `python3 -m pytest tests_backend/test_decor_assets.py -q` (без Pillow/HA в песочнице ревьюера) | 21 passed | | Golden | `npm run golden:verify` | 153/153 passed — существующие сцены не пострадали от нового decor-слоя | | Docs fingerprint | не прогонялся отдельно — `npm run build`/`bundle:sync` не жаловались; diff `src/**` есть, но `docs`-скриншот-гейт CI не эмулировался локально (нет соответствующего npm-скрипта в перечне пройденных мной команд) | **не проверял**, см. «Чего не проверял» | | Model invariants (`npm run invariants`) | не прогонялся | diff не трогает `marker.space`, `open_spans`, thickness-записи, `layout` (проверено grep по `src/` и `custom_components/houseplan/*.py` — совпадений нет); decor — не геометрия комнат/стен. Инварианты сочтены нерелевантными, не пропущенными | | smoke_decor_images.mjs, smoke_decor_layer_order.mjs, smoke_furniture.mjs (названы автором) | `node demo/` | OK (переподтверждено) | | Дополнительные smoke по `smoke-select.mjs` (см. ниже) | `node demo/` × 11 | 10 зелёных, **1 красный — см. Finding 2** | | `python -m pytest tests_backend/test_ha_websocket.py test_ha_import_export.py` (полный HA-harness) | — | **не прогонял**: нет `homeassistant`/Pillow в песочнице ревьюера (по канону AGENTS.md — Linux CI/WSL). Разобрано чтением (см. ниже) | ### Выбор дополнительных smoke (`scripts/smoke-select.mjs --base origin/dev --head HEAD`) Инструмент: изменено 10 файлов `src/**`, 146 символов на изменённых строках, порог «широкого» символа — 42. 67 файлов дали **прямое совпадение**, 48 — слабую связь. Полный прогон всех 67 непропорционален задаче (диапазон широк из-за общих символов `_curSpaceCfg`/`_mode`/`_config`/`_saveConfig` — они везде). Прогнал 11 из прямых совпадений, выбранных по риску: `smoke_decor.mjs` (общий decor-контракт), `smoke_furniture_polish.mjs` (transform-код теперь общий с image), `smoke_grid_snap.mjs`, `smoke_hide_layers.mjs` (AC5 `hide_decor`), `smoke_wall_junctions.mjs` (**прецедент #234** — по имени про стыки стен, ранее ловил регресс толщины из внешне не связанного изменения), `smoke_backdrop_guard.mjs` (переиспользован в `backdrop-pick.ts` для upload-диалога), `smoke_space_card.mjs`, `smoke_space_card_bg.mjs` (AC5 static card parity), `smoke_sign_cap.mjs`, `smoke_plan_signed.mjs` (contentsigning), `smoke_infinite_canvas.mjs` (`decorBoxItem`/«Вписать всё»). Результат: 10 OK, **1 упал** — `demo/smoke_decor.mjs` (см. Finding 2). Не прогонял оставшиеся ~56 прямых совпадений и все 48 слабых связей — не пропорционально задаче; полный набор остаётся обязанностью пре-релиза. ## Находки ### Finding 1 (High) — SVG-санитайзер обходится кодировкой UTF-16: entity-bomb достигает `ET.fromstring` в обход собственной защиты от DTD/entity **Файл:** `custom_components/houseplan/decor_assets.py:161-172`. Проверка DTD/entity/PI — это префильтр по байтовым ASCII-подстрокам (`b"`…``, исходный размер ~800 байт), закодировал в UTF-16, скормил `custom_components/houseplan/decor_assets.validate_asset`. Результат: файл ушёл дальше строки 170 (`ET.fromstring` успешно раскрыл entity — сам разросшийся текст, ~32 000 символов, дошёл до элемента `` и был отклонён **только** последующей проверкой длины текста `title`/`desc` — то есть раскрытие уже произошло и уже стоило памяти/CPU **до** того, как сработал любой из декларированных в ТЗ лимитов «element/depth/attribute» (они применяются в цикле по уже построенному дереву, а не во время парсинга). Если поместить растущую сущность не в `<title>`, а в атрибут вроде `d`, до неё вообще не дойдёт ни один из этих лимитов раньше, чем `ET.fromstring` целиком построит раздутое дерево в памяти — при нескольких дополнительных уровнях вложенности из файла кратно меньше 2 МиБ получается экспоненциальный рост в память/CPU, всё в HA executor-потоке (не фризит event loop, но реально грузит воркер и память процесса HA — то есть DoS на типичном для HA маломощном хосте). Это прямой пробой явно заявленного в ТЗ контракта («XML parser запрещает DTD, entity declarations/resolution» и «Risks»/«Streaming byte cap ставится до parse, затем element/depth/... limits») — контракта, который является центральным для issue с меткой `security`. Ни один существующий тест не ловит этот класс обхода: `tests_backend/test_decor_assets.py:38` содержит DOCTYPE/ entity-кейс, но только в ASCII-кодировке — «тест умеет падать» на прямой ASCII атаке, но не умеет упасть на этой (кодировочной) вариации, а значит не доказывает заявленный AC7 целиком. **Направление фикса** (не мне решать точный путь — это техническое предположение автора): использовать `defusedxml.ElementTree.fromstring` (или явно сконфигурировать expat-парсер так, чтобы `StartDoctypeDeclHandler`/ `EntityDeclHandler` фейлили закрыто) вместо байтового префильтра — это единственный способ не зависеть от кодировки на входе. **Блокирует.** AC7 не может считаться доказанным до исправления. ### Finding 2 (Medium, в скоупе) — диф ломает существующий, не относящийся к задаче smoke: `demo/smoke_decor.mjs` **Файлы:** `src/houseplan-card.ts` (диф убирает делегатор), `demo/smoke_decor.mjs` (не тронут этим diff, но полагается на убранный метод). `git diff origin/dev...HEAD -- src/houseplan-card.ts` убирает из класса `HouseplanCard` тонкие делегаторы `_furnPlace`, `_furnPick`, `_furnFieldValue`, `_furnFieldToCm`, `_furnMoveUpdate`, `_decorApplyBox`, `_renderFurnPalette`, `_renderFurniturePlacementPreview` и др. (были вида `private _furnPlace(...) { return this._editorRuntimeOrThrow()._furnPlace(...); }`) — методы теперь живут только внутри `HouseplanEditorRuntime` и вызываются как `this._furnPlace(...)` изнутри самого runtime, что не ломает ни один реальный пользовательский сценарий (пользователь не видит разницы). Но `demo/smoke_decor.mjs:192` вызывает `c._furnPlace(...)` напрямую на элементе карточки — это существующая (не относящаяся к image) конвенция теста. Автор **знал** про эту архитектурную смену: `demo/smoke_furniture.mjs` в этом же diff переписан с `c._furniturePreviewPlacement` на `c._editorRuntime?._furniturePreviewPlacement()` ровно по этой причине. Но `smoke_decor.mjs` пропущен — не входил в список прогнанных автором smoke («node demo/smoke_decor_images.mjs — OK; …smoke_decor_layer_order.mjs — OK; …smoke_furniture.mjs — OK» — `smoke_decor.mjs` в списке нет). **Воспроизведено лично, дисциплина «тест умеет падать» подтверждена в обе стороны:** - на `HEAD` (`38205d87`): `node demo/smoke_decor.mjs` → `TypeError: c._furnPlace is not a function`, процесс падает с ненулевым кодом; - на базе (`origin/dev` = `e438c25e`, отдельный `git worktree`, свежая пересборка): тот же smoke проходит полностью, `OK`. Других мест, вызывающих остальные убранные делегаторы напрямую на `c.`, не нашлось (`grep` по `demo/*.mjs test/*.mjs` — пусто) — поражение точечное, единственный файл. Это не поведенческий дефект для пользователя, но это красный существующий гейт, который переживёт этот раунд ревью незамеченным, если его не запустить специально (что и произошло — автор его не запускал), и всплывёт при следующем полном пре-бета прогоне ровно по сценарию #230/#234/#237 («красный job до следующей задачи»). Фикс механический — тот же паттерн, что уже применён к `smoke_furniture.mjs`. В скоупе задачи (тот же файл `houseplan-card.ts`, тот же коммит), чинится в этом же issue, отдельный issue не заводится (#202). ### Finding 3 (Medium, в скоупе) — «единая проекционная функция» AC5 не буквальна: рендер дублирован между View и Background editor **Файлы:** `src/houseplan-card.ts:8762-8779` (Background editor слой) и `src/space-render.ts:636-655` (View/space-card слой). Оба места независимо собирают одну и ту же трансформ-строку (`translate(cx cy) rotate(angle) scale(±1 ±1) translate(-cx -cy)`) и клэмп `opacity` — но как две раздельные реализации, а не единая функция, которую оба вызывают. Спека прямо требует: «Полный View и `houseplan-space-card` используют одну **projection-функцию**». `houseplan-card.ts` использует `normalizeAngle`, `space-render.ts` — свой `Number(shape.angle) || 0`; сегодня они численно эквивалентны (период вращения, одинаковая трактовка нечисловых значений), видимого дефекта нет. Это ровно класс проблемы из «Одно число — один источник»: #233/#234 родились из двух независимых формул для «одного и того же» значения, которые молча разошлись при последующей правке одной из копий. Конкретный сценарий отказа: кто-то поправит клэмп `opacity` в одном файле (например, изменит fallback с 1 на «унаследовать предыдущее значение») и не тронет второй — тогда full View и `houseplan-space-card` отрисуют один и тот же `DecorImage` с разной прозрачностью, и ни один unit-тест этого не поймает (существующий `furniture-stroke-contract` проверяет паритет preview/placement для мебели, не paritet View/static-card для image). Единственная защита сегодня — ручное внимание при следующей правке. В скоупе (оба файла в этом же diff), чинится в этом же issue сведением к одной экспортируемой функции проекции. ### Finding 4 (Medium, в скоупе) — нет negative/positive-кэша resolve по `asset_id`; явное требование производительности из ТЗ не выполнено **Файл:** `src/decor-assets.ts` (`resolveDecorAssets`, batching по 200, дедупликация `Set` — есть), вызовы `houseplan-card.ts:4310` и `space-card.ts:732`. Спека, раздел «Производительность»: «Missing ids также negative-cache-ятся на текущую config revision». Кэша (ни отрицательного, ни положительного) нет: `_syncDecorAssets`/`_load` перевызывают `houseplan/assets/resolve` при каждом `onConfigChange`/`_reloadConfigOnly`, включая случаи, когда набор `decorAssetIds` не изменился и/или id уже был подтверждён отсутствующим секундой раньше. Конкретный сценарий: план с одним missing image; любая структурная правка в редакторе (подвинуть мебель, переименовать комнату) шлёт `resolve` заново на тот же уже-известный-missing id — вместо нуля запросов на не относящуюся к декору правку получаем один WS-вызов на каждое изменение. AC14 (1000 записей, ограниченный resolve) формально проходит благодаря одной только батч-дедупликации на батч в 200 — тест не проверяет повторные вызовы между разными конфиг-ревизиями, поэтому пропускает именно то отклонение, которое здесь описано. В скоупе (тот же новый файл), не архитектурный блокер, но явно недовыполненный пункт спеки — годится для правки в этом же issue. ### Finding 5 (Low) — тач-committed размещение всегда использует mouse-допуск снапа, не touch `src/houseplan-editor-runtime.ts:5130-5150`: `_decorImagePlace` вызывает `this._decorSnap(raw)` без `pointerType`, получая допуск по умолчанию (`'mouse'`, ~8px) даже когда коммит пришёл с тач-пути (`_furnPointerUp` не прокидывает `pending.pointerType`, в отличие от `_furnPlace`, который явно форвардит его дальше). Практический эффект — тач-размещение картинки получает более тесный магнит, чем предусмотренный touch-радиус (~14px); паритет с мебелью небольшой, не架构ный. Можно поправить или снять с записью. ### Finding 6 (Low, информационно, не находка) — SVG: числовые/строковые bounds отдельных атрибутов не проверяются, только допустимость имени `custom_components/houseplan/decor_assets.py` ограничивает суммарный бюджет символов атрибутов (`MAX_SVG_ATTR_CHARS=512000`) на весь документ, но не проверяет диапазон/конечность значения отдельных атрибутов (`transform`, `stroke-width`, `opacity` и т. п. — только их допустимость по имени). Спека формулирует это как «числовые/строковые bounds» для этих атрибутов. Поскольку SVG вставляется только как `<image>` (изолированный растровый контекст в браузере, не inline DOM — см. п. 6 «Отображение и слои»), практический риск — не XSS/SSRF, а максимум деградация рендера самого файла в собственном изолированном контексте. Не блокирует, не Same класс, что Finding 1 (там пробита реальная защита от конкретной атаки; здесь просто не реализован явно заявленный, но не критичный по факту изоляции пункт спеки). Годится либо поправить, либо явно снять с owner-note в спеке «принято предположительно». ### Finding 7 (Low, информационно) — javascript:/data:-substring проверка в SVG сама по себе тривиально обходима, но не является рабочей защитой `decor_assets.py:207-209`: проверка `low` на подстроки `javascript:`, `data:`, `http:`, `https:`, `//` теоретически обходится непечатным символом внутри схемы (например, табуляцией), но это не единственная защита — `href` дополнительно ограничен строгим `^#id`, и ни один другой allowlisted атрибут не трактуется как исполняемый URL-приёмник. Не эксплуатируется при текущем allowlist. Отмечено для полноты, не как отдельный блокер. ## Проверено и корректно (по AC) - **AC1** (кнопка/palette/one-shot) — подтверждено чтением + `demo/smoke_decor_images.mjs` (прогнан, OK) + переподтверждено фронтенд-агентом построчно (`houseplan-editor-runtime.ts:5875`, `:5130-5148`, `:5909`). - **AC2** (100 см / aspect / cap 200) — `initialDecorImageCm` (`src/decor-assets.ts:66-76`) единая функция и для preview, и для commit; unit `test/decor-assets.test.mjs` покрывает cap-кейс реальными числами (400×200→100×50, 100×400→50×200 с реальным capping, не тавтология). - **AC3** (furniture-parity, без wall magnet, hit-area — весь прямоугольник) — подтверждено: wall magnet явно гейтится `kind === 'furniture'` (`houseplan-editor-runtime.ts:4316`), image идёт через общий decor/room-магнит; `pointer-events: bounding-box` в `src/styles/plan.styles.ts` для `.dimage`/`.dimage-missing`. Демо-негативный свидетель есть (`noFurnitureWallMagnet`), хоть и не самый сильный (см. отчёт агента — не заводит отдельную находку, слабое место без конкретного сценария отказа). - **AC4** (properties/replace/no-delete-on-replace) — подтверждено чтением (`_decorSaveShape` пишет только по `shape.id`, replace меняет только `asset_id` выбранного объекта) + smoke. - **AC5** (общий рендер, hide_decor, пассивность) — hide_decor и pointer-inert подтверждены в обоих файлах; «одна проекционная функция» — см. Finding 3 (дублирование, не расхождение сегодня). - **AC6** (raster/SVG сигнатуры, лимиты, атомарность) — подтверждено чтением `decor_assets.py`/`http_api.py` + `test_ha_websocket.py` (согласно отчёту агента: concurrent-upload dedup тест реально бы упал без lock/atomic-rename) — проверено чтением, не исполнением (HA-harness недоступен в песочнице ревьюера). - **AC7** (SVG allowlist, sandbox headers) — **не полностью доказан**, см. Finding 1 (High). Заголовки (exact MIME, nosniff, sandbox CSP) подтверждены чтением `http_api.py:195,211-213`. - **AC8** (content-addressed id, dedup) — подтверждено: `asset_id` есть sha256 канонических байт (`decor_assets.py:71-72`), resolve/GET перепроверяют хэш на каждое чтение. - **AC9** (server-side refcount, in_use, no auto-cleanup) — подтверждено: delete не принимает client refcount вообще, пересчитывает под `write_lock`+`upload_lock` из `config_store` (`websocket_api.py:1164-1190`); `test_ha_websocket.py` содержит race-тест на `in_use` и идемпотентность — проверено чтением утверждений теста, не исполнением. - **AC10** (missing asset/repair) — подтверждено: View возвращает `[]` при пустом `href`, editor рисует selectable placeholder с «Заменить»; smoke покрывает. - **AC11** (export v2/import v1, manifest без байт) — подтверждено чтением `import_export.py`: `_internal_path` строгий regex на 64-hex+расширение (защита от path traversal), `content_manifest` пересчитывает hash с диска (не доверяет заявленному), импорт помечает `missing_preserved` без удаления geometry. - **AC12** (rolling compatibility) — backend: схема аддитивна (`vol.Any`), `decor_assets_api: 1` отдаётся безусловно; frontend fail-closed gate подтверждён (`houseplan-card.ts:4300-4306`, `_haDecorAssetsApi` сверяется с `DECOR_ASSETS_API_VERSION` перед показом кнопки/upload). - **AC13** (i18n паритет) — подтверждено программно: ключи en/ru/de/fr идентичны построчно (сверка множеств ключей, разницы нет). - **AC14** (bounded batched resolve) — батчинг/дедуп на вызов подтверждён; явно заявленный negative-cache отсутствует, см. Finding 4. - **AC15** (гейты зелёные, targeted smoke до ревью) — typecheck/test/build зелёные лично переподтверждены; targeted-смоки, названные автором, зелёные; smoke_decor.mjs красный — см. Finding 2 (это не входит в AC15 буквально, так как AC15 требует только «targeted image smoke», но красный существующий smoke — самостоятельная находка). - **AC16** (документация/changelog) — подтверждено: `DECOR-EDITOR.md`, `ARCHITECTURE.md`, `CONFIG-COMPATIBILITY.md`, `USER-GUIDE.md`/`.ru.md`, `TESTING.md`, `TESTING-DEMO.md` правлены; оба changelog в этом же `User-Visible: yes` коммите (проверено `git show --format=%b`). ## Чего не проверял - Полный HA-harness (`tests_backend/test_ha_websocket.py`, `test_ha_import_export.py`, concurrency/race-тесты, golden HA-фикстуры) — в песочнице ревьюера нет `homeassistant`/Pillow (по канону AGENTS.md это Linux CI/WSL-only гейт), запуск дал бы молчаливый skip, а не доказательство. Разобрано **чтением** утверждений тестов и сверкой с кодом (см. AC6/AC9/AC11 выше) — не исполнением. - Полный `demo/smoke_*` набор (214 файлов, 67 прямых совпадений) — прогнал 14 из них (3 названных автором + 11 отобранных по риску), не 67. Оставшиеся ~53 прямых совпадения и 48 слабых связей не прогнаны — непропорционально объёму задачи; остаются обязанностью пре-бета прогона (AGENTS.md). Вывод `smoke-select.mjs` приложен выше целиком со списком «прямое совпадение»/ «слабая связь», решение по каждой из непрогнанных строки не расписано построчно — учтён общий довод (общие символы `_mode`/`_config` дают широкий, малоинформативный список). - Performance smoke / large-house 1000-record профиль (AC14 численно) — не прогонял; это предрелизный гейт, доказательство AC14 принято чтением батчинг-кода + unit-теста дедупликации. - `npm run docs:check`/`scripts/check-docs.mjs` (screenshot fingerprint) — не нашёл отдельного npm-скрипта с таким именем в `package.json`, не прогонял отдельно; `npm run build`/`bundle:sync` не жаловались на fingerprint-рассинхрон, но это не то же самое утверждение. - Точный численный порог AC2 «bounds» на промежуточных `cell_cm`/DPI комбинациях сверх того, что покрывает `test/decor-assets.test.mjs`, — принял unit-покрытие как достаточное доказательство, не гонял ручной матрицы. - Touch/accessibility (AC13 non-i18n часть: 44×44 таргеты, focus trap, aria) — не тестировал в браузере руками (ручного тестирования в цикле нет по процессу); принял на основе чтения CSS/aria-атрибутов в diff, не верифицировал реальным screen-reader/touch-эмулятором. ## Материал раунда - Ветка на момент вывода: `38205d87a6e65bf670e214050223a9e22a9d2a42` (сверено `git rev-parse HEAD` непосредственно перед выводом — совпадает). - База: `origin/dev` = `e438c25ebe5ffbd4214da473d62a305016a3f3df`. - Предыдущего кодового раунда не было (это r1 code review); раздел «Закрытие предыдущего раунда» неприменим. --- <!-- material-anchors: сгенерировано конвейером (#414) --> ## Материал раунда - Ветка: `issue/51-custom-decor-images`, коммит `c4e45aa99c63` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. - Дерево материала: `5fe4f2859927979f0fdec07a24b989a67c5ccb38` ``` git log --all --format='%H %T' | grep 5fe4f2859927 ```