mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
HP-1454-01 (high, release blocker): an uploaded SVG plan opened directly is a top-level document of Home Assistant's own origin, so a <script> inside it reaches the session's localStorage and API. Uploading needs write access, which by default every authenticated user has. SVG responses now carry a sandbox CSP; only SVG, because a CSP on a PDF can break the browser's viewer and a raster image has nothing to disable. Verified in Chromium both ways: the script runs without the header and does not with it. HP-1454-02: attachment uploads wrote straight to <marker>/<filename>, outside the config transaction — a cancelled dialog or a rejected save left the stored url serving new bytes, and every new icon shared one 'new' folder, so two of them attaching manual.pdf pointed at one file. Uploads take a free name, a new icon gets a per-dialog staging folder promoted on an accepted save, and config/set collects superseded and aged-orphan attachments like it does plans. HP-1454-03: the debounce spaced out the starts of a write, not the writes. A save slower than 500 ms let the next edit go out with the same expected_rev; the server accepted the first, rejected the second, and the conflict handler reloaded over the local copy. Writes are chained now — one in flight, each with the revision the previous returned. HP-1454-04: _openPairsCache keyed on room ids and links only, so an aspect change or a dragged vertex left open boundaries and their glow cuts at old coordinates. It keys on the rendered model object now — the same invalidation the model cache already has, not a second strategy. The fingerprint also gained an O(1) geometry roll-up per room. HP-1454-05: outer collections were capped, inner ones were not. Limits for poly points, open_to, controls, pdfs, text and url lengths, plus a total serialized size cap; legacy is dropped server-side. HP-1454-06: upload streams to a temp file and downloads use FileResponse, so a 50 MB manual no longer costs ~100 MB of RSS per transfer. HP-1454-07: spaceModels() dropped room.settings, so the static card ignored the per-room fill override. HP-1454-08: layout had no revision on point-wise writes and no event, leaving static cards stale forever; it now keeps a revision, returns it and fires houseplan_layout_updated. HP-1454-09: repair cleanup only walked existing spaces, so a deleted space kept its warning. HP-1454-10: serialize-javascript pinned past two advisories. Tests: smoke_svg_sandbox (proves both directions), smoke_config_writer and smoke_render_parity (both verified failing against a v1.45.4 build), six pure tests for attachment collection and inner limits, four HA-harness tests for the CSP, non-overwriting uploads, the size cap and layout revisions. Docs: CHANGELOG.md + CHANGELOG.ru.md + ARCHITECTURE.md + TESTING.md + STATUS.md.
68 lines
2.8 KiB
JavaScript
68 lines
2.8 KiB
JavaScript
// HP-1454-03: две локальные правки уходили с одной ревизией, вторая терялась.
|
|
// Debounce разносил только СТАРТЫ. Если первый config/set отвечал дольше 500 мс,
|
|
// вторая правка уходила с тем же expected_rev, сервер принимал первую и
|
|
// отклонял вторую как conflict — а обработчик конфликта перечитывал серверную
|
|
// копию поверх локальной. Правка исчезала, и тост винил «другое окно».
|
|
import { launch, checkAll, finish } from './serve.mjs';
|
|
const { page, browser } = await launch();
|
|
const res = await page.evaluate(async () => {
|
|
const out = {};
|
|
const c = window.__card;
|
|
const base = c.hass.callWS;
|
|
const writes = [];
|
|
let rev = 10;
|
|
let releaseFirst;
|
|
const firstGate = new Promise((r) => { releaseFirst = r; });
|
|
|
|
c.hass = { ...c.hass, callWS: async (m) => {
|
|
if (m.type === 'houseplan/config/set') {
|
|
const n = writes.length + 1;
|
|
writes.push({ expected: m.expected_rev, titles: m.config.spaces.map((s) => s.title) });
|
|
if (n === 1) await firstGate; // первый ответ задержан
|
|
if (m.expected_rev !== rev) { const e = new Error('conflict'); e.code = 'conflict'; throw e; }
|
|
rev += 1;
|
|
return { ok: true, rev };
|
|
}
|
|
if (m.type === 'houseplan/config/get') {
|
|
const r = await base(m);
|
|
return { config: JSON.parse(JSON.stringify(r.config)), rev };
|
|
}
|
|
return base(m);
|
|
} };
|
|
c._cfgRev = rev;
|
|
|
|
// правка №1 и, пока первая запись висит, правка №2
|
|
c._serverCfg.spaces[0].title = 'FIRST';
|
|
c._saveConfig();
|
|
c._saveConfigDebounced.flush();
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
out.oneInFlight = writes.length === 1;
|
|
|
|
c._serverCfg.spaces[0].title = 'SECOND';
|
|
c._saveConfig();
|
|
c._saveConfigDebounced.flush();
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
out.stillOneInFlight = writes.length === 1; // вторая ждёт очереди, не летит параллельно
|
|
|
|
releaseFirst();
|
|
await new Promise((r) => setTimeout(r, 120));
|
|
|
|
out.writes = writes.length;
|
|
out.revisions = writes.map((w) => w.expected); // вторая обязана взять новую ревизию
|
|
out.secondCarriedTheEdit = writes[1]?.titles[0] === 'SECOND';
|
|
out.editSurvived = c._serverCfg.spaces[0].title === 'SECOND';
|
|
out.noConflictToast = !(c._toast || '').length;
|
|
return out;
|
|
});
|
|
// зафиксировано прогоном на v1.46.0 и сверено с кодом
|
|
checkAll(res, {
|
|
oneInFlight: true,
|
|
stillOneInFlight: true,
|
|
writes: 2,
|
|
revisions: [10, 11],
|
|
secondCarriedTheEdit: true,
|
|
editSurvived: true,
|
|
noConflictToast: true,
|
|
});
|
|
await finish(browser);
|