diff --git a/CHANGELOG.md b/CHANGELOG.md index 2da7556..a9c7228 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,7 +53,7 @@ IMPORTANT NOTE: This version only works on CACTI 1.x++! * feature#311: Simplify link interface/source/hover workflows, limit the form to two previews, add node display-name positioning, move comment colours to Manage Colours and clarify raw-edit controls * feature#312: Make hover graphs individually clickable and sharper on high-density displays without changing their displayed size * feature#313: Bundle transparent 96px access-point, NAS, server, printer, CCTV recorder, UPS and CCTV camera node icons -* feature#307: Add two-step Undo and Redo for saved map edits, with keyboard shortcuts and protection against stale restores (PR #316) +* feature#307: Add two-step Undo and Redo per editor tab for saved node, link, note and map-setting edits, with keyboard shortcuts and protection against stale restores (PR #316) * feature#302: Add traffic colour presets and optional high-load link pulsing * feature#303: Add separate icon and background image upload and management libraries * feature#301: Add editable map notes with alignment and sharp text at logical map dimensions diff --git a/css/editor.css b/css/editor.css index e5b0ebf..503655f 100644 --- a/css/editor.css +++ b/css/editor.css @@ -419,6 +419,49 @@ body#mainView .wm-icon-choice[aria-pressed="true"] { border-color: var(--text-color-highlight-a, #2874aa); } +/* History uses the same square, transparent tiles as the editor toolbar. */ +#toolbar ul li.wm-history-controls { + float: right; + display: flex; + gap: 0; + padding: 0; +} +#toolbar .wm-history-controls button { + appearance: none; + box-sizing: border-box; + height: 39px; + min-width: 55px; + margin: 0; + padding: 5px 10px; + border: 1px solid var(--border-color, rgba(255, 255, 255, 0.3)); + border-radius: 0; + background: transparent; + box-shadow: none; + color: inherit; + font: inherit; + line-height: normal; + text-align: center; + white-space: nowrap; + cursor: pointer; +} +#toolbar .wm-history-controls button:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.15); +} +#toolbar .wm-history-controls button:disabled { + opacity: .5; + cursor: default; +} +.wm-visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} /* Link properties has one scrolling body and a persistent action row. */ #dlgLinkProperties { overflow: hidden !important; diff --git a/docs/editor-workflow.md b/docs/editor-workflow.md index 87c7cee..445859a 100644 --- a/docs/editor-workflow.md +++ b/docs/editor-workflow.md @@ -26,3 +26,36 @@ graph only enables **Add** and **Replace**; it does not apply it automatically. Delete controls are separate from Save and Cancel. Confirmation identifies the item by display name and explains what is removed. Deleting a node also removes its connected map links. Cacti devices and graphs remain. + + +## Undo and Redo + +Use **Undo** and **Redo** at the top right of the editor toolbar to reverse or +reapply the last two saved map changes. Each completed move, placement or saved +dialog counts as one action. Buttons are disabled when no matching action is +available; their tooltips identify the next action. Keyboard shortcuts are +**Ctrl/Cmd+Z** for Undo and **Ctrl/Cmd+Shift+Z** for Redo. Text fields retain their +normal text-editing shortcuts. Save or cancel an open dialog first. + +History covers map configuration changes: nodes, connected links, notes, via +points, labels, icons, map properties, colours and raw object configuration. +Undoing a node deletion restores its connected map links too. A new change after +Undo clears Redo. No-op saves and failed saves do not consume a step. + +History belongs to the current browser tab, map and authenticated session. +Duplicated tabs start a separate history; reloading the same tab keeps it. +Tab ownership uses Web Locks, with a SharedWorker fallback for HTTP installations. +If neither browser API is available, history stays disabled and normal editing +continues. Changes made elsewhere invalidate it rather than overwriting someone else's +work. Included configuration files and managed images are checked before a +restore, including image references inherited through INCLUDE files. Older +snapshots without a complete image-reference list are discarded safely. +Uploading or deleting library files, deleting map files, Cacti devices, +graphs and personal editor preferences are outside this history. + +Snapshots are stored privately outside the web root, not in browser data or +Cacti's concurrently written session rows. They expire after four hours of +inactivity. Storage retains up to eight recently changed maps/tabs and 8 MiB per +session; older histories are discarded when either limit is reached. Temporary +storage must be writable by the PHP service account. No database upgrade is +required. diff --git a/js/editor.history.js b/js/editor.history.js new file mode 100644 index 0000000..017fed0 --- /dev/null +++ b/js/editor.history.js @@ -0,0 +1,141 @@ +/* + * Copyright (C) 2004-2026 The Cacti Group. + * Licensed under the GNU General Public License, version 2 or later. + */ +(function(root) { + 'use strict'; + + function randomId(win) { + return Array.from(win.crypto.getRandomValues(new Uint8Array(16)), function(byte) { return byte.toString(16).padStart(2, '0'); }).join(''); + } + + // sessionStorage is copied when a tab is duplicated. An origin-wide lock gives + // each live document exclusive ownership while preserving IDs across reloads. + function claimHistoryId(win, storageKey) { + var id = win.sessionStorage.getItem(storageKey); + if (!/^[a-f0-9]{32}$/.test(id || '')) id = randomId(win); + if (!win.navigator || !win.navigator.locks) { + // SharedWorker also works on HTTP installations where Web Locks is absent. + var worker = new win.SharedWorker(new URL('js/editor.history.worker.js', new URL(win.editor_url, win.location.href)), 'weathermap-history'); + return new Promise(function(resolve, reject) { + worker.onerror = reject; + worker.port.onmessage = function(event) { + if (!event.data.granted) { id = randomId(win); claim(); return; } + try { win.sessionStorage.setItem(storageKey, id); } + catch (error) { worker.port.postMessage({release: true}); worker.port.close(); reject(error); return; } + win.addEventListener('pagehide', function() { worker.port.postMessage({release: true}); worker.port.close(); }, {once: true}); + win.addEventListener('pageshow', function(event) { if (event.persisted) win.location.reload(); }); + resolve(id); + }; + function claim() { worker.port.postMessage({key: storageKey + ':' + id}); } + worker.port.start(); + claim(); + }); + } + return new Promise(function(resolve, reject) { + function claim(candidate) { + win.navigator.locks.request(storageKey + ':' + candidate, {ifAvailable: true}, function(lock) { + if (!lock) { claim(randomId(win)); return; } + win.sessionStorage.setItem(storageKey, candidate); + return new Promise(function(release) { + win.addEventListener('pagehide', release, {once: true}); + // A document restored from the back/forward cache must reacquire ownership. + win.addEventListener('pageshow', function(event) { if (event.persisted) win.location.reload(); }); + resolve(candidate); + }); + }).catch(reject); + } + claim(id); + }); + } + + async function init($, win) { + if (!$('#wm-undo').length || typeof win.WMConfigRevision !== 'string') return; + var id, initializing = true, pending = 0, state = {undo: 0, redo: 0}; + var text = win.wmEditorText; + + function render() { + ['undo', 'redo'].forEach(function(direction) { + var label = direction === 'undo' ? text.historyUndo : text.historyRedo; + $('#wm-' + direction).prop('disabled', pending > 0 || !state[direction]) + .attr('title', state[direction] ? label + ': ' + state[direction + 'Label'] : text.historyEmpty); + }); + $('#wm-history-status').text(text.historyAvailable.replace('%s', state.undo).replace('%s', state.redo)); + } + + function request(action) { + var values = {action: action, mapname: $('#mapname').val(), config_revision: win.WMConfigRevision}; + if (typeof win.csrfMagicToken !== 'undefined') values.__csrf_magic = win.csrfMagicToken; + return $.ajax({url: win.editor_url, type: 'POST', dataType: 'json', data: values}); + } + + // All editor save paths, including Manage Colors, receive the tab's history ID. + $.ajaxPrefilter(function(options, original, xhr) { + var destination = new URL(options.url, win.location.href); + var editor = new URL(win.editor_url, win.location.href); + if (options.type.toUpperCase() !== 'POST' || destination.href !== editor.href || options.processData === false) return; + var data = new URLSearchParams(options.data || ''); + var action = data.get('action'); + var readOnly = ['history_status', 'graphs', 'datasources', 'image_data', 'colour_data', 'delete_image'].indexOf(action) !== -1; + if (initializing && !readOnly) { xhr.abort(); return; } + if (!id) return; // Ownership failure leaves normal editing available. + data.set('history_id', id); + options.data = data.toString(); + if (readOnly) return; + pending++; + render(); + xhr.always(function() { pending--; render(); }); + }); + + // Prevent editing while the worker/lock owner is being established. + var wasInert = win.document.body.inert; + win.document.body.inert = true; + try { + var storageKey = 'weathermap-history:' + win.location.pathname; + id = await claimHistoryId(win, storageKey); + } catch (error) { + $('#wm-undo,#wm-redo').attr('title', text.historyStorage); + return; + } finally { + initializing = false; + win.document.body.inert = wasInert; + } + + function apply(direction) { + if (pending || !state[direction]) return; + if ($('.ui-dialog-content:visible').length) { + win.alert(text.historyDialog); + return; + } + request(direction).done(function() { + // Always fetch a new document, including when the current URL has a fragment. + var url = new URL(win.location.href); + url.searchParams.set('mapname', $('#mapname').val()); + url.searchParams.set('action', 'nothing'); + url.searchParams.delete('config_revision'); + url.searchParams.delete('__csrf_magic'); + url.hash = ''; + if (win.location.href.split('#')[0] === url.href) win.location.reload(); + else win.location.replace(url.href); + }).fail(function(xhr) { + state = {undo: 0, redo: 0}; + render(); + win.alert(xhr.responseJSON && xhr.responseJSON.error || text.saveError); + }); + } + + $('#wm-undo').on('click.wmhistory', function() { apply('undo'); }); + $('#wm-redo').on('click.wmhistory', function() { apply('redo'); }); + $(win.document).on('keydown.wmhistory', function(event) { + if (!(event.ctrlKey || event.metaKey) || event.altKey || event.key.toLowerCase() !== 'z') return; + if ($(event.target).closest('input,textarea,select,[contenteditable]:not([contenteditable="false"])').length || $('.ui-dialog-content:visible').length) return; + var direction = event.shiftKey ? 'redo' : 'undo'; + if (!pending && state[direction]) { event.preventDefault(); apply(direction); } + }); + request('history_status').done(function(data) { state = data; render(); }) + .fail(function() { $('#wm-undo,#wm-redo').attr('title', text.saveError); }); + } + + if (typeof module !== 'undefined' && module.exports) module.exports = {init: init, claimHistoryId: claimHistoryId}; + else root.jQuery(function() { init(root.jQuery, root); }); +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/js/editor.history.worker.js b/js/editor.history.worker.js new file mode 100644 index 0000000..cdf50be --- /dev/null +++ b/js/editor.history.worker.js @@ -0,0 +1,24 @@ +/* Copyright (C) 2004-2026 The Cacti Group. GPL-2.0-or-later. */ +'use strict'; +// One owner per tab history, including on HTTP where Web Locks is unavailable. +// Only opaque browser IDs live here; map snapshots remain on the server. +var owners = new Map(); +function connect(event) { + var port = event.ports[0], owned; + port.onmessage = function(message) { + if (message.data.release) { + if (owned && owners.get(owned) === port) owners.delete(owned); + port.close(); + return; + } + var key = message.data.key; + if (typeof key !== 'string' || owned) return; + var granted = !owners.has(key); + if (granted) { owners.set(key, port); owned = key; } + port.postMessage({granted: granted}); + }; + port.start(); +} + +if (typeof module !== 'undefined' && module.exports) module.exports = connect; +else self.onconnect = connect; diff --git a/lib/WeatherMap.class.php b/lib/WeatherMap.class.php index 81e4291..e783ae2 100644 --- a/lib/WeatherMap.class.php +++ b/lib/WeatherMap.class.php @@ -3691,15 +3691,10 @@ function WriteDataFile($filename) { } } - /** - * @param string $filename - * @param bool $createOnly Require a new destination. - * @param string|null $expectedRevision Optional request snapshot revision. - * @param resource|null $heldLock Caller-owned destination directory mutex. - * @return bool + /** Generate the normal configuration text without writing a destination. + * @return string|false */ - function WriteConfig($filename, $createOnly = false, $expectedRevision = null, $heldLock = null) { - require_once __DIR__ . '/WeatherMap.config.php'; + function ConfigText() { $fd = fopen('php://temp', 'w+b'); $output = ''; @@ -3946,44 +3941,50 @@ function WriteConfig($filename, $createOnly = false, $expectedRevision = null, $ $contents = stream_get_contents($fd); $closed = fclose($fd); - $this->config_asset_conflict = false; - $savedAssets = $contents !== false ? $this->ManagedImagePaths($contents) : []; - $sameDestination = \WeathermapConfig\sameFile($filename, $this->configfile); - $newAssets = ($createOnly || !$sameDestination) ? array_unique(array_merge($this->ManagedImagePaths(), $savedAssets)) : array_diff($savedAssets, $this->config_managed_assets); - $validate = function () use ($newAssets) { - foreach ($newAssets as $asset) { - if (preg_match('/\{[^}]+\}/', $asset)) continue; - if (!is_file(dirname(__DIR__) . '/' . $asset)) { - $this->config_asset_conflict = true; - return false; - } + return $write_ok && $contents !== false && $closed ? $contents : false; + } + return false; + } + + /** Save generated configuration using the existing atomic revision checks. */ + function WriteConfig($filename, $createOnly = false, $expectedRevision = null, $heldLock = null) { + require_once __DIR__ . '/WeatherMap.config.php'; + $contents = $this->ConfigText(); + if ($contents === false) return false; + + $this->config_asset_conflict = false; + $savedAssets = $this->ManagedImagePaths($contents); + $sameDestination = \WeathermapConfig\sameFile($filename, $this->configfile); + $newAssets = ($createOnly || !$sameDestination) ? array_unique(array_merge($this->ManagedImagePaths(), $savedAssets)) : array_diff($savedAssets, $this->config_managed_assets); + $validate = function () use ($newAssets) { + foreach ($newAssets as $asset) { + if (preg_match('/\{[^}]+\}/', $asset)) continue; + if (!is_file(dirname(__DIR__) . '/' . $asset)) { + $this->config_asset_conflict = true; + return false; } - return true; - }; - $sameFile = \WeathermapConfig\sameFile($filename, $this->configfile); - $dependencies = $this->config_dependencies; - foreach ($this->config_root_identity as $logical => $state) { - $dependencies[$logical] = $state; - if ($logical !== $state['target']) $dependencies[$state['target']] = $state['revision']; - } - if (!$sameFile && $this->configfile !== '' && is_string($this->config_revision)) { - $logicalSource = \WeathermapConfig\logicalPath($this->configfile); - $physicalSource = realpath($this->configfile) ?: $logicalSource; - if (!isset($dependencies[$physicalSource])) $dependencies[$physicalSource] = $this->config_revision; - if ($logicalSource !== $physicalSource && !isset($dependencies[$logicalSource])) $dependencies[$logicalSource] = ['target' => $physicalSource, 'revision' => $this->config_revision]; - } - $saved = $write_ok && $contents !== false && $closed && \WeathermapConfig\atomicWrite($filename, $contents, $sameFile, $expectedRevision ?? ($sameFile ? $this->config_revision : null), $createOnly, $heldLock, $dependencies, $validate); - if ($saved) $this->config_managed_assets = $savedAssets; - if ($saved && $sameFile) $this->config_revision = hash('sha256', $contents); - if ($saved) foreach ($this->config_root_identity as $logical => $state) { - if (realpath($filename) === $state['target']) $this->config_root_identity[$logical]['revision'] = hash('sha256', $contents); } - return $saved; - } else { - wm_warn("Couldn't open config file $filename for writing"); - - return (false); + return true; + }; + $sameFile = \WeathermapConfig\sameFile($filename, $this->configfile); + $dependencies = $this->config_dependencies; + foreach ($this->config_root_identity as $logical => $state) { + $dependencies[$logical] = $state; + if ($logical !== $state['target']) $dependencies[$state['target']] = $state['revision']; + } + if (!$sameFile && $this->configfile !== '' && is_string($this->config_revision)) { + $logicalSource = \WeathermapConfig\logicalPath($this->configfile); + $physicalSource = realpath($this->configfile) ?: $logicalSource; + if (!isset($dependencies[$physicalSource])) $dependencies[$physicalSource] = $this->config_revision; + if ($logicalSource !== $physicalSource && !isset($dependencies[$logicalSource])) $dependencies[$logicalSource] = ['target' => $physicalSource, 'revision' => $this->config_revision]; + } + $saved = \WeathermapConfig\atomicWrite($filename, $contents, $sameFile, $expectedRevision ?? ($sameFile ? $this->config_revision : null), $createOnly, $heldLock, $dependencies, $validate); + if ($saved) $this->config_managed_assets = $savedAssets; + if ($saved && $sameFile) $this->config_revision = hash('sha256', $contents); + if ($saved) foreach ($this->config_root_identity as $logical => $state) { + if (realpath($filename) === $state['target']) $this->config_root_identity[$logical]['revision'] = hash('sha256', $contents); } + return $saved; } // pre-allocate colour slots for the colours used by the arrows diff --git a/lib/editor.actions.php b/lib/editor.actions.php index 23a4926..20dcd46 100644 --- a/lib/editor.actions.php +++ b/lib/editor.actions.php @@ -41,6 +41,7 @@ */ require_once __DIR__ . '/WeatherMap.config.php'; +require_once __DIR__ . '/editor.history.php'; /** editor.actions.php * @@ -89,7 +90,7 @@ function wm_editor_read_config($map, $mapfile, $requireRevision = false) { * @return bool Whether the complete configuration was saved. */ function wm_editor_write_config($map, $mapfile, $createOnly = false) { - if (!$map->WriteConfig($mapfile, $createOnly, $map->editor_config_revision)) { + if (!\WeathermapEditorHistory\write($map, $mapfile, $createOnly, $map->editor_config_revision)) { $current = is_file($mapfile) ? @hash_file('sha256', $mapfile) : false; $stale = $map->config_asset_conflict || !\WeathermapConfig\dependenciesMatch(array_merge($map->config_dependencies, $map->config_root_identity)) || $map->editor_config_revision !== null && $current !== false && $current !== $map->editor_config_revision; http_response_code($stale ? 409 : 500); @@ -1304,7 +1305,7 @@ function wm_editor_colours($mapfile, $action) { $data = json_decode($payload, true); if (!is_array($data)) throw new InvalidArgumentException(__('Invalid colour settings.', 'weathermap')); wm_asset_save_colours($map, $data); - if (!$map->WriteConfig($mapfile, false, $map->config_revision, $lock)) { + if (!\WeathermapEditorHistory\write($map, $mapfile, false, $map->config_revision, $lock)) { $current = @hash_file('sha256', $mapfile); if ($current !== $map->config_revision || !\WeathermapConfig\dependenciesMatch(array_merge($map->config_dependencies, $map->config_root_identity))) { http_response_code(409); diff --git a/lib/editor.history.php b/lib/editor.history.php new file mode 100644 index 0000000..cbd6696 --- /dev/null +++ b/lib/editor.history.php @@ -0,0 +1,319 @@ + $entry) { + if ($entry['time'] < time() - 14400 || !hasAssetMetadata($entry)) unset($histories[$key]); + } + while (count($histories) > 8 || strlen(json_encode($histories, JSON_THROW_ON_ERROR)) > 8 * 1024 * 1024) array_shift($histories); + $GLOBALS['weathermap_editor_history'] = $histories; +} + +/** Older snapshots cannot prove which assets their INCLUDE files reintroduce. */ +function hasAssetMetadata($entry) { + foreach (array_merge($entry['undo'] ?? [], $entry['redo'] ?? []) as $step) { + if (!is_array($step['snapshot']['assets'] ?? null)) return false; + } + return true; +} + +/** Read exact root bytes and bind them to the loaded includes and path identities. */ +function snapshot($map, $mapfile) { + $contents = @file_get_contents($mapfile); + if (!is_string($contents) || hash('sha256', $contents) !== $map->config_revision) return null; + return [ + 'contents' => base64_encode($contents), + 'root' => $map->config_revision, + // This is the complete asset list captured at ReadConfig(), before edits. + 'assets' => $map->config_managed_assets, + 'dependencies' => $map->config_dependencies, + 'identity' => $map->config_root_identity, + 'revision' => \WeathermapConfig\snapshotRevision($map->config_revision, $map->config_dependencies, $map->config_root_identity) + ]; +} + +/** Describe a completed map action in toolbar tooltips. */ +function actionLabel() { + $labels = [ + 'add_node' => \__('Add node', 'weathermap'), 'clone_node' => \__('Clone node', 'weathermap'), + 'delete_node' => \__('Delete node', 'weathermap'), 'move_node' => \__('Move node or note', 'weathermap'), + 'add_link2' => \__('Add link', 'weathermap'), 'delete_link' => \__('Delete link', 'weathermap'), + 'via_link' => \__('Move link via point', 'weathermap'), 'link_tidy' => \__('Tidy link', 'weathermap'), + 'retidy' => \__('Tidy links', 'weathermap'), 'retidy_all' => \__('Tidy all links', 'weathermap'), + 'untidy' => \__('Untidy links', 'weathermap'), 'set_note_properties' => \__('Edit note', 'weathermap'), + 'set_node_properties' => \__('Edit node', 'weathermap'), 'set_link_properties' => \__('Edit link', 'weathermap'), + 'set_node_config' => \__('Edit raw node configuration', 'weathermap'), 'set_link_config' => \__('Edit raw link configuration', 'weathermap'), + 'set_map_properties' => \__('Edit map properties', 'weathermap'), 'set_map_style' => \__('Edit map style', 'weathermap'), + 'set_map_colours' => \__('Edit map colours', 'weathermap'), 'place_legend' => \__('Move legend', 'weathermap'), + 'place_stamp' => \__('Move timestamp', 'weathermap') + ]; + return $labels[\get_nfilter_request_var('action')] ?? \__('Edit map', 'weathermap'); +} + +/** Record only successful changes; a new action discards the redo branch. */ +function remember($key, $before, $after) { + prune(); + if ($before['revision'] === $after['revision']) return; + $entry = $GLOBALS['weathermap_editor_history'][$key] ?? []; + if (($entry['current'] ?? null) !== $before['revision']) $entry = ['undo' => [], 'redo' => []]; + $entry['undo'][] = ['snapshot' => $before, 'label' => actionLabel()]; + $entry['undo'] = array_slice($entry['undo'], -2); + $entry['redo'] = []; + $entry['current'] = $after['revision']; + $entry['time'] = time(); + // Reinsert so pruning removes the least recently changed map first. + unset($GLOBALS['weathermap_editor_history'][$key]); + $GLOBALS['weathermap_editor_history'][$key] = $entry; + prune(); +} + +/** Compare structured output before rewriting hand-formatted root bytes. */ +function unchanged($map, $mapfile, $expectedRevision) { + $loaded = new \WeatherMap; + $loaded->context = 'editor'; + if (!$loaded->ReadConfig($mapfile) + || ($expectedRevision !== null && $expectedRevision !== $loaded->config_revision) + || !\WeathermapConfig\dependenciesMatch(array_merge($map->config_dependencies, $map->config_root_identity))) return false; + $action = \get_nfilter_request_var('action'); + if ($action === 'set_map_colours') return effectiveColours($loaded) === effectiveColours($map); + if (in_array($action, ['set_node_config', 'set_link_config'], true)) return unchangedRaw($map, $loaded, $action); + $original = $loaded->ConfigText(); + return is_string($original) && $original === $map->ConfigText(); +} + +/** Compare effective settings; numeric scale bounds are the only coerced values. */ +function effectiveColours($map) { + require_once __DIR__ . '/editor.colours.php'; + $data = \wm_asset_colour_data($map); + $scales = []; + foreach ($data['rows'] as $row) { + $row['bottom'] = (float) $row['bottom']; + $row['top'] = (float) $row['top']; + $scales[$row['scale']][] = $row; + } + ksort($scales, SORT_STRING); + $data['rows'] = $scales; + sort($data['locked_scales'], SORT_STRING); + return $data; +} + +/** Compare exactly what fetchConfig() displayed, allowing only transport newlines. */ +function unchangedRaw($map, $loaded, $action) { + $isNode = $action === 'set_node_config'; + $name = \get_nfilter_request_var($isNode ? 'node_name' : 'link_name'); + $items = $isNode ? 'nodes' : 'links'; + $original = $loaded->{$items}[$name] ?? null; + $edited = $map->{$items}[$name] ?? null; + if ($original === null || $edited === null || !is_string($edited->config_override)) return false; + $normalize = static fn($text) => str_replace(["\r\n", "\r"], "\n", $text); + return $normalize($original->WriteConfig()) === $normalize($edited->config_override); +} + +/** Capture history under the same directory lock as the normal atomic save. */ +function write($map, $mapfile, $createOnly, $expectedRevision, $heldLock = null) { + $key = key($mapfile); + if ($key === null || $createOnly) return $map->WriteConfig($mapfile, $createOnly, $expectedRevision, $heldLock); + $lock = $heldLock ?? \WeathermapConfig\acquire(dirname($mapfile)); + if (!$lock) return false; + try { + try { load(); } catch (\Throwable $error) { + if (function_exists('cacti_log')) \cacti_log('Weathermap Undo/Redo storage is unavailable; saving without history.', false, 'WEATHERMAP'); + return $map->WriteConfig($mapfile, false, $expectedRevision, $lock); + } + $before = snapshot($map, $mapfile); + if ($before !== null && unchanged($map, $mapfile, $expectedRevision)) return true; + if (!$map->WriteConfig($mapfile, false, $expectedRevision, $lock)) return false; + // Raw configuration edits may change INCLUDE dependencies. Read the saved result. + $saved = new \WeatherMap; + $saved->context = 'editor'; + $after = $saved->ReadConfig($mapfile) ? snapshot($saved, $mapfile) : null; + if ($before !== null && $after !== null) remember($key, $before, $after); + else unset($GLOBALS['weathermap_editor_history'][$key]); + persistAfterSave(); + return true; + } finally { + if ($heldLock === null) \WeathermapConfig\release($lock); + } +} + +/** Return safe toolbar data, never configuration bytes. */ +function status($entry) { + return [ + 'undo' => count($entry['undo'] ?? []), 'redo' => count($entry['redo'] ?? []), + 'undoLabel' => empty($entry['undo']) ? '' : end($entry['undo'])['label'], + 'redoLabel' => empty($entry['redo']) ? '' : end($entry['redo'])['label'] + ]; +} + +/** Restore exact map bytes while checking includes, aliases, assets and stale writes. */ +function restore($mapfile, $current, $target, $lock) { + if (!\WeathermapConfig\dependenciesMatch($target['dependencies'])) return false; + $contents = base64_decode($target['contents'], true); + if ($contents === false || hash('sha256', $contents) !== $target['root']) return false; + if (!is_array($target['assets'] ?? null) || !is_array($current['assets'] ?? null)) return false; + $assets = array_diff($target['assets'], $current['assets']); + $validate = function() use ($assets) { + foreach ($assets as $asset) { + if (!preg_match('/\{[^}]+\}/', $asset) && !is_file(dirname(__DIR__) . '/' . $asset)) return false; + } + return true; + }; + $dependencies = array_merge($current['dependencies'], $current['identity'], $target['dependencies']); + return \WeathermapConfig\atomicWrite($mapfile, $contents, true, $current['root'], false, $lock, $dependencies, $validate); +} + +/** Apply one step, leaving both stacks untouched if the atomic restore fails. */ +function step($mapfile, $current, $entry, $action, $lock) { + $direction = $action === 'undo' ? 'undo' : 'redo'; + if (empty($entry[$direction])) throw new \DomainException(\__('No matching history remains. Reload the editor.', 'weathermap')); + $step = end($entry[$direction]); + if (!restore($mapfile, $current, $step['snapshot'], $lock)) throw new \RuntimeException(\__('Unable to restore this map. A configuration or image may have changed. Reload the editor.', 'weathermap')); + array_pop($entry[$direction]); + $opposite = $direction === 'undo' ? 'redo' : 'undo'; + $entry[$opposite][] = ['snapshot' => $current, 'label' => $step['label']]; + $entry[$opposite] = array_slice($entry[$opposite], -2); + $entry['current'] = $step['snapshot']['revision']; + $entry['time'] = time(); + return $entry; +} + +/** Keep registered-map navigation in sync with the successfully restored root. */ +function syncTitle($mapfile, $previousTitle) { + $restored = new \WeatherMap; + $restored->context = 'editor'; + if ($restored->ReadConfig($mapfile) && $restored->title !== $previousTitle) { + \db_execute_prepared('UPDATE weathermap_maps SET titlecache = ? WHERE configfile = ?', [$restored->title, basename($mapfile)]); + } +} + +/** Authenticated editor endpoint: POST plus Cacti's existing CSRF verification. */ +function handle($mapfile, $action) { + header('Content-Type: application/json'); + $lock = false; + try { + if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST' || key($mapfile) === null) { + http_response_code(400); + echo json_encode(['error' => \__('Invalid history request.', 'weathermap')]); + return; + } + $lock = \WeathermapConfig\acquire(dirname($mapfile)); + if (!$lock) throw new \RuntimeException(\__('Could not lock map configurations.', 'weathermap')); + $map = new \WeatherMap; + $map->context = 'editor'; + if (!\wm_editor_read_config($map, $mapfile, true)) { + echo json_encode(['error' => \__('This map has changed. Reload the editor before using Undo or Redo.', 'weathermap')]); + return; + } + $key = key($mapfile); + load(); + prune(); + $current = snapshot($map, $mapfile); + $entry = $GLOBALS['weathermap_editor_history'][$key] ?? []; + if ($current === null || ($entry['current'] ?? null) !== $current['revision']) { + unset($GLOBALS['weathermap_editor_history'][$key]); + $entry = []; + } + if ($action !== 'history_status') { + $entry = step($mapfile, $current, $entry, $action, $lock); + syncTitle($mapfile, $map->title); + unset($GLOBALS['weathermap_editor_history'][$key]); + $GLOBALS['weathermap_editor_history'][$key] = $entry; + prune(); + $entry = $GLOBALS['weathermap_editor_history'][$key] ?? []; + } + persistAfterSave(); + echo json_encode(status($entry)); + } catch (\Throwable $error) { + http_response_code($error instanceof \DomainException ? 409 : 500); + echo json_encode(['error' => $error->getMessage()]); + } finally { + if ($lock) \WeathermapConfig\release($lock); + } +} diff --git a/lib/editor.inc.php b/lib/editor.inc.php index 6556aca..bc76bcb 100644 --- a/lib/editor.inc.php +++ b/lib/editor.inc.php @@ -1062,6 +1062,12 @@ function getEditorJs() { 'colourText6' => __('Choose a PNG, JPG or GIF file up to 5 MB.', 'weathermap'), 'colourText7' => __('Remove range', 'weathermap'), 'colourText8' => __('Custom (%s)', '%s', 'weathermap'), + 'historyUndo' => __('Undo', 'weathermap'), + 'historyRedo' => __('Redo', 'weathermap'), + 'historyEmpty' => __('No map changes to undo or redo.', 'weathermap'), + 'historyAvailable' => __('Undo: %s steps; Redo: %s steps.', '%s', '%s', 'weathermap'), + 'historyDialog' => __('Save or cancel the open dialog before using Undo or Redo.', 'weathermap'), + 'historyStorage' => __('Undo and Redo require browser session storage.', 'weathermap'), 'saveError' => __('Unable to save changes. Please try again.', 'weathermap'), 'staleError' => __('This map has changed since you opened it. Reload the editor before saving your changes.', 'weathermap'), 'backgroundColour' => __('Map Background Colour', 'weathermap'), diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 0efed20..2bcf9ea 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -2926,3 +2926,148 @@ msgstr "" #: lib/editor.comment-style.php msgid "Uses a TrueType font. Tight bends or text longer than the link use straight text." msgstr "" + +#: lib/editor.history.php +msgid "An active session is required for Undo and Redo." +msgstr "" + +#: lib/editor.history.php +msgid "Could not create private map history storage." +msgstr "" + +#: lib/editor.history.php +msgid "Map history storage is not private." +msgstr "" + +#: lib/editor.history.php +msgid "Invalid map history storage." +msgstr "" + +#: lib/editor.history.php +msgid "Add node" +msgstr "" + +#: lib/editor.history.php +msgid "Clone node" +msgstr "" + +#: lib/editor.history.php +msgid "Delete node" +msgstr "" + +#: lib/editor.history.php +msgid "Move node or note" +msgstr "" + +#: lib/editor.history.php +msgid "Add link" +msgstr "" + +#: lib/editor.history.php +msgid "Delete link" +msgstr "" + +#: lib/editor.history.php +msgid "Move link via point" +msgstr "" + +#: lib/editor.history.php +msgid "Tidy link" +msgstr "" + +#: lib/editor.history.php +msgid "Tidy links" +msgstr "" + +#: lib/editor.history.php +msgid "Tidy all links" +msgstr "" + +#: lib/editor.history.php +msgid "Untidy links" +msgstr "" + +#: lib/editor.history.php +msgid "Edit note" +msgstr "" + +#: lib/editor.history.php +msgid "Edit node" +msgstr "" + +#: lib/editor.history.php +msgid "Edit link" +msgstr "" + +#: lib/editor.history.php +msgid "Edit raw node configuration" +msgstr "" + +#: lib/editor.history.php +msgid "Edit raw link configuration" +msgstr "" + +#: lib/editor.history.php +msgid "Edit map properties" +msgstr "" + +#: lib/editor.history.php +msgid "Edit map style" +msgstr "" + +#: lib/editor.history.php +msgid "Edit map colours" +msgstr "" + +#: lib/editor.history.php +msgid "Move legend" +msgstr "" + +#: lib/editor.history.php +msgid "Move timestamp" +msgstr "" + +#: lib/editor.history.php +msgid "Edit map" +msgstr "" + +#: lib/editor.history.php +msgid "No matching history remains. Reload the editor." +msgstr "" + +#: lib/editor.history.php +msgid "Unable to restore this map. A configuration or image may have changed. Reload the editor." +msgstr "" + +#: lib/editor.history.php +msgid "Invalid history request." +msgstr "" + +#: lib/editor.history.php +msgid "This map has changed. Reload the editor before using Undo or Redo." +msgstr "" + +#: lib/editor.inc.php weathermap-cacti-plugin-editor.php +msgid "Undo" +msgstr "" + +#: lib/editor.inc.php weathermap-cacti-plugin-editor.php +msgid "Redo" +msgstr "" + +#: lib/editor.inc.php +msgid "No map changes to undo or redo." +msgstr "" + +#: lib/editor.inc.php +#, php-format +msgid "Undo: %s steps; Redo: %s steps." +msgstr "" + +#: lib/editor.inc.php +msgid "Save or cancel the open dialog before using Undo or Redo." +msgstr "" + +#: lib/editor.inc.php +msgid "Undo and Redo require browser session storage." +msgstr "" diff --git a/tests/Browser/EditorHistoryTest.cjs b/tests/Browser/EditorHistoryTest.cjs new file mode 100644 index 0000000..a2fd867 --- /dev/null +++ b/tests/Browser/EditorHistoryTest.cjs @@ -0,0 +1,126 @@ +/* Copyright (C) 2004-2026 The Cacti Group. GPL-2.0-or-later. */ +const assert = require('node:assert/strict'); +const {JSDOM} = require('jsdom'); +const jquery = require('jquery'); +const connectWorker = require('../../js/editor.history.worker.js'); +const {MessageChannel} = require('node:worker_threads'); +const {webcrypto} = require('node:crypto'); +const {init, claimHistoryId} = require('../../js/editor.history.js'); +(async () => { +const dom = new JSDOM('
', {url: 'https://example.test/editor.php?action=nothing&mapname=map.conf#node'}); +const $ = jquery(dom.window), requests = [], navigation = [], alerts = []; +$.expr.pseudos.visible = element => element.dataset.visible === 'true'; +// Model the browser's origin-wide LockManager across independent tab storage copies. +const held = new Set(); +const locks = {request(name, options, callback) { + if (held.has(name)) return Promise.resolve().then(() => callback(null)); + held.add(name); + return Promise.resolve().then(() => callback({name})).finally(() => held.delete(name)); +}}; +const win = { + navigator: {locks}, addEventListener: dom.window.addEventListener.bind(dom.window), + document: dom.window.document, sessionStorage: dom.window.sessionStorage, crypto: webcrypto, + location: {href: dom.window.location.href, pathname: '/editor.php', reload: () => navigation.push('reload'), replace: url => navigation.push(url)}, + WMConfigRevision: 'revision', editor_url: 'editor.php', csrfMagicToken: 'csrf', alert: message => alerts.push(message), + wmEditorText: {historyUndo: 'Undo', historyRedo: 'Redo', historyEmpty: 'No map changes', historyAvailable: 'Undo: %s steps; Redo: %s steps.', historyDialog: 'Save or cancel first', historyStorage: 'Storage unavailable', saveError: 'Failed'} +}; +$.ajaxTransport('+*', options => ({send(headers, complete) {requests.push({options, complete});}, abort() {}})); +function respond(index, status, body) {requests[index].complete(status, status === 200 ? 'OK' : 'Conflict', {text: JSON.stringify(body)}, 'Content-Type: application/json');} +function count() {return requests.length;} +function key(target, extra = {}) {$(target).trigger($.Event('keydown', {key: 'z', ctrlKey: true, ...extra}));} +const startup = init($, win); +assert.equal(count(), 0, 'history is not requested until tab ownership is established'); +assert.equal(dom.window.document.body.inert, true, 'editing is gated while tab ownership is pending'); +const earlySave = $.ajax({url: 'editor.php', type: 'POST', data: {action: 'move_node'}}); +assert.equal(earlySave.state(), 'rejected', 'a programmatic save is gated during ownership startup'); +assert.equal(count(), 0, 'no untracked mutation reaches the server'); +await startup; +assert.notEqual(dom.window.document.body.inert, true, 'editing is restored after initialization'); +assert.equal(count(), 1); +const initial = new URLSearchParams(requests[0].options.data); +assert.equal(initial.get('action'), 'history_status'); +assert.match(initial.get('history_id'), /^[a-f0-9]{32}$/); +assert.equal(initial.get('__csrf_magic'), 'csrf'); +respond(0, 200, {undo: 2, redo: 1, undoLabel: 'Delete node', redoLabel: 'Move node'}); +assert.equal($('#wm-undo').prop('disabled'), false); +assert.equal($('#wm-redo').attr('title'), 'Redo: Move node'); +assert.equal($('#wm-undo').attr('title'), 'Undo: Delete node'); +key('#text'); assert.equal(count(), 1, 'text fields keep native Undo'); +$('.ui-dialog-content').attr('data-visible', 'true'); +key(dom.window.document); assert.equal(count(), 1, 'open dialogs keep native text Undo'); +$('#wm-undo').trigger('click'); assert.deepEqual(alerts, ['Save or cancel first']); +$('.ui-dialog-content').attr('data-visible', 'false'); +key(dom.window.document); +assert.equal(count(), 2); +assert.equal(new URLSearchParams(requests[1].options.data).get('action'), 'undo'); +assert.equal($('#wm-redo').prop('disabled'), true, 'a pending mutation disables both buttons'); +key(dom.window.document); assert.equal(count(), 2, 'cannot dispatch a duplicate Undo'); +respond(1, 200, {undo: 1, redo: 2}); +assert.deepEqual(navigation, ['reload'], 'even fragment URLs fetch a fresh document'); +key(dom.window.document, {shiftKey: true, ctrlKey: false, metaKey: true}); +assert.equal(new URLSearchParams(requests[2].options.data).get('action'), 'redo'); +respond(2, 409, {error: 'Map changed elsewhere'}); +assert.equal(alerts.at(-1), 'Map changed elsewhere'); +assert.equal($('#wm-undo').prop('disabled'), true); +assert.equal($('#wm-redo').prop('disabled'), true); +const colour = $.ajax({url: 'editor.php', type: 'POST', data: {action: 'set_map_colours'}}); +assert.equal(new URLSearchParams(requests.at(-1).options.data).get('history_id'), initial.get('history_id'), 'separate Manage Colors requests share the same history'); +respond(requests.length - 1, 200, {}); +$.ajax({url: 'other.php', type: 'POST', data: {action: 'save'}}); +assert.equal(new URLSearchParams(requests.at(-1).options.data).has('history_id'), false, 'unrelated requests are untouched'); +respond(requests.length - 1, 200, {}); +// A duplicated tab inherits sessionStorage, but cannot inherit the live tab's stack. +const storageKey = 'weathermap-history:/editor.php'; +function tab(copiedId, manager = locks) { + const events = new Map(), store = new Map([[storageKey, copiedId]]); + return {crypto: webcrypto, navigator: {locks: manager}, sessionStorage: {getItem: key => store.get(key), setItem: (key,value) => store.set(key,value)}, + location: {href: 'http://example.test/editor.php', reload() {events.set('reloaded', true);}}, editor_url: 'editor.php', addEventListener: (name, callback) => events.set(name, callback), events}; +} +const duplicated = tab(initial.get('history_id')); +const duplicateId = await claimHistoryId(duplicated, storageKey); +assert.notEqual(duplicateId, initial.get('history_id'), 'a duplicated/opener tab claims a fresh history'); +assert.equal(duplicated.sessionStorage.getItem(storageKey), duplicateId); +duplicated.events.get('pagehide')(); +await new Promise(resolve => setImmediate(resolve)); +const reloaded = tab(duplicateId); +assert.equal(await claimHistoryId(reloaded, storageKey), duplicateId, 'same-tab reload keeps its history ID after releasing ownership'); +reloaded.events.get('pageshow')({persisted: true}); +assert.equal(reloaded.events.get('reloaded'), true, 'back/forward restoration reacquires ownership'); +reloaded.events.get('pagehide')(); +// Exercise the actual shared worker protocol as the HTTP-compatible fallback. +class SharedWorker { + constructor() { + const channel = new MessageChannel(); + this.port = channel.port2; + connectWorker({ports: [channel.port1]}); + } +} +const httpTab = tab(duplicateId, null); httpTab.SharedWorker = SharedWorker; +assert.equal(await claimHistoryId(httpTab, storageKey), duplicateId, 'HTTP fallback can reuse an unowned ID'); +const httpDuplicate = tab(duplicateId, null); httpDuplicate.SharedWorker = SharedWorker; +assert.notEqual(await claimHistoryId(httpDuplicate, storageKey), duplicateId, 'HTTP duplicated tab gets a fresh ID'); +httpTab.events.get('pagehide')(); +await new Promise(resolve => setTimeout(resolve, 20)); +const httpReload = tab(duplicateId, null); httpReload.SharedWorker = SharedWorker; +assert.equal(await claimHistoryId(httpReload, storageKey), duplicateId, 'HTTP reload keeps its history'); +httpDuplicate.events.get('pagehide')(); httpReload.events.get('pagehide')(); +// Failure to claim ownership must release the editor and allow ordinary saves. +const failedDom = new JSDOM('', {url: 'https://example.test/editor.php'}); +const failed$ = jquery(failedDom.window), failedRequests = []; +failed$.ajaxTransport('+*', options => ({send(headers, complete) {failedRequests.push({options, complete});}, abort() {}})); +const failedWin = {...win, document: failedDom.window.document, sessionStorage: failedDom.window.sessionStorage, + navigator: {locks: {request() {return Promise.reject(new Error('Ownership unavailable'));}}}}; +const failure = init(failed$, failedWin); +assert.equal(failedDom.window.document.body.inert, true); +await failure; +assert.notEqual(failedDom.window.document.body.inert, true, 'failed ownership does not lock normal editing'); +failed$.ajax({url: 'editor.php', type: 'POST', data: {action: 'move_node'}}); +assert.equal(failedRequests.length, 1); +assert.equal(new URLSearchParams(failedRequests[0].options.data).has('history_id'), false); +failedRequests[0].complete(200, 'OK', {text: '{}'}, 'Content-Type: application/json'); +assert.equal(failed$('#wm-undo').attr('title'), 'Storage unavailable'); +failedDom.window.close(); +console.log('PASS: history buttons, shortcuts, tab identity, CSRF, pending saves, native text Undo, and stale-map errors.'); +dom.window.close(); + +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/Unit/EditorHistoryTest.php b/tests/Unit/EditorHistoryTest.php new file mode 100644 index 0000000..660d6c5 --- /dev/null +++ b/tests/Unit/EditorHistoryTest.php @@ -0,0 +1,533 @@ +cwd = getcwd(); + chdir(dirname(__DIR__, 2)); + require_once 'setup.php'; + require_once 'lib/WeatherMap.class.php'; + require_once 'lib/editor.inc.php'; + require_once 'lib/editor.actions.php'; + $this->directory = sys_get_temp_dir() . '/wm-history-' . bin2hex(random_bytes(6)); + mkdir($this->directory); + $this->file = $this->directory . '/map.conf'; + file_put_contents($this->file, "# preserve original formatting\nWIDTH 600\nHEIGHT 300\nNODE a\n LABEL A\n POSITION 40 50\nNODE b\n LABEL B\n POSITION 200 100\nLINK a-b\n NODES a b\n BANDWIDTH 100M\n VIA 100 70\n"); + session_start(); + $_SESSION = []; + $_SERVER['REQUEST_METHOD'] = 'POST'; + $GLOBALS['__test_nfilter_request'] = []; + } + + protected function tearDown(): void { + foreach (glob($this->directory . '/{*,.*}', GLOB_BRACE) as $file) if (is_file($file) || is_link($file)) unlink($file); + rmdir($this->directory); + $history = WeathermapEditorHistory\storagePath(); + if (is_file($history) || is_link($history)) unlink($history); + session_destroy(); + chdir($this->cwd); + } + + private function map(): WeatherMap { + $map = new WeatherMap; + $map->context = 'editor'; + self::assertTrue($map->ReadConfig($this->file)); + return $map; + } + + private function request(string $action, array $extra = []): void { + $map = $this->map(); + $GLOBALS['__test_request'] = array_merge([ + 'action' => $action, 'history_id' => str_repeat('a', 32), + 'config_revision' => WeathermapConfig\snapshotRevision($map->config_revision, $map->config_dependencies, $map->config_root_identity) + ], $extra); + http_response_code(200); + } + + private function history(string $action): array { + $this->request($action); + ob_start(); + try { WeathermapEditorHistory\handle($this->file, $action); return json_decode(ob_get_contents(), true, 512, JSON_THROW_ON_ERROR); } + finally { ob_end_clean(); } + } + + private function move(int $x): string { + $this->request('move_node', ['node_name' => 'a', 'x' => $x, 'y' => 50]); + moveNode($this->file, 0); + self::assertSame(200, http_response_code()); + return file_get_contents($this->file); + } + + public function testTwoStepsBackAndForwardRestoreExactBytesIncludingViaChanges(): void { + $this->move(50); + $first = file_get_contents($this->file); + $second = $this->move(70); + $third = $this->move(90); + self::assertSame(2, $this->history('history_status')['undo']); + self::assertSame(1, $this->history('undo')['undo']); + self::assertSame($second, file_get_contents($this->file)); + self::assertSame(2, $this->history('undo')['redo']); + self::assertSame($first, file_get_contents($this->file)); + self::assertArrayHasKey('error', $this->history('undo')); + self::assertSame(409, http_response_code()); + $this->history('redo'); self::assertSame($second, file_get_contents($this->file)); + $this->history('redo'); self::assertSame($third, file_get_contents($this->file)); + self::assertSame(0, $this->history('history_status')['redo']); + } + + public function testDeletingNodeRestoresItsConnectedLinkAndOriginalFormatting(): void { + $original = file_get_contents($this->file); + $this->request('delete_node', ['param' => 'a']); + deleteNode($this->file); + self::assertArrayNotHasKey('a', $this->map()->nodes); + self::assertArrayNotHasKey('a-b', $this->map()->links); + $this->history('undo'); + self::assertSame($original, file_get_contents($this->file)); + self::assertArrayHasKey('a-b', $this->map()->links); + $this->history('redo'); + self::assertArrayNotHasKey('a-b', $this->map()->links); + } + + public function testLinkCreationDeletionAndNotesUseSameHistory(): void { + $this->request('delete_link', ['param' => 'a-b']); deleteLink($this->file); + self::assertArrayNotHasKey('a-b', $this->map()->links); + $this->history('undo'); self::assertArrayHasKey('a-b', $this->map()->links); + $this->request('add_link2', ['param' => 'a', 'param2' => 'b']); addLink($this->file); + self::assertArrayHasKey('a-ba', $this->map()->links); + $this->history('undo'); self::assertArrayNotHasKey('a-ba', $this->map()->links); + self::assertSame(1, $this->history('history_status')['redo']); + $this->request('set_note_properties', ['note_name' => '', 'note_text' => '5G backup', 'note_font' => '3', 'note_colour' => '#0000ff', 'note_align' => 'left', 'note_x' => 100, 'note_y' => 100]); + setNoteProperties($this->file, 0); + self::assertCount(1, wm_editor_note_data($this->map())); + self::assertSame(0, $this->history('history_status')['redo'], 'a new action clears redo'); + $this->history('undo'); self::assertCount(0, wm_editor_note_data($this->map())); + $this->history('redo'); self::assertCount(1, wm_editor_note_data($this->map())); + } + + public function testOtherTabsMapsAndExternalChangesCannotOverwriteCurrentConfiguration(): void { + $this->move(60); + $this->request('undo', ['history_id' => str_repeat('b', 32)]); + ob_start(); WeathermapEditorHistory\handle($this->file, 'undo'); ob_end_clean(); + self::assertSame(409, http_response_code()); + $other = $this->directory . '/other.conf'; copy($this->file, $other); + self::assertNotSame(WeathermapEditorHistory\key($other), WeathermapEditorHistory\key($this->file)); + $this->request('undo'); + file_put_contents($this->file, "\n# another editor saved\n", FILE_APPEND); + $external = file_get_contents($this->file); + ob_start(); WeathermapEditorHistory\handle($this->file, 'undo'); ob_end_clean(); + self::assertSame(409, http_response_code()); + self::assertSame($external, file_get_contents($this->file)); + self::assertSame(0, $this->history('history_status')['undo']); + } + + public function testIncludeChangesAndDeletedMapsAreProtected(): void { + $include = $this->directory . '/shared.conf'; + file_put_contents($include, "NODE template\n LABEL shared\n"); + file_put_contents($this->file, "INCLUDE $include\n" . file_get_contents($this->file)); + $this->move(60); + file_put_contents($include, "# changed\n", FILE_APPEND); + $expected = file_get_contents($this->file); + self::assertArrayHasKey('error', $this->history('undo')); + self::assertSame($expected, file_get_contents($this->file)); + $this->move(70); + $this->request('undo'); unlink($this->file); + ob_start(); WeathermapEditorHistory\handle($this->file, 'undo'); ob_end_clean(); + self::assertSame(404, http_response_code()); + self::assertFileDoesNotExist($this->file); + } + + public function testFailedAndUnchangedSavesDoNotConsumeHistory(): void { + $this->move(60); + $this->move(60); + self::assertSame(1, $this->history('history_status')['undo']); + $this->request('move_node', ['node_name' => 'a', 'x' => 80, 'y' => 50, 'config_revision' => str_repeat('0', 64)]); + moveNode($this->file, 0); + self::assertSame(409, http_response_code()); + self::assertSame(1, $this->history('history_status')['undo']); + } + + public function testReadOnlyHistoryStatusDoesNotExposeConfigAndGetCannotRestore(): void { + $this->move(60); + $status = $this->history('history_status'); + self::assertSame(['undo', 'redo', 'undoLabel', 'redoLabel'], array_keys($status)); + $this->request('undo'); $_SERVER['REQUEST_METHOD'] = 'GET'; + $before = file_get_contents($this->file); + ob_start(); WeathermapEditorHistory\handle($this->file, 'undo'); ob_end_clean(); + self::assertSame(400, http_response_code()); + self::assertSame($before, file_get_contents($this->file)); + } + public function testNormalPageRequestsCannotOverwritePrivateHistory(): void { + $this->move(60); + $first = file_get_contents($this->file); + $this->move(80); + // Model a late page/graph request writing an old Cacti session snapshot. + $_SESSION = ['weathermap_editor_history' => []]; + $GLOBALS['weathermap_editor_history'] = []; + $this->history('undo'); + self::assertSame($first, file_get_contents($this->file)); + self::assertSame(1, $this->history('history_status')['redo']); + self::assertSame(0700, fileperms(dirname(WeathermapEditorHistory\storagePath())) & 0777); + self::assertSame(0600, fileperms(WeathermapEditorHistory\storagePath()) & 0777); + } + + public function testFailedAtomicRestorePreservesBothStacks(): void { + $this->move(60); + $before = file_get_contents($this->file); + $this->request('undo'); + WeathermapEditorHistory\load(); + $key = WeathermapEditorHistory\key($this->file); + $entry = $GLOBALS['weathermap_editor_history'][$key]; + $current = WeathermapEditorHistory\snapshot($this->map(), $this->file); + $lock = WeathermapConfig\acquire($this->directory); + try { + // Another writer wins immediately before our atomic revision check. + file_put_contents($this->file, "# concurrent change\n", FILE_APPEND); + $step = end($entry['undo']); + self::assertFalse(WeathermapEditorHistory\restore($this->file, $current, $step['snapshot'], $lock)); + self::assertSame($entry, $GLOBALS['weathermap_editor_history'][$key]); + self::assertSame($before . "# concurrent change\n", file_get_contents($this->file)); + } finally { WeathermapConfig\release($lock); } + } + + public function testRawConfigurationAndLegacyRequestsRemainSupported(): void { + $this->request('set_node_config', ['node_name' => 'a', 'item_configtext' => "NODE a\n LABEL Changed\n POSITION 40 50\n"]); + setNodeConfig($this->file); + self::assertSame('Changed', $this->map()->nodes['a']->label); + $this->history('undo'); self::assertSame('A', $this->map()->nodes['a']->label); + $this->history('redo'); self::assertSame('Changed', $this->map()->nodes['a']->label); + $this->request('move_node', ['history_id' => '', 'node_name' => 'a', 'x' => 90, 'y' => 50]); + moveNode($this->file, 0); + self::assertSame(90, (int) $this->map()->nodes['a']->x); + self::assertSame(0, $this->history('history_status')['undo'], 'untracked legacy saves invalidate old history safely'); + } + + public function testMapColoursAndNewNodesCanBeUndone(): void { + $before = file_get_contents($this->file); + require_once 'lib/editor.colours.php'; + $data = wm_asset_colour_data($this->map()); + $data['outline'] = '#ff0000'; + $this->request('set_map_colours', ['colours_json' => json_encode($data)]); + ob_start(); wm_editor_colours($this->file, 'set_map_colours'); $response = json_decode(ob_get_clean(), true); + self::assertArrayNotHasKey('error', $response); + self::assertNotSame($before, file_get_contents($this->file)); + $this->history('undo'); self::assertSame($before, file_get_contents($this->file)); + $this->request('add_node', ['x' => 300, 'y' => 100]); addNode($this->file, 0); + self::assertCount(3, array_filter($this->map()->nodes, fn($node) => $node->x !== null)); + $this->history('undo'); self::assertSame($before, file_get_contents($this->file)); + $this->history('redo'); self::assertCount(3, array_filter($this->map()->nodes, fn($node) => $node->x !== null)); + } + + public function testAnIconDeletedAfterTheNodeWasDeletedIsNotRestoredAsBrokenReference(): void { + // The library image is now absent; deleting its node must still be possible. + $original = str_replace(' LABEL A', " ICON images/objects/removed-history-fixture.png\n LABEL A", file_get_contents($this->file)); + file_put_contents($this->file, $original); + $this->request('delete_node', ['param' => 'a']); deleteNode($this->file); + $deleted = file_get_contents($this->file); + self::assertArrayHasKey('error', $this->history('undo')); + self::assertSame($deleted, file_get_contents($this->file)); + self::assertSame(1, $this->history('history_status')['undo']); + } + + public function testRetargetedRootAliasCannotUseOldHistory(): void { + $original = $this->file; + $alias = $this->directory . '/alias.conf'; symlink($original, $alias); + $this->file = $alias; + $this->move(60); + $other = $this->directory . '/other.conf'; copy($original, $other); + unlink($alias); symlink($other, $alias); + $before = file_get_contents($other); + self::assertArrayHasKey('error', $this->history('undo')); + self::assertSame(409, http_response_code()); + self::assertSame($before, file_get_contents($other)); + } + + public function testMalformedOldHistoryDoesNotPreventSaving(): void { + $this->move(60); + file_put_contents(WeathermapEditorHistory\storagePath(), '{incomplete'); + $this->move(80); + self::assertSame(1, $this->history('history_status')['undo']); + $this->history('undo'); self::assertSame(60, (int) $this->map()->nodes['a']->x); + } + + public function testUnavailableHistoryStorageStillAllowsNormalMapSaves(): void { + $this->request('move_node', ['node_name' => 'a', 'x' => 85, 'y' => 50]); + $history = WeathermapEditorHistory\storagePath(); + symlink($this->file, $history); + try { + moveNode($this->file, 0); + self::assertSame(200, http_response_code()); + self::assertSame(85, (int) $this->map()->nodes['a']->x); + self::assertTrue(is_link($history), 'untrusted history paths must not be followed or replaced'); + } finally { unlink($history); } + } + + public function testSavingWithoutALoadedSnapshotDoesNotInventAnUndoState(): void { + $this->move(60); + $this->request('set_map_properties'); + $revision = hash_file('sha256', $this->file); + $map = new WeatherMap; + $map->context = 'editor'; + $map->title = 'Fresh configuration'; + self::assertTrue(WeathermapEditorHistory\write($map, $this->file, false, $revision)); + self::assertSame('Fresh configuration', $this->map()->title); + self::assertSame(0, $this->history('history_status')['undo']); + } + + public function testSavingAfterExpiryStartsANewHistoryInsteadOfRevivingOldSteps(): void { + $this->move(60); + $this->move(70); + foreach ($GLOBALS['weathermap_editor_history'] as &$entry) $entry['time'] = time() - 14401; + unset($entry); + WeathermapEditorHistory\persist(); + $before = file_get_contents($this->file); + $this->move(80); + self::assertSame(1, $this->history('history_status')['undo']); + $this->history('undo'); + self::assertSame($before, file_get_contents($this->file)); + self::assertSame(0, $this->history('history_status')['undo']); + } + + public function testUndoPrunesAnOversizedRedoSnapshotBeforeReportingAvailableSteps(): void { + $before = WeathermapEditorHistory\snapshot($this->map(), $this->file); + // A valid large raw edit can fit on disk but exceed the encoded history budget. + file_put_contents($this->file, file_get_contents($this->file) . str_repeat('#' . str_repeat('x', 1022) . "\n", 7168)); + $large = file_get_contents($this->file); + $this->request('set_map_properties'); + $after = WeathermapEditorHistory\snapshot($this->map(), $this->file); + WeathermapEditorHistory\load(); + WeathermapEditorHistory\remember(WeathermapEditorHistory\key($this->file), $before, $after); + WeathermapEditorHistory\persist(); + self::assertSame(1, $this->history('history_status')['undo']); + $result = $this->history('undo'); + self::assertSame(base64_decode($before['contents']), file_get_contents($this->file)); + self::assertNotSame($large, file_get_contents($this->file)); + self::assertSame(0, $result['undo']); + self::assertSame(0, $result['redo']); + self::assertLessThanOrEqual(8 * 1024 * 1024, filesize(WeathermapEditorHistory\storagePath())); + self::assertSame($result, $this->history('history_status')); + } + + public function testUnchangedDialogAfterUndoPreservesOriginalBytesAndBothStacks(): void { + $original = file_get_contents($this->file); + $this->move(60); + $history = $this->history('undo'); + self::assertSame($original, file_get_contents($this->file)); + $this->request('set_node_properties', ['node_name' => 'a', 'node_new_name' => 'a', 'node_label' => 'A', + 'node_infourl' => '', 'node_hover' => '', 'node_x' => 40, 'node_y' => 50, 'node_iconfilename' => '--NONE--']); + setNodeProperties($this->file); + self::assertSame(200, http_response_code()); + self::assertSame($original, file_get_contents($this->file)); + self::assertSame($history, $this->history('history_status')); + $this->history('redo'); + self::assertSame(60, (int) $this->map()->nodes['a']->x); + } + + public function testIntentionalRawFormattingEditsStillConsumeAStep(): void { + $original = file_get_contents($this->file); + $this->request('set_node_config', ['node_name' => 'a', 'item_configtext' => "NODE a\n # retain this raw comment\n LABEL A\n POSITION 40 50\n"]); + setNodeConfig($this->file); + self::assertStringContainsString('# retain this raw comment', file_get_contents($this->file)); + self::assertSame(1, $this->history('history_status')['undo']); + $this->history('undo'); + self::assertSame($original, file_get_contents($this->file)); + } + + public function testQuotaMeasuresActualJsonBytesForSlashHeavyBase64Snapshots(): void { + file_put_contents($this->file, file_get_contents($this->file) . str_repeat('#' . str_repeat("\xEF\xBF\xBF", 255) . "\n", 6800)); + $snapshot = WeathermapEditorHistory\snapshot($this->map(), $this->file); + self::assertLessThan(8 * 1024 * 1024, strlen(serialize($snapshot))); + self::assertGreaterThan(8 * 1024 * 1024, strlen(json_encode($snapshot, JSON_THROW_ON_ERROR))); + $this->move(60); + self::assertSame(0, $this->history('history_status')['undo']); + clearstatcache(true, WeathermapEditorHistory\storagePath()); + self::assertLessThanOrEqual(8 * 1024 * 1024, filesize(WeathermapEditorHistory\storagePath())); + } + + public function testConfigurationRenderingFailureDoesNotWriteTheDestination(): void { + $map = $this->map(); + $before = file_get_contents($this->file); + stream_wrapper_unregister('php'); + try { + self::assertFalse(@$map->ConfigText()); + self::assertFalse(@$map->WriteConfig($this->file)); + } finally { stream_wrapper_restore('php'); } + self::assertSame($before, file_get_contents($this->file)); + } + + public function testUntouchedRawNodeAndLinkDialogsPreserveBothStacksAndExactRootBytes(): void { + foreach (['node' => 'a', 'link' => 'a-b'] as $type => $name) { + $original = file_get_contents($this->file); + $this->move(60); + $history = $this->history('undo'); + $this->request('fetch_config', ['item_type' => $type, 'item_name' => $name]); + ob_start(); fetchConfig($this->file); $dialog = ob_get_clean(); + foreach (["\n", "\r\n", "\r"] as $newline) { + $this->request('set_' . $type . '_config', [$type . '_name' => $name, 'item_configtext' => str_replace("\n", $newline, $dialog)]); + $save = $type === 'node' ? 'setNodeConfig' : 'setLinkConfig'; + $save($this->file); + self::assertSame(200, http_response_code()); + self::assertSame($original, file_get_contents($this->file)); + self::assertSame($history, $this->history('history_status')); + } + $this->history('redo'); + self::assertSame(60, (int) $this->map()->nodes['a']->x); + $this->history('undo'); + } + } + + public function testIntentionalRawWhitespaceEditsStillClearRedoAndCanBeUndone(): void { + $this->move(60); + $this->history('undo'); + $original = file_get_contents($this->file); + $this->request('fetch_config', ['item_type' => 'link', 'item_name' => 'a-b']); + ob_start(); fetchConfig($this->file); $dialog = ob_get_clean(); + $changed = str_replace('BANDWIDTH', ' BANDWIDTH', $dialog) . "\n"; + $this->request('set_link_config', ['link_name' => 'a-b', 'item_configtext' => $changed]); + setLinkConfig($this->file); + self::assertNotSame($original, file_get_contents($this->file)); + $status = $this->history('history_status'); + self::assertSame(1, $status['undo']); + self::assertSame(0, $status['redo']); + $this->history('undo'); + self::assertSame($original, file_get_contents($this->file)); + } + + private function saveColours(array $data): void { + $this->request('set_map_colours', ['colours_json' => json_encode($data)]); + ob_start(); wm_editor_colours($this->file, 'set_map_colours'); $response = json_decode(ob_get_clean(), true); + self::assertSame(200, http_response_code()); + self::assertArrayNotHasKey('error', $response); + } + + public function testUntouchedColoursAfterUndoKeepBytesAndRedoWithoutAnExplicitPulseHint(): void { + require_once 'lib/editor.colours.php'; + file_put_contents($this->file, "SCALE DEFAULT 0 100 0 255 0 0\n" . file_get_contents($this->file)); + $original = file_get_contents($this->file); + $this->move(60); + $history = $this->history('undo'); + $data = wm_asset_colour_data($this->map()); + foreach ($data['rows'] as &$row) { + $row['bottom'] = number_format($row['bottom'], 6, '.', ''); + $row['top'] = number_format($row['top'], 6, '.', ''); + } + unset($row); + $this->saveColours($data); + self::assertSame($original, file_get_contents($this->file)); + self::assertSame($history, $this->history('history_status')); + // Numeric-looking labels remain text and must not compare loosely. + $data['rows'][0]['tag'] = '00'; + $this->saveColours($data); + self::assertSame('00', wm_asset_colour_data($this->map())['rows'][0]['tag']); + self::assertSame(0, $this->history('history_status')['redo']); + $this->history('undo'); + self::assertSame($original, file_get_contents($this->file)); + $data = wm_asset_colour_data($this->map()); $data['pulse'] = false; + $this->saveColours($data); + self::assertFalse(wm_asset_colour_data($this->map())['pulse']); + $this->history('undo'); + self::assertSame($original, file_get_contents($this->file)); + } + + public function testRedoCannotReintroduceADeletedImageUsedOnlyThroughAnExternalInclude(): void { + $asset = 'images/objects/history-include-' . bin2hex(random_bytes(6)) . '.png'; + file_put_contents($asset, base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aX1sAAAAASUVORK5CYII=')); + $include = $this->directory . '/external.inc'; + file_put_contents($include, "NODE included\n LABEL Included\n ICON $asset\n POSITION 300 100\n"); + $original = file_get_contents($this->file); + try { + $this->request('set_map_properties'); + $map = $this->map(); + $map->has_includes = true; + $map->include_directives[] = $include; + self::assertTrue(wm_editor_write_config($map, $this->file)); + self::assertSame(200, http_response_code()); + self::assertArrayHasKey('included', $this->map()->nodes); + self::assertStringNotContainsString($asset, file_get_contents($this->file)); + self::assertContains($asset, WeathermapEditorHistory\snapshot($this->map(), $this->file)['assets']); + $this->history('undo'); + self::assertSame($original, file_get_contents($this->file)); + self::assertArrayNotHasKey('error', $this->history('redo')); + self::assertArrayHasKey('included', $this->map()->nodes); + $this->history('undo'); + unlink($asset); + self::assertArrayHasKey('error', $this->history('redo')); + self::assertSame(500, http_response_code()); + self::assertSame($original, file_get_contents($this->file)); + self::assertSame(1, $this->history('history_status')['redo']); + } finally { if (is_file($asset)) unlink($asset); } + } + + public function testOlderHistoryWithoutCompleteAssetMetadataIsDiscardedSafely(): void { + $this->move(60); + $original = file_get_contents($this->file); + WeathermapEditorHistory\load(); + $key = WeathermapEditorHistory\key($this->file); + unset($GLOBALS['weathermap_editor_history'][$key]['undo'][0]['snapshot']['assets']); + WeathermapEditorHistory\persist(); + self::assertSame(0, $this->history('history_status')['undo']); + self::assertSame($original, file_get_contents($this->file)); + $this->move(80); + self::assertSame(1, $this->history('history_status')['undo']); + $this->history('undo'); + self::assertSame($original, file_get_contents($this->file)); + } + + public function testUntouchedColoursWithIncludedNamedScalePreserveRedoAndLocalRowOrderMatters(): void { + require_once 'lib/editor.colours.php'; + $include = $this->directory . '/shared-scale.conf'; + file_put_contents($include, "SCALE Shared 0 50 0 255 0 Low\nSCALE Shared 50 100 255 0 0 High\n"); + file_put_contents($this->file, "INCLUDE $include\n" . file_get_contents($this->file)); + $original = file_get_contents($this->file); + $this->move(60); $history = $this->history('undo'); + $data = wm_asset_colour_data($this->map()); + $this->saveColours($data); + self::assertSame($original, file_get_contents($this->file)); + self::assertSame($history, $this->history('history_status')); + $indices = array_keys(array_filter($data['rows'], fn($row) => $row['scale'] === 'DEFAULT')); + [$data['rows'][$indices[0]], $data['rows'][$indices[1]]] = [$data['rows'][$indices[1]], $data['rows'][$indices[0]]]; + $this->saveColours($data); + self::assertNotSame($original, file_get_contents($this->file)); + self::assertSame(0, $this->history('history_status')['redo']); + $this->history('undo'); + self::assertSame($original, file_get_contents($this->file)); + } + + public function testUndoRedoSynchronizeTheRegisteredTitleCacheOnlyWhenTitleChanges(): void { + file_put_contents($this->file, "TITLE Original title\n" . file_get_contents($this->file)); + $this->request('set_map_properties', ['map_title' => 'Changed title', 'map_legend' => 'Traffic', 'map_stamp' => 'Created', + 'map_width' => 600, 'map_height' => 300, 'map_bgfile' => '--NONE--', 'map_linkdefaultwidth' => 7, + 'map_linkdefaultbwin' => '100M', 'map_linkdefaultbwout' => '100M']); + setMapProperties($this->file); + $GLOBALS['__test_db_calls'] = []; + $this->history('history_status'); self::assertSame([], $GLOBALS['__test_db_calls']); + $this->history('undo'); + self::assertSame('Original title', $this->map()->title); + self::assertSame(['Original title', basename($this->file)], $GLOBALS['__test_db_calls'][0]['params']); + self::assertStringContainsString('titlecache', $GLOBALS['__test_db_calls'][0]['sql']); + $GLOBALS['__test_db_calls'] = []; + $this->history('redo'); + self::assertSame('Changed title', $this->map()->title); + self::assertSame(['Changed title', basename($this->file)], $GLOBALS['__test_db_calls'][0]['params']); + $this->move(80); $GLOBALS['__test_db_calls'] = []; + $this->history('undo'); + self::assertSame([], $GLOBALS['__test_db_calls'], 'position restores do not update the title cache'); + $this->request('redo', ['config_revision' => str_repeat('0', 64)]); + ob_start(); WeathermapEditorHistory\handle($this->file, 'redo'); ob_end_clean(); + self::assertSame(409, http_response_code()); + self::assertSame([], $GLOBALS['__test_db_calls'], 'failed restores do not update the title cache'); + } + +} diff --git a/weathermap-cacti-plugin-editor.php b/weathermap-cacti-plugin-editor.php index 20aee15..68e32a1 100644 --- a/weathermap-cacti-plugin-editor.php +++ b/weathermap-cacti-plugin-editor.php @@ -92,7 +92,7 @@ if (isset_request_var('action')) { $action = wm_editor_sanitize_action(get_nfilter_request_var('action'), [ - 'graphs', 'datasources', 'newmap', 'newmapcopy', 'font_samples', 'draw', + 'history_status', 'undo', 'redo', 'graphs', 'datasources', 'newmap', 'newmapcopy', 'font_samples', 'draw', 'show_config', 'fetch_config', 'set_link_config', 'set_node_config', 'set_note_properties', 'set_node_properties', 'set_link_properties', 'set_map_properties', 'set_map_style', 'image_data', 'upload_image', 'delete_image', 'colour_data', 'set_map_colours', 'add_link2', 'place_legend', 'place_stamp', 'via_link', @@ -130,6 +130,11 @@ wm_debug('=========================================================================================================='); switch($action) { + case 'history_status': + case 'undo': + case 'redo': + \WeathermapEditorHistory\handle($mapfile, $action); + exit; case 'image_data': case 'upload_image': case 'delete_image': @@ -307,6 +312,7 @@ + @@ -330,6 +336,11 @@