From 8793fe61fe72e8ae6b484cd844dcbee3ada56aa7 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:45:05 +1030 Subject: [PATCH 01/41] Implement separate image libraries with validated uploads --- css/editor.css | 10 ++ js/editor.js | 57 ++++++++++- lib/editor.actions.php | 27 ++++++ lib/editor.assets.php | 97 +++++++++++++++++++ .../Support/EditorImageFormatsRegression.php | 70 +++++++++++++ tests/Unit/EditorImageFormatsTest.php | 19 ++++ weathermap-cacti-plugin-editor.php | 33 ++++--- 7 files changed, 296 insertions(+), 17 deletions(-) create mode 100644 lib/editor.assets.php create mode 100644 tests/Support/EditorImageFormatsRegression.php create mode 100644 tests/Unit/EditorImageFormatsTest.php diff --git a/css/editor.css b/css/editor.css index 4f8571b1..eb974fab 100644 --- a/css/editor.css +++ b/css/editor.css @@ -316,3 +316,13 @@ body#mainView #wm-node-delete-bar { min-height: 32px; } .ui-dialog.wm-editor-dialog { overflow: visible; } + +.wm-image-delete { color: #c62828 !important; background: #fff !important; border: 1px solid #c62828 !important; font-size: 20px; } +#dlgImages .wm-image-library { width: 100%; box-sizing: border-box; display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 10px; max-height: 240px; overflow: auto; align-content: start; padding: 4px; } +#dlgImages .wm-image-card { min-width: 0; padding: 8px; border: 1px solid #999; display: flex; flex-direction: column; gap: 6px; overflow-wrap: anywhere; align-items: center; } +#dlgImages .wm-image-card img { width: 100%; max-width: 130px; height: 70px; object-fit: contain; flex: none; } +#dlgImages .wm-image-uploader { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 10px 0; } +/* Use Cacti's own theme heading colours in every grouped editor dialog. */ +.dlgProperties .wm-editor-section { display: table; width: 100%; box-sizing: border-box; margin: 10px 0 6px; } +.dlgProperties table .wm-editor-section { margin: 0; } +#wm-images-error:not(:empty) { color: inherit; border-left: 3px solid #c62828; padding-left: 8px; font-weight: bold; } diff --git a/js/editor.js b/js/editor.js index dad82c1a..afd61bef 100644 --- a/js/editor.js +++ b/js/editor.js @@ -700,6 +700,7 @@ function manage_images() { $('#action').val('set_image'); show_dialog('dlgImages'); + wmOpenImages(); } function prefs() { @@ -1159,7 +1160,7 @@ function show_dialog(dlg) { $('#'+dlg).dialog({ autoOpen: true, - width: 600, + width: dlg === 'dlgImages' ? Math.min(900, $(window).width() - 40) : 600, height: 'auto', modal: false, resizable: false, @@ -1663,3 +1664,57 @@ function wmFriendlyLinkNames() { function wmDecodeEditorUrl(value) { return String(value || '').replace(/&/g, '&'); } +function wmAssetRequest(action, extra, callback, errorId) { + var values = {action: action, mapname: $('#mapname').val()}; + if (typeof csrfMagicToken !== 'undefined') values.__csrf_magic = csrfMagicToken; + $.ajax({url: editor_url, type: action === 'image_data' ? 'GET' : 'POST', dataType: 'json', data: $.extend(values, extra), + success: callback, error: function(xhr) { $('#' + errorId).text(xhr.responseJSON && xhr.responseJSON.error || 'The request failed. Please try again.'); }}); +} +function wmAssetImages(images) { + $('#wm-images-objects, #wm-images-backgrounds').empty(); + images.forEach(function(item) { + var card = $('
').text('No images in this library yet.')); + }); + // Rebuild the existing image selectors so uploads are available without reloading the editor. + [{id: 'node_iconfilename', kind: 'objects', label: 'Label Only'}, {id: 'map_bgfile', kind: 'backgrounds', label: 'No background'}].forEach(function(spec) { + var select = $('#' + spec.id), old = select.val(); + var slick = select.data('ddslick'); + if (slick) { old = slick.selectedData && slick.selectedData.value; select.ddslick('destroy'); select = $('#' + spec.id); } + select.find('option').filter(function() { return /^images\/.*\.(png|gif|jpe?g)$/i.test(this.value); }).remove(); + if (!select.find('option[value="--NONE--"]').length) select.prepend($('').val('--NONE--').text(spec.label)); + images.filter(function(item) { return item.kind === spec.kind; }).forEach(function(item) { + select.append($('').val('images/' + item.kind + '/' + item.name).text(item.name).attr({'data-imagesrc': item.url, 'data-description': item.name})); + }); + select.val(old); + }); + $('#dlgImages').dialog('option', {maxHeight: $(window).height() - 80, position: {my: 'center', at: 'center', of: window}}); +} +function wmOpenImages() { + $('#wm-images-error').empty(); + wmAssetRequest('image_data', {}, function(data) { wmAssetImages(data.images); }, 'wm-images-error'); + $('.wm-image-upload').off('click').on('click', function() { + var button = $(this), kind = button.attr('data-kind'), input = $('#wm-image-file-' + kind); + var file = input[0].files[0]; + if (!file) { $('#wm-images-error').text('Choose a PNG, JPG or GIF to upload.'); return; } + var data = new FormData(); + data.append('action', 'upload_image'); data.append('mapname', $('#mapname').val()); + data.append('image_kind', kind); data.append('image_file', file); + if (typeof csrfMagicToken !== 'undefined') data.append('__csrf_magic', csrfMagicToken); + button.prop('disabled', true); + $.ajax({url: editor_url, type: 'POST', data: data, dataType: 'json', contentType: false, processData: false, + success: function(result) { $('#wm-images-error').text('Uploaded ' + result.name); input.val(''); wmAssetImages(result.images); }, + error: function(xhr) { $('#wm-images-error').text(xhr.responseJSON && xhr.responseJSON.error || 'Upload failed.'); }, + complete: function() { button.prop('disabled', false); }}); + }); +} diff --git a/lib/editor.actions.php b/lib/editor.actions.php index bf976588..daed6afa 100644 --- a/lib/editor.actions.php +++ b/lib/editor.actions.php @@ -1135,3 +1135,30 @@ function getImageURL($mapname, $selected) { return $imageurl; } + +function wm_asset_upload_image($kind, $file) { + if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || !is_uploaded_file($file['tmp_name'] ?? '')) throw new InvalidArgumentException('Choose a PNG, JPG or GIF file up to 5 MB.'); + return wm_asset_store_image($kind, $file['tmp_name'], $file['name']); +} +function wm_editor_assets($mapfile, $action) { + header('Content-Type: application/json'); + try { + if ($action !== 'image_data' && ($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') throw new InvalidArgumentException('Use POST to save changes.'); + if ($action === 'image_data') { + $result = ['images' => wm_asset_image_list()]; + } elseif ($action === 'upload_image') { + $name = wm_asset_upload_image(get_nfilter_request_var('image_kind'), $_FILES['image_file'] ?? []); + $result = ['name' => $name, 'images' => wm_asset_image_list()]; + } else { + $name = get_nfilter_request_var('image_name'); + $path = wm_asset_image_path(get_nfilter_request_var('image_kind'), $name); + if (wm_asset_image_users($name)) throw new InvalidArgumentException('This image is still referenced by a map configuration.'); + if (!is_file($path) || !unlink($path)) throw new RuntimeException('Could not delete the image.'); + $result = ['images' => wm_asset_image_list()]; + } + echo json_encode($result); + } catch (Throwable $error) { + http_response_code(400); + echo json_encode(['error' => $error->getMessage()]); + } +} diff --git a/lib/editor.assets.php b/lib/editor.assets.php new file mode 100644 index 00000000..f5e4158f --- /dev/null +++ b/lib/editor.assets.php @@ -0,0 +1,97 @@ +isFile()) $queue[] = $file->getPathname(); + $seen = []; + while ($queue) { + $path = array_pop($queue); + $real = realpath($path); + if ($real === false || !is_readable($real)) throw new RuntimeException('Cannot check configuration references.'); + if (isset($seen[$real])) continue; + $seen[$real] = true; + if (count($seen) > 1000) throw new RuntimeException('Too many included configurations to check.'); + $text = file_get_contents($real); + if ($text === false) throw new RuntimeException('Cannot check configuration references.'); + if (strpos($text, $name) !== false) $users[] = basename($real); + if (preg_match_all('/^\s*INCLUDE\s+(.+)$/mi', $text, $matches)) foreach ($matches[1] as $include) { + $include = trim($include); + $queue[] = strpos($include, '/') === 0 ? $include : $root . '/' . $include; + } + } + return $users; +} +function wm_asset_image_list() { + $result = []; + foreach (['objects', 'backgrounds'] as $kind) { + $dir = wm_asset_directory($kind); + foreach (scandir($dir) ?: [] as $name) { + $path = $dir . '/' . $name; + if (!is_file($path) || is_link($path) || !is_readable($path) || !preg_match('/\.(png|gif|jpe?g)$/iD', $name)) continue; + $result[] = ['kind' => $kind, 'name' => $name, 'url' => 'images/' . $kind . '/' . rawurlencode($name), 'deletable' => (bool) preg_match('/^[A-Za-z0-9_.-]+\.(png|gif|jpe?g)$/iD', $name) && strpos($name, '..') === false]; + } + } + return $result; +} +function wm_asset_store_image($kind, $source, $original_name) { + $dir = wm_asset_directory($kind); + if (is_link($dir) || !is_writable($dir)) throw new RuntimeException('The image directory is not writable.'); + if (!is_file($source) || filesize($source) > 5 * 1024 * 1024) throw new InvalidArgumentException('Choose a PNG, JPG or GIF file up to 5 MB.'); + $size = @getimagesize($source); + $formats = [IMAGETYPE_PNG => ['png', 'imagecreatefrompng', 'imagepng'], IMAGETYPE_JPEG => ['jpg', 'imagecreatefromjpeg', 'imagejpeg'], IMAGETYPE_GIF => ['gif', 'imagecreatefromgif', 'imagegif']]; + if (!$size || !isset($formats[$size[2]]) || $size[0] * $size[1] > 4000000) throw new InvalidArgumentException('Choose a PNG, JPG or GIF image up to 4 million pixels.'); + [$extension, $decode, $encode] = $formats[$size[2]]; + $image = @$decode($source); + if (!$image) throw new InvalidArgumentException('The image could not be read.'); + if ($size[2] === IMAGETYPE_PNG) { + imagealphablending($image, false); + imagesavealpha($image, true); + } + $stem = preg_replace('/[^A-Za-z0-9_-]/', '_', pathinfo(basename($original_name), PATHINFO_FILENAME)); + $name = substr($stem ?: 'image', 0, 60) . '-' . bin2hex(random_bytes(4)) . '.' . $extension; + $path = wm_asset_image_path($kind, $name); + try { + // Re-encode decoded pixels; never publish the original uploaded bytes. + $written = $size[2] === IMAGETYPE_JPEG ? $encode($image, $path, 90) : $encode($image, $path); + if (!$written) throw new RuntimeException('Could not save the image.'); + chmod($path, 0644); + } finally { imagedestroy($image); } + return $name; +} diff --git a/tests/Support/EditorImageFormatsRegression.php b/tests/Support/EditorImageFormatsRegression.php new file mode 100644 index 00000000..c504718a --- /dev/null +++ b/tests/Support/EditorImageFormatsRegression.php @@ -0,0 +1,70 @@ + IMAGETYPE_PNG, 'jpeg' => IMAGETYPE_JPEG, 'gif' => IMAGETYPE_GIF] as $extension => $type) { + $source = $temporary . '/source.' . $extension; + $image = imagecreatetruecolor(12, 8); + imagefill($image, 0, 0, imagecolorallocate($image, 40, 120, 200)); + if ($extension === 'png') imagepng($image, $source); + elseif ($extension === 'jpeg') imagejpeg($image, $source); + else imagegif($image, $source); + unset($image); + file_put_contents($source, 'UNTRUSTED_TRAILING_BYTES', FILE_APPEND); + foreach (['objects', 'backgrounds'] as $kind) { + $name = wm_asset_store_image($kind, $source, 'misleading.php'); + $path = wm_asset_image_path($kind, $name); + $created[] = $path; + $size = getimagesize($path); + image_format_assert($size[0] === 12 && $size[1] === 8 && $size[2] === $type, 'decoded format and dimensions preserved'); + image_format_assert(strpos(file_get_contents($path), 'UNTRUSTED_TRAILING_BYTES') === false, 'uploaded trailing bytes removed'); + $listed = array_values(array_filter(wm_asset_image_list(), function($item) use ($kind, $name) { return $item['kind'] === $kind && $item['name'] === $name; })); + image_format_assert(count($listed) === 1 && $listed[0]['deletable'], 'each format listed and deletable'); + file_put_contents($config, 'BACKGROUND images/' . $kind . '/' . $name . "\n"); + image_format_assert(in_array(basename($config), wm_asset_image_users($name), true), 'reference protection applies to every format'); + } + } + foreach (['safe.png', 'safe.jpg', 'safe.jpeg', 'safe.gif', 'safe.JPG'] as $name) image_format_assert(str_ends_with(wm_asset_image_path('backgrounds', $name), $name), 'accepted filename'); + foreach (['../outside.jpg', 'bad.php', 'bad.svg', 'bad.jpg.php', 'bad..gif'] as $name) { + try { wm_asset_image_path('objects', $name); throw new RuntimeException('accepted invalid filename'); } catch (InvalidArgumentException $expected) {} + } + $invalid = $temporary . '/fake.png'; file_put_contents($invalid, 'not an image'); + try { wm_asset_store_image('objects', $invalid, 'fake.png'); throw new RuntimeException('accepted fake image'); } catch (InvalidArgumentException $expected) {} + try { wm_asset_upload_image('objects', ['error' => UPLOAD_ERR_OK, 'tmp_name' => $invalid, 'name' => 'fake.png']); throw new RuntimeException('accepted non-upload'); } catch (InvalidArgumentException $expected) {} + $large = $temporary . '/large.png'; $handle = fopen($large, 'w'); ftruncate($handle, 5 * 1024 * 1024 + 1); fclose($handle); + try { wm_asset_store_image('objects', $large, 'large.png'); throw new RuntimeException('accepted oversized file'); } catch (InvalidArgumentException $expected) {} + $image = imagecreatetruecolor(2001, 2000); imagepng($image, $large); unset($image); + try { wm_asset_store_image('backgrounds', $large, 'large.png'); throw new RuntimeException('accepted excessive dimensions'); } catch (InvalidArgumentException $expected) {} + $included = $temporary . '/included.conf'; + file_put_contents($included, "BACKGROUND images/backgrounds/included-image.png\n"); + file_put_contents($config, "INCLUDE " . $included . "\n"); + image_format_assert(in_array(basename($included), wm_asset_image_users('included-image.png'), true), 'included configurations protect images from deletion'); + // Read-only listings, reference protection and the final server unlink. + $_SERVER['REQUEST_METHOD'] = 'GET'; + ob_start(); wm_editor_assets($config, 'image_data'); $response = json_decode(ob_get_clean(), true); + image_format_assert(isset($response['images']), 'library JSON returned'); + $deletePath = $created[0]; $deleteName = basename($deletePath); + $GLOBALS['__test_nfilter_request'] = ['image_kind' => 'objects', 'image_name' => $deleteName]; + ob_start(); wm_editor_assets($config, 'delete_image'); $response = json_decode(ob_get_clean(), true); + image_format_assert(isset($response['error']) && is_file($deletePath), 'GET cannot delete an image'); + $_SERVER['REQUEST_METHOD'] = 'POST'; + file_put_contents($config, "ICON images/objects/" . $deleteName . "\n"); + ob_start(); wm_editor_assets($config, 'delete_image'); $response = json_decode(ob_get_clean(), true); + image_format_assert(isset($response['error']) && is_file($deletePath), 'referenced image cannot be deleted'); + file_put_contents($config, "# no image reference\n"); + ob_start(); wm_editor_assets($config, 'delete_image'); $response = json_decode(ob_get_clean(), true); + image_format_assert(isset($response['images']) && !is_file($deletePath), 'delete removes the actual server file'); + echo "Editor image formats regression PASS\n"; +} finally { + foreach ($created as $path) if (is_file($path)) unlink($path); + if (is_file($config)) unlink($config); + foreach (glob($temporary . '/*') as $path) unlink($path); + rmdir($temporary); +} diff --git a/tests/Unit/EditorImageFormatsTest.php b/tests/Unit/EditorImageFormatsTest.php new file mode 100644 index 00000000..d3f7d225 --- /dev/null +++ b/tests/Unit/EditorImageFormatsTest.php @@ -0,0 +1,19 @@ + - - - Nothing in here works yet. - The aim is to have some nice way to upload images which can be used as icons or backgrounds. - These images are what would appear in the dropdown boxes that don't currently do anything in the Node and Map Properties dialogs. This may end up being a separate page rather than a dialog box... - - - - Cancel - Save - - - - In the future, this form will allow you to manage adding additional icons to the Icon library. For now, you can copy your png files to either <path_cacti>/plugins/weathermap/images/backgrounds/ for Background Images and <path_cacti>/plugins/weathermap/images/objects/ for Icon files. - + + + Node Icons + Upload node iconUpload Node Icon + + Background Images + Upload backgroundUpload Background + + Close + PNG, JPG/JPEG or GIF files up to 5 MB and 4 million pixels. GIF uploads use the first frame. Uploads are saved immediately. Images referenced by a map configuration cannot be deleted. From 7a6f5b83b11b967acbe93807fcecbc99f24a0960 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:00:38 +1030 Subject: [PATCH 02/41] Preserve full-width image library dialog layout --- css/editor.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/css/editor.css b/css/editor.css index eb974fab..740af4c7 100644 --- a/css/editor.css +++ b/css/editor.css @@ -326,3 +326,6 @@ body#mainView #wm-node-delete-bar { min-height: 32px; } .dlgProperties .wm-editor-section { display: table; width: 100%; box-sizing: border-box; margin: 10px 0 6px; } .dlgProperties table .wm-editor-section { margin: 0; } #wm-images-error:not(:empty) { color: inherit; border-left: 3px solid #c62828; padding-left: 8px; font-weight: bold; } + +#dlgImages > .dlgBody { display: block; width: 100%; box-sizing: border-box; } +#dlgImages .wm-editor-section { float: none; } From eaf031be22ad4a13742a67af1864ee544c82f107 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:52:58 +1030 Subject: [PATCH 03/41] Protect image references and expose library usage metadata --- js/editor.js | 20 +- lib/editor.actions.php | 42 ++-- lib/editor.assets.php | 63 ++++-- lib/editor.inc.php | 19 ++ locales/po/cacti.pot | 196 +++++++++++++++++- tests/Browser/EditorImagesTest.cjs | 23 ++ .../Support/EditorImageFormatsRegression.php | 70 +++++-- tests/Unit/EditorImageFormatsTest.php | 46 ++-- weathermap-cacti-plugin-editor.php | 18 +- 9 files changed, 403 insertions(+), 94 deletions(-) create mode 100644 tests/Browser/EditorImagesTest.cjs diff --git a/js/editor.js b/js/editor.js index afd61bef..f260bf5e 100644 --- a/js/editor.js +++ b/js/editor.js @@ -1668,7 +1668,7 @@ function wmAssetRequest(action, extra, callback, errorId) { var values = {action: action, mapname: $('#mapname').val()}; if (typeof csrfMagicToken !== 'undefined') values.__csrf_magic = csrfMagicToken; $.ajax({url: editor_url, type: action === 'image_data' ? 'GET' : 'POST', dataType: 'json', data: $.extend(values, extra), - success: callback, error: function(xhr) { $('#' + errorId).text(xhr.responseJSON && xhr.responseJSON.error || 'The request failed. Please try again.'); }}); + success: callback, error: function(xhr) { $('#' + errorId).text(xhr.responseJSON && xhr.responseJSON.error || wmEditorText.imageText3); }}); } function wmAssetImages(images) { $('#wm-images-objects, #wm-images-backgrounds').empty(); @@ -1676,18 +1676,20 @@ function wmAssetImages(images) { var card = $('').appendTo('#wm-images-' + item.kind); $('').attr({src: item.url, alt: item.name, loading: 'lazy'}).appendTo(card); $('').text(item.name).appendTo(card); - if (item.deletable !== false) $('').text('−').attr('aria-label', 'Delete ' + item.name).appendTo(card).on('click', function() { - if (!window.confirm('Delete image "' + item.name + '" from the server?')) return; + if (item.width && item.height) $('').text(item.width + ' × ' + item.height + ' px').appendTo(card); + $('').text(item.users && item.users.length ? wmEditorText.imageUsed.replace('%s', item.users.join(', ')) : wmEditorText.imageUnused).appendTo(card); + if (item.deletable !== false) $('').text(wmEditorText.imageText0).attr('aria-label', wmEditorText.imageText9.replace('%s', item.name)).appendTo(card).on('click', function() { + if (!window.confirm(wmEditorText.imageText10.replace('%s', item.name))) return; wmAssetRequest('delete_image', {image_kind: item.kind, image_name: item.name}, function(data) { wmAssetImages(data.images); }, 'wm-images-error'); }); }); ['objects', 'backgrounds'].forEach(function(kind) { var count = images.filter(function(item) { return item.kind === kind; }).length; - $('#wm-images-' + kind + '-count').text(count + (count === 1 ? ' image' : ' images')); - if (!count) $('#wm-images-' + kind).append($('').text('No images in this library yet.')); + $('#wm-images-' + kind + '-count').text((count === 1 ? wmEditorText.imageText11 : wmEditorText.imageText12).replace('%s', String(count))); + if (!count) $('#wm-images-' + kind).append($('').text(wmEditorText.imageText1)); }); // Rebuild the existing image selectors so uploads are available without reloading the editor. - [{id: 'node_iconfilename', kind: 'objects', label: 'Label Only'}, {id: 'map_bgfile', kind: 'backgrounds', label: 'No background'}].forEach(function(spec) { + [{id: 'node_iconfilename', kind: 'objects', label: wmEditorText.imageText14}, {id: 'map_bgfile', kind: 'backgrounds', label: wmEditorText.imageText15}].forEach(function(spec) { var select = $('#' + spec.id), old = select.val(); var slick = select.data('ddslick'); if (slick) { old = slick.selectedData && slick.selectedData.value; select.ddslick('destroy'); select = $('#' + spec.id); } @@ -1706,15 +1708,15 @@ function wmOpenImages() { $('.wm-image-upload').off('click').on('click', function() { var button = $(this), kind = button.attr('data-kind'), input = $('#wm-image-file-' + kind); var file = input[0].files[0]; - if (!file) { $('#wm-images-error').text('Choose a PNG, JPG or GIF to upload.'); return; } + if (!file) { $('#wm-images-error').text(wmEditorText.imageText2); return; } var data = new FormData(); data.append('action', 'upload_image'); data.append('mapname', $('#mapname').val()); data.append('image_kind', kind); data.append('image_file', file); if (typeof csrfMagicToken !== 'undefined') data.append('__csrf_magic', csrfMagicToken); button.prop('disabled', true); $.ajax({url: editor_url, type: 'POST', data: data, dataType: 'json', contentType: false, processData: false, - success: function(result) { $('#wm-images-error').text('Uploaded ' + result.name); input.val(''); wmAssetImages(result.images); }, - error: function(xhr) { $('#wm-images-error').text(xhr.responseJSON && xhr.responseJSON.error || 'Upload failed.'); }, + success: function(result) { $('#wm-images-error').text(wmEditorText.imageText13.replace('%s', result.name)); input.val(''); wmAssetImages(result.images); }, + error: function(xhr) { $('#wm-images-error').text(xhr.responseJSON && xhr.responseJSON.error || wmEditorText.imageText4); }, complete: function() { button.prop('disabled', false); }}); }); } diff --git a/lib/editor.actions.php b/lib/editor.actions.php index daed6afa..7f4a0b61 100644 --- a/lib/editor.actions.php +++ b/lib/editor.actions.php @@ -1137,28 +1137,28 @@ function getImageURL($mapname, $selected) { } function wm_asset_upload_image($kind, $file) { - if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || !is_uploaded_file($file['tmp_name'] ?? '')) throw new InvalidArgumentException('Choose a PNG, JPG or GIF file up to 5 MB.'); + if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || !is_uploaded_file($file['tmp_name'] ?? '')) throw new InvalidArgumentException(__('Choose a PNG, JPG or GIF file up to 5 MB.', 'weathermap')); return wm_asset_store_image($kind, $file['tmp_name'], $file['name']); } function wm_editor_assets($mapfile, $action) { - header('Content-Type: application/json'); - try { - if ($action !== 'image_data' && ($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') throw new InvalidArgumentException('Use POST to save changes.'); - if ($action === 'image_data') { - $result = ['images' => wm_asset_image_list()]; - } elseif ($action === 'upload_image') { - $name = wm_asset_upload_image(get_nfilter_request_var('image_kind'), $_FILES['image_file'] ?? []); - $result = ['name' => $name, 'images' => wm_asset_image_list()]; - } else { - $name = get_nfilter_request_var('image_name'); - $path = wm_asset_image_path(get_nfilter_request_var('image_kind'), $name); - if (wm_asset_image_users($name)) throw new InvalidArgumentException('This image is still referenced by a map configuration.'); - if (!is_file($path) || !unlink($path)) throw new RuntimeException('Could not delete the image.'); - $result = ['images' => wm_asset_image_list()]; - } - echo json_encode($result); - } catch (Throwable $error) { - http_response_code(400); - echo json_encode(['error' => $error->getMessage()]); - } + header('Content-Type: application/json'); + try { + if ($action !== 'image_data' && ($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') throw new InvalidArgumentException(__('Use POST to save changes.', 'weathermap')); + if ($action === 'image_data') { + $result = ['images' => wm_asset_image_list()]; + } elseif ($action === 'upload_image') { + $name = wm_asset_upload_image(get_nfilter_request_var('image_kind'), $_FILES['image_file'] ?? []); + $result = ['name' => $name, 'images' => wm_asset_image_list()]; + } else { + $name = get_nfilter_request_var('image_name'); + $path = wm_asset_image_path(get_nfilter_request_var('image_kind'), $name); + if (wm_asset_image_users($name, get_nfilter_request_var('image_kind'))) throw new InvalidArgumentException(__('This image is still referenced by a map configuration.', 'weathermap')); + if (!is_file($path) || !unlink($path)) throw new RuntimeException(__('Could not delete the image.', 'weathermap')); + $result = ['images' => wm_asset_image_list()]; + } + echo json_encode($result); + } catch (Throwable $error) { + http_response_code(400); + echo json_encode(['error' => $error->getMessage()]); + } } diff --git a/lib/editor.assets.php b/lib/editor.assets.php index f5e4158f..06943b8b 100644 --- a/lib/editor.assets.php +++ b/lib/editor.assets.php @@ -24,17 +24,17 @@ /** Editor image library management. */ function wm_asset_directory($kind) { - if (!in_array($kind, ['objects', 'backgrounds'], true)) throw new InvalidArgumentException('Invalid image category.'); + if (!in_array($kind, ['objects', 'backgrounds'], true)) throw new InvalidArgumentException(__('Invalid image category.', 'weathermap')); return dirname(__DIR__) . '/images/' . $kind; } function wm_asset_image_path($kind, $name) { $dir = wm_asset_directory($kind); - if (!is_string($name) || !preg_match('/^[A-Za-z0-9_.-]+\.(png|gif|jpe?g)$/iD', $name) || strpos($name, '..') !== false) throw new InvalidArgumentException('Invalid image filename.'); + if (!is_string($name) || !preg_match('/^[A-Za-z0-9_.-]+\.(png|gif|jpe?g)$/iD', $name) || strpos($name, '..') !== false) throw new InvalidArgumentException(__('Invalid image filename.', 'weathermap')); $path = $dir . '/' . $name; - if (is_link($dir) || is_link($path)) throw new InvalidArgumentException('Symbolic links cannot be managed here.'); + if (is_link($dir) || is_link($path)) throw new InvalidArgumentException(__('Symbolic links cannot be managed here.', 'weathermap')); return $path; } -function wm_asset_image_users($name) { +function wm_asset_image_users($name, $kind = null) { $users = []; $queue = []; $root = dirname(__DIR__); @@ -44,16 +44,35 @@ function wm_asset_image_users($name) { while ($queue) { $path = array_pop($queue); $real = realpath($path); - if ($real === false || !is_readable($real)) throw new RuntimeException('Cannot check configuration references.'); + if ($real === false || !is_readable($real)) throw new RuntimeException(__('Cannot check configuration references.', 'weathermap')); if (isset($seen[$real])) continue; $seen[$real] = true; - if (count($seen) > 1000) throw new RuntimeException('Too many included configurations to check.'); + if (count($seen) > 1000) throw new RuntimeException(__('Too many included configurations to check.', 'weathermap')); $text = file_get_contents($real); - if ($text === false) throw new RuntimeException('Cannot check configuration references.'); - if (strpos($text, $name) !== false) $users[] = basename($real); + if ($text === false) throw new RuntimeException(__('Cannot check configuration references.', 'weathermap')); + if (preg_match_all('/^\s*(ICON|BACKGROUND)\s+(?:[0-9]+\s+[0-9]+\s+)?(.+?)\s*$/mi', $text, $directives, PREG_SET_ORDER)) { + foreach ($directives as $directive) { + $image = trim($directive[2]); + $image = strpos($image, '/') === 0 ? $image : $root . '/' . $image; + $parts = []; + foreach (explode('/', $image) as $part) { + if ($part === '' || $part === '.') continue; + if ($part === '..') array_pop($parts); else $parts[] = $part; + } + $image = '/' . implode('/', $parts); + // Dynamic token values are unknown here: protect all possible paths. + $pattern = implode('.*', array_map(function($part) { return preg_quote($part, '~'); }, preg_split('/\{[^}]+\}/', $image))); + foreach ($kind === null ? ['objects', 'backgrounds'] : [$kind] as $library) { + if (preg_match('~^' . $pattern . '$~D', $root . '/images/' . $library . '/' . $name)) { $users[] = basename($real); break 2; } + } + } + } if (preg_match_all('/^\s*INCLUDE\s+(.+)$/mi', $text, $matches)) foreach ($matches[1] as $include) { $include = trim($include); - $queue[] = strpos($include, '/') === 0 ? $include : $root . '/' . $include; + $include = strpos($include, '/') === 0 ? $include : $root . '/' . $include; + $matches = glob($include); + if (!$matches) $queue[] = $include; + else foreach ($matches as $match) $queue[] = $match; } } return $users; @@ -65,33 +84,41 @@ function wm_asset_image_list() { foreach (scandir($dir) ?: [] as $name) { $path = $dir . '/' . $name; if (!is_file($path) || is_link($path) || !is_readable($path) || !preg_match('/\.(png|gif|jpe?g)$/iD', $name)) continue; - $result[] = ['kind' => $kind, 'name' => $name, 'url' => 'images/' . $kind . '/' . rawurlencode($name), 'deletable' => (bool) preg_match('/^[A-Za-z0-9_.-]+\.(png|gif|jpe?g)$/iD', $name) && strpos($name, '..') === false]; + $size = @getimagesize($path); + $users = wm_asset_image_users($name, $kind); + $result[] = ['width' => $size ? $size[0] : null, 'height' => $size ? $size[1] : null, 'users' => $users, 'kind' => $kind, 'name' => $name, 'url' => 'images/' . $kind . '/' . rawurlencode($name), 'deletable' => !$users && (bool) preg_match('/^[A-Za-z0-9_.-]+\.(png|gif|jpe?g)$/iD', $name) && strpos($name, '..') === false]; } } return $result; } function wm_asset_store_image($kind, $source, $original_name) { $dir = wm_asset_directory($kind); - if (is_link($dir) || !is_writable($dir)) throw new RuntimeException('The image directory is not writable.'); - if (!is_file($source) || filesize($source) > 5 * 1024 * 1024) throw new InvalidArgumentException('Choose a PNG, JPG or GIF file up to 5 MB.'); + if (is_link($dir) || !is_writable($dir)) throw new RuntimeException(__('The image directory is not writable.', 'weathermap')); + if (!is_file($source) || filesize($source) > 5 * 1024 * 1024) throw new InvalidArgumentException(__('Choose a PNG, JPG or GIF file up to 5 MB.', 'weathermap')); $size = @getimagesize($source); $formats = [IMAGETYPE_PNG => ['png', 'imagecreatefrompng', 'imagepng'], IMAGETYPE_JPEG => ['jpg', 'imagecreatefromjpeg', 'imagejpeg'], IMAGETYPE_GIF => ['gif', 'imagecreatefromgif', 'imagegif']]; - if (!$size || !isset($formats[$size[2]]) || $size[0] * $size[1] > 4000000) throw new InvalidArgumentException('Choose a PNG, JPG or GIF image up to 4 million pixels.'); + if (!$size || !isset($formats[$size[2]]) || $size[0] * $size[1] > 4000000) throw new InvalidArgumentException(__('Choose a PNG, JPG or GIF image up to 4 million pixels.', 'weathermap')); [$extension, $decode, $encode] = $formats[$size[2]]; $image = @$decode($source); - if (!$image) throw new InvalidArgumentException('The image could not be read.'); + if (!$image) throw new InvalidArgumentException(__('The image could not be read.', 'weathermap')); if ($size[2] === IMAGETYPE_PNG) { imagealphablending($image, false); imagesavealpha($image, true); } $stem = preg_replace('/[^A-Za-z0-9_-]/', '_', pathinfo(basename($original_name), PATHINFO_FILENAME)); - $name = substr($stem ?: 'image', 0, 60) . '-' . bin2hex(random_bytes(4)) . '.' . $extension; + $name = substr($stem ?: 'image', 0, 60) . '-' . bin2hex(random_bytes(16)) . '.' . $extension; $path = wm_asset_image_path($kind, $name); + $handle = @fopen($path, 'x'); + if (!$handle) { imagedestroy($image); throw new RuntimeException(__('Could not reserve an image filename.', 'weathermap')); } try { // Re-encode decoded pixels; never publish the original uploaded bytes. - $written = $size[2] === IMAGETYPE_JPEG ? $encode($image, $path, 90) : $encode($image, $path); - if (!$written) throw new RuntimeException('Could not save the image.'); + $written = $size[2] === IMAGETYPE_JPEG ? $encode($image, $handle, 90) : $encode($image, $handle); + if (!$written) throw new RuntimeException(__('Could not save the image.', 'weathermap')); + if (!fflush($handle)) throw new RuntimeException(__('Could not save the image.', 'weathermap')); chmod($path, 0644); - } finally { imagedestroy($image); } + } catch (Throwable $error) { + unlink($path); + throw $error; + } finally { fclose($handle); imagedestroy($image); } return $name; } diff --git a/lib/editor.inc.php b/lib/editor.inc.php index ba03d20b..26201ebd 100644 --- a/lib/editor.inc.php +++ b/lib/editor.inc.php @@ -1012,6 +1012,25 @@ function getEditorJs() { var addNodeHelp = ''; var wmEditorText = __('−', 'weathermap'), + 'imageText1' => __('No images in this library yet.', 'weathermap'), + 'imageText2' => __('Choose a PNG, JPG or GIF to upload.', 'weathermap'), + 'imageText3' => __('The request failed. Please try again.', 'weathermap'), + 'imageText4' => __('Upload failed.', 'weathermap'), + 'imageText5' => __('Choose a PNG, JPG or GIF file up to 5 MB.', 'weathermap'), + 'imageText6' => __('No gradient', 'weathermap'), + 'imageText7' => __('Custom colour', 'weathermap'), + 'imageText8' => __('Remove range', 'weathermap'), + 'imageText9' => __('Delete %s', '%s', 'weathermap'), + 'imageText10' => __('Delete image "%s" from the server?', '%s', 'weathermap'), + 'imageText11' => __('%s image', '%s', 'weathermap'), + 'imageText12' => __('%s images', '%s', 'weathermap'), + 'imageText13' => __('Uploaded %s', '%s', 'weathermap'), + 'imageText14' => __('Label Only', 'weathermap'), + 'imageText15' => __('No background', 'weathermap'), + 'imageUsed' => __('Used by: %s', '%s', 'weathermap'), + 'imageUnused' => __('Not used by a map', 'weathermap'), + 'currentInterface' => __('Current interface', 'weathermap'), 'advanced' => __('Advanced settings', 'weathermap'), 'hideAdvanced' => __('Hide advanced settings', 'weathermap'), diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 6963759a..3df11429 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -6,9 +6,9 @@ #, fuzzy msgid "" msgstr "" -"Project-Id-Version: Cacti\n" +"Project-Id-Version: Cacti 1.7\n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-11 10:30+1030\n" +"POT-Creation-Date: 2026-10-12 00:51+1030\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" @@ -18,6 +18,62 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" +#: lib/editor.actions.php lib/editor.assets.php lib/editor.inc.php +msgid "Choose a PNG, JPG or GIF file up to 5 MB." +msgstr "" + +#: lib/editor.actions.php +msgid "Use POST to save changes." +msgstr "" + +#: lib/editor.actions.php +msgid "This image is still referenced by a map configuration." +msgstr "" + +#: lib/editor.actions.php +msgid "Could not delete the image." +msgstr "" + +#: lib/editor.assets.php +msgid "Invalid image category." +msgstr "" + +#: lib/editor.assets.php +msgid "Invalid image filename." +msgstr "" + +#: lib/editor.assets.php +msgid "Symbolic links cannot be managed here." +msgstr "" + +#: lib/editor.assets.php +msgid "Cannot check configuration references." +msgstr "" + +#: lib/editor.assets.php +msgid "Too many included configurations to check." +msgstr "" + +#: lib/editor.assets.php +msgid "The image directory is not writable." +msgstr "" + +#: lib/editor.assets.php +msgid "Choose a PNG, JPG or GIF image up to 4 million pixels." +msgstr "" + +#: lib/editor.assets.php +msgid "The image could not be read." +msgstr "" + +#: lib/editor.assets.php +msgid "Could not reserve an image filename." +msgstr "" + +#: lib/editor.assets.php +msgid "Could not save the image." +msgstr "" + #: lib/editor.comment-style.php #, php-format msgid "%s — %s pt (font %s)" @@ -89,6 +145,16 @@ msgstr "" msgid "Link Comment Colour" msgstr "" +#: lib/editor.config-delete.php +#, php-format +msgid "Delete configuration file %s" +msgstr "" + +#: lib/editor.config-delete.php +#, php-format +msgid "Are you sure you want to permanently delete the configuration file \"%s\"?" +msgstr "" + #: lib/editor.inc.php msgid "Next results" msgstr "" @@ -166,6 +232,80 @@ msgstr "" msgid "Click on the map where you would like to add a new node." msgstr "" +#: lib/editor.inc.php +msgid "−" +msgstr "" + +#: lib/editor.inc.php +msgid "No images in this library yet." +msgstr "" + +#: lib/editor.inc.php +msgid "Choose a PNG, JPG or GIF to upload." +msgstr "" + +#: lib/editor.inc.php +msgid "The request failed. Please try again." +msgstr "" + +#: lib/editor.inc.php +msgid "Upload failed." +msgstr "" + +#: lib/editor.inc.php +msgid "No gradient" +msgstr "" + +#: lib/editor.inc.php +msgid "Custom colour" +msgstr "" + +#: lib/editor.inc.php +msgid "Remove range" +msgstr "" + +#: lib/editor.inc.php +#, php-format +msgid "Delete %s" +msgstr "" + +#: lib/editor.inc.php +#, php-format +msgid "Delete image \"%s\" from the server?" +msgstr "" + +#: lib/editor.inc.php +#, php-format +msgid "%s image" +msgstr "" + +#: lib/editor.inc.php +#, php-format +msgid "%s images" +msgstr "" + +#: lib/editor.inc.php +#, php-format +msgid "Uploaded %s" +msgstr "" + +#: lib/editor.inc.php +msgid "Label Only" +msgstr "" + +#: lib/editor.inc.php +msgid "No background" +msgstr "" + +#: lib/editor.inc.php +#, php-format +msgid "Used by: %s" +msgstr "" + +#: lib/editor.inc.php +msgid "Not used by a map" +msgstr "" + #: lib/editor.inc.php msgid "Current interface" msgstr "" @@ -1282,6 +1422,50 @@ msgstr "" msgid "Link Hover Height (pixels)" msgstr "" +#: weathermap-cacti-plugin-editor.php +msgid "Manage Images" +msgstr "" + +#: weathermap-cacti-plugin-editor.php +msgid "Node Icons" +msgstr "" + +#: weathermap-cacti-plugin-editor.php +msgid "Upload Node Icon" +msgstr "" + +#: weathermap-cacti-plugin-editor.php +msgid "Upload node icon" +msgstr "" + +#: weathermap-cacti-plugin-editor.php +msgid "Existing node icons" +msgstr "" + +#: weathermap-cacti-plugin-editor.php +msgid "Background Images" +msgstr "" + +#: weathermap-cacti-plugin-editor.php +msgid "Upload Background" +msgstr "" + +#: weathermap-cacti-plugin-editor.php +msgid "Upload background" +msgstr "" + +#: weathermap-cacti-plugin-editor.php +msgid "Existing background images" +msgstr "" + +#: weathermap-cacti-plugin-editor.php +msgid "Close" +msgstr "" + +#: weathermap-cacti-plugin-editor.php +msgid "PNG, JPG/JPEG or GIF files up to 5 MB and 4 million pixels. GIF uploads use the first frame. Uploads are saved immediately. Images referenced by a map configuration cannot be deleted." +msgstr "" + #: weathermap-cacti-plugin-mgmt.php msgid "Duplicate" msgstr "" @@ -2009,6 +2193,10 @@ msgstr "" msgid "Unable to delete the last remaining Map Group." msgstr "" +#: weathermap-cacti-plugin-mgmt.php +msgid "Map title is too long. Use at most 4088 bytes after escaping." +msgstr "" + #: weathermap-cacti-plugin-mgmt.php #, php-format msgid "The New Map File name %s already exists!" @@ -2142,7 +2330,3 @@ msgstr "" #: weathermap-cacti-plugin.php msgid "Paused" msgstr "" - -#: weathermap-cacti-plugin-mgmt.php -msgid "Map title is too long. Use at most 4088 bytes after escaping." -msgstr "" diff --git a/tests/Browser/EditorImagesTest.cjs b/tests/Browser/EditorImagesTest.cjs new file mode 100644 index 00000000..b70c2438 --- /dev/null +++ b/tests/Browser/EditorImagesTest.cjs @@ -0,0 +1,23 @@ +// Verify library metadata, guarded deletion and upload/request wiring. +const {JSDOM}=require('jsdom'); +const jquery=require('jquery'); +const fs=require('node:fs'); +const path=require('node:path'); +const assert=require('node:assert/strict'); +const dom=new JSDOM('Label OnlyNo background',{runScripts:'outside-only',pretendToBeVisual:true}); +const w=dom.window,$=jquery(w);w.$=w.jQuery=$;w.editor_url='/editor';w.csrfMagicToken='fixture';$.fn.dialog=function(){return this;}; +const inc=fs.readFileSync(path.join(__dirname,'../../lib/editor.inc.php'),'utf8');w.wmEditorText={}; +for(const m of inc.matchAll(/'(imageText[0-9]+|imageUsed|imageUnused)' => __\('((?:\\.|[^'])+)'/g))w.wmEditorText[m[1]]=m[2].replace(/\\'/g,"'"); +w.wmEditorText.imageUnused='Non utilisée'; +const source=fs.readFileSync(path.join(__dirname,'../../js/editor.js'),'utf8');w.eval(source.slice(source.indexOf('function wmAssetRequest('))); +const images=[{kind:'objects',name:'hospital.png',url:'images/objects/hospital.png',width:96,height:96,users:['map.conf'],deletable:false},{kind:'backgrounds',name:'background.jpg',url:'images/backgrounds/background.jpg',width:800,height:600,users:[],deletable:true}]; +w.wmAssetImages(images); +assert.equal($('#wm-images-objects .wm-image-delete').length,0);assert.match($('#wm-images-objects').text(),/96 × 96 px/);assert.match($('#wm-images-objects').text(),/map.conf/);assert.match($('#wm-images-backgrounds').text(),/Non utilisée/); +assert.equal($('#node_iconfilename option[value="images/objects/hospital.png"]').length,1); +let request;$.ajax=opts=>request=opts;w.confirm=()=>false;$('#wm-images-backgrounds .wm-image-delete').trigger('click');assert.equal(request,undefined); +w.confirm=()=>true;$('#wm-images-backgrounds .wm-image-delete').trigger('click');assert.equal(request.type,'POST');assert.equal(request.data.action,'delete_image');assert.equal(request.data.image_kind,'backgrounds');assert.equal(request.data.__csrf_magic,'fixture'); +request.error({responseJSON:{error:'In use'}});assert.equal($('#wm-images-error').text(),'In use'); +w.wmOpenImages();assert.equal(request.type,'GET');assert.equal(request.data.action,'image_data');request.success({images}); +$('.wm-image-upload').trigger('click');assert.match($('#wm-images-error').text(),/Choose a PNG/); +Object.defineProperty($('#wm-image-file-objects')[0],'files',{value:[new w.File(['image'],'test.png',{type:'image/png'})]});$('.wm-image-upload').trigger('click');assert.equal(request.data.get('action'),'upload_image');assert.equal(request.data.get('image_kind'),'objects');assert.equal(request.data.get('__csrf_magic'),'fixture');assert.equal($('.wm-image-upload').prop('disabled'),true);request.complete();assert.equal($('.wm-image-upload').prop('disabled'),false); +dom.window.close();console.log('PASS: image metadata, translations, protected delete, confirmation, request errors and upload wiring'); diff --git a/tests/Support/EditorImageFormatsRegression.php b/tests/Support/EditorImageFormatsRegression.php index c504718a..af528a6c 100644 --- a/tests/Support/EditorImageFormatsRegression.php +++ b/tests/Support/EditorImageFormatsRegression.php @@ -1,4 +1,26 @@ 'objects', 'image_name' => $deleteName]; - ob_start(); wm_editor_assets($config, 'delete_image'); $response = json_decode(ob_get_clean(), true); - image_format_assert(isset($response['error']) && is_file($deletePath), 'GET cannot delete an image'); - $_SERVER['REQUEST_METHOD'] = 'POST'; - file_put_contents($config, "ICON images/objects/" . $deleteName . "\n"); - ob_start(); wm_editor_assets($config, 'delete_image'); $response = json_decode(ob_get_clean(), true); - image_format_assert(isset($response['error']) && is_file($deletePath), 'referenced image cannot be deleted'); - file_put_contents($config, "# no image reference\n"); - ob_start(); wm_editor_assets($config, 'delete_image'); $response = json_decode(ob_get_clean(), true); - image_format_assert(isset($response['images']) && !is_file($deletePath), 'delete removes the actual server file'); + $included = $temporary . '/included.conf'; + file_put_contents($included, "BACKGROUND images/backgrounds/included-image.png\n"); + file_put_contents($config, "INCLUDE " . $included . "\n"); + image_format_assert(in_array(basename($included), wm_asset_image_users('included-image.png'), true), 'included configurations protect images from deletion'); + // Read-only listings, reference protection and the final server unlink. + $_SERVER['REQUEST_METHOD'] = 'GET'; + ob_start(); wm_editor_assets($config, 'image_data'); $response = json_decode(ob_get_clean(), true); + image_format_assert(isset($response['images']), 'library JSON returned'); + $deletePath = $created[0]; $deleteName = basename($deletePath); + $GLOBALS['__test_nfilter_request'] = ['image_kind' => 'objects', 'image_name' => $deleteName]; + ob_start(); wm_editor_assets($config, 'delete_image'); $response = json_decode(ob_get_clean(), true); + image_format_assert(isset($response['error']) && is_file($deletePath), 'GET cannot delete an image'); + $_SERVER['REQUEST_METHOD'] = 'POST'; + file_put_contents($config, "ICON images/objects/" . $deleteName . "\n"); + ob_start(); wm_editor_assets($config, 'delete_image'); $response = json_decode(ob_get_clean(), true); + image_format_assert(isset($response['error']) && is_file($deletePath), 'referenced image cannot be deleted'); + file_put_contents($config, "# no image reference\n"); + ob_start(); wm_editor_assets($config, 'delete_image'); $response = json_decode(ob_get_clean(), true); + image_format_assert(isset($response['images']) && !is_file($deletePath), 'delete removes the actual server file'); echo "Editor image formats regression PASS\n"; } finally { foreach ($created as $path) if (is_file($path)) unlink($path); diff --git a/tests/Unit/EditorImageFormatsTest.php b/tests/Unit/EditorImageFormatsTest.php index d3f7d225..4304d156 100644 --- a/tests/Unit/EditorImageFormatsTest.php +++ b/tests/Unit/EditorImageFormatsTest.php @@ -1,19 +1,41 @@ - + '> - Node Icons - Upload node iconUpload Node Icon - - Background Images - Upload backgroundUpload Background - + + + '> + + + '> - Close - PNG, JPG/JPEG or GIF files up to 5 MB and 4 million pixels. GIF uploads use the first frame. Uploads are saved immediately. Images referenced by a map configuration cannot be deleted. + + From d74b3eb521a8cafb1583189fc7bcba10c522b3ec Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:56:05 +1030 Subject: [PATCH 04/41] Clean up incomplete image writes in the finalizer --- lib/editor.assets.php | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/lib/editor.assets.php b/lib/editor.assets.php index 06943b8b..28783664 100644 --- a/lib/editor.assets.php +++ b/lib/editor.assets.php @@ -110,15 +110,17 @@ function wm_asset_store_image($kind, $source, $original_name) { $path = wm_asset_image_path($kind, $name); $handle = @fopen($path, 'x'); if (!$handle) { imagedestroy($image); throw new RuntimeException(__('Could not reserve an image filename.', 'weathermap')); } + $written = false; try { // Re-encode decoded pixels; never publish the original uploaded bytes. - $written = $size[2] === IMAGETYPE_JPEG ? $encode($image, $handle, 90) : $encode($image, $handle); - if (!$written) throw new RuntimeException(__('Could not save the image.', 'weathermap')); - if (!fflush($handle)) throw new RuntimeException(__('Could not save the image.', 'weathermap')); - chmod($path, 0644); - } catch (Throwable $error) { - unlink($path); - throw $error; - } finally { fclose($handle); imagedestroy($image); } + $encoded = $size[2] === IMAGETYPE_JPEG ? $encode($image, $handle, 90) : $encode($image, $handle); + $written = $encoded && fflush($handle); + } finally { + fclose($handle); + imagedestroy($image); + if (!$written) unlink($path); + } + if (!$written) throw new RuntimeException(__('Could not save the image.', 'weathermap')); + chmod($path, 0644); return $name; } From 4548ed4e73be8f4fc53c89e37487c83610165acb Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:30:27 +1030 Subject: [PATCH 05/41] Guard image library parents and reuse configuration reference indexes --- CHANGELOG.md | 2 + js/editor.js | 2 +- lib/editor.assets.php | 39 +++++++++++++------ lib/editor.inc.php | 3 +- locales/po/cacti.pot | 13 ++----- .../Support/EditorImageFormatsRegression.php | 23 +++++++++++ 6 files changed, 59 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d13427ee..22ae57b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ IMPORTANT NOTE: This version only works on CACTI 1.x++! --- develop --- +* Add separate icon and background image upload and management libraries + * issue#299: Keep editor picker results open when browsing and allow changing an existing selection * feature#298: Fit or stretch background images within the map canvas diff --git a/js/editor.js b/js/editor.js index f260bf5e..3a04ecdf 100644 --- a/js/editor.js +++ b/js/editor.js @@ -1685,7 +1685,7 @@ function wmAssetImages(images) { }); ['objects', 'backgrounds'].forEach(function(kind) { var count = images.filter(function(item) { return item.kind === kind; }).length; - $('#wm-images-' + kind + '-count').text((count === 1 ? wmEditorText.imageText11 : wmEditorText.imageText12).replace('%s', String(count))); + $('#wm-images-' + kind + '-count').text(wmEditorText.imageText11.replace('%s', String(count))); if (!count) $('#wm-images-' + kind).append($('').text(wmEditorText.imageText1)); }); // Rebuild the existing image selectors so uploads are available without reloading the editor. diff --git a/lib/editor.assets.php b/lib/editor.assets.php index 28783664..f1963858 100644 --- a/lib/editor.assets.php +++ b/lib/editor.assets.php @@ -25,7 +25,10 @@ /** Editor image library management. */ function wm_asset_directory($kind) { if (!in_array($kind, ['objects', 'backgrounds'], true)) throw new InvalidArgumentException(__('Invalid image category.', 'weathermap')); - return dirname(__DIR__) . '/images/' . $kind; + $parent = dirname(__DIR__) . '/images'; + $dir = $parent . '/' . $kind; + if (is_link($parent) || is_link($dir)) throw new InvalidArgumentException(__('Symbolic links cannot be managed here.', 'weathermap')); + return $dir; } function wm_asset_image_path($kind, $name) { $dir = wm_asset_directory($kind); @@ -34,8 +37,8 @@ function wm_asset_image_path($kind, $name) { if (is_link($dir) || is_link($path)) throw new InvalidArgumentException(__('Symbolic links cannot be managed here.', 'weathermap')); return $path; } -function wm_asset_image_users($name, $kind = null) { - $users = []; +function wm_asset_image_reference_index() { + $references = []; $queue = []; $root = dirname(__DIR__); $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root . '/configs', FilesystemIterator::SKIP_DOTS)); @@ -62,30 +65,43 @@ function wm_asset_image_users($name, $kind = null) { $image = '/' . implode('/', $parts); // Dynamic token values are unknown here: protect all possible paths. $pattern = implode('.*', array_map(function($part) { return preg_quote($part, '~'); }, preg_split('/\{[^}]+\}/', $image))); - foreach ($kind === null ? ['objects', 'backgrounds'] : [$kind] as $library) { - if (preg_match('~^' . $pattern . '$~D', $root . '/images/' . $library . '/' . $name)) { $users[] = basename($real); break 2; } - } + $references[] = ['pattern' => '~^' . $pattern . '$~D', 'user' => basename($real)]; } } if (preg_match_all('/^\s*INCLUDE\s+(.+)$/mi', $text, $matches)) foreach ($matches[1] as $include) { $include = trim($include); $include = strpos($include, '/') === 0 ? $include : $root . '/' . $include; $matches = glob($include); - if (!$matches) $queue[] = $include; - else foreach ($matches as $match) $queue[] = $match; + // The renderer tolerates absent optional includes; unreadable matches still fail closed. + foreach ($matches ?: [] as $match) $queue[] = $match; + } + } + return $references; +} +function wm_asset_image_users($name, $kind = null, $references = null) { + $references = $references ?? wm_asset_image_reference_index(); + $users = []; + $root = dirname(__DIR__); + foreach ($references as $reference) { + foreach ($kind === null ? ['objects', 'backgrounds'] : [$kind] as $library) { + if (preg_match($reference['pattern'], $root . '/images/' . $library . '/' . $name)) { + $users[] = $reference['user']; + break; + } } } - return $users; + return array_values(array_unique($users)); } function wm_asset_image_list() { $result = []; + $references = wm_asset_image_reference_index(); foreach (['objects', 'backgrounds'] as $kind) { $dir = wm_asset_directory($kind); foreach (scandir($dir) ?: [] as $name) { $path = $dir . '/' . $name; if (!is_file($path) || is_link($path) || !is_readable($path) || !preg_match('/\.(png|gif|jpe?g)$/iD', $name)) continue; $size = @getimagesize($path); - $users = wm_asset_image_users($name, $kind); + $users = wm_asset_image_users($name, $kind, $references); $result[] = ['width' => $size ? $size[0] : null, 'height' => $size ? $size[1] : null, 'users' => $users, 'kind' => $kind, 'name' => $name, 'url' => 'images/' . $kind . '/' . rawurlencode($name), 'deletable' => !$users && (bool) preg_match('/^[A-Za-z0-9_.-]+\.(png|gif|jpe?g)$/iD', $name) && strpos($name, '..') === false]; } } @@ -116,7 +132,8 @@ function wm_asset_store_image($kind, $source, $original_name) { $encoded = $size[2] === IMAGETYPE_JPEG ? $encode($image, $handle, 90) : $encode($image, $handle); $written = $encoded && fflush($handle); } finally { - fclose($handle); + $closed = fclose($handle); + $written = $written && $closed; imagedestroy($image); if (!$written) unlink($path); } diff --git a/lib/editor.inc.php b/lib/editor.inc.php index 26201ebd..843e6ad0 100644 --- a/lib/editor.inc.php +++ b/lib/editor.inc.php @@ -1023,8 +1023,7 @@ function getEditorJs() { 'imageText8' => __('Remove range', 'weathermap'), 'imageText9' => __('Delete %s', '%s', 'weathermap'), 'imageText10' => __('Delete image "%s" from the server?', '%s', 'weathermap'), - 'imageText11' => __('%s image', '%s', 'weathermap'), - 'imageText12' => __('%s images', '%s', 'weathermap'), + 'imageText11' => __('Images: %s', '%s', 'weathermap'), 'imageText13' => __('Uploaded %s', '%s', 'weathermap'), 'imageText14' => __('Label Only', 'weathermap'), 'imageText15' => __('No background', 'weathermap'), diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 3df11429..070b8c88 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti 1.7\n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-12 00:51+1030\n" +"POT-Creation-Date: 2026-10-12 01:28+1030\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" @@ -39,11 +39,11 @@ msgid "Invalid image category." msgstr "" #: lib/editor.assets.php -msgid "Invalid image filename." +msgid "Symbolic links cannot be managed here." msgstr "" #: lib/editor.assets.php -msgid "Symbolic links cannot be managed here." +msgid "Invalid image filename." msgstr "" #: lib/editor.assets.php @@ -276,12 +276,7 @@ msgstr "" #: lib/editor.inc.php #, php-format -msgid "%s image" -msgstr "" - -#: lib/editor.inc.php -#, php-format -msgid "%s images" +msgid "Images: %s" msgstr "" #: lib/editor.inc.php diff --git a/tests/Support/EditorImageFormatsRegression.php b/tests/Support/EditorImageFormatsRegression.php index af528a6c..5eb1c758 100644 --- a/tests/Support/EditorImageFormatsRegression.php +++ b/tests/Support/EditorImageFormatsRegression.php @@ -78,6 +78,29 @@ function image_format_assert($value, $message) { if (!$value) throw new RuntimeE file_put_contents($included, "BACKGROUND images/backgrounds/included-image.png\n"); file_put_contents($config, "INCLUDE " . $included . "\n"); image_format_assert(in_array(basename($included), wm_asset_image_users('included-image.png'), true), 'included configurations protect images from deletion'); + // Missing optional includes must not disable the library, and one index serves all names. + file_put_contents($config, "INCLUDE " . $temporary . "/missing-*.conf\nINCLUDE " . $included . "\n"); + $index = wm_asset_image_reference_index(); + file_put_contents($included, "# changed after indexing\n"); + image_format_assert(in_array(basename($included), wm_asset_image_users('included-image.png', 'backgrounds', $index), true), 'reference snapshot reused without rescanning configurations'); + image_format_assert(wm_asset_image_users('included-image.png', 'backgrounds') === [], 'fresh request reflects changed configuration'); + image_format_assert(is_array(wm_asset_image_list()), 'unmatched optional include permits listing'); + // Test symlink rejection only inside this isolated fixture, and always restore its library. + $library = $root . '/images'; + $originalLibrary = $temporary . '/library'; + image_format_assert(rename($library, $originalLibrary), 'reserve fixture library'); + try { + image_format_assert(symlink($originalLibrary, $library), 'create linked fixture library'); + foreach (['objects', 'backgrounds'] as $kind) { + try { wm_asset_directory($kind); throw new RuntimeException('accepted symlinked library parent'); } catch (InvalidArgumentException $expected) {} + } + try { wm_asset_image_list(); throw new RuntimeException('listed linked library'); } catch (InvalidArgumentException $expected) {} + try { wm_asset_store_image('objects', $source, 'linked.png'); throw new RuntimeException('uploaded to linked library'); } catch (InvalidArgumentException $expected) {} + try { wm_asset_image_path('objects', basename($created[0])); throw new RuntimeException('accepted deletion path in linked library'); } catch (InvalidArgumentException $expected) {} + } finally { + if (is_link($library)) unlink($library); + rename($originalLibrary, $library); + } // Read-only listings, reference protection and the final server unlink. $_SERVER['REQUEST_METHOD'] = 'GET'; ob_start(); wm_editor_assets($config, 'image_data'); $response = json_decode(ob_get_clean(), true); From 1d14e0c98b4c154a81994a3dec6c921acce90939 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:33:38 +1030 Subject: [PATCH 06/41] Reuse the image reference snapshot after deletion --- lib/editor.actions.php | 5 +++-- lib/editor.assets.php | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/editor.actions.php b/lib/editor.actions.php index 7f4a0b61..3463ecda 100644 --- a/lib/editor.actions.php +++ b/lib/editor.actions.php @@ -1152,9 +1152,10 @@ function wm_editor_assets($mapfile, $action) { } else { $name = get_nfilter_request_var('image_name'); $path = wm_asset_image_path(get_nfilter_request_var('image_kind'), $name); - if (wm_asset_image_users($name, get_nfilter_request_var('image_kind'))) throw new InvalidArgumentException(__('This image is still referenced by a map configuration.', 'weathermap')); + $references = wm_asset_image_reference_index(); + if (wm_asset_image_users($name, get_nfilter_request_var('image_kind'), $references)) throw new InvalidArgumentException(__('This image is still referenced by a map configuration.', 'weathermap')); if (!is_file($path) || !unlink($path)) throw new RuntimeException(__('Could not delete the image.', 'weathermap')); - $result = ['images' => wm_asset_image_list()]; + $result = ['images' => wm_asset_image_list($references)]; } echo json_encode($result); } catch (Throwable $error) { diff --git a/lib/editor.assets.php b/lib/editor.assets.php index f1963858..66abbce1 100644 --- a/lib/editor.assets.php +++ b/lib/editor.assets.php @@ -92,9 +92,9 @@ function wm_asset_image_users($name, $kind = null, $references = null) { } return array_values(array_unique($users)); } -function wm_asset_image_list() { +function wm_asset_image_list($references = null) { $result = []; - $references = wm_asset_image_reference_index(); + $references = $references ?? wm_asset_image_reference_index(); foreach (['objects', 'backgrounds'] as $kind) { $dir = wm_asset_directory($kind); foreach (scandir($dir) ?: [] as $name) { From b1f1e2a2b2f75b1aa1d30452f9c31936292aad00 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:08:24 +1030 Subject: [PATCH 07/41] Validate references before persisting image uploads --- lib/editor.actions.php | 19 +++++++- lib/editor.assets.php | 47 ++++++++++++++++++- locales/po/cacti.pot | 2 +- .../Support/EditorImageFormatsRegression.php | 12 +++++ 4 files changed, 77 insertions(+), 3 deletions(-) diff --git a/lib/editor.actions.php b/lib/editor.actions.php index 3463ecda..73e55656 100644 --- a/lib/editor.actions.php +++ b/lib/editor.actions.php @@ -1136,10 +1136,26 @@ function getImageURL($mapname, $selected) { return $imageurl; } +/** + * Validate a PHP upload and store its decoded image. + * + * @param string $kind The destination library category. + * @param array $file The PHP upload metadata from $_FILES. + * + * @return string The stored image filename. + */ function wm_asset_upload_image($kind, $file) { if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || !is_uploaded_file($file['tmp_name'] ?? '')) throw new InvalidArgumentException(__('Choose a PNG, JPG or GIF file up to 5 MB.', 'weathermap')); return wm_asset_store_image($kind, $file['tmp_name'], $file['name']); } +/** + * Read or mutate an image library and emit a JSON response. + * + * @param string $mapfile The current validated map path. + * @param string $action The library operation requested by the editor. + * + * @return void Errors are returned as HTTP 400 JSON responses. + */ function wm_editor_assets($mapfile, $action) { header('Content-Type: application/json'); try { @@ -1147,8 +1163,9 @@ function wm_editor_assets($mapfile, $action) { if ($action === 'image_data') { $result = ['images' => wm_asset_image_list()]; } elseif ($action === 'upload_image') { + $references = wm_asset_image_reference_index(); $name = wm_asset_upload_image(get_nfilter_request_var('image_kind'), $_FILES['image_file'] ?? []); - $result = ['name' => $name, 'images' => wm_asset_image_list()]; + $result = ['name' => $name, 'images' => wm_asset_image_list($references)]; } else { $name = get_nfilter_request_var('image_name'); $path = wm_asset_image_path(get_nfilter_request_var('image_kind'), $name); diff --git a/lib/editor.assets.php b/lib/editor.assets.php index 66abbce1..1b33f802 100644 --- a/lib/editor.assets.php +++ b/lib/editor.assets.php @@ -22,7 +22,13 @@ +-------------------------------------------------------------------------+ */ -/** Editor image library management. */ +/** + * Resolve a managed image library, rejecting symlinked directories. + * + * @param string $kind The objects or backgrounds library. + * + * @return string The library directory. + */ function wm_asset_directory($kind) { if (!in_array($kind, ['objects', 'backgrounds'], true)) throw new InvalidArgumentException(__('Invalid image category.', 'weathermap')); $parent = dirname(__DIR__) . '/images'; @@ -30,6 +36,14 @@ function wm_asset_directory($kind) { if (is_link($parent) || is_link($dir)) throw new InvalidArgumentException(__('Symbolic links cannot be managed here.', 'weathermap')); return $dir; } +/** + * Validate a filename and resolve its non-symlinked library path. + * + * @param string $kind The library category. + * @param mixed $name The filename to validate. + * + * @return string The validated image path. + */ function wm_asset_image_path($kind, $name) { $dir = wm_asset_directory($kind); if (!is_string($name) || !preg_match('/^[A-Za-z0-9_.-]+\.(png|gif|jpe?g)$/iD', $name) || strpos($name, '..') !== false) throw new InvalidArgumentException(__('Invalid image filename.', 'weathermap')); @@ -37,6 +51,12 @@ function wm_asset_image_path($kind, $name) { if (is_link($dir) || is_link($path)) throw new InvalidArgumentException(__('Symbolic links cannot be managed here.', 'weathermap')); return $path; } +/** + * Read configuration files and includes once to index image references. + * + * + * @return array The reference patterns and source filenames. + */ function wm_asset_image_reference_index() { $references = []; $queue = []; @@ -78,6 +98,15 @@ function wm_asset_image_reference_index() { } return $references; } +/** + * Find map configurations referencing an image. + * + * @param string $name The image filename. + * @param string|null $kind One library category, or both when null. + * @param array|null $references A request-local reference index; null builds an index. + * + * @return string[] Unique configuration filenames using the image. + */ function wm_asset_image_users($name, $kind = null, $references = null) { $references = $references ?? wm_asset_image_reference_index(); $users = []; @@ -92,6 +121,13 @@ function wm_asset_image_users($name, $kind = null, $references = null) { } return array_values(array_unique($users)); } +/** + * List image metadata using one shared configuration reference index. + * + * @param array|null $references A precomputed index, or null to read configurations. + * + * @return array The image dimensions, usage and deletion permissions. + */ function wm_asset_image_list($references = null) { $result = []; $references = $references ?? wm_asset_image_reference_index(); @@ -107,6 +143,15 @@ function wm_asset_image_list($references = null) { } return $result; } +/** + * Validate and re-encode an upload under an exclusively reserved filename. + * + * @param string $kind The destination library category. + * @param string $source The temporary source image path. + * @param string $original_name The original name used to derive a safe filename. + * + * @return string The stored filename; invalid images or write failures raise exceptions. + */ function wm_asset_store_image($kind, $source, $original_name) { $dir = wm_asset_directory($kind); if (is_link($dir) || !is_writable($dir)) throw new RuntimeException(__('The image directory is not writable.', 'weathermap')); diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 070b8c88..df8dac5e 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti 1.7\n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-12 01:28+1030\n" +"POT-Creation-Date: 2026-10-12 09:02+1030\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" diff --git a/tests/Support/EditorImageFormatsRegression.php b/tests/Support/EditorImageFormatsRegression.php index 5eb1c758..46f5bb32 100644 --- a/tests/Support/EditorImageFormatsRegression.php +++ b/tests/Support/EditorImageFormatsRegression.php @@ -101,6 +101,18 @@ function image_format_assert($value, $message) { if (!$value) throw new RuntimeE if (is_link($library)) unlink($library); rename($originalLibrary, $library); } + // A reference-scan failure must be reported before upload validation or persistence. + $broken = $temporary . '/broken.conf'; + symlink($temporary . '/missing-target.conf', $broken); + file_put_contents($config, "INCLUDE " . $broken . "\n"); + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_FILES['image_file'] = ['error' => UPLOAD_ERR_OK, 'tmp_name' => $source, 'name' => 'must-not-persist.png']; + $GLOBALS['__test_nfilter_request'] = ['image_kind' => 'objects']; + ob_start(); wm_editor_assets($config, 'upload_image'); $response = json_decode(ob_get_clean(), true); + image_format_assert(str_contains($response['error'], 'Cannot check configuration references'), 'configuration failure precedes upload persistence'); + image_format_assert(glob($root . '/images/objects/must-not-persist-*') === [], 'reference error cannot create an uploaded image'); + unlink($broken); unset($_FILES['image_file']); + file_put_contents($config, "# no image references\n"); // Read-only listings, reference protection and the final server unlink. $_SERVER['REQUEST_METHOD'] = 'GET'; ob_start(); wm_editor_assets($config, 'image_data'); $response = json_decode(ob_get_clean(), true); From 7db90e4d3b903d0df8c9a34ebe34ab723ef98f36 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:09:56 +1030 Subject: [PATCH 08/41] Synchronize image deletion with portable atomic configuration saves --- js/editor.js | 2 +- lib/WeatherMap.class.php | 53 +++-- lib/WeatherMap.config.php | 107 ++++++++++ lib/editor.actions.php | 13 +- lib/editor.assets.php | 46 +++-- lib/editor.inc.php | 2 +- locales/po/cacti.pot | 14 +- tests/Browser/EditorImagesTest.cjs | 2 + tests/Support/ConfigWriteAtomicRegression.php | 193 ++++++++++++++++++ .../Support/EditorImageFormatsRegression.php | 25 ++- tests/Unit/ConfigWriteAtomicTest.php | 41 ++++ weathermap-cacti-plugin-mgmt.php | 21 +- 12 files changed, 453 insertions(+), 66 deletions(-) create mode 100644 lib/WeatherMap.config.php create mode 100644 tests/Support/ConfigWriteAtomicRegression.php create mode 100644 tests/Unit/ConfigWriteAtomicTest.php diff --git a/js/editor.js b/js/editor.js index 3a04ecdf..8fa055a8 100644 --- a/js/editor.js +++ b/js/editor.js @@ -1693,7 +1693,7 @@ function wmAssetImages(images) { var select = $('#' + spec.id), old = select.val(); var slick = select.data('ddslick'); if (slick) { old = slick.selectedData && slick.selectedData.value; select.ddslick('destroy'); select = $('#' + spec.id); } - select.find('option').filter(function() { return /^images\/.*\.(png|gif|jpe?g)$/i.test(this.value); }).remove(); + select.find('option').filter(function() { return this.value.indexOf('images/' + spec.kind + '/') === 0 && /\.(png|gif|jpe?g)$/i.test(this.value); }).remove(); if (!select.find('option[value="--NONE--"]').length) select.prepend($('').val('--NONE--').text(spec.label)); images.filter(function(item) { return item.kind === spec.kind; }).forEach(function(item) { select.append($('').val('images/' + item.kind + '/' + item.name).text(item.name).attr({'data-imagesrc': item.url, 'data-description': item.name})); diff --git a/lib/WeatherMap.class.php b/lib/WeatherMap.class.php index b78c8849..9e2f630d 100644 --- a/lib/WeatherMap.class.php +++ b/lib/WeatherMap.class.php @@ -3607,21 +3607,28 @@ function WriteDataFile($filename) { * @return bool */ function WriteConfig($filename) { - $fd = false; - - if (is_writable(dirname($filename)) || (file_exists($filename) && is_writable($filename))) { - $fd = fopen($filename, 'w'); - assert($fd !== false); - } + require_once __DIR__ . '/WeatherMap.config.php'; + $fd = fopen('php://temp', 'w+b'); $output = ''; $weathermap_version = plugin_weathermap_numeric_version(); if ($fd) { + $write_ok = true; + $write = function($data) use ($fd, &$write_ok) { + if (!$write_ok) return; + $length = strlen($data); + $offset = 0; + while ($offset < $length) { + $count = @fwrite($fd, substr($data, $offset)); + if ($count === false || $count === 0) { $write_ok = false; return; } + $offset += $count; + } + }; $output .= "# Automatically generated by php-weathermap v$weathermap_version\n\n"; - if (count($this->fonts) > 0) { + if (cacti_count($this->fonts) > 0) { foreach ($this->fonts as $fontnumber => $font) { if ($font->type == 'truetype') { $output .= sprintf("FONTDEFINE %d %s %d\n", $fontnumber, $font->file, $font->size); @@ -3782,18 +3789,18 @@ function WriteConfig($filename) { $output .= "\n# End of global section\n\n"; - fwrite($fd, $output); + $write($output); // # fwrite($fd,$this->nodes['DEFAULT']->WriteConfig()); // # fwrite($fd,$this->links['DEFAULT']->WriteConfig()); - // fwrite($fd, "\n\n# Node definitions:\n"); + // $write("\n\n# Node definitions:\n"); foreach (['template', 'normal'] as $which) { if ($which == 'template') { - fwrite($fd, "\n# TEMPLATE-only NODEs:\n"); + $write("\n# TEMPLATE-only NODEs:\n"); } elseif ($which == 'normal') { - fwrite($fd, "\n# regular NODEs:\n"); + $write("\n# regular NODEs:\n"); } foreach ($this->nodes as $node_name => $node) { @@ -3801,21 +3808,21 @@ function WriteConfig($filename) { if ($node->defined_in == $this->configfile || $node_name == 'DEFAULT') { if ($which == 'template' && $node_name == 'DEFAULT') { wm_debug('TEMPLATE Node DEFAULT'); - fwrite($fd, $node->WriteConfig()); + $write($node->WriteConfig()); } elseif ($which == 'template' && $node->x === null) { wm_debug('TEMPLATE Node NON-DEFAULT'); - fwrite($fd, $node->WriteConfig()); + $write($node->WriteConfig()); } elseif ($which == 'normal' && $node->x !== null) { - fwrite($fd, $node->WriteConfig()); + $write($node->WriteConfig()); } } } } if ($which == 'template') { - fwrite($fd, "\n# TEMPLATE-only LINKs:\n"); + $write("\n# TEMPLATE-only LINKs:\n"); } elseif ($which == 'normal') { - fwrite($fd, "\n# regular LINKs:\n"); + $write("\n# regular LINKs:\n"); } foreach ($this->links as $link_name => $link) { @@ -3823,22 +3830,24 @@ function WriteConfig($filename) { if ($link->defined_in == $this->configfile || $link_name == 'DEFAULT') { if ($which == 'template' && $link_name == 'DEFAULT') { wm_debug('TEMPLATE Link DEFAULT'); - fwrite($fd, $link->WriteConfig()); + $write($link->WriteConfig()); } elseif ($which == 'template' && $link->a === null) { wm_debug('TEMPLATE Link NON-DEFAULT'); - fwrite($fd, $link->WriteConfig()); + $write($link->WriteConfig()); } elseif ($which == 'normal' && $link->a !== null) { - fwrite($fd, $link->WriteConfig()); + $write($link->WriteConfig()); } } } } } - fwrite($fd, "\n\n# That's All Folks!\n"); - fclose($fd); + $write("\n\n# That's All Folks!\n"); + rewind($fd); + $contents = stream_get_contents($fd); + $closed = fclose($fd); - return (true); + return $write_ok && $contents !== false && $closed && \WeathermapConfig\atomicWrite($filename, $contents); } else { wm_warn("Couldn't open config file $filename for writing"); diff --git a/lib/WeatherMap.config.php b/lib/WeatherMap.config.php new file mode 100644 index 00000000..543d3240 --- /dev/null +++ b/lib/WeatherMap.config.php @@ -0,0 +1,107 @@ + wm_asset_image_list($references)]; } echo json_encode($result); diff --git a/lib/editor.assets.php b/lib/editor.assets.php index 1b33f802..b25a1136 100644 --- a/lib/editor.assets.php +++ b/lib/editor.assets.php @@ -51,6 +51,30 @@ function wm_asset_image_path($kind, $name) { if (is_link($dir) || is_link($path)) throw new InvalidArgumentException(__('Symbolic links cannot be managed here.', 'weathermap')); return $path; } +/** + * Canonicalise directive paths using portable forward-slash separators. + * + * @param string $path The image or include path. + * @param string $root The base for relative paths. + * + * @return string The normalised absolute path. + */ +function wm_asset_normalize_path($path, $root) { + $path = str_replace('\\', '/', $path); + $root = str_replace('\\', '/', $root); + if (substr($path, 0, 1) !== '/' && !preg_match('~^[A-Za-z]:/~', $path)) $path = rtrim($root, '/') . '/' . $path; + $prefix = substr($path, 0, 2) === '//' ? '//' : '/'; + if (preg_match('~^([A-Za-z]:)/~', $path, $drive)) { + $prefix = $drive[1] . '/'; + $path = substr($path, 3); + } + $parts = []; + foreach (explode('/', $path) as $part) { + if ($part === '' || $part === '.') continue; + if ($part === '..') array_pop($parts); else $parts[] = $part; + } + return $prefix . implode('/', $parts); +} /** * Read configuration files and includes once to index image references. * @@ -62,7 +86,7 @@ function wm_asset_image_reference_index() { $queue = []; $root = dirname(__DIR__); $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root . '/configs', FilesystemIterator::SKIP_DOTS)); - foreach ($iterator as $file) if ($file->isFile()) $queue[] = $file->getPathname(); + foreach ($iterator as $file) if ($file->isFile() && $file->getFilename() !== '.weathermap-config.lock' && strpos($file->getFilename(), '.wm-config-') !== 0) $queue[] = $file->getPathname(); $seen = []; while ($queue) { $path = array_pop($queue); @@ -70,27 +94,19 @@ function wm_asset_image_reference_index() { if ($real === false || !is_readable($real)) throw new RuntimeException(__('Cannot check configuration references.', 'weathermap')); if (isset($seen[$real])) continue; $seen[$real] = true; - if (count($seen) > 1000) throw new RuntimeException(__('Too many included configurations to check.', 'weathermap')); + if (cacti_count($seen) > 1000) throw new RuntimeException(__('Too many included configurations to check.', 'weathermap')); $text = file_get_contents($real); if ($text === false) throw new RuntimeException(__('Cannot check configuration references.', 'weathermap')); if (preg_match_all('/^\s*(ICON|BACKGROUND)\s+(?:[0-9]+\s+[0-9]+\s+)?(.+?)\s*$/mi', $text, $directives, PREG_SET_ORDER)) { foreach ($directives as $directive) { - $image = trim($directive[2]); - $image = strpos($image, '/') === 0 ? $image : $root . '/' . $image; - $parts = []; - foreach (explode('/', $image) as $part) { - if ($part === '' || $part === '.') continue; - if ($part === '..') array_pop($parts); else $parts[] = $part; - } - $image = '/' . implode('/', $parts); + $image = wm_asset_normalize_path(trim($directive[2]), $root); // Dynamic token values are unknown here: protect all possible paths. $pattern = implode('.*', array_map(function($part) { return preg_quote($part, '~'); }, preg_split('/\{[^}]+\}/', $image))); - $references[] = ['pattern' => '~^' . $pattern . '$~D', 'user' => basename($real)]; + $references[] = ['pattern' => '~^' . $pattern . '$~iD', 'user' => basename($real)]; } } if (preg_match_all('/^\s*INCLUDE\s+(.+)$/mi', $text, $matches)) foreach ($matches[1] as $include) { - $include = trim($include); - $include = strpos($include, '/') === 0 ? $include : $root . '/' . $include; + $include = wm_asset_normalize_path(trim($include), $root); $matches = glob($include); // The renderer tolerates absent optional includes; unreadable matches still fail closed. foreach ($matches ?: [] as $match) $queue[] = $match; @@ -113,7 +129,7 @@ function wm_asset_image_users($name, $kind = null, $references = null) { $root = dirname(__DIR__); foreach ($references as $reference) { foreach ($kind === null ? ['objects', 'backgrounds'] : [$kind] as $library) { - if (preg_match($reference['pattern'], $root . '/images/' . $library . '/' . $name)) { + if (preg_match($reference['pattern'], wm_asset_normalize_path('images/' . $library . '/' . $name, $root))) { $users[] = $reference['user']; break; } @@ -169,7 +185,7 @@ function wm_asset_store_image($kind, $source, $original_name) { $stem = preg_replace('/[^A-Za-z0-9_-]/', '_', pathinfo(basename($original_name), PATHINFO_FILENAME)); $name = substr($stem ?: 'image', 0, 60) . '-' . bin2hex(random_bytes(16)) . '.' . $extension; $path = wm_asset_image_path($kind, $name); - $handle = @fopen($path, 'x'); + $handle = @fopen($path, 'xb'); if (!$handle) { imagedestroy($image); throw new RuntimeException(__('Could not reserve an image filename.', 'weathermap')); } $written = false; try { diff --git a/lib/editor.inc.php b/lib/editor.inc.php index 843e6ad0..a4a58999 100644 --- a/lib/editor.inc.php +++ b/lib/editor.inc.php @@ -645,7 +645,7 @@ function get_imagelist($imagedir) { $realfile = $imdir . '/' . $file; $uri = "images/$imagedir/$file"; - if (is_readable($realfile) && (preg_match('/\.(gif|jpg|png)$/i', $file))) { + if (is_readable($realfile) && (preg_match('/\.(gif|jpe?g|png)$/i', $file))) { $imagelist[] = $uri; $n++; } diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index df8dac5e..0bf5b072 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti 1.7\n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-12 09:02+1030\n" +"POT-Creation-Date: 2026-10-12 11:05+1030\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" @@ -26,6 +26,10 @@ msgstr "" msgid "Use POST to save changes." msgstr "" +#: lib/editor.actions.php +msgid "Could not lock map configurations." +msgstr "" + #: lib/editor.actions.php msgid "This image is still referenced by a map configuration." msgstr "" @@ -1906,13 +1910,7 @@ msgid "The new Map with the name %s was created using config file %s" msgstr "" #: weathermap-cacti-plugin-mgmt.php -#, php-format -msgid "The new Map %s was copied, but its title could not be updated in the config file %s" -msgstr "" - -#: weathermap-cacti-plugin-mgmt.php -#, php-format -msgid "The new Map with the name %s was unable to create the config file %s" +msgid "Could not save the map configuration." msgstr "" #: weathermap-cacti-plugin-mgmt.php diff --git a/tests/Browser/EditorImagesTest.cjs b/tests/Browser/EditorImagesTest.cjs index b70c2438..46c9dc8e 100644 --- a/tests/Browser/EditorImagesTest.cjs +++ b/tests/Browser/EditorImagesTest.cjs @@ -11,7 +11,9 @@ for(const m of inc.matchAll(/'(imageText[0-9]+|imageUsed|imageUnused)' => __\('( w.wmEditorText.imageUnused='Non utilisée'; const source=fs.readFileSync(path.join(__dirname,'../../js/editor.js'),'utf8');w.eval(source.slice(source.indexOf('function wmAssetRequest('))); const images=[{kind:'objects',name:'hospital.png',url:'images/objects/hospital.png',width:96,height:96,users:['map.conf'],deletable:false},{kind:'backgrounds',name:'background.jpg',url:'images/backgrounds/background.jpg',width:800,height:600,users:[],deletable:true}]; +$('#node_iconfilename').append('External icon').val('images/icons/external.png'); w.wmAssetImages(images); +assert.equal($('#node_iconfilename').val(),'images/icons/external.png');assert.equal($('#node_iconfilename option[value="images/icons/external.png"]').length,1); assert.equal($('#wm-images-objects .wm-image-delete').length,0);assert.match($('#wm-images-objects').text(),/96 × 96 px/);assert.match($('#wm-images-objects').text(),/map.conf/);assert.match($('#wm-images-backgrounds').text(),/Non utilisée/); assert.equal($('#node_iconfilename option[value="images/objects/hospital.png"]').length,1); let request;$.ajax=opts=>request=opts;w.confirm=()=>false;$('#wm-images-backgrounds .wm-image-delete').trigger('click');assert.equal(request,undefined); diff --git a/tests/Support/ConfigWriteAtomicRegression.php b/tests/Support/ConfigWriteAtomicRegression.php new file mode 100644 index 00000000..91c3a38b --- /dev/null +++ b/tests/Support/ConfigWriteAtomicRegression.php @@ -0,0 +1,193 @@ +ReadConfig($file); $map->title = 'Changed'; + atomic_assert(!$map->WriteConfig($file) && file_get_contents($file) === $original, 'engine failure preserves original'); + $GLOBALS['__test_nfilter_request'] = ['node_name' => 'note1', 'param' => 'note1', 'x' => '70', 'y' => '80']; + if (function_exists('wm_editor_save_note')) { + http_response_code(200); moveNode($file, 0); + atomic_assert(http_response_code() === 500 && file_get_contents($file) === $original, 'failed move surfaces error without changing file'); + http_response_code(200); deleteNode($file); + atomic_assert(http_response_code() === 500 && file_get_contents($file) === $original, 'failed delete surfaces error without changing file'); + } + echo "Atomic configuration write and locking regression PASS\n"; + } finally { + $GLOBALS['config_fault'] = ''; + foreach (glob($dir . '/*') as $path) unlink($path); + foreach (glob($dir . '/.wm-config-*') as $path) unlink($path); + rmdir($dir); + } +} diff --git a/tests/Support/EditorImageFormatsRegression.php b/tests/Support/EditorImageFormatsRegression.php index 46f5bb32..92eae132 100644 --- a/tests/Support/EditorImageFormatsRegression.php +++ b/tests/Support/EditorImageFormatsRegression.php @@ -24,7 +24,20 @@ require dirname(__DIR__) . "/bootstrap-unit.php"; require dirname(__DIR__, 2) . '/lib/editor.assets.php'; require dirname(__DIR__, 2) . '/lib/editor.actions.php'; -function image_format_assert($value, $message) { if (!$value) throw new RuntimeException($message); } +require dirname(__DIR__, 2) . '/lib/editor.inc.php'; +/** + * Assert an image-format regression condition. + * + * @param bool $value Whether the condition holds. + * @param string $message The failure description. + * + * @return void + */ +function image_format_assert($value, $message) { + if (!$value) { + throw new RuntimeException($message); + } +} $root = dirname(__DIR__, 2); $temporary = $root . '/tests/wm-image-formats-' . bin2hex(random_bytes(4)); mkdir($temporary); @@ -48,13 +61,21 @@ function image_format_assert($value, $message) { if (!$value) throw new RuntimeE image_format_assert($size[0] === 12 && $size[1] === 8 && $size[2] === $type, 'decoded format and dimensions preserved'); image_format_assert(strpos(file_get_contents($path), 'UNTRUSTED_TRAILING_BYTES') === false, 'uploaded trailing bytes removed'); $listed = array_values(array_filter(wm_asset_image_list(), function($item) use ($kind, $name) { return $item['kind'] === $kind && $item['name'] === $name; })); - image_format_assert(count($listed) === 1 && $listed[0]['deletable'], 'each format listed and deletable'); + image_format_assert(cacti_count($listed) === 1 && $listed[0]['deletable'], 'each format listed and deletable'); file_put_contents($config, 'BACKGROUND images/' . $kind . '/' . $name . "\n"); image_format_assert(in_array(basename($config), wm_asset_image_users($name), true), 'reference protection applies to every format'); } } file_put_contents($config, "ICON images/objects/core-router.png\n# router.png is merely a comment\n"); image_format_assert(wm_asset_image_users('router.png', 'objects') === [], 'substring and comment do not count as references'); + file_put_contents($config, "ICON images/objects/ROUTER.PNG\n"); + image_format_assert(wm_asset_image_users('router.png', 'objects') !== [], 'case-insensitive paths protect in-use images'); + image_format_assert(wm_asset_normalize_path('images\\objects\\Router.PNG', 'C:\\Cacti\\plugins\\weathermap') === 'C:/Cacti/plugins/weathermap/images/objects/Router.PNG', 'Windows relative path canonicalised'); + image_format_assert(wm_asset_normalize_path('D:\\assets\\router.png', 'C:/Cacti') === 'D:/assets/router.png', 'Windows absolute drive path retained'); + image_format_assert(wm_asset_normalize_path('\\\\server\\share\\router.png', 'C:/Cacti') === '//server/share/router.png', 'UNC absolute path retained'); + $jpegPath = $root . '/images/backgrounds/format-reload-' . bin2hex(random_bytes(4)) . '.jpeg'; + copy($created[3], $jpegPath); $created[] = $jpegPath; + image_format_assert(in_array('images/backgrounds/' . basename($jpegPath), get_imagelist('backgrounds'), true), 'existing JPEG background survives selector reload'); file_put_contents($config, "ICON /outside/router.png\n"); image_format_assert(wm_asset_image_users('router.png', 'objects') === [], 'complete paths distinguish external images'); file_put_contents($config, "ICON 32 32 images/objects/updown_{node:this:bandwidth_in}.png\n"); diff --git a/tests/Unit/ConfigWriteAtomicTest.php b/tests/Unit/ConfigWriteAtomicTest.php new file mode 100644 index 00000000..48736556 --- /dev/null +++ b/tests/Unit/ConfigWriteAtomicTest.php @@ -0,0 +1,41 @@ + Date: Mon, 12 Oct 2026 11:12:09 +1030 Subject: [PATCH 09/41] Verify atomic saves preserve template and traffic objects --- tests/Support/ConfigWriteAtomicRegression.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/Support/ConfigWriteAtomicRegression.php b/tests/Support/ConfigWriteAtomicRegression.php index 91c3a38b..056c2c09 100644 --- a/tests/Support/ConfigWriteAtomicRegression.php +++ b/tests/Support/ConfigWriteAtomicRegression.php @@ -173,6 +173,12 @@ function atomic_assert($condition, $message) { pcntl_waitpid($child, $status); \WeathermapConfig\release($lock); atomic_assert(pcntl_wexitstatus($status) === 0, 'shared lock excludes concurrent configuration writers'); } + $templateFile = $dir . '/templates.conf'; + file_put_contents($templateFile, "NODE templateNode\n LABEL Template\nNODE A\n POSITION 10 10\nNODE B\n POSITION 50 50\nLINK templateLink\n WIDTH 8\nLINK trafficLink\n NODES A B\n"); + $templateMap = new \WeatherMap; $templateMap->ReadConfig($templateFile); + atomic_assert($templateMap->WriteConfig($templateFile), 'atomic save supports templates and traffic links'); + $reloaded = new \WeatherMap; $reloaded->ReadConfig($templateFile); + atomic_assert(isset($reloaded->nodes['templateNode'], $reloaded->links['templateLink'], $reloaded->links['trafficLink']), 'template and real objects survive atomic roundtrip'); $GLOBALS['config_fault'] = 'rename'; $map = new \WeatherMap; $map->ReadConfig($file); $map->title = 'Changed'; atomic_assert(!$map->WriteConfig($file) && file_get_contents($file) === $original, 'engine failure preserves original'); From 075099e8773509d3e467fe0d76e0a31566f5e486 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:21:32 +1030 Subject: [PATCH 10/41] Keep atomic-save regression checks portable across platforms --- tests/Support/ConfigWriteAtomicRegression.php | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/Support/ConfigWriteAtomicRegression.php b/tests/Support/ConfigWriteAtomicRegression.php index 056c2c09..6090fa12 100644 --- a/tests/Support/ConfigWriteAtomicRegression.php +++ b/tests/Support/ConfigWriteAtomicRegression.php @@ -144,7 +144,7 @@ function atomic_assert($condition, $message) { $original = "WIDTH 600\nHEIGHT 300\nNODE note1\n LABEL Original\n POSITION 50 50\n SET editor_note 1\n"; file_put_contents($file, $original); try { - foreach (['lock-open', 'lock', 'temp-name', 'temp-open', 'write-false', 'write-zero', 'flush', 'close', 'group', 'mode', 'rename'] as $fault) { + foreach (array_filter(['lock-open', 'lock', 'temp-name', 'temp-open', 'write-false', 'write-zero', 'flush', 'close', 'group', 'mode', 'rename'], function($fault) { return PHP_OS_FAMILY !== 'Windows' || $fault !== 'group'; }) as $fault) { $GLOBALS['config_fault'] = $fault; atomic_assert(\WeathermapConfig\atomicWrite($file, 'Replacement') === false, 'failure propagated: ' . $fault); atomic_assert(file_get_contents($file) === $original, 'original retained: ' . $fault); @@ -153,15 +153,17 @@ function atomic_assert($condition, $message) { $GLOBALS['config_fault'] = 'short-write'; atomic_assert(\WeathermapConfig\atomicWrite($file, 'Complete replacement'), 'short writes completed'); atomic_assert(file_get_contents($file) === 'Complete replacement', 'complete bytes retained'); - $GLOBALS['config_fault'] = 'lock-read'; + $GLOBALS['config_fault'] = PHP_OS_FAMILY === 'Windows' ? '' : 'lock-read'; atomic_assert(\WeathermapConfig\atomicWrite($file, $original), 'readable lock supports another writer account'); $GLOBALS['config_fault'] = ''; atomic_assert(!\WeathermapConfig\atomicWrite('notewritefail://map/config', 'Replacement'), 'unsupported stream rejected'); atomic_assert(!\WeathermapConfig\atomicWrite($dir . '/missing/map.conf', 'Replacement'), 'missing directory rejected'); atomic_assert(!\WeathermapConfig\atomicWrite($dir, 'Replacement'), 'directory target rejected'); - $link = $dir . '/link.conf'; symlink($file, $link); - atomic_assert(\WeathermapConfig\atomicWrite($link, $original), 'configuration symlink target preserved'); - atomic_assert(is_link($link), 'link itself retained'); unlink($link); + $link = $dir . '/link.conf'; + if (@symlink($file, $link)) { + atomic_assert(\WeathermapConfig\atomicWrite($link, $original), 'configuration symlink target preserved'); + atomic_assert(is_link($link), 'link itself retained'); unlink($link); + } if (function_exists('pcntl_fork')) { $lock = \WeathermapConfig\acquire(); $child = pcntl_fork(); From ad6a77bf73e0e8c6495d08fa90677bf1796b3edd Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:40:28 +1030 Subject: [PATCH 11/41] Accept uppercase raster extensions when saving backgrounds --- lib/editor.inc.php | 2 +- tests/Support/EditorImageFormatsRegression.php | 1 + tests/Unit/EditorImageFormatsTest.php | 5 +++++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/editor.inc.php b/lib/editor.inc.php index a4a58999..4503b9db 100644 --- a/lib/editor.inc.php +++ b/lib/editor.inc.php @@ -337,7 +337,7 @@ function wm_editor_sanitize_file($filename,$allowed_exts = []) { foreach ($allowed_exts as $ext) { $match = '.' . $ext; - if (substr($filename, -strlen($match),strlen($match)) == $match) { + if (strcasecmp(substr($filename, -strlen($match), strlen($match)), $match) === 0) { $ok = true; } } diff --git a/tests/Support/EditorImageFormatsRegression.php b/tests/Support/EditorImageFormatsRegression.php index 92eae132..3d292fdc 100644 --- a/tests/Support/EditorImageFormatsRegression.php +++ b/tests/Support/EditorImageFormatsRegression.php @@ -84,6 +84,7 @@ function image_format_assert($value, $message) { file_put_contents($config, "ICON images/objects/" . basename($created[0]) . "\n"); $listed = array_values(array_filter(wm_asset_image_list(), function($item) use ($created) { return $item['kind'] === 'objects' && $item['name'] === basename($created[0]); })); image_format_assert($listed[0]['width'] === 12 && $listed[0]['height'] === 8 && !$listed[0]['deletable'] && $listed[0]['users'], 'dimensions and usage disable referenced image deletion'); + image_format_assert(wm_editor_sanitize_file('images/backgrounds/legacy.JPG', ['png', 'jpg', 'gif', 'jpeg']) === 'images/backgrounds/legacy.JPG', 'uppercase background save retains path'); foreach (['safe.png', 'safe.jpg', 'safe.jpeg', 'safe.gif', 'safe.JPG'] as $name) image_format_assert(str_ends_with(wm_asset_image_path('backgrounds', $name), $name), 'accepted filename'); foreach (['../outside.jpg', 'bad.php', 'bad.svg', 'bad.jpg.php', 'bad..gif'] as $name) { try { wm_asset_image_path('objects', $name); throw new RuntimeException('accepted invalid filename'); } catch (InvalidArgumentException $expected) {} diff --git a/tests/Unit/EditorImageFormatsTest.php b/tests/Unit/EditorImageFormatsTest.php index 4304d156..efdd770a 100644 --- a/tests/Unit/EditorImageFormatsTest.php +++ b/tests/Unit/EditorImageFormatsTest.php @@ -26,6 +26,11 @@ use PHPUnit\Framework\TestCase; final class EditorImageFormatsTest extends TestCase { + /** + * Exercise raster image selection, validation and save compatibility. + * + * @return void + */ #[RunInSeparateProcess] #[PreserveGlobalState(false)] public function testRealEngineRegression(): void { From 1303b624211c6821e13af1897d020e89a3c4f825 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:23:09 +1030 Subject: [PATCH 12/41] Synchronize repairs and config deletion with destination directory locks --- lib/WeatherMap.class.php | 2 +- lib/WeatherMap.config.php | 22 +++++-- lib/editor.config-delete.php | 65 ++++++++++--------- lib/poller-common.php | 5 +- .../BackgroundSizingEngineRegression.php | 4 +- tests/Support/ConfigWriteAtomicRegression.php | 61 ++++++++++++++++- tests/Support/GalleryPreviewRender.php | 1 + .../Support/NewMapPresetEngineRegression.php | 4 +- tests/Support/NewMapTitleEngineRegression.php | 4 +- .../Support/SavedPresetFallbackRegression.php | 1 + tests/Unit/ConfigFileDeleteTest.php | 8 ++- tests/Unit/ConfigRegistrationLockTest.php | 1 + tests/Unit/WeathermapUpgradeTest.php | 5 ++ tests/bootstrap-unit.php | 9 +-- weathermap-cacti-plugin-mgmt.php | 64 ++++++++++-------- 15 files changed, 178 insertions(+), 78 deletions(-) diff --git a/lib/WeatherMap.class.php b/lib/WeatherMap.class.php index 9e2f630d..01c46ae3 100644 --- a/lib/WeatherMap.class.php +++ b/lib/WeatherMap.class.php @@ -3847,7 +3847,7 @@ function WriteConfig($filename) { $contents = stream_get_contents($fd); $closed = fclose($fd); - return $write_ok && $contents !== false && $closed && \WeathermapConfig\atomicWrite($filename, $contents); + return $write_ok && $contents !== false && $closed && \WeathermapConfig\atomicWrite($filename, $contents, $this->configfile !== '' && $filename === $this->configfile); } else { wm_warn("Couldn't open config file $filename for writing"); diff --git a/lib/WeatherMap.config.php b/lib/WeatherMap.config.php index 543d3240..7d104001 100644 --- a/lib/WeatherMap.config.php +++ b/lib/WeatherMap.config.php @@ -25,15 +25,20 @@ namespace WeathermapConfig; /** - * Acquire the lock shared by configuration writers and image deletion. + * Acquire a persistent directory lock shared by config writers and deletion. + * + * @param string|null $directory Destination directory; null selects plugin configs. + * @param bool $blocking Wait for the lock, or return false when it is busy. * * @return resource|false The exclusive lock handle, or false on failure. */ -function acquire() { - $path = dirname(__DIR__) . '/configs/.weathermap-config.lock'; +function acquire($directory = null, $blocking = true) { + $directory = realpath($directory ?? dirname(__DIR__) . '/configs'); + if ($directory === false) return false; + $path = $directory . '/.weathermap-config.lock'; $handle = @fopen($path, 'c+b'); if (!$handle) $handle = @fopen($path, 'rb'); - if ($handle && flock($handle, LOCK_EX)) return $handle; + if ($handle && flock($handle, LOCK_EX | ($blocking ? 0 : LOCK_NB))) return $handle; if ($handle) fclose($handle); return false; } @@ -74,19 +79,24 @@ function writeAll($stream, $contents) { * * @param string $filename The destination configuration path. * @param string $contents The complete new configuration. + * @param bool $mustExist Refuse to recreate an existing map deleted during editing. * * @return bool Success; failures preserve the existing configuration. */ -function atomicWrite($filename, $contents) { +function atomicWrite($filename, $contents, $mustExist = false) { if (strpos($filename, '://') !== false) return false; if (is_link($filename)) $filename = realpath($filename); if ($filename === false || !is_dir(dirname($filename)) || !is_writable(dirname($filename))) return false; if (file_exists($filename) && (!is_file($filename) || !is_writable($filename))) return false; - $lock = acquire(); + $mustExist = $mustExist || file_exists($filename); + $lock = acquire(dirname($filename)); if (!$lock) return false; $temp = false; $stream = false; try { + clearstatcache(true, $filename); + if ($mustExist && !is_file($filename)) return false; + if (file_exists($filename) && (!is_file($filename) || !is_writable($filename))) return false; $temp = @tempnam(dirname($filename), '.wm-config-'); if ($temp === false) return false; $stream = @fopen($temp, 'wb'); diff --git a/lib/editor.config-delete.php b/lib/editor.config-delete.php index b438f7e1..9b3b529a 100644 --- a/lib/editor.config-delete.php +++ b/lib/editor.config-delete.php @@ -90,45 +90,52 @@ function wm_config_delete($directory, $file) { strpos($file, chr(92)) !== false || preg_match('/[\x00-\x1f\x7f]/', $file) || substr($file, -5) !== '.conf' || !$directory) { return 'invalid'; } - $path = $directory . '/' . $file; - $resolved = realpath($path); - - if (is_link($path) || !is_file($path) || $resolved === false || dirname($resolved) !== $directory) { - return 'invalid'; - } - - $lock = wm_config_lock($path); - if ($lock === false) { - return 'failed'; - } + require_once __DIR__ . '/WeatherMap.config.php'; + $directory_lock = \WeathermapConfig\acquire($directory); + if (!$directory_lock) return 'failed'; try { - $usage = db_fetch_cell_prepared('SELECT COUNT(*) FROM weathermap_maps WHERE configfile IN (?, ?)', [$file, $path]); - - if ($usage === false || $usage === null) { - return 'failed'; - } + $path = $directory . '/' . $file; + $resolved = realpath($path); - if ($usage) { - return 'used'; + if (is_link($path) || !is_file($path) || $resolved === false || dirname($resolved) !== $directory) { + return 'invalid'; } - // Existing registrations may use ./, absolute paths or other aliases. - $maps = db_fetch_assoc_prepared('SELECT configfile FROM weathermap_maps', []); - if (!is_array($maps)) { + $lock = wm_config_lock($path); + if ($lock === false) { return 'failed'; } - foreach ($maps as $map) { - $registered = $map['configfile']; - $registered_path = realpath(wm_config_registered_path($directory, $registered)); - if ($registered_path === $resolved) { + try { + $usage = db_fetch_cell_prepared('SELECT COUNT(*) FROM weathermap_maps WHERE configfile IN (?, ?)', [$file, $path]); + + if ($usage === false || $usage === null) { + return 'failed'; + } + + if ($usage) { return 'used'; } - } - return @unlink($path) ? 'deleted' : 'failed'; + // Existing registrations may use ./, absolute paths or other aliases. + $maps = db_fetch_assoc_prepared('SELECT configfile FROM weathermap_maps', []); + if (!is_array($maps)) { + return 'failed'; + } + foreach ($maps as $map) { + $registered = $map['configfile']; + $registered_path = realpath(wm_config_registered_path($directory, $registered)); + if ($registered_path === $resolved) { + return 'used'; + } + } + + return @unlink($path) ? 'deleted' : 'failed'; + } finally { + flock($lock, LOCK_UN); + fclose($lock); + } } finally { - flock($lock, LOCK_UN); - fclose($lock); + \WeathermapConfig\release($directory_lock); } } /** diff --git a/lib/poller-common.php b/lib/poller-common.php index f5f75103..ca63f097 100644 --- a/lib/poller-common.php +++ b/lib/poller-common.php @@ -271,7 +271,10 @@ function weathermap_repair_maps() { if ($changes) { wm_debug('Writing updated file to: ' . $configfile); - file_put_contents($configfile, implode("\n", $outcontents), LOCK_EX); + require_once __DIR__ . '/WeatherMap.config.php'; + if (!\WeathermapConfig\atomicWrite($configfile, implode("\n", $outcontents), true)) { + wm_warn('Unable to save repaired configuration: ' . $configfile . ' [WMPOLL13]'); + } } else { wm_debug('File has correct BACKGROUND and ICON paths: ' . $configfile); } diff --git a/tests/Support/BackgroundSizingEngineRegression.php b/tests/Support/BackgroundSizingEngineRegression.php index 98ad1a20..00b8457b 100644 --- a/tests/Support/BackgroundSizingEngineRegression.php +++ b/tests/Support/BackgroundSizingEngineRegression.php @@ -100,5 +100,7 @@ public function run($map) { } finally { foreach (glob($directory . '/*') as $file) { unlink($file); - }rmdir($directory); + } + if (is_file($directory . '/.weathermap-config.lock')) unlink($directory . '/.weathermap-config.lock'); + rmdir($directory); } diff --git a/tests/Support/ConfigWriteAtomicRegression.php b/tests/Support/ConfigWriteAtomicRegression.php index 6090fa12..75b7989b 100644 --- a/tests/Support/ConfigWriteAtomicRegression.php +++ b/tests/Support/ConfigWriteAtomicRegression.php @@ -121,6 +121,7 @@ function rename($source, $target) { } } namespace { + require dirname(__DIR__, 2) . '/lib/poller-common.php'; require dirname(__DIR__) . '/bootstrap-unit.php'; chdir(dirname(__DIR__, 2)); require dirname(__DIR__, 2) . '/setup.php'; @@ -165,22 +166,77 @@ function atomic_assert($condition, $message) { atomic_assert(is_link($link), 'link itself retained'); unlink($link); } if (function_exists('pcntl_fork')) { - $lock = \WeathermapConfig\acquire(); + $lock = \WeathermapConfig\acquire($dir); $child = pcntl_fork(); if ($child === 0) { - $probe = \fopen(dirname(__DIR__, 2) . '/configs/.weathermap-config.lock', 'rb'); + $probe = \fopen($dir . '/.weathermap-config.lock', 'rb'); $blocked = !\flock($probe, LOCK_EX | LOCK_NB); \fclose($probe); exit($blocked ? 0 : 1); } pcntl_waitpid($child, $status); \WeathermapConfig\release($lock); atomic_assert(pcntl_wexitstatus($status) === 0, 'shared lock excludes concurrent configuration writers'); } + // Deletion waits for the same stable directory mutex used by atomic replacement. + if (function_exists('pcntl_fork')) { + require_once dirname(__DIR__, 2) . '/lib/editor.config-delete.php'; + $GLOBALS['__test_db_fetch_cell_prepared'] = fn () => 0; + $GLOBALS['__test_db_fetch_assoc_prepared'] = fn () => []; + $lock = \WeathermapConfig\acquire($dir); + $ready = $dir . '/ready'; $done = $dir . '/done'; + $child = pcntl_fork(); + if ($child === 0) { + \fclose($lock); + file_put_contents($ready, 'ready'); + $result = wm_config_delete($dir, 'map.conf'); + file_put_contents($done, $result); + exit($result === 'deleted' ? 0 : 1); + } + for ($wait = 0; $wait < 100 && !is_file($ready); $wait++) usleep(10000); + usleep(50000); + $blocked = !is_file($done) && is_file($file); + \WeathermapConfig\release($lock); + pcntl_waitpid($child, $status); + atomic_assert($blocked && pcntl_wexitstatus($status) === 0, 'config deletion cannot unlink during a directory-locked write'); + unlink($ready); unlink($done); + unset($GLOBALS['__test_db_fetch_cell_prepared'], $GLOBALS['__test_db_fetch_assoc_prepared']); + file_put_contents($file, $original); + } + atomic_assert(!\WeathermapConfig\acquire($dir . '/missing'), 'missing lock directory rejected'); + atomic_assert(!\WeathermapConfig\atomicWrite($dir . '/deleted.conf', 'Replacement', true), 'deleted existing maps are not recreated'); + atomic_assert(is_file($dir . '/.weathermap-config.lock'), 'arbitrary output uses its destination directory lock'); $templateFile = $dir . '/templates.conf'; file_put_contents($templateFile, "NODE templateNode\n LABEL Template\nNODE A\n POSITION 10 10\nNODE B\n POSITION 50 50\nLINK templateLink\n WIDTH 8\nLINK trafficLink\n NODES A B\n"); $templateMap = new \WeatherMap; $templateMap->ReadConfig($templateFile); atomic_assert($templateMap->WriteConfig($templateFile), 'atomic save supports templates and traffic links'); $reloaded = new \WeatherMap; $reloaded->ReadConfig($templateFile); atomic_assert(isset($reloaded->nodes['templateNode'], $reloaded->links['templateLink'], $reloaded->links['trafficLink']), 'template and real objects survive atomic roundtrip'); + $plugin = dirname(__DIR__, 2); + $repairName = 'atomic-repair-' . bin2hex(random_bytes(4)) . '.conf'; + $repairFile = $plugin . '/configs/' . $repairName; + $repairImage = $plugin . '/images/backgrounds/' . $repairName . '.png'; + $legacy = 'BACKGROUND ' . basename($repairImage) . "\n"; + file_put_contents($repairFile, $legacy); file_put_contents($repairImage, 'fixture'); + $oldConfig = $GLOBALS['config']; + $GLOBALS['config']['base_path'] = dirname($plugin, 2); + $GLOBALS['__test_db_fetch_assoc'] = fn ($sql) => [['configfile' => $repairName]]; + try { + $GLOBALS['config_fault'] = 'rename'; + $oldSuppress = $GLOBALS['weathermap_error_suppress'] ?? []; + $GLOBALS['weathermap_error_suppress'] = ['WMPOLL13']; + try { weathermap_repair_maps(); } finally { $GLOBALS['weathermap_error_suppress'] = $oldSuppress; } + atomic_assert(file_get_contents($repairFile) === $legacy, 'failed repair preserves original config'); + $GLOBALS['config_fault'] = ''; + weathermap_repair_maps(); + atomic_assert(file_get_contents($repairFile) === 'BACKGROUND images/backgrounds/' . basename($repairImage), 'repair uses complete atomic replacement'); + } finally { + $GLOBALS['config'] = $oldConfig; + unset($GLOBALS['__test_db_fetch_assoc']); + unlink($repairFile); unlink($repairImage); + } + $deletedMap = new \WeatherMap; $deletedMap->ReadConfig($file); + unlink($file); + atomic_assert(!$deletedMap->WriteConfig($file) && !file_exists($file), 'saving a previously loaded deleted map fails without resurrection'); + file_put_contents($file, $original); $GLOBALS['config_fault'] = 'rename'; $map = new \WeatherMap; $map->ReadConfig($file); $map->title = 'Changed'; atomic_assert(!$map->WriteConfig($file) && file_get_contents($file) === $original, 'engine failure preserves original'); @@ -196,6 +252,7 @@ function atomic_assert($condition, $message) { $GLOBALS['config_fault'] = ''; foreach (glob($dir . '/*') as $path) unlink($path); foreach (glob($dir . '/.wm-config-*') as $path) unlink($path); + if (is_file($dir . '/.weathermap-config.lock')) unlink($dir . '/.weathermap-config.lock'); rmdir($dir); } } diff --git a/tests/Support/GalleryPreviewRender.php b/tests/Support/GalleryPreviewRender.php index 467a2084..553e8a91 100644 --- a/tests/Support/GalleryPreviewRender.php +++ b/tests/Support/GalleryPreviewRender.php @@ -143,5 +143,6 @@ function weathermap_tabs($group) { unlink($file); } rmdir($directory . '/output'); + if (is_file($directory . '/.weathermap-config.lock')) unlink($directory . '/.weathermap-config.lock'); rmdir($directory); } diff --git a/tests/Support/NewMapPresetEngineRegression.php b/tests/Support/NewMapPresetEngineRegression.php index 6323b6e1..d738d431 100644 --- a/tests/Support/NewMapPresetEngineRegression.php +++ b/tests/Support/NewMapPresetEngineRegression.php @@ -86,5 +86,7 @@ function wm_engine_assert($value,$message) { } finally { foreach (glob($directory . '/*') as $file) { unlink($file); - }rmdir($directory); + } + if (is_file($directory . '/.weathermap-config.lock')) unlink($directory . '/.weathermap-config.lock'); + rmdir($directory); } diff --git a/tests/Support/NewMapTitleEngineRegression.php b/tests/Support/NewMapTitleEngineRegression.php index 636f6d7c..3410b656 100644 --- a/tests/Support/NewMapTitleEngineRegression.php +++ b/tests/Support/NewMapTitleEngineRegression.php @@ -112,5 +112,7 @@ function wm_engine_assert($value,$message) { } finally { foreach (glob($directory . '/*') as $file) { unlink($file); - }rmdir($directory); + } + if (is_file($directory . '/.weathermap-config.lock')) unlink($directory . '/.weathermap-config.lock'); + rmdir($directory); } diff --git a/tests/Support/SavedPresetFallbackRegression.php b/tests/Support/SavedPresetFallbackRegression.php index a0738740..993321d6 100644 --- a/tests/Support/SavedPresetFallbackRegression.php +++ b/tests/Support/SavedPresetFallbackRegression.php @@ -70,5 +70,6 @@ function clean_up_name($value) { foreach (glob($directory . '/*') as $file) { unlink($file); } + if (is_file($directory . '/.weathermap-config.lock')) unlink($directory . '/.weathermap-config.lock'); rmdir($directory); } diff --git a/tests/Unit/ConfigFileDeleteTest.php b/tests/Unit/ConfigFileDeleteTest.php index 57ab72b7..e4a2a249 100644 --- a/tests/Unit/ConfigFileDeleteTest.php +++ b/tests/Unit/ConfigFileDeleteTest.php @@ -54,7 +54,9 @@ foreach (glob($dir . '/*') as $p) { unlink($p); - }rmdir($dir); + } + if (is_file($dir . '/.weathermap-config.lock')) unlink($dir . '/.weathermap-config.lock'); + rmdir($dir); } }); it('ReportsADeletionFailureWithoutClaimingSuccess', function () { @@ -79,6 +81,7 @@ restore_error_handler(); chmod($dir,0700); unlink($dir . '/test.conf'); + if (is_file($dir . '/.weathermap-config.lock')) unlink($dir . '/.weathermap-config.lock'); rmdir($dir); unset($GLOBALS['__test_db_fetch_cell_prepared']); } @@ -120,6 +123,7 @@ foreach (glob($weathermap_confdir . '/*') as $path) { unlink($path); } + if (is_file($weathermap_confdir . '/.weathermap-config.lock')) unlink($weathermap_confdir . '/.weathermap-config.lock'); rmdir($weathermap_confdir); unset($GLOBALS['__test_nfilter_request']['file'], $GLOBALS['__test_raise_message'], $GLOBALS['__test_delete_message'], $GLOBALS['__test_db_fetch_cell_prepared']); @@ -153,7 +157,9 @@ unset($GLOBALS['__test_db_fetch_cell_prepared'], $GLOBALS['__test_db_fetch_assoc_prepared']); unlink($directory . '/Other.conf'); if (file_exists($directory . '/Map.conf')) { unlink($directory . '/Map.conf'); } + if (is_file($directory . '/sub' . '/.weathermap-config.lock')) unlink($directory . '/sub' . '/.weathermap-config.lock'); rmdir($directory . '/sub'); + if (is_file($directory . '/.weathermap-config.lock')) unlink($directory . '/.weathermap-config.lock'); rmdir($directory); } }); diff --git a/tests/Unit/ConfigRegistrationLockTest.php b/tests/Unit/ConfigRegistrationLockTest.php index 6c5b9464..7a15701a 100644 --- a/tests/Unit/ConfigRegistrationLockTest.php +++ b/tests/Unit/ConfigRegistrationLockTest.php @@ -101,6 +101,7 @@ function map_resort() {} $weathermap_confdir = $old_directory; unset($GLOBALS['__test_db_execute_prepared'], $GLOBALS['__test_db_fetch_cell_prepared']); foreach (glob($directory . '/*') as $entry) { unlink($entry); } + if (is_file($directory . '/.weathermap-config.lock')) unlink($directory . '/.weathermap-config.lock'); rmdir($directory); } }); diff --git a/tests/Unit/WeathermapUpgradeTest.php b/tests/Unit/WeathermapUpgradeTest.php index 6800e6ae..24ca2c17 100644 --- a/tests/Unit/WeathermapUpgradeTest.php +++ b/tests/Unit/WeathermapUpgradeTest.php @@ -21,6 +21,11 @@ * suite stubs. */ +// Only upgrade tests stub the filesystem repair; isolated engine tests load it. +if (!function_exists('weathermap_repair_maps')) { + function weathermap_repair_maps() {} +} + beforeAll(function () { require_once __DIR__ . '/../../setup.php'; }); diff --git a/tests/bootstrap-unit.php b/tests/bootstrap-unit.php index 5e4a2190..de6f6e41 100644 --- a/tests/bootstrap-unit.php +++ b/tests/bootstrap-unit.php @@ -67,6 +67,7 @@ function db_execute_prepared($sql, $params = []) { if (!function_exists('db_fetch_assoc')) { function db_fetch_assoc($sql) { + if (isset($GLOBALS['__test_db_fetch_assoc'])) return ($GLOBALS['__test_db_fetch_assoc'])($sql); return []; } } @@ -184,14 +185,6 @@ function test_set_current_page($page) { } } -// Real implementation lives in lib/poller-common.php, which is never included -// at runtime by the test suites (it pulls in WeatherMap.class.php); stub it so -// the upgrade-path test can drive plugin_weathermap_upgrade() safely. -if (!function_exists('weathermap_repair_maps')) { - function weathermap_repair_maps() { - } -} - if (!function_exists('isset_request_var')) { function isset_request_var($n) { return isset($GLOBALS['__test_request'][$n]); diff --git a/weathermap-cacti-plugin-mgmt.php b/weathermap-cacti-plugin-mgmt.php index 4d17ae26..81271715 100644 --- a/weathermap-cacti-plugin-mgmt.php +++ b/weathermap-cacti-plugin-mgmt.php @@ -1752,38 +1752,48 @@ function add_config($file) { print '' . __('Path mismatch', 'weathermap') . ''; } else { require_once __DIR__ . '/lib/editor.config-delete.php'; - $file = basename($file); - $realfile = $weathermap_confdir . '/' . $file; - $lock = wm_config_lock($realfile); - if ($lock === false) { + require_once __DIR__ . '/lib/WeatherMap.config.php'; + $directory_lock = \WeathermapConfig\acquire($weathermap_confdir, false); + if (!$directory_lock) { raise_message('path_missing', __esc('The configuration file is unavailable. Please retry.', 'weathermap'), MESSAGE_LEVEL_ERROR); return; } try { - $title = wmap_get_title($realfile); - - db_execute_prepared("INSERT INTO weathermap_maps - (configfile, titlecache, active, imagefile, htmlfile, filehash, config) - VALUES (?, ?, 'on', '', '', '', '')", - [$file, $title]); - - $last_id = db_fetch_insert_id(); - $myuid = (isset($_SESSION['sess_user_id']) ? intval($_SESSION['sess_user_id']) : 1); - - db_execute_prepared('INSERT INTO weathermap_auth - (mapid, userid) - VALUES (?, ?)', - [$last_id, $myuid]); - - db_execute_prepared('UPDATE weathermap_maps - SET filehash = LEFT(MD5(CONCAT(id, configfile, rand())), 20) - WHERE id = ?', - [$last_id]); - - map_resort(); + $file = basename($file); + $realfile = $weathermap_confdir . '/' . $file; + $lock = wm_config_lock($realfile); + if ($lock === false) { + raise_message('path_missing', __esc('The configuration file is unavailable. Please retry.', 'weathermap'), MESSAGE_LEVEL_ERROR); + return; + } + try { + $title = wmap_get_title($realfile); + + db_execute_prepared("INSERT INTO weathermap_maps + (configfile, titlecache, active, imagefile, htmlfile, filehash, config) + VALUES (?, ?, 'on', '', '', '', '')", + [$file, $title]); + + $last_id = db_fetch_insert_id(); + $myuid = (isset($_SESSION['sess_user_id']) ? intval($_SESSION['sess_user_id']) : 1); + + db_execute_prepared('INSERT INTO weathermap_auth + (mapid, userid) + VALUES (?, ?)', + [$last_id, $myuid]); + + db_execute_prepared('UPDATE weathermap_maps + SET filehash = LEFT(MD5(CONCAT(id, configfile, rand())), 20) + WHERE id = ?', + [$last_id]); + + map_resort(); + } finally { + flock($lock, LOCK_UN); + fclose($lock); + } } finally { - flock($lock, LOCK_UN); - fclose($lock); + \WeathermapConfig\release($directory_lock); } } } From 17a85466d6f4818da386a641b6bf7b3782818d3a Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:25:40 +1030 Subject: [PATCH 13/41] Return a retry when configuration deletion encounters a busy directory --- lib/editor.config-delete.php | 2 +- tests/Support/ConfigWriteAtomicRegression.php | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/editor.config-delete.php b/lib/editor.config-delete.php index 9b3b529a..9d445604 100644 --- a/lib/editor.config-delete.php +++ b/lib/editor.config-delete.php @@ -91,7 +91,7 @@ function wm_config_delete($directory, $file) { return 'invalid'; } require_once __DIR__ . '/WeatherMap.config.php'; - $directory_lock = \WeathermapConfig\acquire($directory); + $directory_lock = \WeathermapConfig\acquire($directory, false); if (!$directory_lock) return 'failed'; try { $path = $directory . '/' . $file; diff --git a/tests/Support/ConfigWriteAtomicRegression.php b/tests/Support/ConfigWriteAtomicRegression.php index 75b7989b..0fe0d0e2 100644 --- a/tests/Support/ConfigWriteAtomicRegression.php +++ b/tests/Support/ConfigWriteAtomicRegression.php @@ -176,7 +176,7 @@ function atomic_assert($condition, $message) { pcntl_waitpid($child, $status); \WeathermapConfig\release($lock); atomic_assert(pcntl_wexitstatus($status) === 0, 'shared lock excludes concurrent configuration writers'); } - // Deletion waits for the same stable directory mutex used by atomic replacement. + // Deletion refuses a busy stable directory mutex rather than blocking the UI. if (function_exists('pcntl_fork')) { require_once dirname(__DIR__, 2) . '/lib/editor.config-delete.php'; $GLOBALS['__test_db_fetch_cell_prepared'] = fn () => 0; @@ -189,14 +189,14 @@ function atomic_assert($condition, $message) { file_put_contents($ready, 'ready'); $result = wm_config_delete($dir, 'map.conf'); file_put_contents($done, $result); - exit($result === 'deleted' ? 0 : 1); + exit($result === 'failed' ? 0 : 1); } - for ($wait = 0; $wait < 100 && !is_file($ready); $wait++) usleep(10000); - usleep(50000); - $blocked = !is_file($done) && is_file($file); + for ($wait = 0; $wait < 100 && !is_file($done); $wait++) usleep(10000); + $blocked = is_file($done) && file_get_contents($done) === 'failed' && is_file($file); \WeathermapConfig\release($lock); pcntl_waitpid($child, $status); atomic_assert($blocked && pcntl_wexitstatus($status) === 0, 'config deletion cannot unlink during a directory-locked write'); + atomic_assert(wm_config_delete($dir, 'map.conf') === 'deleted', 'deletion succeeds after the writer releases its lock'); unlink($ready); unlink($done); unset($GLOBALS['__test_db_fetch_cell_prepared'], $GLOBALS['__test_db_fetch_assoc_prepared']); file_put_contents($file, $original); From 04a44cd1a5366c3b350fc9ae7ed19c583a420e9b Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:06:27 +1030 Subject: [PATCH 14/41] Surface editor save failures and isolate upgrade regression stubs --- CHANGELOG.md | 2 + js/editor.js | 1 + lib/editor.actions.php | 138 ++++++++++++------ lib/editor.inc.php | 1 + locales/po/cacti.pot | 6 +- tests/Browser/EditorSaveFailureTest.cjs | 6 + tests/Support/ConfigWriteAtomicRegression.php | 10 +- tests/Unit/WeathermapUpgradeTest.php | 115 +++++++-------- weathermap-cacti-plugin-editor.php | 2 +- 9 files changed, 171 insertions(+), 110 deletions(-) create mode 100644 tests/Browser/EditorSaveFailureTest.cjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 90352cc0..2e1a8a63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ IMPORTANT NOTE: This version only works on CACTI 1.x++! --- develop --- +* Make configuration saves and repairs atomic; coordinate writes, registration and deletion with destination-directory locks and report failed editor saves + * Add separate icon and background image upload and management libraries * Reduce gallery preview cards by 20% while preserving generated thumbnail resolution * Add a searchable node icon picker and bundled enterprise site icons diff --git a/js/editor.js b/js/editor.js index 41ccd72d..6e5a9bbc 100644 --- a/js/editor.js +++ b/js/editor.js @@ -878,6 +878,7 @@ function form_submit() { type: 'POST', url: editor_url, data: data, + error: function() { window.alert(wmEditorText.saveError); }, success: function(html) { hide_all_dialogs(); diff --git a/lib/editor.actions.php b/lib/editor.actions.php index aab93135..9c89ff5c 100644 --- a/lib/editor.actions.php +++ b/lib/editor.actions.php @@ -48,6 +48,40 @@ * @param mixed $mapfile */ +/** + * Read an existing editor map and reject stale or unreadable targets. + * + * @param WeatherMap $map Engine receiving the configuration. + * @param string $mapfile Configuration path. + * @return bool Whether the map was loaded. + */ +function wm_editor_read_config($map, $mapfile) { + if (!is_file($mapfile) || !is_readable($mapfile)) { + http_response_code(404); + return false; + } + if (!$map->ReadConfig($mapfile)) { + http_response_code(422); + return false; + } + return true; +} + +/** + * Propagate failed editor configuration saves to the AJAX caller. + * + * @param WeatherMap $map Engine containing the edited configuration. + * @param string $mapfile Configuration path. + * @return bool Whether the complete configuration was saved. + */ +function wm_editor_write_config($map, $mapfile) { + if (!$map->WriteConfig($mapfile)) { + http_response_code(500); + return false; + } + return true; +} + /** * @return void * @param mixed $mapfile @@ -59,7 +93,7 @@ function newMap($mapfile) { require_once __DIR__ . '/editor.new-map-preset.php'; wm_new_map_preset($map); - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } /** @@ -86,7 +120,7 @@ function newMapCopy($mapfile) { if (file_exists($sourcemap) && is_readable($sourcemap)) { $map->ReadConfig($sourcemap); - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } } } @@ -100,7 +134,7 @@ function getMapJavaScript($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; print $map->asJS(); } @@ -114,7 +148,7 @@ function getMapAreaData($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; // we need to draw and throw away a map, to get the // dimensions for the imagemap. @@ -141,7 +175,7 @@ function drawMap($mapfile, $selected, $use_overlay, $use_relative_overlay) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; if ($selected != '') { if (substr($selected, 0, 5) == 'NODE:') { @@ -186,7 +220,7 @@ function fetchConfig($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; header('Content-type: text/plain'); @@ -223,7 +257,7 @@ function setNodeConfig($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $node_name = get_nfilter_request_var('node_name'); $node_config = get_nfilter_request_var('item_configtext'); @@ -231,7 +265,7 @@ function setNodeConfig($mapfile) { if (isset($map->nodes[$node_name])) { $map->nodes[$node_name]->config_override = $node_config; - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); // now clear and reload the map object, because the in-memory one is out of sync // - we don't know what changes the user made here, so we just have to reload. @@ -241,7 +275,7 @@ function setNodeConfig($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; } } @@ -254,7 +288,7 @@ function setLinkConfig($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $link_name = get_nfilter_request_var('link_name'); $link_config = get_nfilter_request_var('item_configtext'); @@ -262,7 +296,7 @@ function setLinkConfig($mapfile) { if (isset($map->links[$link_name])) { $map->links[$link_name]->config_override = $link_config; - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); // now clear and reload the map object, because the in-memory one is out of sync // - we don't know what changes the user made here, so we just have to reload. @@ -272,7 +306,7 @@ function setLinkConfig($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; } } @@ -285,7 +319,7 @@ function setNodeProperties($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $node_name = get_nfilter_request_var('node_name'); $new_node_name = get_nfilter_request_var('node_new_name'); @@ -362,7 +396,7 @@ function setNodeProperties($mapfile) { $map->nodes[$new_node_name]->iconfile = $iconfile; } - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } /** @@ -374,7 +408,7 @@ function setLinkProperties($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $link_name = get_nfilter_request_var('link_name'); @@ -442,7 +476,7 @@ function setLinkProperties($mapfile) { // $map->links[$link_name]->SetBandwidth($bwin,$bwout); - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } } @@ -455,7 +489,7 @@ function setMapProperties($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $map->title = wm_editor_sanitize_string(get_nfilter_request_var('map_title')); $map->keytext['DEFAULT'] = wm_editor_sanitize_string(get_nfilter_request_var('map_legend')); @@ -528,7 +562,7 @@ function setMapProperties($mapfile) { } } - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } /** @@ -540,7 +574,7 @@ function setMapStyle($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; if (wm_editor_validate_one_of(get_nfilter_request_var('mapstyle_htmlstyle'), ['static', 'overlib'], false)) { $map->htmlstyle = strtolower(get_nfilter_request_var('mapstyle_htmlstyle')); @@ -565,7 +599,7 @@ function setMapStyle($mapfile) { wm_comment_update_default($map, 'bwfont', wm_comment_resolve_font($map, get_nfilter_request_var('mapstyle_linkfont'))); wm_comment_apply_style($map, get_nfilter_request_var('mapstyle_commentfont'), get_nfilter_request_var('mapstyle_commentcolour')); - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } /** @@ -577,7 +611,7 @@ function addLink($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $a = get_nfilter_request_var('param'); $b = get_nfilter_request_var('param2'); @@ -609,7 +643,7 @@ function addLink($mapfile) { $map->links[$newlinkname] = $newlink; array_push($map->seen_zlayers[$newlink->zorder], $newlink); - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } } @@ -628,12 +662,12 @@ function placeLegend($mapfile, $grid_snap_value) { $scalename = wm_editor_sanitize_name(get_nfilter_request_var('param')); - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $map->keyx[$scalename] = $x; $map->keyy[$scalename] = $y; - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } /** @@ -649,12 +683,12 @@ function placeStamp($mapfile, $grid_snap_value) { $x = snap(intval(get_nfilter_request_var('x')), $grid_snap_value); $y = snap(intval(get_nfilter_request_var('y')), $grid_snap_value); - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $map->timex = $x; $map->timey = $y; - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } /** @@ -671,11 +705,11 @@ function viaLink($mapfile) { $link_name = wm_editor_sanitize_name(get_nfilter_request_var('link_name')); - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; if (isset($map->links[$link_name])) { $map->links[$link_name]->vialist = [[0 =>$x, 1=>$y]]; - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } } @@ -694,8 +728,12 @@ function moveNode($mapfile, $grid_snap_value) { $node_name = wm_editor_sanitize_name(get_nfilter_request_var('node_name')); - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; + if (!isset($map->nodes[$node_name])) { + http_response_code(404); + return; + } if (isset($map->nodes[$node_name])) { // This is a complicated bit. Find out if this node is involved in any // links that have VIAs. If it is, we want to rotate those VIA points @@ -788,7 +826,7 @@ function moveNode($mapfile, $grid_snap_value) { $map->nodes[$node_name]->x = $x; $map->nodes[$node_name]->y = $y; - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } } @@ -801,7 +839,7 @@ function linkTidy($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $target = wm_editor_sanitize_name(get_nfilter_request_var('param')); @@ -811,7 +849,7 @@ function linkTidy($mapfile) { tidy_link($map, $target); - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } } @@ -824,13 +862,13 @@ function reTidy($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; // draw a map and throw it away, to calculate all the bounding boxes $map->DrawMap('null'); retidy_links($map); - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } /** @@ -842,13 +880,13 @@ function reTidyAll($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; // draw a map and throw it away, to calculate all the bounding boxes $map->DrawMap('null'); retidy_links($map,true); - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } /** @@ -860,13 +898,13 @@ function unTidy($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; // draw a map and throw it away, to calculate all the bounding boxes $map->DrawMap('null'); untidy_links($map); - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } /** @@ -878,7 +916,7 @@ function deleteLink($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $target = wm_editor_sanitize_name(get_nfilter_request_var('param')); $log = 'delete link ' . $target; @@ -886,7 +924,7 @@ function deleteLink($mapfile) { if (isset($map->links[$target])) { unset($map->links[$target]); - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } } @@ -903,7 +941,7 @@ function addNode($mapfile, $grid_snap_value) { $x = snap(intval(get_nfilter_request_var('x')), $grid_snap_value); $y = snap(intval(get_nfilter_request_var('y')), $grid_snap_value); - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $newnodename = sprintf('node%05d', time() % 10000); @@ -932,7 +970,7 @@ function addNode($mapfile, $grid_snap_value) { $map->nodes[$node->name] = $node; $log = "added a node called $newnodename at $x,$y to $mapfile"; - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } /** @@ -947,7 +985,7 @@ function editorSettings($mapfile) { $map->context = 'editor'; // have to do this, otherwise the editor will be unresponsive afterwards - not actually going to change anything! - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $use_overlay = (isset_request_var('editorsettings_showvias') ? intval(get_nfilter_request_var('editorsettings_showvias')) : false); $use_relative_overlay = (isset_request_var('editorsettings_showrelative') ? intval(get_nfilter_request_var('editorsettings_showrelative')) : false); @@ -963,10 +1001,14 @@ function deleteNode($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $target = wm_editor_sanitize_name(get_nfilter_request_var('param')); + if (!isset($map->nodes[$target])) { + http_response_code(404); + return; + } if (isset($map->nodes[$target])) { $log = 'delete node ' . $target; @@ -980,7 +1022,7 @@ function deleteNode($mapfile) { unset($map->nodes[$target]); - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } } @@ -993,7 +1035,7 @@ function cloneNode($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $target = wm_editor_sanitize_name(get_nfilter_request_var('param')); @@ -1024,7 +1066,7 @@ function cloneNode($mapfile) { array_push($map->seen_zlayers[$node->zorder], $node); - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } } @@ -1037,7 +1079,7 @@ function displayFontSamples($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; ksort($map->fonts); header('Content-type: image/png'); diff --git a/lib/editor.inc.php b/lib/editor.inc.php index e314229a..19a0cb6a 100644 --- a/lib/editor.inc.php +++ b/lib/editor.inc.php @@ -1036,6 +1036,7 @@ function getEditorJs() { 'iconMatchCount' => __('Matching icons: %s', '%s', 'weathermap'), 'iconNoMatches' => __('No matching icons', 'weathermap'), + 'saveError' => __('Unable to save changes. Please try again.', 'weathermap'), 'currentInterface' => __('Current interface', 'weathermap'), 'advanced' => __('Advanced settings', 'weathermap'), 'hideAdvanced' => __('Hide advanced settings', 'weathermap'), diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index c13cbea6..d326380e 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti 1.7\n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-12 12:29+1030\n" +"POT-Creation-Date: 2026-10-12 13:03+1030\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" @@ -330,6 +330,10 @@ msgstr "" msgid "No matching icons" msgstr "" +#: lib/editor.inc.php +msgid "Unable to save changes. Please try again." +msgstr "" + #: lib/editor.inc.php msgid "Current interface" msgstr "" diff --git a/tests/Browser/EditorSaveFailureTest.cjs b/tests/Browser/EditorSaveFailureTest.cjs new file mode 100644 index 00000000..447a1c29 --- /dev/null +++ b/tests/Browser/EditorSaveFailureTest.cjs @@ -0,0 +1,6 @@ +const {JSDOM}=require('jsdom'),jquery=require('jquery'),fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict'); +const dom=new JSDOM('',{runScripts:'outside-only'}),w=dom.window,$=jquery(w);w.$=w.jQuery=$;w.editor_url='/editor';w.wmEditorText={saveError:'Unable to save changes'}; +let request,message,closed=0,refreshed=0;$.ajax=opts=>request=opts;w.alert=text=>message=text;w.hide_all_dialogs=()=>closed++;$.get=()=>refreshed++; +const source=fs.readFileSync(path.join(__dirname,'../../js/editor.js'),'utf8');w.eval(source.slice(source.indexOf('function form_submit()'),source.indexOf('function map_properties()'))); +w.form_submit();request.error();assert.equal(message,'Unable to save changes');assert.equal(closed,0);assert.equal(refreshed,0);assert.equal($('#action').val(),'set_map_properties'); +w.close();console.log('PASS: save failure reports retry and preserves the form'); diff --git a/tests/Support/ConfigWriteAtomicRegression.php b/tests/Support/ConfigWriteAtomicRegression.php index 0fe0d0e2..df55f377 100644 --- a/tests/Support/ConfigWriteAtomicRegression.php +++ b/tests/Support/ConfigWriteAtomicRegression.php @@ -241,12 +241,20 @@ function atomic_assert($condition, $message) { $map = new \WeatherMap; $map->ReadConfig($file); $map->title = 'Changed'; atomic_assert(!$map->WriteConfig($file) && file_get_contents($file) === $original, 'engine failure preserves original'); $GLOBALS['__test_nfilter_request'] = ['node_name' => 'note1', 'param' => 'note1', 'x' => '70', 'y' => '80']; - if (function_exists('wm_editor_save_note')) { + { http_response_code(200); moveNode($file, 0); atomic_assert(http_response_code() === 500 && file_get_contents($file) === $original, 'failed move surfaces error without changing file'); http_response_code(200); deleteNode($file); atomic_assert(http_response_code() === 500 && file_get_contents($file) === $original, 'failed delete surfaces error without changing file'); } + $GLOBALS['config_fault'] = ''; + $GLOBALS['__test_nfilter_request'] = ['node_name' => 'missing', 'param' => 'missing', 'x' => '70', 'y' => '80']; + http_response_code(200); moveNode($file, 0); + atomic_assert(http_response_code() === 404 && file_get_contents($file) === $original, 'missing move target is an error'); + http_response_code(200); deleteNode($file); + atomic_assert(http_response_code() === 404 && file_get_contents($file) === $original, 'missing delete target is an error'); + unlink($file); http_response_code(200); moveNode($file, 0); + atomic_assert(http_response_code() === 404 && !file_exists($file), 'deleted map cannot report a successful edit'); echo "Atomic configuration write and locking regression PASS\n"; } finally { $GLOBALS['config_fault'] = ''; diff --git a/tests/Unit/WeathermapUpgradeTest.php b/tests/Unit/WeathermapUpgradeTest.php index 24ca2c17..9a3bdad8 100644 --- a/tests/Unit/WeathermapUpgradeTest.php +++ b/tests/Unit/WeathermapUpgradeTest.php @@ -5,66 +5,63 @@ +-------------------------------------------------------------------------+ */ -/* - * Unit coverage for plugin_weathermap_upgrade() in setup.php: the - * page-guard. - * - * The rest of the function (reached on any allowed page) is intentionally - * NOT covered here: it include_once()s lib/poller-common.php, which itself - * include_once()s lib/WeatherMap.class.php - a file this plugin's own - * other test suites (e.g. tests/Handoff) already load directly by a - * different path, and loading it a second time via a different resolved - * path fatals with "Cannot declare class WeatherMapDataSource, because - * the name is already in use". The version-drift branch also calls - * weathermap_repair_maps(), which does a real chdir() and reads/writes - * map config files on disk - a much larger and riskier surface than this - * suite stubs. - */ - -// Only upgrade tests stub the filesystem repair; isolated engine tests load it. -if (!function_exists('weathermap_repair_maps')) { - function weathermap_repair_maps() {} -} - -beforeAll(function () { - require_once __DIR__ . '/../../setup.php'; -}); - -beforeEach(function () { - $GLOBALS['__test_db_calls'] = []; - $GLOBALS['__test_current_page'] = ''; -}); +use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; +use PHPUnit\Framework\Attributes\PreserveGlobalState; +use PHPUnit\Framework\TestCase; -it('does nothing on a page that does not need the version check', function () { - test_set_current_page('graphs.php'); +/** Exercise upgrade dispatch without sharing production-function stubs. */ +#[RunTestsInSeparateProcesses] +#[PreserveGlobalState(false)] +final class WeathermapUpgradeTest extends TestCase { + /** + * Install the upgrade-only repair stub in this isolated process. + * + * @return void + */ + protected function setUp(): void { + require_once __DIR__ . '/../../setup.php'; + if (!function_exists('weathermap_repair_maps')) { + function weathermap_repair_maps() {} + } + $GLOBALS['__test_db_calls'] = []; + $GLOBALS['__test_current_page'] = ''; + } - plugin_weathermap_upgrade(); + /** + * Verify unrelated pages do not trigger upgrade queries. + * + * @return void + */ + public function testUnrelatedPageSkipsUpgrade(): void { + test_set_current_page('graphs.php'); + plugin_weathermap_upgrade(); + self::assertEmpty($GLOBALS['__test_db_calls']); + } - expect($GLOBALS['__test_db_calls'])->toBeEmpty(); -}); - -it('runs the realm/version updates and prune on a version drift', function () { -test_set_current_page('plugins.php'); - -// Sandbox base_path with an empty poller-common stub + minimal INFO so the -// upgrade-time prune runs against a throwaway tree, never the real checkout, -// and the heavy lib include is a no-op. -$restore = $GLOBALS['config']['base_path']; -$base = sys_get_temp_dir() . '/weathermap-upg-' . uniqid(); -mkdir($base . '/plugins/weathermap/lib', 0777, true); -file_put_contents($base . '/plugins/weathermap/lib/poller-common.php', "not->toBeEmpty(); -}); diff --git a/weathermap-cacti-plugin-editor.php b/weathermap-cacti-plugin-editor.php index 8196770b..46efd3c8 100644 --- a/weathermap-cacti-plugin-editor.php +++ b/weathermap-cacti-plugin-editor.php @@ -873,7 +873,7 @@ '> - + From 25e27e1d683ed96e8f311d4464e3e561d0aba4f8 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:54:12 +1030 Subject: [PATCH 15/41] Compare configuration revisions before saving edits and repairs --- lib/WeatherMap.class.php | 17 +++++-- lib/WeatherMap.config.php | 9 +++- lib/editor.actions.php | 10 ++-- lib/poller-common.php | 3 +- tests/Support/ConfigWriteAtomicRegression.php | 48 +++++++++++++++++++ tests/Unit/ConfigWriteAtomicTest.php | 5 ++ tests/Unit/WeathermapUpgradeTest.php | 5 ++ weathermap-cacti-plugin-mgmt.php | 2 +- 8 files changed, 88 insertions(+), 11 deletions(-) diff --git a/lib/WeatherMap.class.php b/lib/WeatherMap.class.php index 01c46ae3..7bdd2db4 100644 --- a/lib/WeatherMap.class.php +++ b/lib/WeatherMap.class.php @@ -341,6 +341,8 @@ class WeatherMap extends WeatherMapBase { var $colours; /** @var mixed */ var $configfile; + var $config_revision = null; + var $editor_config_revision = null; /** @var mixed */ var $imagefile; /** @var mixed */ @@ -2445,6 +2447,7 @@ function DrawTitle($image, $font, $colour) { */ function ReadConfig($input, $is_include = false) { global $config, $weathermap_error_suppress; + if (!$is_include) $this->config_revision = null; $curnode = null; $curlink = null; @@ -2491,16 +2494,19 @@ function ReadConfig($input, $is_include = false) { } if (is_file($filename)) { - $fd = fopen($filename, 'r'); + $fd = fopen($filename, 'rb'); if ($fd) { + $revision = hash_init('sha256'); while (($buffer = fgets($fd, 4096)) !== false) { + hash_update($revision, $buffer); // strip out any Windows line-endings that have gotten in here $buffer = str_replace("\r", '', $buffer); $lines[] = $buffer; } fclose($fd); + if (!$is_include) $this->config_revision = hash_final($revision); } } } @@ -3604,9 +3610,11 @@ function WriteDataFile($filename) { /** * @param string $filename + * @param bool $createOnly Require a new destination. + * @param string|null $expectedRevision Optional request snapshot revision. * @return bool */ - function WriteConfig($filename) { + function WriteConfig($filename, $createOnly = false, $expectedRevision = null) { require_once __DIR__ . '/WeatherMap.config.php'; $fd = fopen('php://temp', 'w+b'); @@ -3847,7 +3855,10 @@ function WriteConfig($filename) { $contents = stream_get_contents($fd); $closed = fclose($fd); - return $write_ok && $contents !== false && $closed && \WeathermapConfig\atomicWrite($filename, $contents, $this->configfile !== '' && $filename === $this->configfile); + $sameFile = $this->configfile !== '' && $filename === $this->configfile; + $saved = $write_ok && $contents !== false && $closed && \WeathermapConfig\atomicWrite($filename, $contents, $sameFile, $expectedRevision ?? ($sameFile ? $this->config_revision : null), $createOnly); + if ($saved && $sameFile) $this->config_revision = hash('sha256', $contents); + return $saved; } else { wm_warn("Couldn't open config file $filename for writing"); diff --git a/lib/WeatherMap.config.php b/lib/WeatherMap.config.php index 7d104001..b89db55e 100644 --- a/lib/WeatherMap.config.php +++ b/lib/WeatherMap.config.php @@ -80,22 +80,27 @@ function writeAll($stream, $contents) { * @param string $filename The destination configuration path. * @param string $contents The complete new configuration. * @param bool $mustExist Refuse to recreate an existing map deleted during editing. + * @param string|null $expectedRevision SHA-256 of the snapshot being edited. + * @param bool $createOnly Refuse to replace an existing destination. * * @return bool Success; failures preserve the existing configuration. */ -function atomicWrite($filename, $contents, $mustExist = false) { +function atomicWrite($filename, $contents, $mustExist = false, $expectedRevision = null, $createOnly = false) { if (strpos($filename, '://') !== false) return false; if (is_link($filename)) $filename = realpath($filename); if ($filename === false || !is_dir(dirname($filename)) || !is_writable(dirname($filename))) return false; if (file_exists($filename) && (!is_file($filename) || !is_writable($filename))) return false; - $mustExist = $mustExist || file_exists($filename); + $existed = file_exists($filename); + $mustExist = $mustExist || $existed; $lock = acquire(dirname($filename)); if (!$lock) return false; $temp = false; $stream = false; try { clearstatcache(true, $filename); + if (($createOnly || !$existed) && file_exists($filename)) return false; if ($mustExist && !is_file($filename)) return false; + if ($expectedRevision !== null && @hash_file('sha256', $filename) !== $expectedRevision) return false; if (file_exists($filename) && (!is_file($filename) || !is_writable($filename))) return false; $temp = @tempnam(dirname($filename), '.wm-config-'); if ($temp === false) return false; diff --git a/lib/editor.actions.php b/lib/editor.actions.php index 9c89ff5c..9b0e09cf 100644 --- a/lib/editor.actions.php +++ b/lib/editor.actions.php @@ -64,6 +64,7 @@ function wm_editor_read_config($map, $mapfile) { http_response_code(422); return false; } + $map->editor_config_revision = $map->config_revision; return true; } @@ -72,10 +73,11 @@ function wm_editor_read_config($map, $mapfile) { * * @param WeatherMap $map Engine containing the edited configuration. * @param string $mapfile Configuration path. + * @param bool $createOnly Require a new destination for map creation. * @return bool Whether the complete configuration was saved. */ -function wm_editor_write_config($map, $mapfile) { - if (!$map->WriteConfig($mapfile)) { +function wm_editor_write_config($map, $mapfile, $createOnly = false) { + if (!$map->WriteConfig($mapfile, $createOnly, $map->editor_config_revision)) { http_response_code(500); return false; } @@ -93,7 +95,7 @@ function newMap($mapfile) { require_once __DIR__ . '/editor.new-map-preset.php'; wm_new_map_preset($map); - wm_editor_write_config($map, $mapfile); + wm_editor_write_config($map, $mapfile, true); } /** @@ -120,7 +122,7 @@ function newMapCopy($mapfile) { if (file_exists($sourcemap) && is_readable($sourcemap)) { $map->ReadConfig($sourcemap); - wm_editor_write_config($map, $mapfile); + wm_editor_write_config($map, $mapfile, true); } } } diff --git a/lib/poller-common.php b/lib/poller-common.php index ca63f097..362516f3 100644 --- a/lib/poller-common.php +++ b/lib/poller-common.php @@ -144,6 +144,7 @@ function weathermap_repair_maps() { if (file_exists($configfile) && is_writable($configfile)) { $contents = file($configfile) ?: []; + $revision = hash('sha256', implode('', $contents)); foreach ($contents as $line) { if (strpos($line, 'BACKGROUND') !== false) { @@ -272,7 +273,7 @@ function weathermap_repair_maps() { if ($changes) { wm_debug('Writing updated file to: ' . $configfile); require_once __DIR__ . '/WeatherMap.config.php'; - if (!\WeathermapConfig\atomicWrite($configfile, implode("\n", $outcontents), true)) { + if (!\WeathermapConfig\atomicWrite($configfile, implode("\n", $outcontents), true, $revision)) { wm_warn('Unable to save repaired configuration: ' . $configfile . ' [WMPOLL13]'); } } else { diff --git a/tests/Support/ConfigWriteAtomicRegression.php b/tests/Support/ConfigWriteAtomicRegression.php index df55f377..2f4fb4cf 100644 --- a/tests/Support/ConfigWriteAtomicRegression.php +++ b/tests/Support/ConfigWriteAtomicRegression.php @@ -44,6 +44,7 @@ function fopen($path, $mode) { */ function flock($stream, $mode) { if (($GLOBALS['config_fault'] ?? '') === 'lock' && $mode === LOCK_EX) return false; + if (($mode & LOCK_EX) && isset($GLOBALS['config_before_lock'])) ($GLOBALS['config_before_lock'])(); return \flock($stream, $mode); } /** @@ -204,6 +205,44 @@ function atomic_assert($condition, $message) { atomic_assert(!\WeathermapConfig\acquire($dir . '/missing'), 'missing lock directory rejected'); atomic_assert(!\WeathermapConfig\atomicWrite($dir . '/deleted.conf', 'Replacement', true), 'deleted existing maps are not recreated'); atomic_assert(is_file($dir . '/.weathermap-config.lock'), 'arbitrary output uses its destination directory lock'); + atomic_assert(!\WeathermapConfig\atomicWrite($file, 'Overwrite', false, null, true), 'create-only destination refuses existing files'); + $first = new \WeatherMap; $second = new \WeatherMap; + $first->ReadConfig($file); $second->ReadConfig($file); + $first->title = 'First edit'; $second->title = 'Stale edit'; + atomic_assert($first->WriteConfig($file), 'first revision saves'); + $current = file_get_contents($file); + atomic_assert(!$second->WriteConfig($file) && file_get_contents($file) === $current, 'stale reader cannot overwrite the first edit'); + $first->title = 'Next edit'; + atomic_assert($first->WriteConfig($file), 'successful save updates its revision for subsequent writes'); + $raw = new \WeatherMap; wm_editor_read_config($raw, $file); + $raw->ReadConfig("TITLE Fragment edit\n"); + file_put_contents($file, $original); + http_response_code(200); + atomic_assert(!wm_editor_write_config($raw, $file) && http_response_code() === 500 && file_get_contents($file) === $original, 'raw fragment edits retain the original request revision'); + if (function_exists('pcntl_fork')) { + $createFile = $dir . '/create.conf'; + $lock = \WeathermapConfig\acquire($dir); $children = []; + for ($index = 0; $index < 2; $index++) { + $child = pcntl_fork(); + if ($child === 0) { + \fclose($lock); + $GLOBALS['config_before_lock'] = function() use ($dir, $index) { file_put_contents($dir . '/create-ready-' . $index, 'ready'); }; + $saved = \WeathermapConfig\atomicWrite($createFile, 'Creator ' . $index, false, null, true); + file_put_contents($dir . '/create-result-' . $index, $saved ? '1' : '0'); + exit(0); + } + $children[] = $child; + } + for ($wait = 0; $wait < 100 && cacti_count(glob($dir . '/create-ready-*')) < 2; $wait++) usleep(10000); + \WeathermapConfig\release($lock); + foreach ($children as $child) pcntl_waitpid($child, $status); + atomic_assert((int)file_get_contents($dir . '/create-result-0') + (int)file_get_contents($dir . '/create-result-1') === 1, 'exactly one concurrent creator commits'); + atomic_assert(in_array(file_get_contents($createFile), ['Creator 0', 'Creator 1'], true), 'winning creator content remains intact'); + } + $crlfFile = $dir . '/crlf.conf'; + $crlf = "WIDTH 600\r\nHEIGHT 300\r\n"; file_put_contents($crlfFile, $crlf); + $crlfMap = new \WeatherMap; $crlfMap->ReadConfig($crlfFile); + atomic_assert($crlfMap->config_revision === hash('sha256', $crlf) && $crlfMap->WriteConfig($crlfFile), 'revision hashes preserve raw CRLF bytes'); $templateFile = $dir . '/templates.conf'; file_put_contents($templateFile, "NODE templateNode\n LABEL Template\nNODE A\n POSITION 10 10\nNODE B\n POSITION 50 50\nLINK templateLink\n WIDTH 8\nLINK trafficLink\n NODES A B\n"); $templateMap = new \WeatherMap; $templateMap->ReadConfig($templateFile); @@ -228,6 +267,15 @@ function atomic_assert($condition, $message) { $GLOBALS['config_fault'] = ''; weathermap_repair_maps(); atomic_assert(file_get_contents($repairFile) === 'BACKGROUND images/backgrounds/' . basename($repairImage), 'repair uses complete atomic replacement'); + file_put_contents($repairFile, $legacy); + $newerRepair = 'TITLE Concurrent editor save' . "\n" . $legacy; + $GLOBALS['config_before_lock'] = function() use ($repairFile, $newerRepair) { + unset($GLOBALS['config_before_lock']); + file_put_contents($repairFile, $newerRepair); + }; + $GLOBALS['weathermap_error_suppress'] = ['WMPOLL13']; + try { weathermap_repair_maps(); } finally { $GLOBALS['weathermap_error_suppress'] = $oldSuppress; } + atomic_assert(file_get_contents($repairFile) === $newerRepair, 'repair CAS preserves a concurrent editor save'); } finally { $GLOBALS['config'] = $oldConfig; unset($GLOBALS['__test_db_fetch_assoc']); diff --git a/tests/Unit/ConfigWriteAtomicTest.php b/tests/Unit/ConfigWriteAtomicTest.php index 48736556..913fe485 100644 --- a/tests/Unit/ConfigWriteAtomicTest.php +++ b/tests/Unit/ConfigWriteAtomicTest.php @@ -26,6 +26,11 @@ use PHPUnit\Framework\TestCase; final class ConfigWriteAtomicTest extends TestCase { + /** + * Verify real engine persistence and coordinated filesystem failures. + * + * @return void + */ #[RunInSeparateProcess] #[PreserveGlobalState(false)] public function testRealEngineRegression(): void { diff --git a/tests/Unit/WeathermapUpgradeTest.php b/tests/Unit/WeathermapUpgradeTest.php index 9a3bdad8..fd63cdbd 100644 --- a/tests/Unit/WeathermapUpgradeTest.php +++ b/tests/Unit/WeathermapUpgradeTest.php @@ -21,6 +21,11 @@ final class WeathermapUpgradeTest extends TestCase { protected function setUp(): void { require_once __DIR__ . '/../../setup.php'; if (!function_exists('weathermap_repair_maps')) { + /** + * Stub filesystem repair only within an isolated upgrade test. + * + * @return void + */ function weathermap_repair_maps() {} } $GLOBALS['__test_db_calls'] = []; diff --git a/weathermap-cacti-plugin-mgmt.php b/weathermap-cacti-plugin-mgmt.php index 81271715..28d68cde 100644 --- a/weathermap-cacti-plugin-mgmt.php +++ b/weathermap-cacti-plugin-mgmt.php @@ -2026,7 +2026,7 @@ function map_duplicate($id, $titlecache, $configfile = null) { if ($contents !== false) { $contents = str_replace("TITLE {$map['titlecache']}", "TITLE {$save['titlecache']}", $contents); require_once __DIR__ . '/lib/WeatherMap.config.php'; - if (!\WeathermapConfig\atomicWrite($newfile, $contents)) { + if (!\WeathermapConfig\atomicWrite($newfile, $contents, false, null, true)) { raise_message('new_map_' . $newid, __('Could not save the map configuration.', 'weathermap'), MESSAGE_LEVEL_ERROR); } } else { From fff23c61d51f8853b5d1a7b2dfba689ac5bdf5ee Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:21:49 +1030 Subject: [PATCH 16/41] Protect literal image includes and prevent failed clone registrations --- js/editor.js | 3 +- lib/editor.actions.php | 16 ++++-- lib/editor.assets.php | 10 ++-- lib/editor.inc.php | 1 + locales/po/cacti.pot | 20 +++++--- tests/Browser/EditorSaveFailureTest.cjs | 3 +- tests/Support/ConfigWriteAtomicRegression.php | 39 +++++++++++++++ .../Support/EditorImageFormatsRegression.php | 25 +++++----- tests/Support/MapDuplicatePayload.php | 16 ++++-- tests/bootstrap-unit.php | 2 + weathermap-cacti-plugin-mgmt.php | 49 ++++++++++++------- 11 files changed, 129 insertions(+), 55 deletions(-) diff --git a/js/editor.js b/js/editor.js index 6e5a9bbc..0ce9fd0e 100644 --- a/js/editor.js +++ b/js/editor.js @@ -873,12 +873,13 @@ function delete_link() { function form_submit() { var data = $('input, select, textarea').serialize(); + if (typeof WMConfigRevision !== 'undefined') data += '&config_revision=' + encodeURIComponent(WMConfigRevision); $.ajax({ type: 'POST', url: editor_url, data: data, - error: function() { window.alert(wmEditorText.saveError); }, + error: function(xhr) { window.alert(xhr && xhr.status === 409 ? wmEditorText.staleError : wmEditorText.saveError); }, success: function(html) { hide_all_dialogs(); diff --git a/lib/editor.actions.php b/lib/editor.actions.php index 9b0e09cf..47d5347e 100644 --- a/lib/editor.actions.php +++ b/lib/editor.actions.php @@ -64,6 +64,11 @@ function wm_editor_read_config($map, $mapfile) { http_response_code(422); return false; } + $revision = get_nfilter_request_var('config_revision'); + if ($revision !== null && $revision !== '' && (!is_string($revision) || !hash_equals($map->config_revision, $revision))) { + http_response_code(409); + return false; + } $map->editor_config_revision = $map->config_revision; return true; } @@ -139,6 +144,7 @@ function getMapJavaScript($mapfile) { if (!wm_editor_read_config($map, $mapfile)) return; print $map->asJS(); + print 'var WMConfigRevision = ' . json_encode($map->config_revision) . ';'; } /** @@ -515,10 +521,6 @@ function setMapProperties($mapfile) { $map->add_hint('background_sizing', $background_sizing); } - db_execute_prepared('UPDATE weathermap_maps - SET titlecache = ? - WHERE configfile = ?', - [$map->title, basename($mapfile)]); $inheritables = [ ['link', 'width', 'map_linkdefaultwidth', 'float'] @@ -564,7 +566,11 @@ function setMapProperties($mapfile) { } } - wm_editor_write_config($map, $mapfile); + if (!wm_editor_write_config($map, $mapfile)) return; + db_execute_prepared('UPDATE weathermap_maps + SET titlecache = ? + WHERE configfile = ?', + [$map->title, basename($mapfile)]); } /** diff --git a/lib/editor.assets.php b/lib/editor.assets.php index b25a1136..d309715c 100644 --- a/lib/editor.assets.php +++ b/lib/editor.assets.php @@ -26,12 +26,13 @@ * Resolve a managed image library, rejecting symlinked directories. * * @param string $kind The objects or backgrounds library. + * @param string|null $root Optional library root; defaults to the plugin directory. * * @return string The library directory. */ -function wm_asset_directory($kind) { +function wm_asset_directory($kind, $root = null) { if (!in_array($kind, ['objects', 'backgrounds'], true)) throw new InvalidArgumentException(__('Invalid image category.', 'weathermap')); - $parent = dirname(__DIR__) . '/images'; + $parent = ($root ?? dirname(__DIR__)) . '/images'; $dir = $parent . '/' . $kind; if (is_link($parent) || is_link($dir)) throw new InvalidArgumentException(__('Symbolic links cannot be managed here.', 'weathermap')); return $dir; @@ -107,9 +108,8 @@ function wm_asset_image_reference_index() { } if (preg_match_all('/^\s*INCLUDE\s+(.+)$/mi', $text, $matches)) foreach ($matches[1] as $include) { $include = wm_asset_normalize_path(trim($include), $root); - $matches = glob($include); - // The renderer tolerates absent optional includes; unreadable matches still fail closed. - foreach ($matches ?: [] as $match) $queue[] = $match; + // INCLUDE is a literal filename in the renderer, including glob metacharacters. + if (file_exists($include) || is_link($include)) $queue[] = $include; } } return $references; diff --git a/lib/editor.inc.php b/lib/editor.inc.php index 19a0cb6a..40a420b2 100644 --- a/lib/editor.inc.php +++ b/lib/editor.inc.php @@ -1036,6 +1036,7 @@ function getEditorJs() { 'iconMatchCount' => __('Matching icons: %s', '%s', 'weathermap'), 'iconNoMatches' => __('No matching icons', 'weathermap'), + 'staleError' => __('This map has changed since you opened it. Reload the editor before saving your changes.', 'weathermap'), 'saveError' => __('Unable to save changes. Please try again.', 'weathermap'), 'currentInterface' => __('Current interface', 'weathermap'), 'advanced' => __('Advanced settings', 'weathermap'), diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index d326380e..fd3d1932 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti 1.7\n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-12 13:03+1030\n" +"POT-Creation-Date: 2026-10-12 23:20+1030\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" @@ -330,6 +330,10 @@ msgstr "" msgid "No matching icons" msgstr "" +#: lib/editor.inc.php +msgid "This map has changed since you opened it. Reload the editor before saving your changes." +msgstr "" + #: lib/editor.inc.php msgid "Unable to save changes. Please try again." msgstr "" @@ -1934,17 +1938,17 @@ msgid "(No Title)" msgstr "" #: weathermap-cacti-plugin-mgmt.php -#, php-format -msgid "The new Map with the name %s was created using config file %s" +msgid "Could not save the map configuration." msgstr "" #: weathermap-cacti-plugin-mgmt.php -msgid "Could not save the map configuration." +#, php-format +msgid "The new Map with the name %s was unable to locate the config file %s to copy" msgstr "" #: weathermap-cacti-plugin-mgmt.php #, php-format -msgid "The new Map with the name %s was unable to locate the config file %s to copy" +msgid "The new Map with the name %s was created using config file %s" msgstr "" #: weathermap-cacti-plugin-mgmt.php @@ -2225,12 +2229,12 @@ msgid "The New Map File name %s already exists!" msgstr "" #: weathermap-cacti-plugin-mgmt.php -#, php-format -msgid "New Map file %s created from %s" +msgid "The Source Map File name is not readable or does not exist!" msgstr "" #: weathermap-cacti-plugin-mgmt.php -msgid "The Source Map File name is not readable or does not exist!" +#, php-format +msgid "New Map file %s created from %s" msgstr "" #: weathermap-cacti-plugin-mgmt.php diff --git a/tests/Browser/EditorSaveFailureTest.cjs b/tests/Browser/EditorSaveFailureTest.cjs index 447a1c29..108b9acf 100644 --- a/tests/Browser/EditorSaveFailureTest.cjs +++ b/tests/Browser/EditorSaveFailureTest.cjs @@ -1,6 +1,7 @@ const {JSDOM}=require('jsdom'),jquery=require('jquery'),fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict'); -const dom=new JSDOM('',{runScripts:'outside-only'}),w=dom.window,$=jquery(w);w.$=w.jQuery=$;w.editor_url='/editor';w.wmEditorText={saveError:'Unable to save changes'}; +const dom=new JSDOM('',{runScripts:'outside-only'}),w=dom.window,$=jquery(w);w.$=w.jQuery=$;w.editor_url='/editor';w.wmEditorText={staleError:'Reload the editor',saveError:'Unable to save changes'}; let request,message,closed=0,refreshed=0;$.ajax=opts=>request=opts;w.alert=text=>message=text;w.hide_all_dialogs=()=>closed++;$.get=()=>refreshed++; const source=fs.readFileSync(path.join(__dirname,'../../js/editor.js'),'utf8');w.eval(source.slice(source.indexOf('function form_submit()'),source.indexOf('function map_properties()'))); w.form_submit();request.error();assert.equal(message,'Unable to save changes');assert.equal(closed,0);assert.equal(refreshed,0);assert.equal($('#action').val(),'set_map_properties'); +w.WMConfigRevision='b'.repeat(64);w.form_submit();assert.match(request.data,/config_revision=b{64}/);request.error({status:409});assert.equal(message,'Reload the editor');assert.equal(closed,0);assert.equal(refreshed,0); w.close();console.log('PASS: save failure reports retry and preserves the form'); diff --git a/tests/Support/ConfigWriteAtomicRegression.php b/tests/Support/ConfigWriteAtomicRegression.php index 2f4fb4cf..aecd9104 100644 --- a/tests/Support/ConfigWriteAtomicRegression.php +++ b/tests/Support/ConfigWriteAtomicRegression.php @@ -214,6 +214,13 @@ function atomic_assert($condition, $message) { atomic_assert(!$second->WriteConfig($file) && file_get_contents($file) === $current, 'stale reader cannot overwrite the first edit'); $first->title = 'Next edit'; atomic_assert($first->WriteConfig($file), 'successful save updates its revision for subsequent writes'); + $GLOBALS['__test_nfilter_request'] = ['config_revision' => hash('sha256', $original), 'node_name' => 'note1', 'x' => 90, 'y' => 90]; + http_response_code(200); moveNode($file, 0); + atomic_assert(http_response_code() === 409 && file_get_contents($file) !== $original, 'stale loaded page cannot overwrite another user save'); + $GLOBALS['__test_nfilter_request'] = ['config_revision' => hash_file('sha256', $file)]; + $matching = new \WeatherMap; + atomic_assert(wm_editor_read_config($matching, $file), 'matching loaded page revision accepted'); + $GLOBALS['__test_nfilter_request'] = []; $raw = new \WeatherMap; wm_editor_read_config($raw, $file); $raw->ReadConfig("TITLE Fragment edit\n"); file_put_contents($file, $original); @@ -281,6 +288,31 @@ function atomic_assert($condition, $message) { unset($GLOBALS['__test_db_fetch_assoc']); unlink($repairFile); unlink($repairImage); } + $management = file_get_contents($plugin . '/weathermap-cacti-plugin-mgmt.php'); + $start = strpos($management, 'function newMap('); + eval(str_replace(['__DIR__', 'function newMap('], [var_export($plugin, true), 'function wm_management_newMap('], substr($management, $start))); + $GLOBALS['weathermap_confdir'] = $dir; + foreach (['', 'map.conf'] as $source) { + $destination = $dir . '/management-create.conf'; + $GLOBALS['config_before_lock'] = function() use ($destination) { file_put_contents($destination, 'Concurrent creator'); unset($GLOBALS['config_before_lock']); }; + wm_management_newMap('management-create.conf', $source); + atomic_assert(file_get_contents($destination) === 'Concurrent creator', 'management create-only preserves concurrent destination'); + unlink($destination); + } + $start = strpos($management, 'function map_get_next_name('); $end = strpos($management, "\n/**", $start); + eval(str_replace('__DIR__', var_export($plugin, true), substr($management, $start, $end - $start))); + $start = strpos($management, 'function map_duplicate('); $end = strpos($management, "\n/**", $start); + eval(str_replace('__DIR__', var_export($plugin, true), substr($management, $start, $end - $start))); + $duplicateSource = 'atomic-duplicate-' . bin2hex(random_bytes(4)) . '.conf'; + file_put_contents($plugin . '/configs/' . $duplicateSource, "TITLE Source\n"); + $GLOBALS['__test_db_fetch_row_prepared'] = fn () => ['configfile' => $duplicateSource, 'titlecache' => 'Source', 'group_id' => 1, 'active' => 'on', 'thumb_height' => 10, 'thumb_width' => 10, 'schedule' => '', 'archiving' => '',]; + $GLOBALS['__test_db_calls'] = []; $GLOBALS['atomic_sql_saves'] = []; + $GLOBALS['__test_db_fetch_cell'] = fn () => 0; + $GLOBALS['__test_sql_save'] = function($record) { $GLOBALS['atomic_sql_saves'][] = $record; return 1; }; $GLOBALS['config_fault'] = 'rename'; + try { + map_duplicate(1, 'Clone', 'clone'); + atomic_assert($GLOBALS['__test_db_calls'] === [] && $GLOBALS['atomic_sql_saves'] === [], 'failed clone never registers auth or settings or invokes runner'); + } finally { unset($GLOBALS['__test_db_fetch_row_prepared'], $GLOBALS['__test_sql_save'], $GLOBALS['__test_db_fetch_cell']); unlink($plugin . '/configs/' . $duplicateSource); $GLOBALS['config_fault'] = ''; } $deletedMap = new \WeatherMap; $deletedMap->ReadConfig($file); unlink($file); atomic_assert(!$deletedMap->WriteConfig($file) && !file_exists($file), 'saving a previously loaded deleted map fails without resurrection'); @@ -288,6 +320,13 @@ function atomic_assert($condition, $message) { $GLOBALS['config_fault'] = 'rename'; $map = new \WeatherMap; $map->ReadConfig($file); $map->title = 'Changed'; atomic_assert(!$map->WriteConfig($file) && file_get_contents($file) === $original, 'engine failure preserves original'); + $GLOBALS['__test_nfilter_request'] = ['map_title' => 'Failed 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']; + $GLOBALS['__test_db_calls'] = []; + http_response_code(200); setMapProperties($file); + atomic_assert(http_response_code() === 500 && $GLOBALS['__test_db_calls'] === [], 'failed properties save cannot change titlecache'); + $GLOBALS['config_fault'] = ''; http_response_code(200); setMapProperties($file); + atomic_assert(http_response_code() === 200 && cacti_count($GLOBALS['__test_db_calls']) === 1, 'successful properties save updates titlecache'); + file_put_contents($file, $original); $GLOBALS['config_fault'] = 'rename'; $GLOBALS['__test_nfilter_request'] = ['node_name' => 'note1', 'param' => 'note1', 'x' => '70', 'y' => '80']; { http_response_code(200); moveNode($file, 0); diff --git a/tests/Support/EditorImageFormatsRegression.php b/tests/Support/EditorImageFormatsRegression.php index 3d292fdc..fb59333d 100644 --- a/tests/Support/EditorImageFormatsRegression.php +++ b/tests/Support/EditorImageFormatsRegression.php @@ -107,22 +107,21 @@ function image_format_assert($value, $message) { image_format_assert(in_array(basename($included), wm_asset_image_users('included-image.png', 'backgrounds', $index), true), 'reference snapshot reused without rescanning configurations'); image_format_assert(wm_asset_image_users('included-image.png', 'backgrounds') === [], 'fresh request reflects changed configuration'); image_format_assert(is_array(wm_asset_image_list()), 'unmatched optional include permits listing'); - // Test symlink rejection only inside this isolated fixture, and always restore its library. - $library = $root . '/images'; - $originalLibrary = $temporary . '/library'; - image_format_assert(rename($library, $originalLibrary), 'reserve fixture library'); + // Test directory rejection without renaming any shared checkout assets. + $fixtureRoot = $temporary . '/fixture'; mkdir($fixtureRoot); + $library = $fixtureRoot . '/images'; + image_format_assert(symlink($root . '/images', $library), 'create linked fixture library'); try { - image_format_assert(symlink($originalLibrary, $library), 'create linked fixture library'); foreach (['objects', 'backgrounds'] as $kind) { - try { wm_asset_directory($kind); throw new RuntimeException('accepted symlinked library parent'); } catch (InvalidArgumentException $expected) {} + try { wm_asset_directory($kind, $fixtureRoot); throw new RuntimeException('accepted symlinked library parent'); } catch (InvalidArgumentException $expected) {} } - try { wm_asset_image_list(); throw new RuntimeException('listed linked library'); } catch (InvalidArgumentException $expected) {} - try { wm_asset_store_image('objects', $source, 'linked.png'); throw new RuntimeException('uploaded to linked library'); } catch (InvalidArgumentException $expected) {} - try { wm_asset_image_path('objects', basename($created[0])); throw new RuntimeException('accepted deletion path in linked library'); } catch (InvalidArgumentException $expected) {} - } finally { - if (is_link($library)) unlink($library); - rename($originalLibrary, $library); - } + } finally { unlink($library); rmdir($fixtureRoot); } + $literal = $temporary . '/site[1].conf'; + file_put_contents($literal, "BACKGROUND images/backgrounds/literal-image.png\n"); + file_put_contents($config, "INCLUDE " . $literal . "\n"); + image_format_assert(in_array(basename($literal), wm_asset_image_users('literal-image.png'), true), 'literal brackets in INCLUDE protect referenced images'); + file_put_contents($config, "INCLUDE " . $temporary . "/site*.conf\n"); + image_format_assert(wm_asset_image_users('literal-image.png') === [], 'wildcard text is not expanded beyond renderer semantics'); // A reference-scan failure must be reported before upload validation or persistence. $broken = $temporary . '/broken.conf'; symlink($temporary . '/missing-target.conf', $broken); diff --git a/tests/Support/MapDuplicatePayload.php b/tests/Support/MapDuplicatePayload.php index ad558dd5..64da377e 100644 --- a/tests/Support/MapDuplicatePayload.php +++ b/tests/Support/MapDuplicatePayload.php @@ -14,7 +14,11 @@ if ($start === false || $end === false) { throw new RuntimeException('Unable to locate the map duplication function.'); } -eval(substr($source, $start, $end - $start)); +$fixture = sys_get_temp_dir() . '/wm-duplicate-' . bin2hex(random_bytes(5)); +mkdir($fixture); mkdir($fixture . '/configs'); +symlink(dirname(__DIR__, 2) . '/lib', $fixture . '/lib'); +file_put_contents($fixture . '/configs/Original.conf', "TITLE Original Map\n"); +eval(str_replace('__DIR__', var_export($fixture, true), substr($source, $start, $end - $start))); /** * Return the source map fixture. @@ -92,5 +96,11 @@ function sql_save($payload, $table) { return 0; } -map_duplicate(5, ' Copy'); -print json_encode($GLOBALS['duplicate_save'], JSON_THROW_ON_ERROR); +try { + map_duplicate(5, ' Copy'); + print json_encode($GLOBALS['duplicate_save'], JSON_THROW_ON_ERROR); +} finally { + foreach (glob($fixture . '/configs/*') as $file) unlink($file); + if (is_file($fixture . '/configs/.weathermap-config.lock')) unlink($fixture . '/configs/.weathermap-config.lock'); + unlink($fixture . '/lib'); rmdir($fixture . '/configs'); rmdir($fixture); +} diff --git a/tests/bootstrap-unit.php b/tests/bootstrap-unit.php index de6f6e41..7cebba05 100644 --- a/tests/bootstrap-unit.php +++ b/tests/bootstrap-unit.php @@ -104,6 +104,7 @@ function db_fetch_row_prepared($sql, $p = []) { if (!function_exists('db_fetch_cell')) { function db_fetch_cell($sql) { + if (isset($GLOBALS['__test_db_fetch_cell'])) return ($GLOBALS['__test_db_fetch_cell'])($sql); return ''; } } @@ -339,6 +340,7 @@ function is_error_message() { if (!function_exists('sql_save')) { function sql_save($a, $t, $k = 'id') { + if (isset($GLOBALS['__test_sql_save'])) return ($GLOBALS['__test_sql_save'])($a, $t, $k); return isset($a['id']) ? $a['id'] : 1; } } diff --git a/weathermap-cacti-plugin-mgmt.php b/weathermap-cacti-plugin-mgmt.php index 28d68cde..0edff281 100644 --- a/weathermap-cacti-plugin-mgmt.php +++ b/weathermap-cacti-plugin-mgmt.php @@ -1998,6 +1998,24 @@ function map_duplicate($id, $titlecache, $configfile = null) { $save['duration'] = 0; $save['last_runtime'] = 0; + $confdir = __DIR__ . '/configs'; + + $oldfile = $confdir . '/' . $map['configfile']; + $newfile = $confdir . '/' . $save['configfile']; + + $contents = is_file($oldfile) ? file_get_contents($oldfile) : false; + if ($contents !== false) { + $contents = str_replace("TITLE {$map['titlecache']}", "TITLE {$save['titlecache']}", $contents); + require_once __DIR__ . '/lib/WeatherMap.config.php'; + if (!\WeathermapConfig\atomicWrite($newfile, $contents, false, null, true)) { + raise_message('map_message', __('Could not save the map configuration.', 'weathermap'), MESSAGE_LEVEL_ERROR); + return; + } + } else { + raise_message('map_message', __('The new Map with the name %s was unable to locate the config file %s to copy', $save['titlecache'], $map['configfile'], 'weathermap'), MESSAGE_LEVEL_ERROR); + return; + } + $newid = sql_save($save, 'weathermap_maps'); if ($newid) { @@ -2017,22 +2035,6 @@ function map_duplicate($id, $titlecache, $configfile = null) { raise_message('new_map_' . $newid, __('The new Map with the name %s was created using config file %s', $save['titlecache'], $save['configfile'], 'weathermap'), MESSAGE_LEVEL_INFO); - $confdir = __DIR__ . '/configs'; - - $oldfile = $confdir . '/' . $map['configfile']; - $newfile = $confdir . '/' . $save['configfile']; - - $contents = is_file($oldfile) ? file_get_contents($oldfile) : false; - if ($contents !== false) { - $contents = str_replace("TITLE {$map['titlecache']}", "TITLE {$save['titlecache']}", $contents); - require_once __DIR__ . '/lib/WeatherMap.config.php'; - if (!\WeathermapConfig\atomicWrite($newfile, $contents, false, null, true)) { - raise_message('new_map_' . $newid, __('Could not save the map configuration.', 'weathermap'), MESSAGE_LEVEL_ERROR); - } - } else { - raise_message('new_map_' . $newid, __('The new Map with the name %s was unable to locate the config file %s to copy', $save['titlecache'], $map['configfile'], 'weathermap'), MESSAGE_LEVEL_ERROR); - } - weathermap_run_maps(__DIR__, true, [$newid]); } } @@ -3432,11 +3434,17 @@ function newMap($mapfile, $sourcemapfile = '', $title = '') { if ($mapfile != '') { if ($sourcemapfile != '') { if (file_exists($sourcemapfile) && is_readable($sourcemapfile)) { - $map->ReadConfig($sourcemapfile); + if (!$map->ReadConfig($sourcemapfile)) { + raise_message('map_message', __('The Source Map File name is not readable or does not exist!', 'weathermap'), MESSAGE_LEVEL_ERROR); + return; + } if ($title !== '') { $map->title = $title; } - $map->WriteConfig($mapfile); + if (!$map->WriteConfig($mapfile, true)) { + raise_message('map_message', __('Could not save the map configuration.', 'weathermap'), MESSAGE_LEVEL_ERROR); + return; + } raise_message('map_message', __('New Map file %s created from %s', basename($mapfile), basename($sourcemapfile), 'weathermap'), MESSAGE_LEVEL_INFO); } else { raise_message('map_message', __('The Source Map File name is not readable or does not exist!', 'weathermap'), MESSAGE_LEVEL_ERROR); @@ -3445,7 +3453,10 @@ function newMap($mapfile, $sourcemapfile = '', $title = '') { if ($title !== '') { $map->title = $title; } - $map->WriteConfig($mapfile); + if (!$map->WriteConfig($mapfile, true)) { + raise_message('map_message', __('Could not save the map configuration.', 'weathermap'), MESSAGE_LEVEL_ERROR); + return; + } raise_message('map_message', __('New Map file %s created.', basename($mapfile), 'weathermap'), MESSAGE_LEVEL_INFO); } } else { From d558f0ea9dbf2a4ea4ef73189cddfd24c2fdb23f Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:09:43 +1030 Subject: [PATCH 17/41] Clean failed clone registrations and preserve successful raw edits --- lib/WeatherMap.config.php | 10 +- lib/editor.actions.php | 28 +-- lib/editor.assets.php | 2 +- lib/poller-common.php | 216 ++++++++++-------- tests/Support/ConfigWriteAtomicRegression.php | 77 ++++++- .../Support/EditorImageFormatsRegression.php | 3 + tests/Support/MapDuplicatePayload.php | 5 + weathermap-cacti-plugin-mgmt.php | 57 +++-- 8 files changed, 248 insertions(+), 150 deletions(-) diff --git a/lib/WeatherMap.config.php b/lib/WeatherMap.config.php index b89db55e..0e9377a8 100644 --- a/lib/WeatherMap.config.php +++ b/lib/WeatherMap.config.php @@ -82,17 +82,21 @@ function writeAll($stream, $contents) { * @param bool $mustExist Refuse to recreate an existing map deleted during editing. * @param string|null $expectedRevision SHA-256 of the snapshot being edited. * @param bool $createOnly Refuse to replace an existing destination. + * @param resource|null $heldLock Optional caller-owned mutex for this destination directory. * * @return bool Success; failures preserve the existing configuration. */ -function atomicWrite($filename, $contents, $mustExist = false, $expectedRevision = null, $createOnly = false) { +function atomicWrite($filename, $contents, $mustExist = false, $expectedRevision = null, $createOnly = false, $heldLock = null) { if (strpos($filename, '://') !== false) return false; if (is_link($filename)) $filename = realpath($filename); if ($filename === false || !is_dir(dirname($filename)) || !is_writable(dirname($filename))) return false; if (file_exists($filename) && (!is_file($filename) || !is_writable($filename))) return false; $existed = file_exists($filename); $mustExist = $mustExist || $existed; - $lock = acquire(dirname($filename)); + $ownsLock = $heldLock === null; + $lockPath = realpath(dirname($filename) . '/.weathermap-config.lock'); + if (!$ownsLock && ($lockPath === false || !is_resource($heldLock) || get_resource_type($heldLock) !== 'stream' || realpath(stream_get_meta_data($heldLock)['uri']) !== $lockPath)) return false; + $lock = $ownsLock ? acquire(dirname($filename)) : $heldLock; if (!$lock) return false; $temp = false; $stream = false; @@ -117,6 +121,6 @@ function atomicWrite($filename, $contents, $mustExist = false, $expectedRevision } finally { if (is_resource($stream)) fclose($stream); if ($temp !== false && file_exists($temp)) unlink($temp); - release($lock); + if ($ownsLock) release($lock); } } diff --git a/lib/editor.actions.php b/lib/editor.actions.php index 47d5347e..681220e3 100644 --- a/lib/editor.actions.php +++ b/lib/editor.actions.php @@ -83,7 +83,9 @@ function wm_editor_read_config($map, $mapfile) { */ function wm_editor_write_config($map, $mapfile, $createOnly = false) { if (!$map->WriteConfig($mapfile, $createOnly, $map->editor_config_revision)) { - http_response_code(500); + $current = is_file($mapfile) ? @hash_file('sha256', $mapfile) : false; + $stale = $map->editor_config_revision !== null && $current !== false && $current !== $map->editor_config_revision; + http_response_code($stale ? 409 : 500); return false; } return true; @@ -273,17 +275,7 @@ function setNodeConfig($mapfile) { if (isset($map->nodes[$node_name])) { $map->nodes[$node_name]->config_override = $node_config; - wm_editor_write_config($map, $mapfile); - - // now clear and reload the map object, because the in-memory one is out of sync - // - we don't know what changes the user made here, so we just have to reload. - unset($map); - - $map = new WeatherMap; - - $map->context = 'editor'; - - if (!wm_editor_read_config($map, $mapfile)) return; + if (!wm_editor_write_config($map, $mapfile)) return; } } @@ -304,17 +296,7 @@ function setLinkConfig($mapfile) { if (isset($map->links[$link_name])) { $map->links[$link_name]->config_override = $link_config; - wm_editor_write_config($map, $mapfile); - - // now clear and reload the map object, because the in-memory one is out of sync - // - we don't know what changes the user made here, so we just have to reload. - unset($map); - - $map = new WeatherMap; - - $map->context = 'editor'; - - if (!wm_editor_read_config($map, $mapfile)) return; + if (!wm_editor_write_config($map, $mapfile)) return; } } diff --git a/lib/editor.assets.php b/lib/editor.assets.php index d309715c..a4ca3f14 100644 --- a/lib/editor.assets.php +++ b/lib/editor.assets.php @@ -87,7 +87,7 @@ function wm_asset_image_reference_index() { $queue = []; $root = dirname(__DIR__); $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root . '/configs', FilesystemIterator::SKIP_DOTS)); - foreach ($iterator as $file) if ($file->isFile() && $file->getFilename() !== '.weathermap-config.lock' && strpos($file->getFilename(), '.wm-config-') !== 0) $queue[] = $file->getPathname(); + foreach ($iterator as $file) if ($file->isFile() && strtolower($file->getExtension()) === 'conf') $queue[] = $file->getPathname(); $seen = []; while ($queue) { $path = array_pop($queue); diff --git a/lib/poller-common.php b/lib/poller-common.php index 362516f3..57192986 100644 --- a/lib/poller-common.php +++ b/lib/poller-common.php @@ -111,6 +111,19 @@ function weathermap_check_cron($time, $string) { return ($matched); } +/** + * Stage a complete legacy asset copy while keeping every old map reference usable. + * + * @param string $old Source asset path. + * @param string $new New asset path. + * @return bool Whether a complete copy was reserved at the destination. + */ +function weathermap_stage_asset_copy($old, $new) { + require_once __DIR__ . '/WeatherMap.config.php'; + $contents = @file_get_contents($old); + return $contents !== false && \WeathermapConfig\atomicWrite($new, $contents, false, null, true); +} + /** * @return void */ @@ -142,45 +155,54 @@ function weathermap_repair_maps() { wm_debug('---------------------------------------------'); wm_debug("Processing Map File $configfile"); - if (file_exists($configfile) && is_writable($configfile)) { - $contents = file($configfile) ?: []; - $revision = hash('sha256', implode('', $contents)); - - foreach ($contents as $line) { - if (strpos($line, 'BACKGROUND') !== false) { - $parts = explode('BACKGROUND', $line); - $dirgood = false; - $bgfile = trim($parts[1]); + if (!is_file($configfile) || !is_writable($configfile)) continue; + $contents = file($configfile); + if ($contents === false) continue; + $revision = hash('sha256', implode('', $contents)); + require_once __DIR__ . '/WeatherMap.config.php'; + $repairLock = \WeathermapConfig\acquire($confdir); + if (!$repairLock) { wm_warn('Unable to lock repaired configuration: ' . $configfile . ' [WMPOLL13]'); continue; } + try { + if (file_exists($configfile) && is_writable($configfile)) { - if (strpos($bgfile, 'images/backgrounds/') === false) { - wm_debug('Found background file reference in old location: ' . $bgfile); + foreach ($contents as $line) { + if (strpos($line, 'BACKGROUND') !== false) { + $parts = explode('BACKGROUND', $line); + $dirgood = false; + $bgfile = trim($parts[1]); - if (file_exists($mydir . 'images/backgrounds/' . basename($bgfile))) { - wm_debug('Background file found in new location! Updating path only.'); + if (strpos($bgfile, 'images/backgrounds/') === false) { + wm_debug('Found background file reference in old location: ' . $bgfile); - $line = 'BACKGROUND images/backgrounds/' . basename($bgfile); + if (file_exists($mydir . 'images/backgrounds/' . basename($bgfile))) { + wm_debug('Background file found in new location! Updating path only.'); - $changes++; - } else { - wm_debug('Background file not found in new location! Attempting to move.'); + $line = 'BACKGROUND images/backgrounds/' . basename($bgfile); - if (!is_dir($mydir . 'images/backgrounds')) { - $dirgood = mkdir($mydir . 'images/backgrounds'); + $changes++; } else { - $dirgood = true; - } + wm_debug('Background file not found in new location! Attempting to move.'); - if ($dirgood && file_exists($mydir . $bgfile)) { - wm_debug('Found background file on disk: ' . $mydir . $bgfile . ' relocating!'); + if (!is_dir($mydir . 'images/backgrounds')) { + $dirgood = mkdir($mydir . 'images/backgrounds'); + } else { + $dirgood = true; + } + + if ($dirgood && file_exists($mydir . $bgfile)) { + wm_debug('Found background file on disk: ' . $mydir . $bgfile . ' relocating!'); - $old = $mydir . $bgfile; - $new = $mydir . 'images/backgrounds/' . basename($bgfile); + $old = $mydir . $bgfile; + $new = $mydir . 'images/backgrounds/' . basename($bgfile); - if (is_writable($mydir . $bgfile)) { - if (is_writeable($old) && is_writeable(dirname($new)) && is_writeable($new)) { - if (rename($old, $new)) { - $line = 'BACKGROUND images/backgrounds/' . basename($bgfile); - $changes++; + if (is_readable($mydir . $bgfile)) { + if (is_readable($old) && is_writeable(dirname($new))) { + if (weathermap_stage_asset_copy($old, $new)) { + $line = 'BACKGROUND images/backgrounds/' . basename($bgfile); + $changes++; + } else { + wm_warn(sprintf('Unable to move BACKGROUND %s to %s, Permission error [WMPOLL09]', $old, $new)); + } } else { wm_warn(sprintf('Unable to move BACKGROUND %s to %s, Permission error [WMPOLL09]', $old, $new)); } @@ -188,65 +210,65 @@ function weathermap_repair_maps() { wm_warn(sprintf('Unable to move BACKGROUND %s to %s, Permission error [WMPOLL09]', $old, $new)); } } else { - wm_warn(sprintf('Unable to move BACKGROUND %s to %s, Permission error [WMPOLL09]', $old, $new)); + wm_warn(sprintf('BACKGROUND file not found on disk: %s skipping! [WMPOLL10]', $mydir . $bgfile)); } - } else { - wm_warn(sprintf('BACKGROUND file not found on disk: %s skipping! [WMPOLL10]', $mydir . $bgfile)); } + } elseif (strpos($bgfile, 'images/backgrounds/') !== false) { + wm_debug('Found background file reference in correct location: ' . $bgfile); } - } elseif (strpos($bgfile, 'images/backgrounds/') !== false) { - wm_debug('Found background file reference in correct location: ' . $bgfile); - } - } elseif (strpos($line, 'ICON') !== false) { - /** - * We are looking at the ICON parameter. It can take two forms. The first form is - * a path to a filename, or the pattern as follows - * - * ICON height width type - * - * Types include: - * - * - box - a square cornered box - * - rbox - a round cornered box - * - round - a circle or an ellipse - * - inpie & outpie to produce a pie chart of either the in or out value relative to it's maximum - * - nlink - to produce a circular 'yin-yang' style symbox with each half showing the in and out values - * - */ - $parts = explode('ICON', $line); - $dirgood = false; - $attribs = explode(' ', $parts[1]); - - if (cacti_sizeof($attribs) == 1) { - $objfile = trim($parts[1]); - - if (strpos($objfile, 'images/objects/') === false) { - wm_debug('Found icon file reference in old location: ' . $objfile); - - if (file_exists($mydir . 'images/objects/' . basename($objfile))) { - wm_debug('Found file already in new location! Updating path only.'); - - $line = "\tICON images/objects/" . basename($objfile); - - $changes++; - } else { - if (!is_dir($mydir . 'images/objects')) { - $dirgood = mkdir($mydir . 'images/objects'); + } elseif (strpos($line, 'ICON') !== false) { + /** + * We are looking at the ICON parameter. It can take two forms. The first form is + * a path to a filename, or the pattern as follows + * + * ICON height width type + * + * Types include: + * + * - box - a square cornered box + * - rbox - a round cornered box + * - round - a circle or an ellipse + * - inpie & outpie to produce a pie chart of either the in or out value relative to it's maximum + * - nlink - to produce a circular 'yin-yang' style symbox with each half showing the in and out values + * + */ + $parts = explode('ICON', $line); + $dirgood = false; + $attribs = preg_split('/\s+/', trim($parts[1])); + + if (cacti_sizeof($attribs) == 1) { + $objfile = trim($parts[1]); + + if (strpos($objfile, 'images/objects/') === false) { + wm_debug('Found icon file reference in old location: ' . $objfile); + + if (file_exists($mydir . 'images/objects/' . basename($objfile))) { + wm_debug('Found file already in new location! Updating path only.'); + + $line = "\tICON images/objects/" . basename($objfile); + + $changes++; } else { - $dirgood = true; - } + if (!is_dir($mydir . 'images/objects')) { + $dirgood = mkdir($mydir . 'images/objects'); + } else { + $dirgood = true; + } - if ($dirgood && file_exists($mydir . $objfile)) { - wm_debug('Object file found on disk: ' . $mydir . $objfile . ' relocating!'); + if ($dirgood && file_exists($mydir . $objfile)) { + wm_debug('Object file found on disk: ' . $mydir . $objfile . ' relocating!'); - $old = $mydir . $objfile; - $new = $mydir . 'images/objects/' . basename($objfile); + $old = $mydir . $objfile; + $new = $mydir . 'images/objects/' . basename($objfile); - if (is_writable($mydir . $objfile)) { - if (is_writeable($old) && is_writeable(dirname($new)) && is_writeable($new)) { - if (rename($old, $new)) { - $line = "\tICON images/objects/" . basename($objfile); - $changes++; + if (is_readable($mydir . $objfile)) { + if (is_readable($old) && is_writeable(dirname($new))) { + if (weathermap_stage_asset_copy($old, $new)) { + $line = "\tICON images/objects/" . basename($objfile); + $changes++; + } else { + wm_warn(sprintf('Unable to move ICONFILE %s to %s, Permission error [WMPOLL11]', $old, $new)); + } } else { wm_warn(sprintf('Unable to move ICONFILE %s to %s, Permission error [WMPOLL11]', $old, $new)); } @@ -254,31 +276,29 @@ function weathermap_repair_maps() { wm_warn(sprintf('Unable to move ICONFILE %s to %s, Permission error [WMPOLL11]', $old, $new)); } } else { - wm_warn(sprintf('Unable to move ICONFILE %s to %s, Permission error [WMPOLL11]', $old, $new)); + wm_warn(sprintf('ICONFILE file not found on disk: %s skipping! [WMPOLL12]', $mydir . $objfile)); } - } else { - wm_warn(sprintf('ICONFILE file not found on disk: %s skipping! [WMPOLL12]', $mydir . $objfile)); } + } elseif (strpos($objfile, 'images/objects/') !== false) { + wm_debug('Found object file reference in correct location: ' . $objfile); } - } elseif (strpos($objfile, 'images/objects/') !== false) { - wm_debug('Found object file reference in correct location: ' . $objfile); } } - } - $outcontents[] = rtrim($line); + $outcontents[] = rtrim($line); + } } - } - if ($changes) { - wm_debug('Writing updated file to: ' . $configfile); - require_once __DIR__ . '/WeatherMap.config.php'; - if (!\WeathermapConfig\atomicWrite($configfile, implode("\n", $outcontents), true, $revision)) { - wm_warn('Unable to save repaired configuration: ' . $configfile . ' [WMPOLL13]'); + if ($changes) { + wm_debug('Writing updated file to: ' . $configfile); + require_once __DIR__ . '/WeatherMap.config.php'; + if (!\WeathermapConfig\atomicWrite($configfile, implode("\n", $outcontents), true, $revision, false, $repairLock)) { + wm_warn('Unable to save repaired configuration: ' . $configfile . ' [WMPOLL13]'); + } + } else { + wm_debug('File has correct BACKGROUND and ICON paths: ' . $configfile); } - } else { - wm_debug('File has correct BACKGROUND and ICON paths: ' . $configfile); - } + } finally { \WeathermapConfig\release($repairLock); } } } } diff --git a/tests/Support/ConfigWriteAtomicRegression.php b/tests/Support/ConfigWriteAtomicRegression.php index aecd9104..6ddd100a 100644 --- a/tests/Support/ConfigWriteAtomicRegression.php +++ b/tests/Support/ConfigWriteAtomicRegression.php @@ -225,7 +225,7 @@ function atomic_assert($condition, $message) { $raw->ReadConfig("TITLE Fragment edit\n"); file_put_contents($file, $original); http_response_code(200); - atomic_assert(!wm_editor_write_config($raw, $file) && http_response_code() === 500 && file_get_contents($file) === $original, 'raw fragment edits retain the original request revision'); + atomic_assert(!wm_editor_write_config($raw, $file) && http_response_code() === 409 && file_get_contents($file) === $original, 'raw fragment edits retain the original request revision'); if (function_exists('pcntl_fork')) { $createFile = $dir . '/create.conf'; $lock = \WeathermapConfig\acquire($dir); $children = []; @@ -246,6 +246,17 @@ function atomic_assert($condition, $message) { atomic_assert((int)file_get_contents($dir . '/create-result-0') + (int)file_get_contents($dir . '/create-result-1') === 1, 'exactly one concurrent creator commits'); atomic_assert(in_array(file_get_contents($createFile), ['Creator 0', 'Creator 1'], true), 'winning creator content remains intact'); } + $held = \WeathermapConfig\acquire($dir); + atomic_assert(\WeathermapConfig\atomicWrite($dir . '/held.conf', 'Held write', false, null, true, $held), 'caller-owned mutex supports atomic creation'); + if (function_exists('pcntl_fork')) { + $child = pcntl_fork(); + if ($child === 0) { $probe = \fopen($dir . '/.weathermap-config.lock', 'rb'); $busy = !\flock($probe, LOCK_EX | LOCK_NB); exit($busy ? 0 : 1); } + pcntl_waitpid($child, $status); + atomic_assert(pcntl_wexitstatus($status) === 0, 'atomicWrite leaves caller mutex held'); + } + \WeathermapConfig\release($held); + $wrong = \fopen($file, 'rb'); + atomic_assert(!\WeathermapConfig\atomicWrite($dir . '/wrong-lock.conf', 'Wrong', false, null, true, $wrong), 'different lock file rejected'); \fclose($wrong); $crlfFile = $dir . '/crlf.conf'; $crlf = "WIDTH 600\r\nHEIGHT 300\r\n"; file_put_contents($crlfFile, $crlf); $crlfMap = new \WeatherMap; $crlfMap->ReadConfig($crlfFile); @@ -288,6 +299,33 @@ function atomic_assert($condition, $message) { unset($GLOBALS['__test_db_fetch_assoc']); unlink($repairFile); unlink($repairImage); } + $stageSource = $plugin . '/legacy-stage-' . bin2hex(random_bytes(4)) . '.png'; + $stageTarget = $plugin . '/images/backgrounds/' . basename($stageSource); + file_put_contents($stageSource, 'Legacy asset bytes'); + $stageConfig = $plugin . '/configs/' . basename($stageSource) . '.conf'; + $stageLegacy = 'BACKGROUND ' . basename($stageSource) . "\n"; + file_put_contents($stageConfig, $stageLegacy); + $GLOBALS['__test_db_fetch_assoc'] = fn () => [['configfile' => basename($stageConfig)]]; + $oldConfig = $GLOBALS['config']; $GLOBALS['config']['base_path'] = dirname($plugin, 2); + $oldSuppress = $GLOBALS['weathermap_error_suppress'] ?? []; + try { + // A staged copy succeeds, but the config CAS must fail after a concurrent save. + $staged = weathermap_stage_asset_copy($stageSource, $stageTarget); + atomic_assert($staged && is_file($stageSource) && file_get_contents($stageTarget) === 'Legacy asset bytes', 'staging keeps original and complete destination'); + $stageNewer = "TITLE Concurrent repair edit\n" . $stageLegacy; + $GLOBALS['config_before_lock'] = function() use ($stageConfig, $stageNewer) { unset($GLOBALS['config_before_lock']); file_put_contents($stageConfig, $stageNewer); }; + $GLOBALS['weathermap_error_suppress'] = ['WMPOLL13']; weathermap_repair_maps(); + atomic_assert(file_get_contents($stageConfig) === $stageNewer && is_file($stageSource), 'rejected repair leaves original asset usable'); + $GLOBALS['config_fault'] = 'rename'; weathermap_repair_maps(); + atomic_assert(file_get_contents($stageConfig) === $stageNewer && is_file($stageSource), 'I/O failure leaves original asset usable'); + $GLOBALS['config_fault'] = ''; unlink($stageTarget); file_put_contents($stageConfig, $stageLegacy); + weathermap_repair_maps(); + atomic_assert(str_contains(file_get_contents($stageConfig), 'images/backgrounds/') && is_file($stageSource) && is_file($stageTarget), 'successful relocation copies rather than removes legacy asset'); + } finally { + $GLOBALS['config'] = $oldConfig; $GLOBALS['weathermap_error_suppress'] = $oldSuppress; $GLOBALS['config_fault'] = ''; + unset($GLOBALS['__test_db_fetch_assoc'], $GLOBALS['config_before_lock']); + foreach ([$stageSource, $stageTarget, $stageConfig] as $path) if (is_file($path)) unlink($path); + } $management = file_get_contents($plugin . '/weathermap-cacti-plugin-mgmt.php'); $start = strpos($management, 'function newMap('); eval(str_replace(['__DIR__', 'function newMap('], [var_export($plugin, true), 'function wm_management_newMap('], substr($management, $start))); @@ -313,6 +351,43 @@ function atomic_assert($condition, $message) { map_duplicate(1, 'Clone', 'clone'); atomic_assert($GLOBALS['__test_db_calls'] === [] && $GLOBALS['atomic_sql_saves'] === [], 'failed clone never registers auth or settings or invokes runner'); } finally { unset($GLOBALS['__test_db_fetch_row_prepared'], $GLOBALS['__test_sql_save'], $GLOBALS['__test_db_fetch_cell']); unlink($plugin . '/configs/' . $duplicateSource); $GLOBALS['config_fault'] = ''; } + // Real raw node/link saves must not re-check their own pre-save page token. + $rawFile = $dir . '/raw.conf'; + $rawConfig = "WIDTH 300\nHEIGHT 200\nNODE A\n LABEL A\n POSITION 10 10\nNODE B\n LABEL B\n POSITION 50 50\nLINK AB\n NODES A B\n"; + foreach (['node', 'link'] as $kind) { + file_put_contents($rawFile, $rawConfig); + $GLOBALS['__test_nfilter_request'] = ['config_revision' => hash_file('sha256', $rawFile), 'node_name' => 'A', 'link_name' => 'AB', 'item_configtext' => $kind === 'node' ? "NODE A\n LABEL Updated\n POSITION 10 10\n" : "LINK AB\n NODES A B\n WIDTH 12\n"]; + http_response_code(200); if ($kind === 'node') setNodeConfig($rawFile); else setLinkConfig($rawFile); + atomic_assert(http_response_code() === 200 && file_get_contents($rawFile) !== $rawConfig, 'raw ' . $kind . ' edit saves without false conflict'); + } + // Registration failure must retain a mutex through the callback and remove its reservation. + file_put_contents($plugin . '/configs/' . $duplicateSource, "TITLE Source\n"); + $GLOBALS['__test_db_fetch_row_prepared'] = fn () => ['configfile' => $duplicateSource, 'titlecache' => 'Source', 'group_id' => 1, 'active' => 'on', 'thumb_height' => 10, 'thumb_width' => 10, 'schedule' => '', 'archiving' => '']; + $GLOBALS['__test_db_fetch_cell'] = fn () => 0; + $GLOBALS['weathermap_confdir'] = $plugin . '/configs'; + try { + foreach (['false', 'throw', 'auth-false', 'settings-false', 'auth-throw', 'settings-throw'] as $failure) { + $GLOBALS['__test_sql_save'] = function($record) use ($plugin, $failure) { + atomic_assert(is_file($plugin . '/configs/' . $record['configfile']), 'reserved file exists during registration'); + if (function_exists('pcntl_fork')) { + $child = pcntl_fork(); + if ($child === 0) { $probe = \fopen($plugin . '/configs/.weathermap-config.lock', 'rb'); exit(!\flock($probe, LOCK_EX | LOCK_NB) ? 0 : 1); } + pcntl_waitpid($child, $status); atomic_assert(pcntl_wexitstatus($status) === 0, 'registration retains directory mutex'); + } + if ($failure === 'throw') throw new RuntimeException('registration failed'); + return $failure === 'false' ? false : 987; + }; + $GLOBALS['__test_db_calls'] = []; + $GLOBALS['__test_db_execute_prepared'] = function($sql, $params) use ($failure) { + $GLOBALS['__test_db_calls'][] = ['sql' => $sql, 'params' => $params]; + if ((str_contains($sql, 'INSERT INTO weathermap_auth') && $failure === 'auth-throw') || (str_contains($sql, 'INSERT INTO weathermap_settings') && $failure === 'settings-throw')) throw new RuntimeException('insert failed'); + return !((str_contains($sql, 'INSERT INTO weathermap_auth') && $failure === 'auth-false') || (str_contains($sql, 'INSERT INTO weathermap_settings') && $failure === 'settings-false')); + }; + map_duplicate(1, 'Clone', 'clone'); + atomic_assert(!is_file($plugin . '/configs/clone.conf'), 'failed registration removes its own reserved configuration'); + if (str_ends_with($failure, '-false') || str_ends_with($failure, '-throw')) atomic_assert(cacti_count(array_filter($GLOBALS['__test_db_calls'], fn ($call) => str_starts_with($call['sql'], 'DELETE'))) === 3, 'partial registration removes map/auth/settings records'); + } + } finally { unset($GLOBALS['__test_db_fetch_row_prepared'], $GLOBALS['__test_sql_save'], $GLOBALS['__test_db_fetch_cell'], $GLOBALS['__test_db_execute_prepared']); unlink($plugin . '/configs/' . $duplicateSource); } $deletedMap = new \WeatherMap; $deletedMap->ReadConfig($file); unlink($file); atomic_assert(!$deletedMap->WriteConfig($file) && !file_exists($file), 'saving a previously loaded deleted map fails without resurrection'); diff --git a/tests/Support/EditorImageFormatsRegression.php b/tests/Support/EditorImageFormatsRegression.php index fb59333d..21d5d3db 100644 --- a/tests/Support/EditorImageFormatsRegression.php +++ b/tests/Support/EditorImageFormatsRegression.php @@ -100,6 +100,9 @@ function image_format_assert($value, $message) { file_put_contents($included, "BACKGROUND images/backgrounds/included-image.png\n"); file_put_contents($config, "INCLUDE " . $included . "\n"); image_format_assert(in_array(basename($included), wm_asset_image_users('included-image.png'), true), 'included configurations protect images from deletion'); + $backup = $root . '/configs/ignored-backup-' . bin2hex(random_bytes(4)) . '.bak'; + file_put_contents($backup, "ICON images/objects/backup-only.png\n"); + try { image_format_assert(wm_asset_image_users('backup-only.png') === [], 'backup files are not map configurations'); } finally { unlink($backup); } // Missing optional includes must not disable the library, and one index serves all names. file_put_contents($config, "INCLUDE " . $temporary . "/missing-*.conf\nINCLUDE " . $included . "\n"); $index = wm_asset_image_reference_index(); diff --git a/tests/Support/MapDuplicatePayload.php b/tests/Support/MapDuplicatePayload.php index 64da377e..3e6dce8d 100644 --- a/tests/Support/MapDuplicatePayload.php +++ b/tests/Support/MapDuplicatePayload.php @@ -96,6 +96,11 @@ function sql_save($payload, $table) { return 0; } +/** Ignore expected registration failure notices in this schema-only fixture. */ +function raise_message($id, $text = '', $level = 0) {} +/** Return the untranslated fixture string. */ +function __($text, ...$args) { return $text; } +define('MESSAGE_LEVEL_ERROR', 3); try { map_duplicate(5, ' Copy'); print json_encode($GLOBALS['duplicate_save'], JSON_THROW_ON_ERROR); diff --git a/weathermap-cacti-plugin-mgmt.php b/weathermap-cacti-plugin-mgmt.php index 0edff281..f6851781 100644 --- a/weathermap-cacti-plugin-mgmt.php +++ b/weathermap-cacti-plugin-mgmt.php @@ -2003,40 +2003,49 @@ function map_duplicate($id, $titlecache, $configfile = null) { $oldfile = $confdir . '/' . $map['configfile']; $newfile = $confdir . '/' . $save['configfile']; - $contents = is_file($oldfile) ? file_get_contents($oldfile) : false; - if ($contents !== false) { - $contents = str_replace("TITLE {$map['titlecache']}", "TITLE {$save['titlecache']}", $contents); - require_once __DIR__ . '/lib/WeatherMap.config.php'; - if (!\WeathermapConfig\atomicWrite($newfile, $contents, false, null, true)) { - raise_message('map_message', __('Could not save the map configuration.', 'weathermap'), MESSAGE_LEVEL_ERROR); - return; - } - } else { - raise_message('map_message', __('The new Map with the name %s was unable to locate the config file %s to copy', $save['titlecache'], $map['configfile'], 'weathermap'), MESSAGE_LEVEL_ERROR); + require_once __DIR__ . '/lib/WeatherMap.config.php'; + $lock = \WeathermapConfig\acquire($confdir); + if (!$lock) { + raise_message('map_message', __('Could not lock map configurations.', 'weathermap'), MESSAGE_LEVEL_ERROR); return; } + $newid = 0; + $created = false; + try { + $contents = is_file($oldfile) ? file_get_contents($oldfile) : false; + if ($contents === false) throw new RuntimeException(__('The new Map with the name %s was unable to locate the config file %s to copy', $save['titlecache'], $map['configfile'], 'weathermap')); + $contents = str_replace("TITLE {$map['titlecache']}", "TITLE {$save['titlecache']}", $contents); + if (!\WeathermapConfig\atomicWrite($newfile, $contents, false, null, true, $lock)) throw new RuntimeException(__('Could not save the map configuration.', 'weathermap')); + $created = true; + $newid = sql_save($save, 'weathermap_maps'); + if (!$newid) throw new RuntimeException(__('Could not register the new map.', 'weathermap')); - $newid = sql_save($save, 'weathermap_maps'); - - if ($newid) { - db_execute_prepared("INSERT INTO weathermap_auth + if (db_execute_prepared("INSERT INTO weathermap_auth (userid, mapid) SELECT userid, '$newid' AS mapid FROM weathermap_auth - WHERE mapid = ?", - [$id]); - - db_execute_prepared("INSERT INTO weathermap_settings + WHERE mapid = ?", [$id]) === false) throw new RuntimeException(__('Could not register the new map.', 'weathermap')); + if (db_execute_prepared("INSERT INTO weathermap_settings (mapid, groupid, optname, optvalue) SELECT '$newid' AS mapid, groupid, optname, optvalue FROM weathermap_settings - WHERE mapid = ?", - [$id]); - - raise_message('new_map_' . $newid, __('The new Map with the name %s was created using config file %s', $save['titlecache'], $save['configfile'], 'weathermap'), MESSAGE_LEVEL_INFO); - - weathermap_run_maps(__DIR__, true, [$newid]); + WHERE mapid = ?", [$id]) === false) throw new RuntimeException(__('Could not register the new map.', 'weathermap')); + } catch (Throwable $error) { + // Only this attempt's records and successfully reserved file may be removed. + try { + if ($newid) { + db_execute_prepared('DELETE FROM weathermap_auth WHERE mapid = ?', [$newid]); + db_execute_prepared('DELETE FROM weathermap_settings WHERE mapid = ?', [$newid]); + db_execute_prepared('DELETE FROM weathermap_maps WHERE id = ?', [$newid]); + } + } finally { if ($created) unlink($newfile); } + raise_message('map_message', $error->getMessage(), MESSAGE_LEVEL_ERROR); + return; + } finally { + \WeathermapConfig\release($lock); } + raise_message('new_map_' . $newid, __('The new Map with the name %s was created using config file %s', $save['titlecache'], $save['configfile'], 'weathermap'), MESSAGE_LEVEL_INFO); + weathermap_run_maps(__DIR__, true, [$newid]); } } From f195c65ebfbbb6bb71cdc415458207e0a152ef97 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:10:17 +1030 Subject: [PATCH 18/41] Refresh translations for registration failure reporting --- locales/po/cacti.pot | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index fd3d1932..a9624bf1 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti 1.7\n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-12 23:20+1030\n" +"POT-Creation-Date: 2026-10-13 09:09+1030\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" @@ -26,7 +26,7 @@ msgstr "" msgid "Use POST to save changes." msgstr "" -#: lib/editor.actions.php +#: lib/editor.actions.php weathermap-cacti-plugin-mgmt.php msgid "Could not lock map configurations." msgstr "" @@ -1937,13 +1937,17 @@ msgstr "" msgid "(No Title)" msgstr "" +#: weathermap-cacti-plugin-mgmt.php +#, php-format +msgid "The new Map with the name %s was unable to locate the config file %s to copy" +msgstr "" + #: weathermap-cacti-plugin-mgmt.php msgid "Could not save the map configuration." msgstr "" #: weathermap-cacti-plugin-mgmt.php -#, php-format -msgid "The new Map with the name %s was unable to locate the config file %s to copy" +msgid "Could not register the new map." msgstr "" #: weathermap-cacti-plugin-mgmt.php From 858c245819c2d221fd14e50fc0cdf450767c55a9 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:13:59 +1030 Subject: [PATCH 19/41] Cover legacy background and icon repair failure paths --- tests/Support/ConfigWriteAtomicRegression.php | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/Support/ConfigWriteAtomicRegression.php b/tests/Support/ConfigWriteAtomicRegression.php index 6ddd100a..23fe13c2 100644 --- a/tests/Support/ConfigWriteAtomicRegression.php +++ b/tests/Support/ConfigWriteAtomicRegression.php @@ -326,6 +326,40 @@ function atomic_assert($condition, $message) { unset($GLOBALS['__test_db_fetch_assoc'], $GLOBALS['config_before_lock']); foreach ([$stageSource, $stageTarget, $stageConfig] as $path) if (is_file($path)) unlink($path); } + // Exercise legacy background and icon relocation in a separate filesystem tree. + $repairRoot = $dir . '/repair-fixture'; + $repairPlugin = $repairRoot . '/cacti/plugins/weathermap'; + mkdir($repairPlugin . '/configs', 0777, true); mkdir($repairPlugin . '/images'); + symlink($plugin . '/lib', $repairPlugin . '/lib'); + file_put_contents($repairPlugin . '/legacy-background.png', 'Background bytes'); + file_put_contents($repairPlugin . '/legacy-icon.png', 'Icon bytes'); + $legacyFixture = "BACKGROUND legacy-background.png\nNODE Legacy\n ICON legacy-icon.png\n"; + $fixtureConfig = $repairPlugin . '/configs/legacy.conf'; + $oldConfig = $GLOBALS['config']; $oldSuppress = $GLOBALS['weathermap_error_suppress'] ?? []; + $GLOBALS['config']['base_path'] = $repairRoot . '/cacti'; + $GLOBALS['__test_db_fetch_assoc'] = fn () => [['configfile' => 'legacy.conf']]; + $GLOBALS['weathermap_error_suppress'] = ['WMPOLL09', 'WMPOLL10', 'WMPOLL11', 'WMPOLL12', 'WMPOLL13']; + try { + file_put_contents($fixtureConfig, $legacyFixture); weathermap_repair_maps(); + $repairedFixture = file_get_contents($fixtureConfig); + atomic_assert(str_contains($repairedFixture, 'BACKGROUND images/backgrounds/legacy-background.png') && str_contains($repairedFixture, 'ICON images/objects/legacy-icon.png'), 'repair creates libraries and copies both legacy asset kinds'); + atomic_assert(is_file($repairPlugin . '/legacy-background.png') && is_file($repairPlugin . '/legacy-icon.png'), 'successful repair preserves both original assets'); + file_put_contents($fixtureConfig, $legacyFixture); weathermap_repair_maps(); + atomic_assert(file_get_contents($fixtureConfig) === $repairedFixture, 'existing complete copies support path-only repair'); + weathermap_repair_maps(); atomic_assert(file_get_contents($fixtureConfig) === $repairedFixture, 'correct paths require no rewrite'); + unlink($repairPlugin . '/images/backgrounds/legacy-background.png'); unlink($repairPlugin . '/images/objects/legacy-icon.png'); + file_put_contents($fixtureConfig, $legacyFixture); $GLOBALS['config_fault'] = 'rename'; weathermap_repair_maps(); + atomic_assert(file_get_contents($fixtureConfig) === $legacyFixture && is_file($repairPlugin . '/legacy-background.png') && is_file($repairPlugin . '/legacy-icon.png'), 'failed background and icon staging preserves usable original references'); + $GLOBALS['config_fault'] = ''; file_put_contents($fixtureConfig, "BACKGROUND absent-background.png\nNODE Missing\n ICON absent-icon.png\n"); + $missingFixture = file_get_contents($fixtureConfig); weathermap_repair_maps(); + atomic_assert(file_get_contents($fixtureConfig) === $missingFixture, 'missing assets do not change config paths'); + } finally { + $GLOBALS['config'] = $oldConfig; $GLOBALS['weathermap_error_suppress'] = $oldSuppress; $GLOBALS['config_fault'] = ''; + unset($GLOBALS['__test_db_fetch_assoc']); chdir($plugin); + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($repairRoot, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($iterator as $entry) { if ($entry->isDir() && !$entry->isLink()) rmdir($entry->getPathname()); else unlink($entry->getPathname()); } + rmdir($repairRoot); + } $management = file_get_contents($plugin . '/weathermap-cacti-plugin-mgmt.php'); $start = strpos($management, 'function newMap('); eval(str_replace(['__DIR__', 'function newMap('], [var_export($plugin, true), 'function wm_management_newMap('], substr($management, $start))); From e59aba91e8312f47c7c554ae9ae54d833673c3e3 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:14:38 +1030 Subject: [PATCH 20/41] Bind clone IDs in prepared registration statements --- lib/WeatherMap.class.php | 5 +++-- tests/Support/ConfigWriteAtomicRegression.php | 1 + weathermap-cacti-plugin-mgmt.php | 8 ++++---- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/lib/WeatherMap.class.php b/lib/WeatherMap.class.php index 7bdd2db4..0e2e09f1 100644 --- a/lib/WeatherMap.class.php +++ b/lib/WeatherMap.class.php @@ -3612,9 +3612,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 */ - function WriteConfig($filename, $createOnly = false, $expectedRevision = null) { + function WriteConfig($filename, $createOnly = false, $expectedRevision = null, $heldLock = null) { require_once __DIR__ . '/WeatherMap.config.php'; $fd = fopen('php://temp', 'w+b'); @@ -3856,7 +3857,7 @@ function WriteConfig($filename, $createOnly = false, $expectedRevision = null) { $closed = fclose($fd); $sameFile = $this->configfile !== '' && $filename === $this->configfile; - $saved = $write_ok && $contents !== false && $closed && \WeathermapConfig\atomicWrite($filename, $contents, $sameFile, $expectedRevision ?? ($sameFile ? $this->config_revision : null), $createOnly); + $saved = $write_ok && $contents !== false && $closed && \WeathermapConfig\atomicWrite($filename, $contents, $sameFile, $expectedRevision ?? ($sameFile ? $this->config_revision : null), $createOnly, $heldLock); if ($saved && $sameFile) $this->config_revision = hash('sha256', $contents); return $saved; } else { diff --git a/tests/Support/ConfigWriteAtomicRegression.php b/tests/Support/ConfigWriteAtomicRegression.php index 23fe13c2..04ce4983 100644 --- a/tests/Support/ConfigWriteAtomicRegression.php +++ b/tests/Support/ConfigWriteAtomicRegression.php @@ -414,6 +414,7 @@ function atomic_assert($condition, $message) { $GLOBALS['__test_db_calls'] = []; $GLOBALS['__test_db_execute_prepared'] = function($sql, $params) use ($failure) { $GLOBALS['__test_db_calls'][] = ['sql' => $sql, 'params' => $params]; + if (str_starts_with($sql,'INSERT')) atomic_assert(!str_contains($sql, '987') && $params === [987,1], 'clone IDs are bound as prepared parameters'); if ((str_contains($sql, 'INSERT INTO weathermap_auth') && $failure === 'auth-throw') || (str_contains($sql, 'INSERT INTO weathermap_settings') && $failure === 'settings-throw')) throw new RuntimeException('insert failed'); return !((str_contains($sql, 'INSERT INTO weathermap_auth') && $failure === 'auth-false') || (str_contains($sql, 'INSERT INTO weathermap_settings') && $failure === 'settings-false')); }; diff --git a/weathermap-cacti-plugin-mgmt.php b/weathermap-cacti-plugin-mgmt.php index f6851781..acabe0f4 100644 --- a/weathermap-cacti-plugin-mgmt.php +++ b/weathermap-cacti-plugin-mgmt.php @@ -2022,14 +2022,14 @@ function map_duplicate($id, $titlecache, $configfile = null) { if (db_execute_prepared("INSERT INTO weathermap_auth (userid, mapid) - SELECT userid, '$newid' AS mapid + SELECT userid, ? AS mapid FROM weathermap_auth - WHERE mapid = ?", [$id]) === false) throw new RuntimeException(__('Could not register the new map.', 'weathermap')); + WHERE mapid = ?", [$newid, $id]) === false) throw new RuntimeException(__('Could not register the new map.', 'weathermap')); if (db_execute_prepared("INSERT INTO weathermap_settings (mapid, groupid, optname, optvalue) - SELECT '$newid' AS mapid, groupid, optname, optvalue + SELECT ? AS mapid, groupid, optname, optvalue FROM weathermap_settings - WHERE mapid = ?", [$id]) === false) throw new RuntimeException(__('Could not register the new map.', 'weathermap')); + WHERE mapid = ?", [$newid, $id]) === false) throw new RuntimeException(__('Could not register the new map.', 'weathermap')); } catch (Throwable $error) { // Only this attempt's records and successfully reserved file may be removed. try { From 35b0d2afbfde653a7b2dee29527c3ea19f5ce8ba Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:54:15 +1030 Subject: [PATCH 21/41] Render controls and revisions from one editor snapshot --- js/editor.js | 33 +++++++++---------- lib/editor.actions.php | 16 +++++---- .../Browser/EditorFilenameNavigationTest.cjs | 12 ++++--- tests/Browser/EditorSaveFailureTest.cjs | 1 + tests/Support/ConfigWriteAtomicRegression.php | 19 +++++++++++ weathermap-cacti-plugin-editor.php | 5 +-- 6 files changed, 56 insertions(+), 30 deletions(-) diff --git a/js/editor.js b/js/editor.js index 0ce9fd0e..347ec39c 100644 --- a/js/editor.js +++ b/js/editor.js @@ -871,6 +871,19 @@ function delete_link() { }); } +function wmEditorReloadUrl() { + var url = new URL(window.location.href); + url.searchParams.set('mapname', $('#mapname').val()); + url.searchParams.set('action', 'nothing'); + url.searchParams.delete('config_revision'); + url.searchParams.delete('__csrf_magic'); + return url.href; +} + +function wmReloadEditor() { + window.location.replace(wmEditorReloadUrl()); +} + function form_submit() { var data = $('input, select, textarea').serialize(); if (typeof WMConfigRevision !== 'undefined') data += '&config_revision=' + encodeURIComponent(WMConfigRevision); @@ -880,28 +893,14 @@ function form_submit() { url: editor_url, data: data, error: function(xhr) { window.alert(xhr && xhr.status === 409 ? wmEditorText.staleError : wmEditorText.saveError); }, - success: function(html) { + success: function() { hide_all_dialogs(); - - $.get('?action=load_area_data&mapname=' + encodeURIComponent($('#mapname').val()), function(data) { - $('.mapData').empty().html(data); - - $.getScript('?action=load_map_javascript&mapname=' + encodeURIComponent($('#mapname').val()), function(data) { - var date = new Date(); - - // Reload the images to update page - $('#existingdata').attr('src', $('#existingdata').attr('src') + '&date=' + date.getTime()); - $('#xycapture').attr('src', $('#xycapture').attr('src') + '&date=' + date.getTime()); - - $('#action').val(''); - - initJS(); - }); - }); + wmReloadEditor(); } }); } + function map_properties() { mapmode('existing'); diff --git a/lib/editor.actions.php b/lib/editor.actions.php index 681220e3..f2a9166a 100644 --- a/lib/editor.actions.php +++ b/lib/editor.actions.php @@ -64,6 +64,10 @@ function wm_editor_read_config($map, $mapfile) { http_response_code(422); return false; } + if (!is_string($map->config_revision)) { + http_response_code(404); + return false; + } $revision = get_nfilter_request_var('config_revision'); if ($revision !== null && $revision !== '' && (!is_string($revision) || !hash_equals($map->config_revision, $revision))) { http_response_code(409); @@ -138,12 +142,12 @@ function newMapCopy($mapfile) { * @param mixed $mapfile * @return void */ -function getMapJavaScript($mapfile) { - $map = new WeatherMap; - - $map->context = 'editor'; - - if (!wm_editor_read_config($map, $mapfile)) return; +function getMapJavaScript($mapfile, $map = null) { + if ($map === null) { + $map = new WeatherMap; + $map->context = 'editor'; + if (!wm_editor_read_config($map, $mapfile)) return; + } print $map->asJS(); print 'var WMConfigRevision = ' . json_encode($map->config_revision) . ';'; diff --git a/tests/Browser/EditorFilenameNavigationTest.cjs b/tests/Browser/EditorFilenameNavigationTest.cjs index b62ea983..a64ef372 100644 --- a/tests/Browser/EditorFilenameNavigationTest.cjs +++ b/tests/Browser/EditorFilenameNavigationTest.cjs @@ -4,7 +4,7 @@ const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const assert = require('node:assert/strict'); -const dom = new JSDOM('One', {runScripts: 'outside-only'}); +const dom = new JSDOM('One', {runScripts: 'outside-only', url:'https://example.test/editor.php?action=edit&config_revision=old&return_to=manage'}); const w = dom.window, $ = jquery(w); w.$ = $; let pickerOptions; @@ -29,13 +29,15 @@ for (const filename of ['ordinary.conf', 'A&B#C? +%é.conf']) { assert.equal(new URLSearchParams(options.data).get('mapname'), filename); options.success(''); }; - $.get = (url, callback) => { check(url); callback(''); }; - $.getScript = (url, callback) => { check(url); callback(''); }; + $.get = $.getScript = () => { throw Error('partial snapshot reload'); }; + let reloadUrl;w.wmReloadEditor=()=>reloadUrl=w.wmEditorReloadUrl(); $('#existingdata, #xycapture').attr('src', '?action=draw&mapname=' + encodeURIComponent(filename)); w.form_submit(); - assert.deepEqual(requests, ['graphs', 'load_area_data', 'load_map_javascript']); + assert.deepEqual(requests, ['graphs']); + check(reloadUrl); + const reloaded=new URL(reloadUrl);assert.equal(reloaded.searchParams.get('action'),'nothing');assert.equal(reloaded.searchParams.has('config_revision'),false);assert.equal(reloaded.searchParams.get('return_to'),'manage'); check($('#existingdata').attr('src')); check($('#xycapture').attr('src')); } w.close(); -console.log('PASS: picker, serialized save, map reload, JavaScript reload and image refresh retain ordinary and delimiter-containing filenames.'); +console.log('PASS: picker, serialized save, complete editor reload and image identity retain ordinary and delimiter-containing filenames.'); diff --git a/tests/Browser/EditorSaveFailureTest.cjs b/tests/Browser/EditorSaveFailureTest.cjs index 108b9acf..4bb4e84b 100644 --- a/tests/Browser/EditorSaveFailureTest.cjs +++ b/tests/Browser/EditorSaveFailureTest.cjs @@ -4,4 +4,5 @@ let request,message,closed=0,refreshed=0;$.ajax=opts=>request=opts;w.alert=text= const source=fs.readFileSync(path.join(__dirname,'../../js/editor.js'),'utf8');w.eval(source.slice(source.indexOf('function form_submit()'),source.indexOf('function map_properties()'))); w.form_submit();request.error();assert.equal(message,'Unable to save changes');assert.equal(closed,0);assert.equal(refreshed,0);assert.equal($('#action').val(),'set_map_properties'); w.WMConfigRevision='b'.repeat(64);w.form_submit();assert.match(request.data,/config_revision=b{64}/);request.error({status:409});assert.equal(message,'Reload the editor');assert.equal(closed,0);assert.equal(refreshed,0); +let reloads=0;w.hide_all_dialogs=()=>closed++;w.wmReloadEditor=()=>reloads++;$.get=()=>{throw Error('partial editor refresh');};request.success();assert.equal(reloads,1); w.close();console.log('PASS: save failure reports retry and preserves the form'); diff --git a/tests/Support/ConfigWriteAtomicRegression.php b/tests/Support/ConfigWriteAtomicRegression.php index 04ce4983..0fc9d026 100644 --- a/tests/Support/ConfigWriteAtomicRegression.php +++ b/tests/Support/ConfigWriteAtomicRegression.php @@ -257,6 +257,25 @@ function atomic_assert($condition, $message) { \WeathermapConfig\release($held); $wrong = \fopen($file, 'rb'); atomic_assert(!\WeathermapConfig\atomicWrite($dir . '/wrong-lock.conf', 'Wrong', false, null, true, $wrong), 'different lock file rejected'); \fclose($wrong); + // Revision metadata must describe the same map snapshot as the controls. + file_put_contents($file, $original); $snapshot = new \WeatherMap; $snapshot->ReadConfig($file); + $loadedRevision = $snapshot->config_revision; + $updatedSnapshot = str_replace('Original', 'NewSnapshot', $original); file_put_contents($file, $updatedSnapshot); + $GLOBALS['__test_nfilter_request'] = []; + ob_start(); getMapJavaScript($file, $snapshot); $javascript = ob_get_clean(); + atomic_assert(str_contains($javascript, 'Original') && !str_contains($javascript, 'NewSnapshot') && str_contains($javascript, $loadedRevision), 'rendered values and revision come from one loaded snapshot'); + $GLOBALS['__test_nfilter_request'] = ['config_revision' => $loadedRevision]; + http_response_code(200); atomic_assert(!wm_editor_read_config(new \WeatherMap, $file) && http_response_code() === 409, 'concurrent save cannot be hidden by a refreshed token'); + // Simulate deletion between the existence check and the engine file read. + class AtomicDeletedDuringReadMap extends \WeatherMap { + /** Simulate an engine load that loses its file before opening it. */ + function ReadConfig($input, $is_include = false) { unlink($input); $this->config_revision = null; return true; } + } + foreach ([[], ['config_revision' => $loadedRevision]] as $request) { + file_put_contents($file, $original); $GLOBALS['__test_nfilter_request'] = $request; + http_response_code(200); atomic_assert(!wm_editor_read_config(new AtomicDeletedDuringReadMap, $file) && http_response_code() === 404, 'deletion race cannot proceed with a null revision'); + } + $GLOBALS['__test_nfilter_request'] = []; file_put_contents($file, $original); $crlfFile = $dir . '/crlf.conf'; $crlf = "WIDTH 600\r\nHEIGHT 300\r\n"; file_put_contents($crlfFile, $crlf); $crlfMap = new \WeatherMap; $crlfMap->ReadConfig($crlfFile); diff --git a/weathermap-cacti-plugin-editor.php b/weathermap-cacti-plugin-editor.php index 46efd3c8..47a6ea18 100644 --- a/weathermap-cacti-plugin-editor.php +++ b/weathermap-cacti-plugin-editor.php @@ -243,7 +243,8 @@ } $map = new WeatherMap; -$map->ReadConfig($mapfile); +$map->context = 'editor'; +if (!wm_editor_read_config($map, $mapfile)) exit; // by here, there should be a valid $map - either a blank one, the existing one, or the existing one with requested changes wm_debug('Finished modifying'); @@ -369,7 +370,7 @@ From 07cf58b25c919d6e72545af64e31ad0827f63584 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:58:27 +1030 Subject: [PATCH 22/41] Document the editor snapshot argument --- lib/editor.actions.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/editor.actions.php b/lib/editor.actions.php index f2a9166a..f9386984 100644 --- a/lib/editor.actions.php +++ b/lib/editor.actions.php @@ -139,7 +139,8 @@ function newMapCopy($mapfile) { } /** - * @param mixed $mapfile + * @param mixed $mapfile Configuration path. + * @param WeatherMap|null $map Loaded control snapshot, or null for a standalone response. * @return void */ function getMapJavaScript($mapfile, $map = null) { From 8107a2251c51060c1321bfc6b0d6cf1ef56bde95 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:33:22 +1030 Subject: [PATCH 23/41] Reject lost copy sources and finish raw saves before rendering --- lib/editor.actions.php | 5 +++- tests/Support/ConfigWriteAtomicRegression.php | 25 +++++++++++++++++++ weathermap-cacti-plugin-editor.php | 4 +-- weathermap-cacti-plugin-mgmt.php | 2 +- 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/lib/editor.actions.php b/lib/editor.actions.php index f9386984..05c61c7c 100644 --- a/lib/editor.actions.php +++ b/lib/editor.actions.php @@ -132,7 +132,10 @@ function newMapCopy($mapfile) { $sourcemap = $mapdir . '/' . $sourcemapname; if (file_exists($sourcemap) && is_readable($sourcemap)) { - $map->ReadConfig($sourcemap); + if (!$map->ReadConfig($sourcemap) || !is_string($map->config_revision)) { + http_response_code(404); + return; + } wm_editor_write_config($map, $mapfile, true); } } diff --git a/tests/Support/ConfigWriteAtomicRegression.php b/tests/Support/ConfigWriteAtomicRegression.php index 0fc9d026..f1d204b9 100644 --- a/tests/Support/ConfigWriteAtomicRegression.php +++ b/tests/Support/ConfigWriteAtomicRegression.php @@ -275,6 +275,21 @@ function ReadConfig($input, $is_include = false) { unlink($input); $this->config file_put_contents($file, $original); $GLOBALS['__test_nfilter_request'] = $request; http_response_code(200); atomic_assert(!wm_editor_read_config(new AtomicDeletedDuringReadMap, $file) && http_response_code() === 404, 'deletion race cannot proceed with a null revision'); } + + $plugin = dirname(__DIR__, 2); + // Exercise both copy entry points with an engine that loses the source during loading. + $actions = file_get_contents($plugin . '/lib/editor.actions.php'); + $copyStart = strpos($actions, 'function newMapCopy('); $copyEnd = strpos($actions, "\n/**", $copyStart); + eval(str_replace(['function newMapCopy(', 'new WeatherMap'], ['function atomicLostSourceCopy(', 'new AtomicDeletedDuringReadMap'], substr($actions, $copyStart, $copyEnd - $copyStart))); + $GLOBALS['mapdir'] = $dir; $GLOBALS['__test_request']['sourcemap'] = basename($file); $GLOBALS['__test_nfilter_request'] = ['sourcemap' => basename($file)]; + file_put_contents($file, $original); http_response_code(200); atomicLostSourceCopy($dir . '/lost-copy.conf'); + atomic_assert(http_response_code() === 404 && !is_file($dir . '/lost-copy.conf'), 'editor copy rejects source lost during load'); + $managementCopy = file_get_contents($plugin . '/weathermap-cacti-plugin-mgmt.php'); + eval(str_replace(['__DIR__', 'function newMap(', 'new WeatherMap'], [var_export($plugin, true), 'function atomicLostManagementCopy(', 'new AtomicDeletedDuringReadMap'], substr($managementCopy, strpos($managementCopy, 'function newMap(')))); + $GLOBALS['weathermap_confdir'] = $dir; file_put_contents($file, $original); + atomicLostManagementCopy('lost-management-copy.conf', basename($file)); + atomic_assert(!is_file($dir . '/lost-management-copy.conf'), 'management copy rejects source lost during load'); + unset($GLOBALS['__test_request']['sourcemap']); $GLOBALS['__test_nfilter_request'] = []; file_put_contents($file, $original); $crlfFile = $dir . '/crlf.conf'; $crlf = "WIDTH 600\r\nHEIGHT 300\r\n"; file_put_contents($crlfFile, $crlf); @@ -412,6 +427,16 @@ function ReadConfig($input, $is_include = false) { unlink($input); $this->config $GLOBALS['__test_nfilter_request'] = ['config_revision' => hash_file('sha256', $rawFile), 'node_name' => 'A', 'link_name' => 'AB', 'item_configtext' => $kind === 'node' ? "NODE A\n LABEL Updated\n POSITION 10 10\n" : "LINK AB\n NODES A B\n WIDTH 12\n"]; http_response_code(200); if ($kind === 'node') setNodeConfig($rawFile); else setLinkConfig($rawFile); atomic_assert(http_response_code() === 200 && file_get_contents($rawFile) !== $rawConfig, 'raw ' . $kind . ' edit saves without false conflict'); + file_put_contents($rawFile, $rawConfig); http_response_code(200); + $controller = file_get_contents($plugin . '/weathermap-cacti-plugin-editor.php'); + $caseStart = strpos($controller, "case 'set_" . $kind . "_config':"); + $caseEnd = strpos($controller, "case '", $caseStart + 5); + $dispatch = substr($controller, strpos($controller, ':', $caseStart) + 1, $caseEnd - strpos($controller, ':', $caseStart) - 1); + $mapfile = $rawFile; $terminated = false; + try { eval(str_replace('exit();', 'throw new \\RuntimeException("request-finished");', $dispatch)); } + catch (\RuntimeException $error) { if ($error->getMessage() !== 'request-finished') throw $error; $terminated = true; } + atomic_assert($terminated && http_response_code() === 200 && file_get_contents($rawFile) !== $rawConfig, 'controller ends successful raw ' . $kind . ' save before rendering with old token'); + } // Registration failure must retain a mutex through the callback and remove its reservation. file_put_contents($plugin . '/configs/' . $duplicateSource, "TITLE Source\n"); diff --git a/weathermap-cacti-plugin-editor.php b/weathermap-cacti-plugin-editor.php index 47a6ea18..fedc76fc 100644 --- a/weathermap-cacti-plugin-editor.php +++ b/weathermap-cacti-plugin-editor.php @@ -169,11 +169,11 @@ case 'set_link_config': setLinkConfig($mapfile); - break; + exit(); case 'set_node_config': setNodeConfig($mapfile); - break; + exit(); case 'set_node_properties': setNodeProperties($mapfile); exit; diff --git a/weathermap-cacti-plugin-mgmt.php b/weathermap-cacti-plugin-mgmt.php index acabe0f4..de7bf3fc 100644 --- a/weathermap-cacti-plugin-mgmt.php +++ b/weathermap-cacti-plugin-mgmt.php @@ -3443,7 +3443,7 @@ function newMap($mapfile, $sourcemapfile = '', $title = '') { if ($mapfile != '') { if ($sourcemapfile != '') { if (file_exists($sourcemapfile) && is_readable($sourcemapfile)) { - if (!$map->ReadConfig($sourcemapfile)) { + if (!$map->ReadConfig($sourcemapfile) || !is_string($map->config_revision)) { raise_message('map_message', __('The Source Map File name is not readable or does not exist!', 'weathermap'), MESSAGE_LEVEL_ERROR); return; } From f5b97d63a6868d4690415da72850d5b350458a0d Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:56:08 +1030 Subject: [PATCH 24/41] Keep logical configuration mutex for symlink writes --- lib/WeatherMap.config.php | 5 +++-- tests/Support/ConfigWriteAtomicRegression.php | 11 +++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/lib/WeatherMap.config.php b/lib/WeatherMap.config.php index 0e9377a8..87c1d1a7 100644 --- a/lib/WeatherMap.config.php +++ b/lib/WeatherMap.config.php @@ -88,15 +88,16 @@ function writeAll($stream, $contents) { */ function atomicWrite($filename, $contents, $mustExist = false, $expectedRevision = null, $createOnly = false, $heldLock = null) { if (strpos($filename, '://') !== false) return false; + $lockDirectory = dirname($filename); if (is_link($filename)) $filename = realpath($filename); if ($filename === false || !is_dir(dirname($filename)) || !is_writable(dirname($filename))) return false; if (file_exists($filename) && (!is_file($filename) || !is_writable($filename))) return false; $existed = file_exists($filename); $mustExist = $mustExist || $existed; $ownsLock = $heldLock === null; - $lockPath = realpath(dirname($filename) . '/.weathermap-config.lock'); + $lockPath = realpath($lockDirectory . '/.weathermap-config.lock'); if (!$ownsLock && ($lockPath === false || !is_resource($heldLock) || get_resource_type($heldLock) !== 'stream' || realpath(stream_get_meta_data($heldLock)['uri']) !== $lockPath)) return false; - $lock = $ownsLock ? acquire(dirname($filename)) : $heldLock; + $lock = $ownsLock ? acquire($lockDirectory) : $heldLock; if (!$lock) return false; $temp = false; $stream = false; diff --git a/tests/Support/ConfigWriteAtomicRegression.php b/tests/Support/ConfigWriteAtomicRegression.php index f1d204b9..82c7c5fe 100644 --- a/tests/Support/ConfigWriteAtomicRegression.php +++ b/tests/Support/ConfigWriteAtomicRegression.php @@ -255,6 +255,17 @@ function atomic_assert($condition, $message) { atomic_assert(pcntl_wexitstatus($status) === 0, 'atomicWrite leaves caller mutex held'); } \WeathermapConfig\release($held); + + $external = sys_get_temp_dir() . '/wm-external-' . bin2hex(random_bytes(5)); + mkdir($external); $externalFile = $external . '/map.conf'; file_put_contents($externalFile, $original); + $alias = $dir . '/external.conf'; + if (@symlink($externalFile, $alias)) { + $logicalLock = \WeathermapConfig\acquire($dir); + atomic_assert(\WeathermapConfig\atomicWrite($alias, $original, true, hash('sha256', $original), false, $logicalLock), 'external symlink accepts caller-held logical configuration mutex'); + atomic_assert(!is_file($external . '/.weathermap-config.lock'), 'symlink replacement does not choose external target mutex'); + \WeathermapConfig\release($logicalLock); unlink($alias); + } + unlink($externalFile); rmdir($external); $wrong = \fopen($file, 'rb'); atomic_assert(!\WeathermapConfig\atomicWrite($dir . '/wrong-lock.conf', 'Wrong', false, null, true, $wrong), 'different lock file rejected'); \fclose($wrong); // Revision metadata must describe the same map snapshot as the controls. From 6c0ed54060bc3453ed14248db41a89c86dbd5cb7 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:19:05 +1030 Subject: [PATCH 25/41] Preserve selected custom images when refreshing libraries --- js/editor.js | 16 +++++++++++++++- tests/Browser/EditorImagesTest.cjs | 11 +++++++++++ tests/Browser/EditorSaveFailureTest.cjs | 2 +- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/js/editor.js b/js/editor.js index 347ec39c..963ae13f 100644 --- a/js/editor.js +++ b/js/editor.js @@ -884,7 +884,17 @@ function wmReloadEditor() { window.location.replace(wmEditorReloadUrl()); } -function form_submit() { +function form_submit(event) { + // Property dialogs can remain open while a toolbar placement action is active. + // Their own Save buttons always dispatch the properties they are showing. + var button = event && event.currentTarget && event.currentTarget.id; + if (button === 'tb_node_submit' || button === 'tb_link_submit') { + var node = button === 'tb_node_submit'; + $('#action').val(node ? 'set_node_properties' : 'set_link_properties'); + $('#param').val($(node ? '#node_name' : '#link_name').val()); + mapmode('existing'); + } + var data = $('input, select, textarea').serialize(); if (typeof WMConfigRevision !== 'undefined') data += '&config_revision=' + encodeURIComponent(WMConfigRevision); @@ -1752,11 +1762,15 @@ function wmAssetImages(images) { var select = $('#' + spec.id), old = select.val(); var slick = select.data('ddslick'); if (slick) { old = slick.selectedData && slick.selectedData.value; select.ddslick('destroy'); select = $('#' + spec.id); } + var current = select.find('option').filter(function() { return this.value === old; }).first().clone(); select.find('option').filter(function() { return this.value.indexOf('images/' + spec.kind + '/') === 0 && /\.(png|gif|jpe?g)$/i.test(this.value); }).remove(); if (!select.find('option[value="--NONE--"]').length) select.prepend($('').val('--NONE--').text(spec.label)); images.filter(function(item) { return item.kind === spec.kind; }).forEach(function(item) { select.append($('').val('images/' + item.kind + '/' + item.name).text(item.name).attr({'data-imagesrc': item.url, 'data-description': item.name})); }); + if (old && !select.find('option').filter(function() { return this.value === old; }).length) { + select.append(current.length ? current : $('').val(old).text(old)); + } select.val(old); }); $('#dlgImages').dialog('option', {maxHeight: $(window).height() - 80, position: {my: 'center', at: 'center', of: window}}); diff --git a/tests/Browser/EditorImagesTest.cjs b/tests/Browser/EditorImagesTest.cjs index 46c9dc8e..20ef2da7 100644 --- a/tests/Browser/EditorImagesTest.cjs +++ b/tests/Browser/EditorImagesTest.cjs @@ -16,6 +16,17 @@ w.wmAssetImages(images); assert.equal($('#node_iconfilename').val(),'images/icons/external.png');assert.equal($('#node_iconfilename option[value="images/icons/external.png"]').length,1); assert.equal($('#wm-images-objects .wm-image-delete').length,0);assert.match($('#wm-images-objects').text(),/96 × 96 px/);assert.match($('#wm-images-objects').text(),/map.conf/);assert.match($('#wm-images-backgrounds').text(),/Non utilisée/); assert.equal($('#node_iconfilename option[value="images/objects/hospital.png"]').length,1); +for (const [id, value] of [['node_iconfilename','images/objects/custom folder/site.png'], ['map_bgfile','images/backgrounds/private.jpg']]) { + const select=$('#'+id); + select.append($('').val(value).text('Current custom image').attr('data-imagesrc',value)).val(value); + w.wmAssetImages(images); + assert.equal(select.val(),value); + assert.equal(select.find('option').filter(function(){return this.value===value;}).length,1); + assert.equal(select.find('option:selected').attr('data-imagesrc'),value); + w.wmAssetImages(images); + assert.equal(select.val(),value); + assert.equal(select.find('option').filter(function(){return this.value===value;}).length,1); +} let request;$.ajax=opts=>request=opts;w.confirm=()=>false;$('#wm-images-backgrounds .wm-image-delete').trigger('click');assert.equal(request,undefined); w.confirm=()=>true;$('#wm-images-backgrounds .wm-image-delete').trigger('click');assert.equal(request.type,'POST');assert.equal(request.data.action,'delete_image');assert.equal(request.data.image_kind,'backgrounds');assert.equal(request.data.__csrf_magic,'fixture'); request.error({responseJSON:{error:'In use'}});assert.equal($('#wm-images-error').text(),'In use'); diff --git a/tests/Browser/EditorSaveFailureTest.cjs b/tests/Browser/EditorSaveFailureTest.cjs index 4bb4e84b..0f5994da 100644 --- a/tests/Browser/EditorSaveFailureTest.cjs +++ b/tests/Browser/EditorSaveFailureTest.cjs @@ -1,7 +1,7 @@ const {JSDOM}=require('jsdom'),jquery=require('jquery'),fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict'); const dom=new JSDOM('',{runScripts:'outside-only'}),w=dom.window,$=jquery(w);w.$=w.jQuery=$;w.editor_url='/editor';w.wmEditorText={staleError:'Reload the editor',saveError:'Unable to save changes'}; let request,message,closed=0,refreshed=0;$.ajax=opts=>request=opts;w.alert=text=>message=text;w.hide_all_dialogs=()=>closed++;$.get=()=>refreshed++; -const source=fs.readFileSync(path.join(__dirname,'../../js/editor.js'),'utf8');w.eval(source.slice(source.indexOf('function form_submit()'),source.indexOf('function map_properties()'))); +const source=fs.readFileSync(path.join(__dirname,'../../js/editor.js'),'utf8');w.eval(source.slice(source.indexOf('function form_submit('),source.indexOf('function map_properties()'))); w.form_submit();request.error();assert.equal(message,'Unable to save changes');assert.equal(closed,0);assert.equal(refreshed,0);assert.equal($('#action').val(),'set_map_properties'); w.WMConfigRevision='b'.repeat(64);w.form_submit();assert.match(request.data,/config_revision=b{64}/);request.error({status:409});assert.equal(message,'Reload the editor');assert.equal(closed,0);assert.equal(refreshed,0); let reloads=0;w.hide_all_dialogs=()=>closed++;w.wmReloadEditor=()=>reloads++;$.get=()=>{throw Error('partial editor refresh');};request.success();assert.equal(reloads,1); From b5847a89bf0576af7c1befc815adb077a417c937 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:54:41 +1030 Subject: [PATCH 26/41] Guard included snapshots and deleted image selections at commit time --- lib/WeatherMap.class.php | 38 +++++++++++-- lib/WeatherMap.config.php | 42 ++++++++++++++- lib/editor.actions.php | 16 +++--- tests/Support/ConfigWriteAtomicRegression.php | 54 +++++++++++++++++++ weathermap-cacti-plugin-editor.php | 10 +++- weathermap-cacti-plugin-mgmt.php | 4 +- 6 files changed, 150 insertions(+), 14 deletions(-) diff --git a/lib/WeatherMap.class.php b/lib/WeatherMap.class.php index 0e2e09f1..c758c565 100644 --- a/lib/WeatherMap.class.php +++ b/lib/WeatherMap.class.php @@ -342,6 +342,9 @@ class WeatherMap extends WeatherMapBase { /** @var mixed */ var $configfile; var $config_revision = null; + var $config_dependencies = []; + var $config_managed_assets = []; + var $config_asset_conflict = false; var $editor_config_revision = null; /** @var mixed */ var $imagefile; @@ -2440,6 +2443,18 @@ function DrawTitle($image, $font, $colour) { $this->imap->addArea('Rectangle', 'TITLE', '', [$x, $y, $x + $boxwidth, $y - $boxheight]); } + /** + * Collect managed image references for commit-time existence checks. + * @return string[] Paths relative to the plugin directory. + */ + function ManagedImagePaths() { + $paths = [$this->background]; + foreach ($this->nodes as $node) $paths[] = $node->iconfile; + return array_values(array_unique(array_filter($paths, function ($path) { + return is_string($path) && preg_match('#^images/(objects|backgrounds)/#', $path); + }))); + } + /** * @param mixed $input * @param bool $is_include @@ -2447,7 +2462,7 @@ function DrawTitle($image, $font, $colour) { */ function ReadConfig($input, $is_include = false) { global $config, $weathermap_error_suppress; - if (!$is_include) $this->config_revision = null; + if (!$is_include) { $this->config_revision = null; $this->config_dependencies = []; } $curnode = null; $curlink = null; @@ -2477,6 +2492,8 @@ function ReadConfig($input, $is_include = false) { $filename = $input; if ($is_include) { + $dependencyPath = (realpath(dirname($filename)) ?: dirname($filename)) . '/' . basename($filename); + if (!array_key_exists($dependencyPath, $this->config_dependencies)) $this->config_dependencies[$dependencyPath] = null; wm_debug('ReadConfig Detected that this is an INCLUDED config filename.'); if ($is_include && in_array($filename, $this->included_files, true)) { @@ -2506,7 +2523,9 @@ function ReadConfig($input, $is_include = false) { } fclose($fd); - if (!$is_include) $this->config_revision = hash_final($revision); + $loadedRevision = hash_final($revision); + if (!$is_include) $this->config_revision = $loadedRevision; + else $this->config_dependencies[$dependencyPath] = $loadedRevision; } } } @@ -3535,6 +3554,7 @@ function ReadConfig($input, $is_include = false) { wm_debug('Finished Pre-Processing Plugins...'); + if (!$is_include) $this->config_managed_assets = $this->ManagedImagePaths(); return (true); } @@ -3856,8 +3876,20 @@ function WriteConfig($filename, $createOnly = false, $expectedRevision = null, $ $contents = stream_get_contents($fd); $closed = fclose($fd); + $this->config_asset_conflict = false; + $newAssets = array_diff($this->ManagedImagePaths(), $this->config_managed_assets); + $validate = function () use ($newAssets) { + foreach ($newAssets as $asset) { + if (!is_file(dirname(__DIR__) . '/' . $asset)) { + $this->config_asset_conflict = true; + return false; + } + } + return true; + }; $sameFile = $this->configfile !== '' && $filename === $this->configfile; - $saved = $write_ok && $contents !== false && $closed && \WeathermapConfig\atomicWrite($filename, $contents, $sameFile, $expectedRevision ?? ($sameFile ? $this->config_revision : null), $createOnly, $heldLock); + $saved = $write_ok && $contents !== false && $closed && \WeathermapConfig\atomicWrite($filename, $contents, $sameFile, $expectedRevision ?? ($sameFile ? $this->config_revision : null), $createOnly, $heldLock, $this->config_dependencies, $validate); + if ($saved) $this->config_managed_assets = $this->ManagedImagePaths(); if ($saved && $sameFile) $this->config_revision = hash('sha256', $contents); return $saved; } else { diff --git a/lib/WeatherMap.config.php b/lib/WeatherMap.config.php index 87c1d1a7..0881ad57 100644 --- a/lib/WeatherMap.config.php +++ b/lib/WeatherMap.config.php @@ -74,6 +74,31 @@ function writeAll($stream, $contents) { return true; } +/** + * Compare every included configuration against the bytes used by the snapshot. + * @param array $dependencies Included filenames and hashes. + * @return bool Whether all dependencies still match. + */ +function dependenciesMatch($dependencies) { + foreach ($dependencies as $path => $revision) { + clearstatcache(true, $path); + if (!is_string($revision) || @hash_file('sha256', $path) !== $revision) return false; + } + return true; +} + +/** + * Bind an editor token to its root configuration and included dependencies. + * @param string|null $root Root configuration hash. + * @param array $dependencies Included filenames and hashes. + * @return string|null Snapshot token. + */ +function snapshotRevision($root, $dependencies) { + if (!$dependencies) return $root; + ksort($dependencies); + return hash('sha256', serialize([$root, $dependencies])); +} + /** * Replace a configuration only after a complete, flushed and closed local write. * @@ -84,9 +109,11 @@ function writeAll($stream, $contents) { * @param bool $createOnly Refuse to replace an existing destination. * @param resource|null $heldLock Optional caller-owned mutex for this destination directory. * + * @param array $dependencies Included configuration snapshots. + * @param callable|null $validate Additional commit-time validation. * @return bool Success; failures preserve the existing configuration. */ -function atomicWrite($filename, $contents, $mustExist = false, $expectedRevision = null, $createOnly = false, $heldLock = null) { +function atomicWrite($filename, $contents, $mustExist = false, $expectedRevision = null, $createOnly = false, $heldLock = null, $dependencies = [], $validate = null) { if (strpos($filename, '://') !== false) return false; $lockDirectory = dirname($filename); if (is_link($filename)) $filename = realpath($filename); @@ -101,12 +128,24 @@ function atomicWrite($filename, $contents, $mustExist = false, $expectedRevision if (!$lock) return false; $temp = false; $stream = false; + $dependencyLocks = []; try { clearstatcache(true, $filename); if (($createOnly || !$existed) && file_exists($filename)) return false; if ($mustExist && !is_file($filename)) return false; if ($expectedRevision !== null && @hash_file('sha256', $filename) !== $expectedRevision) return false; if (file_exists($filename) && (!is_file($filename) || !is_writable($filename))) return false; + // Additional directories use nonblocking locks so opposing include graphs cannot deadlock. + $directories = array_unique(array_map('dirname', array_keys($dependencies))); + sort($directories); + foreach ($directories as $directory) { + if (realpath($directory) === realpath($lockDirectory)) continue; + $dependencyLock = acquire($directory, false); + if (!$dependencyLock) return false; + $dependencyLocks[] = $dependencyLock; + } + if (!dependenciesMatch($dependencies) || ($validate !== null && !$validate())) return false; + $temp = @tempnam(dirname($filename), '.wm-config-'); if ($temp === false) return false; $stream = @fopen($temp, 'wb'); @@ -122,6 +161,7 @@ function atomicWrite($filename, $contents, $mustExist = false, $expectedRevision } finally { if (is_resource($stream)) fclose($stream); if ($temp !== false && file_exists($temp)) unlink($temp); + foreach ($dependencyLocks as $dependencyLock) release($dependencyLock); if ($ownsLock) release($lock); } } diff --git a/lib/editor.actions.php b/lib/editor.actions.php index 05c61c7c..38341821 100644 --- a/lib/editor.actions.php +++ b/lib/editor.actions.php @@ -40,6 +40,8 @@ +-------------------------------------------------------------------------+ */ +require_once __DIR__ . '/WeatherMap.config.php'; + /** editor.actions.php * * All the functions used by the that wrap in request variables @@ -69,7 +71,7 @@ function wm_editor_read_config($map, $mapfile) { return false; } $revision = get_nfilter_request_var('config_revision'); - if ($revision !== null && $revision !== '' && (!is_string($revision) || !hash_equals($map->config_revision, $revision))) { + if ($revision !== null && $revision !== '' && (!is_string($revision) || !hash_equals(\WeathermapConfig\snapshotRevision($map->config_revision, $map->config_dependencies), $revision))) { http_response_code(409); return false; } @@ -88,7 +90,7 @@ function wm_editor_read_config($map, $mapfile) { function wm_editor_write_config($map, $mapfile, $createOnly = false) { if (!$map->WriteConfig($mapfile, $createOnly, $map->editor_config_revision)) { $current = is_file($mapfile) ? @hash_file('sha256', $mapfile) : false; - $stale = $map->editor_config_revision !== null && $current !== false && $current !== $map->editor_config_revision; + $stale = $map->config_asset_conflict || !\WeathermapConfig\dependenciesMatch($map->config_dependencies) || $map->editor_config_revision !== null && $current !== false && $current !== $map->editor_config_revision; http_response_code($stale ? 409 : 500); return false; } @@ -106,7 +108,7 @@ function newMap($mapfile) { require_once __DIR__ . '/editor.new-map-preset.php'; wm_new_map_preset($map); - wm_editor_write_config($map, $mapfile, true); + return wm_editor_write_config($map, $mapfile, true); } /** @@ -134,11 +136,13 @@ function newMapCopy($mapfile) { if (file_exists($sourcemap) && is_readable($sourcemap)) { if (!$map->ReadConfig($sourcemap) || !is_string($map->config_revision)) { http_response_code(404); - return; + return false; } - wm_editor_write_config($map, $mapfile, true); + return wm_editor_write_config($map, $mapfile, true); } } + http_response_code(404); + return false; } /** @@ -154,7 +158,7 @@ function getMapJavaScript($mapfile, $map = null) { } print $map->asJS(); - print 'var WMConfigRevision = ' . json_encode($map->config_revision) . ';'; + print 'var WMConfigRevision = ' . json_encode(\WeathermapConfig\snapshotRevision($map->config_revision, $map->config_dependencies)) . ';'; } /** diff --git a/tests/Support/ConfigWriteAtomicRegression.php b/tests/Support/ConfigWriteAtomicRegression.php index 82c7c5fe..7fa3b059 100644 --- a/tests/Support/ConfigWriteAtomicRegression.php +++ b/tests/Support/ConfigWriteAtomicRegression.php @@ -266,6 +266,36 @@ function atomic_assert($condition, $message) { \WeathermapConfig\release($logicalLock); unlink($alias); } unlink($externalFile); rmdir($external); + + $includeFile = $dir . '/included.conf'; $includeRoot = $dir . '/include-root.conf'; + file_put_contents($includeFile, "TITLE Included old\n"); + $includeConfig = "WIDTH 600\nHEIGHT 300\nINCLUDE " . $includeFile . "\n"; + file_put_contents($includeRoot, $includeConfig); + $dependent = new \WeatherMap; $dependent->ReadConfig($includeRoot); + $token = \WeathermapConfig\snapshotRevision($dependent->config_revision, $dependent->config_dependencies); + file_put_contents($includeFile, "TITLE Included winner\n"); $dependent->title = 'Stale edit'; + atomic_assert(!$dependent->WriteConfig($includeRoot) && file_get_contents($includeRoot) === $includeConfig, 'included file changes reject stale root persistence'); + $GLOBALS['__test_nfilter_request'] = ['config_revision' => $token]; http_response_code(200); + atomic_assert(!wm_editor_read_config(new \WeatherMap, $includeRoot) && http_response_code() === 409, 'included-file changes invalidate loaded editor page token'); + $GLOBALS['__test_nfilter_request'] = []; $fresh = new \WeatherMap; $fresh->ReadConfig($includeRoot); + atomic_assert($fresh->WriteConfig($includeRoot), 'unchanged included dependency can be saved'); + atomic_assert(!\WeathermapConfig\dependenciesMatch([$includeFile => null]), 'unread included dependency rejected'); + $outside = $dir . '/dependency-directory'; mkdir($outside); $outsideFile = $outside . '/outside.conf'; file_put_contents($outsideFile, "TITLE Outside\n"); + file_put_contents($includeRoot, "WIDTH 600\nHEIGHT 300\nINCLUDE " . $outsideFile . "\n"); + $externalMap = new \WeatherMap; $externalMap->ReadConfig($includeRoot); $busy = \WeathermapConfig\acquire($outside); + atomic_assert(!$externalMap->WriteConfig($includeRoot), 'busy dependency directory rejects save without deadlock'); + \WeathermapConfig\release($busy); + atomic_assert($externalMap->WriteConfig($includeRoot), 'external dependency save succeeds after mutex release'); + unlink($outsideFile); unlink($outside . '/.weathermap-config.lock'); rmdir($outside); + + $asset = 'images/objects/atomic-asset-' . bin2hex(random_bytes(5)) . '.png'; $assetFile = dirname(__DIR__, 2) . '/' . $asset; + file_put_contents($assetFile, 'Image fixture'); file_put_contents($file, $original); + $selection = new \WeatherMap; $selection->ReadConfig($file); $selection->nodes['note1']->iconfile = $asset; unlink($assetFile); + atomic_assert(!$selection->WriteConfig($file) && $selection->config_asset_conflict && file_get_contents($file) === $original, 'deleted newly selected image cannot be restored as a broken reference'); + file_put_contents($assetFile, 'Image fixture'); + atomic_assert($selection->WriteConfig($file), 'existing newly selected image saves under mutex'); unlink($assetFile); + atomic_assert($selection->WriteConfig($file), 'already saved references do not prevent repairing legacy missing images'); + file_put_contents($file, $original); $wrong = \fopen($file, 'rb'); atomic_assert(!\WeathermapConfig\atomicWrite($dir . '/wrong-lock.conf', 'Wrong', false, null, true, $wrong), 'different lock file rejected'); \fclose($wrong); // Revision metadata must describe the same map snapshot as the controls. @@ -416,6 +446,30 @@ function ReadConfig($input, $is_include = false) { unlink($input); $this->config atomic_assert(file_get_contents($destination) === 'Concurrent creator', 'management create-only preserves concurrent destination'); unlink($destination); } + + if (!function_exists('db_fetch_insert_id')) { function db_fetch_insert_id() { return 1; } } + // A read-only configs directory with no provisioned mutex still permits registration. + foreach (['add_config', 'wmap_get_title', 'map_resort'] as $functionName) { + $start = strpos($management, 'function ' . $functionName . '('); $end = strpos($management, "\n/**", $start); + eval(str_replace('__DIR__', var_export($plugin, true), substr($management, $start, $end - $start))); + } + $readonly = $dir . '/readonly'; mkdir($readonly); file_put_contents($readonly . '/map.conf', "TITLE Readonly\n"); + chmod($readonly, 0555); $GLOBALS['weathermap_confdir'] = realpath($readonly); $GLOBALS['__test_db_calls'] = []; + try { + add_config($readonly . '/map.conf'); + atomic_assert(cacti_count(array_filter($GLOBALS['__test_db_calls'], fn ($call) => str_contains($call['sql'], 'INSERT INTO weathermap_maps'))) === 1, 'read-only config registered without a directory lock file'); + } finally { chdir($plugin); chmod($readonly, 0755); unlink($readonly . '/map.conf'); if (is_file($readonly . '/.weathermap-config.lock')) unlink($readonly . '/.weathermap-config.lock'); rmdir($readonly); } + $GLOBALS['weathermap_confdir'] = $dir; + foreach (['newmap', 'newmapcopy'] as $createAction) { + $destination = $dir . '/failed-editor-create.conf'; + $GLOBALS['__test_request']['sourcemap'] = basename($file); $GLOBALS['__test_nfilter_request'] = ['sourcemap' => basename($file)]; $GLOBALS['mapdir'] = $dir; + $GLOBALS['config_fault'] = 'rename'; http_response_code(200); + $controller = file_get_contents($plugin . '/weathermap-cacti-plugin-editor.php'); $start = strpos($controller, "case '" . $createAction . "':"); $colon = strpos($controller, ':', $start); $end = strpos($controller, "case '", $colon); + $dispatch = substr($controller, $colon + 1, $end - $colon - 1); $mapfile = $destination; $terminated = false; + ob_start(); try { eval('switch (true) { case true:' . str_replace('exit();', 'throw new \\RuntimeException("create-finished");', $dispatch) . '}'); } catch (\RuntimeException $error) { $terminated = $error->getMessage() === 'create-finished'; } $message = ob_get_clean(); + atomic_assert($terminated && http_response_code() === 500 && !is_file($destination) && str_contains($message, 'Could not save'), 'failed create stops controller with save status and guidance'); + $GLOBALS['config_fault'] = ''; unset($GLOBALS['__test_request']['sourcemap']); + } $start = strpos($management, 'function map_get_next_name('); $end = strpos($management, "\n/**", $start); eval(str_replace('__DIR__', var_export($plugin, true), substr($management, $start, $end - $start))); $start = strpos($management, 'function map_duplicate('); $end = strpos($management, "\n/**", $start); diff --git a/weathermap-cacti-plugin-editor.php b/weathermap-cacti-plugin-editor.php index fedc76fc..ed566413 100644 --- a/weathermap-cacti-plugin-editor.php +++ b/weathermap-cacti-plugin-editor.php @@ -143,11 +143,17 @@ display_datasources(); exit; case 'newmap': - newMap($mapfile); + if (!newMap($mapfile)) { + print __esc('Could not save the map configuration.', 'weathermap'); + exit(); + } break; case 'newmapcopy': - newMapCopy($mapfile); + if (!newMapCopy($mapfile)) { + print __esc('Could not save the map configuration.', 'weathermap'); + exit(); + } break; case 'font_samples': diff --git a/weathermap-cacti-plugin-mgmt.php b/weathermap-cacti-plugin-mgmt.php index de7bf3fc..2e7ec450 100644 --- a/weathermap-cacti-plugin-mgmt.php +++ b/weathermap-cacti-plugin-mgmt.php @@ -1754,7 +1754,7 @@ function add_config($file) { require_once __DIR__ . '/lib/editor.config-delete.php'; require_once __DIR__ . '/lib/WeatherMap.config.php'; $directory_lock = \WeathermapConfig\acquire($weathermap_confdir, false); - if (!$directory_lock) { + if (!$directory_lock && (is_writable($weathermap_confdir) || file_exists($weathermap_confdir . '/.weathermap-config.lock'))) { raise_message('path_missing', __esc('The configuration file is unavailable. Please retry.', 'weathermap'), MESSAGE_LEVEL_ERROR); return; } @@ -1793,7 +1793,7 @@ function add_config($file) { fclose($lock); } } finally { - \WeathermapConfig\release($directory_lock); + if ($directory_lock) \WeathermapConfig\release($directory_lock); } } } From c3583ae9728c970f9b0a1e8ad9d4c1759c6545da Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:03:27 +1030 Subject: [PATCH 27/41] Keep read-only dependency checks and creation retry guidance safe --- lib/WeatherMap.config.php | 6 +++++- tests/Support/ConfigWriteAtomicRegression.php | 6 ++++-- weathermap-cacti-plugin-editor.php | 4 ++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/lib/WeatherMap.config.php b/lib/WeatherMap.config.php index 0881ad57..b56a7899 100644 --- a/lib/WeatherMap.config.php +++ b/lib/WeatherMap.config.php @@ -36,6 +36,7 @@ function acquire($directory = null, $blocking = true) { $directory = realpath($directory ?? dirname(__DIR__) . '/configs'); if ($directory === false) return false; $path = $directory . '/.weathermap-config.lock'; + if (!is_writable($directory) && !file_exists($path) && !is_link($path)) return false; $handle = @fopen($path, 'c+b'); if (!$handle) $handle = @fopen($path, 'rb'); if ($handle && flock($handle, LOCK_EX | ($blocking ? 0 : LOCK_NB))) return $handle; @@ -141,7 +142,10 @@ function atomicWrite($filename, $contents, $mustExist = false, $expectedRevision foreach ($directories as $directory) { if (realpath($directory) === realpath($lockDirectory)) continue; $dependencyLock = acquire($directory, false); - if (!$dependencyLock) return false; + if (!$dependencyLock) { + if (!is_writable($directory) && !file_exists($directory . '/.weathermap-config.lock')) continue; + return false; + } $dependencyLocks[] = $dependencyLock; } if (!dependenciesMatch($dependencies) || ($validate !== null && !$validate())) return false; diff --git a/tests/Support/ConfigWriteAtomicRegression.php b/tests/Support/ConfigWriteAtomicRegression.php index 7fa3b059..28451ca1 100644 --- a/tests/Support/ConfigWriteAtomicRegression.php +++ b/tests/Support/ConfigWriteAtomicRegression.php @@ -286,7 +286,9 @@ function atomic_assert($condition, $message) { atomic_assert(!$externalMap->WriteConfig($includeRoot), 'busy dependency directory rejects save without deadlock'); \WeathermapConfig\release($busy); atomic_assert($externalMap->WriteConfig($includeRoot), 'external dependency save succeeds after mutex release'); - unlink($outsideFile); unlink($outside . '/.weathermap-config.lock'); rmdir($outside); + unlink($outside . '/.weathermap-config.lock'); chmod($outside, 0555); + atomic_assert($externalMap->WriteConfig($includeRoot), 'read-only included dependency needs no new lock file'); + chmod($outside, 0755); unlink($outsideFile); rmdir($outside); $asset = 'images/objects/atomic-asset-' . bin2hex(random_bytes(5)) . '.png'; $assetFile = dirname(__DIR__, 2) . '/' . $asset; file_put_contents($assetFile, 'Image fixture'); file_put_contents($file, $original); @@ -467,7 +469,7 @@ function ReadConfig($input, $is_include = false) { unlink($input); $this->config $controller = file_get_contents($plugin . '/weathermap-cacti-plugin-editor.php'); $start = strpos($controller, "case '" . $createAction . "':"); $colon = strpos($controller, ':', $start); $end = strpos($controller, "case '", $colon); $dispatch = substr($controller, $colon + 1, $end - $colon - 1); $mapfile = $destination; $terminated = false; ob_start(); try { eval('switch (true) { case true:' . str_replace('exit();', 'throw new \\RuntimeException("create-finished");', $dispatch) . '}'); } catch (\RuntimeException $error) { $terminated = $error->getMessage() === 'create-finished'; } $message = ob_get_clean(); - atomic_assert($terminated && http_response_code() === 500 && !is_file($destination) && str_contains($message, 'Could not save'), 'failed create stops controller with save status and guidance'); + atomic_assert($terminated && http_response_code() === 500 && !is_file($destination) && str_contains($message, 'Please try again'), 'failed create stops controller with save status and guidance'); $GLOBALS['config_fault'] = ''; unset($GLOBALS['__test_request']['sourcemap']); } $start = strpos($management, 'function map_get_next_name('); $end = strpos($management, "\n/**", $start); diff --git a/weathermap-cacti-plugin-editor.php b/weathermap-cacti-plugin-editor.php index ed566413..1cfea670 100644 --- a/weathermap-cacti-plugin-editor.php +++ b/weathermap-cacti-plugin-editor.php @@ -144,14 +144,14 @@ exit; case 'newmap': if (!newMap($mapfile)) { - print __esc('Could not save the map configuration.', 'weathermap'); + print __esc('Unable to save changes. Please try again.', 'weathermap'); exit(); } break; case 'newmapcopy': if (!newMapCopy($mapfile)) { - print __esc('Could not save the map configuration.', 'weathermap'); + print __esc('Unable to save changes. Please try again.', 'weathermap'); exit(); } From 42d95c11320fb85159efd6c79e5a7d4312774de0 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:08:46 +1030 Subject: [PATCH 28/41] Validate serialized image paths and lock physical reference sources --- lib/WeatherMap.class.php | 12 ++++++-- lib/WeatherMap.config.php | 2 +- lib/editor.actions.php | 6 ++-- lib/editor.assets.php | 29 ++++++++++++++++++- tests/Support/ConfigWriteAtomicRegression.php | 19 ++++++++++-- .../Support/EditorImageFormatsRegression.php | 12 ++++++++ 6 files changed, 71 insertions(+), 9 deletions(-) diff --git a/lib/WeatherMap.class.php b/lib/WeatherMap.class.php index c758c565..8040f88d 100644 --- a/lib/WeatherMap.class.php +++ b/lib/WeatherMap.class.php @@ -2445,11 +2445,16 @@ function DrawTitle($image, $font, $colour) { /** * Collect managed image references for commit-time existence checks. + * @param string|null $serialized Proposed output, including raw overrides. * @return string[] Paths relative to the plugin directory. */ - function ManagedImagePaths() { + function ManagedImagePaths($serialized = null) { $paths = [$this->background]; foreach ($this->nodes as $node) $paths[] = $node->iconfile; + if ($serialized !== null) { + $paths = []; + if (preg_match_all('/^\s*(?:ICON|BACKGROUND)\s+(?:[0-9]+\s+[0-9]+\s+)?(.+?)\s*$/mi', $serialized, $matches)) $paths = $matches[1]; + } return array_values(array_unique(array_filter($paths, function ($path) { return is_string($path) && preg_match('#^images/(objects|backgrounds)/#', $path); }))); @@ -3877,7 +3882,8 @@ function WriteConfig($filename, $createOnly = false, $expectedRevision = null, $ $closed = fclose($fd); $this->config_asset_conflict = false; - $newAssets = array_diff($this->ManagedImagePaths(), $this->config_managed_assets); + $savedAssets = $contents !== false ? $this->ManagedImagePaths($contents) : []; + $newAssets = array_diff($savedAssets, $this->config_managed_assets); $validate = function () use ($newAssets) { foreach ($newAssets as $asset) { if (!is_file(dirname(__DIR__) . '/' . $asset)) { @@ -3889,7 +3895,7 @@ function WriteConfig($filename, $createOnly = false, $expectedRevision = null, $ }; $sameFile = $this->configfile !== '' && $filename === $this->configfile; $saved = $write_ok && $contents !== false && $closed && \WeathermapConfig\atomicWrite($filename, $contents, $sameFile, $expectedRevision ?? ($sameFile ? $this->config_revision : null), $createOnly, $heldLock, $this->config_dependencies, $validate); - if ($saved) $this->config_managed_assets = $this->ManagedImagePaths(); + if ($saved) $this->config_managed_assets = $savedAssets; if ($saved && $sameFile) $this->config_revision = hash('sha256', $contents); return $saved; } else { diff --git a/lib/WeatherMap.config.php b/lib/WeatherMap.config.php index b56a7899..499b7d35 100644 --- a/lib/WeatherMap.config.php +++ b/lib/WeatherMap.config.php @@ -137,7 +137,7 @@ function atomicWrite($filename, $contents, $mustExist = false, $expectedRevision if ($expectedRevision !== null && @hash_file('sha256', $filename) !== $expectedRevision) return false; if (file_exists($filename) && (!is_file($filename) || !is_writable($filename))) return false; // Additional directories use nonblocking locks so opposing include graphs cannot deadlock. - $directories = array_unique(array_map('dirname', array_keys($dependencies))); + $directories = array_unique(array_merge([dirname($filename)], array_map('dirname', array_keys($dependencies)))); sort($directories); foreach ($directories as $directory) { if (realpath($directory) === realpath($lockDirectory)) continue; diff --git a/lib/editor.actions.php b/lib/editor.actions.php index 38341821..61b50d2d 100644 --- a/lib/editor.actions.php +++ b/lib/editor.actions.php @@ -1216,18 +1216,20 @@ function wm_editor_assets($mapfile, $action) { require_once __DIR__ . '/WeatherMap.config.php'; $lock = \WeathermapConfig\acquire(); if (!$lock) throw new RuntimeException(__('Could not lock map configurations.', 'weathermap')); + $referenceLocks = []; try { - $references = wm_asset_image_reference_index(); + $references = wm_asset_locked_reference_index(dirname(__DIR__) . '/configs', $referenceLocks); if (wm_asset_image_users($name, get_nfilter_request_var('image_kind'), $references)) throw new InvalidArgumentException(__('This image is still referenced by a map configuration.', 'weathermap')); if (!is_file($path) || !unlink($path)) throw new RuntimeException(__('Could not delete the image.', 'weathermap')); } finally { + foreach ($referenceLocks as $referenceLock) \WeathermapConfig\release($referenceLock); \WeathermapConfig\release($lock); } $result = ['images' => wm_asset_image_list($references)]; } echo json_encode($result); } catch (Throwable $error) { - http_response_code(400); + http_response_code($error instanceof InvalidArgumentException ? 400 : 500); echo json_encode(['error' => $error->getMessage()]); } } diff --git a/lib/editor.assets.php b/lib/editor.assets.php index a4ca3f14..c995d400 100644 --- a/lib/editor.assets.php +++ b/lib/editor.assets.php @@ -82,7 +82,8 @@ function wm_asset_normalize_path($path, $root) { * * @return array The reference patterns and source filenames. */ -function wm_asset_image_reference_index() { +function wm_asset_image_reference_index(&$directories = null) { + $directories = []; $references = []; $queue = []; $root = dirname(__DIR__); @@ -94,6 +95,8 @@ function wm_asset_image_reference_index() { $real = realpath($path); if ($real === false || !is_readable($real)) throw new RuntimeException(__('Cannot check configuration references.', 'weathermap')); if (isset($seen[$real])) continue; + $directories[dirname($path)] = true; + $directories[dirname($real)] = true; $seen[$real] = true; if (cacti_count($seen) > 1000) throw new RuntimeException(__('Too many included configurations to check.', 'weathermap')); $text = file_get_contents($real); @@ -114,6 +117,30 @@ function wm_asset_image_reference_index() { } return $references; } +/** + * Lock every physical reference source before deciding whether deletion is safe. + * @param string $rootDirectory Already locked logical config directory. + * @param array $locks Additional locks, released by the caller. + * @return array The stable reference index. + */ +function wm_asset_locked_reference_index($rootDirectory, &$locks) { + $covered = [realpath($rootDirectory) => true]; + while (true) { + $references = wm_asset_image_reference_index($directories); + $added = false; + foreach (array_keys($directories) as $directory) { + $directory = realpath($directory); + if (isset($covered[$directory])) continue; + $lock = \WeathermapConfig\acquire($directory, false); + if (!$lock && (is_writable($directory) || file_exists($directory . '/.weathermap-config.lock'))) throw new RuntimeException(__('Could not lock map configurations.', 'weathermap')); + if ($lock) $locks[] = $lock; + $covered[$directory] = true; + $added = true; + } + if (!$added) return $references; + } +} + /** * Find map configurations referencing an image. * diff --git a/tests/Support/ConfigWriteAtomicRegression.php b/tests/Support/ConfigWriteAtomicRegression.php index 28451ca1..0a9a2ab4 100644 --- a/tests/Support/ConfigWriteAtomicRegression.php +++ b/tests/Support/ConfigWriteAtomicRegression.php @@ -262,10 +262,10 @@ function atomic_assert($condition, $message) { if (@symlink($externalFile, $alias)) { $logicalLock = \WeathermapConfig\acquire($dir); atomic_assert(\WeathermapConfig\atomicWrite($alias, $original, true, hash('sha256', $original), false, $logicalLock), 'external symlink accepts caller-held logical configuration mutex'); - atomic_assert(!is_file($external . '/.weathermap-config.lock'), 'symlink replacement does not choose external target mutex'); + atomic_assert(is_file($external . '/.weathermap-config.lock'), 'symlink writes retain logical mutex and also acquire the physical target mutex'); \WeathermapConfig\release($logicalLock); unlink($alias); } - unlink($externalFile); rmdir($external); + unlink($externalFile); unlink($external . '/.weathermap-config.lock'); rmdir($external); $includeFile = $dir . '/included.conf'; $includeRoot = $dir . '/include-root.conf'; file_put_contents($includeFile, "TITLE Included old\n"); @@ -298,6 +298,21 @@ function atomic_assert($condition, $message) { atomic_assert($selection->WriteConfig($file), 'existing newly selected image saves under mutex'); unlink($assetFile); atomic_assert($selection->WriteConfig($file), 'already saved references do not prevent repairing legacy missing images'); file_put_contents($file, $original); + + $targetDir = $dir . '/alias-target'; mkdir($targetDir); $targetFile = $targetDir . '/map.conf'; file_put_contents($targetFile, $original); + $alias = $dir . '/alias.conf'; symlink($targetFile, $alias); $targetLock = \WeathermapConfig\acquire($targetDir); + atomic_assert(!\WeathermapConfig\atomicWrite($alias, 'Alias change', true, hash('sha256', $original)) && file_get_contents($targetFile) === $original, 'alias cannot bypass a held physical target mutex'); + \WeathermapConfig\release($targetLock); + atomic_assert(\WeathermapConfig\atomicWrite($alias, $original, true, hash('sha256', $original)), 'alias save succeeds after shared target mutex release'); + unlink($alias); unlink($targetFile); unlink($targetDir . '/.weathermap-config.lock'); rmdir($targetDir); + + $rawAsset = 'images/objects/raw-asset-' . bin2hex(random_bytes(5)) . '.png'; $rawAssetFile = dirname(__DIR__, 2) . '/' . $rawAsset; + $GLOBALS['__test_nfilter_request'] = ['node_name' => 'note1', 'item_configtext' => "NODE note1\n LABEL Raw\n POSITION 50 50\n ICON 40 40 " . $rawAsset . "\n"]; + file_put_contents($file, $original); http_response_code(200); setNodeConfig($file); + atomic_assert(http_response_code() === 409 && file_get_contents($file) === $original, 'raw node override cannot commit a missing managed icon'); + file_put_contents($rawAssetFile, 'Image fixture'); http_response_code(200); setNodeConfig($file); + atomic_assert(http_response_code() === 200 && str_contains(file_get_contents($file), $rawAsset), 'raw node override accepts an existing managed icon'); + unlink($rawAssetFile); file_put_contents($file, $original); $GLOBALS['__test_nfilter_request'] = []; $wrong = \fopen($file, 'rb'); atomic_assert(!\WeathermapConfig\atomicWrite($dir . '/wrong-lock.conf', 'Wrong', false, null, true, $wrong), 'different lock file rejected'); \fclose($wrong); // Revision metadata must describe the same map snapshot as the controls. diff --git a/tests/Support/EditorImageFormatsRegression.php b/tests/Support/EditorImageFormatsRegression.php index 21d5d3db..2feaf393 100644 --- a/tests/Support/EditorImageFormatsRegression.php +++ b/tests/Support/EditorImageFormatsRegression.php @@ -150,6 +150,18 @@ function image_format_assert($value, $message) { ob_start(); wm_editor_assets($config, 'delete_image'); $response = json_decode(ob_get_clean(), true); image_format_assert(isset($response['error']) && is_file($deletePath), 'referenced image cannot be deleted'); file_put_contents($config, "# no image reference\n"); + + $externalDir = $temporary . '/reference-source'; mkdir($externalDir); $externalFile = $externalDir . '/map.conf'; file_put_contents($externalFile, "# External source\n"); + $externalAlias = $root . '/configs/alias-' . bin2hex(random_bytes(4)) . '.conf'; symlink($externalFile, $externalAlias); $created[] = $externalAlias; + $externalLock = \WeathermapConfig\acquire($externalDir); + ob_start(); wm_editor_assets($config, 'delete_image'); $response = json_decode(ob_get_clean(), true); + image_format_assert(http_response_code() === 500 && isset($response['error']) && is_file($deletePath), 'image deletion cannot bypass a physical configuration mutex'); + \WeathermapConfig\release($externalLock); + file_put_contents($externalFile, "ICON images/objects/" . $deleteName . "\n"); + ob_start(); wm_editor_assets($config, 'delete_image'); $response = json_decode(ob_get_clean(), true); + image_format_assert(isset($response['error']) && is_file($deletePath), 'external symlink references remain protected after acquiring shared locks'); + unlink($externalAlias); unlink($externalFile); unlink($externalDir . '/.weathermap-config.lock'); rmdir($externalDir); + ob_start(); wm_editor_assets($config, 'delete_image'); $response = json_decode(ob_get_clean(), true); image_format_assert(isset($response['images']) && !is_file($deletePath), 'delete removes the actual server file'); echo "Editor image formats regression PASS\n"; From a2120dc6d938cd50967f9b63d8a659d3294f0d11 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:32:56 +1030 Subject: [PATCH 29/41] Address repair, serialization and dialog submission review findings --- js/editor.js | 20 ++++++++++++---- lib/WeatherMap.class.php | 13 +++++----- lib/poller-common.php | 20 ++++++++++++++-- tests/Browser/EditorPropertySubmitTest.cjs | 14 +++++++++++ tests/Support/ConfigWriteAtomicRegression.php | 24 +++++++++++++++---- 5 files changed, 73 insertions(+), 18 deletions(-) create mode 100644 tests/Browser/EditorPropertySubmitTest.cjs diff --git a/js/editor.js b/js/editor.js index 963ae13f..79d64dee 100644 --- a/js/editor.js +++ b/js/editor.js @@ -391,7 +391,7 @@ function initJS() { $('#frmMain').off('submit').on('submit', function(event) { event.preventDefault(); - form_submit(); + form_submit(event); }); /* Cacti core may have already converted this select to select2; don't @@ -888,10 +888,20 @@ function form_submit(event) { // Property dialogs can remain open while a toolbar placement action is active. // Their own Save buttons always dispatch the properties they are showing. var button = event && event.currentTarget && event.currentTarget.id; - if (button === 'tb_node_submit' || button === 'tb_link_submit') { - var node = button === 'tb_node_submit'; - $('#action').val(node ? 'set_node_properties' : 'set_link_properties'); - $('#param').val($(node ? '#node_name' : '#link_name').val()); + var sources = { + tb_node_submit: ['set_node_properties', '#node_name'], + tb_link_submit: ['set_link_properties', '#link_name'], + tb_map_submit: ['set_map_properties'], + tb_mapstyle_submit: ['set_map_style'] + }; + if (button === 'frmMain') { + var focused = $(document.activeElement).closest('.ui-dialog-content').attr('id'); + var dialogs = {dlgNodeProperties: 'tb_node_submit', dlgLinkProperties: 'tb_link_submit', dlgMapProperties: 'tb_map_submit', dlgMapStyle: 'tb_mapstyle_submit'}; + button = dialogs[focused]; + } + if (sources[button]) { + $('#action').val(sources[button][0]); + if (sources[button][1]) $('#param').val($(sources[button][1]).val()); mapmode('existing'); } diff --git a/lib/WeatherMap.class.php b/lib/WeatherMap.class.php index 8040f88d..2c8801cb 100644 --- a/lib/WeatherMap.class.php +++ b/lib/WeatherMap.class.php @@ -3764,19 +3764,19 @@ function WriteConfig($filename, $createOnly = false, $expectedRevision = null, $ $locale = localeconv(); - $decimal_point = $locale['decimal_point']; + $decimal_point = '.'; foreach ($colours as $k => $colour) { if (!isset($colour['special']) || !$colour['special']) { - $top = rtrim(rtrim(sprintf('%f', $colour['top']), '0'), $decimal_point); - $bottom = rtrim(rtrim(sprintf('%f', $colour['bottom']), '0'), $decimal_point); + $top = rtrim(rtrim(sprintf('%F', $colour['top']), '0'), $decimal_point); + $bottom = rtrim(rtrim(sprintf('%F', $colour['bottom']), '0'), $decimal_point); if ($bottom > 1000) { - $bottom = nice_bandwidth($colour['bottom'], $this->kilo); + $bottom = str_replace(localeconv()['decimal_point'], '.', nice_bandwidth($colour['bottom'], $this->kilo)); } if ($top > 1000) { - $top = nice_bandwidth($colour['top'], $this->kilo); + $top = str_replace(localeconv()['decimal_point'], '.', nice_bandwidth($colour['top'], $this->kilo)); } $tag = ($colour['tag'] ?? ''); @@ -3883,7 +3883,8 @@ function WriteConfig($filename, $createOnly = false, $expectedRevision = null, $ $this->config_asset_conflict = false; $savedAssets = $contents !== false ? $this->ManagedImagePaths($contents) : []; - $newAssets = array_diff($savedAssets, $this->config_managed_assets); + $sameDestination = $this->configfile !== '' && $filename === $this->configfile; + $newAssets = ($createOnly || !$sameDestination) ? $savedAssets : array_diff($savedAssets, $this->config_managed_assets); $validate = function () use ($newAssets) { foreach ($newAssets as $asset) { if (!is_file(dirname(__DIR__) . '/' . $asset)) { diff --git a/lib/poller-common.php b/lib/poller-common.php index 57192986..0af3dca7 100644 --- a/lib/poller-common.php +++ b/lib/poller-common.php @@ -119,9 +119,25 @@ function weathermap_check_cron($time, $string) { * @return bool Whether a complete copy was reserved at the destination. */ function weathermap_stage_asset_copy($old, $new) { + global $config; require_once __DIR__ . '/WeatherMap.config.php'; - $contents = @file_get_contents($old); - return $contents !== false && \WeathermapConfig\atomicWrite($new, $contents, false, null, true); + $root = realpath($config['base_path'] . '/plugins/weathermap'); + $source = realpath($old); + $destination = realpath(dirname($new)); + if ($root === false || $source === false || $destination === false) return false; + $root = str_replace('\\', '/', $root); $source = str_replace('\\', '/', $source); $destination = str_replace('\\', '/', $destination); + if (!str_starts_with($source, $root . '/') + || !in_array($destination, [$root . '/images/objects', $root . '/images/backgrounds'], true) + || !in_array(strtolower(pathinfo($source, PATHINFO_EXTENSION)), ['png', 'jpg', 'jpeg', 'gif'], true)) return false; + $contents = @file_get_contents($source); + if ($contents === false) return false; + $info = @getimagesizefromstring($contents); + if ($info === false || !in_array($info[2], [IMAGETYPE_PNG, IMAGETYPE_JPEG, IMAGETYPE_GIF], true)) return false; + set_error_handler(function () { return true; }, E_WARNING); + try { $image = imagecreatefromstring($contents); } finally { restore_error_handler(); } + if ($image === false) return false; + imagedestroy($image); + return \WeathermapConfig\atomicWrite($new, $contents, false, null, true); } /** diff --git a/tests/Browser/EditorPropertySubmitTest.cjs b/tests/Browser/EditorPropertySubmitTest.cjs new file mode 100644 index 00000000..db135a05 --- /dev/null +++ b/tests/Browser/EditorPropertySubmitTest.cjs @@ -0,0 +1,14 @@ +const {JSDOM}=require('jsdom'),jquery=require('jquery'),fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict'); +const ids=['dlgNodeProperties','dlgLinkProperties','dlgMapProperties','dlgMapStyle']; +const dom=new JSDOM(''+ids.map(id=>``).join('')+'',{runScripts:'outside-only'}); +const w=dom.window,$=jquery(w);w.$=w.jQuery=$;Object.assign(w,{editor_url:'/editor',wmEditorText:{},WMNotes:{},mapmode:()=>{}});let request;$.ajax=opts=>request=opts; +const src=fs.readFileSync(path.join(__dirname,'../../js/editor.js'),'utf8');w.eval(src.slice(src.indexOf('function form_submit('),src.indexOf('function map_properties('))); +for(const [button,action,param] of [['tb_node_submit','set_node_properties','A'],['tb_link_submit','set_link_properties','AB'],['tb_map_submit','set_map_properties'],['tb_mapstyle_submit','set_map_style']]) { + $('#action').val('place_stamp');w.form_submit({currentTarget:{id:button}});const data=new URLSearchParams(request.data);assert.equal(data.get('action'),action);if(param)assert.equal(data.get('param'),param); +} +const start=src.indexOf("$('#frmMain').off('submit')"),end=src.indexOf('\n\t});',start)+6;w.eval(src.slice(start,end)); +for(const [id,action] of ids.map((id,index)=>[id,['set_node_properties','set_link_properties','set_map_properties','set_map_style'][index]])) { + $('#'+id+' input')[0].focus();$('#action').val('place_legend');$('#frmMain').trigger('submit');assert.equal(new URLSearchParams(request.data).get('action'),action); +} +$('#action').val('move_node');w.form_submit();assert.equal(new URLSearchParams(request.data).get('action'),'move_node'); +dom.window.close();console.log('PASS: modeless dialog buttons and implicit form submission restore property actions'); diff --git a/tests/Support/ConfigWriteAtomicRegression.php b/tests/Support/ConfigWriteAtomicRegression.php index 0a9a2ab4..5d35f9fe 100644 --- a/tests/Support/ConfigWriteAtomicRegression.php +++ b/tests/Support/ConfigWriteAtomicRegression.php @@ -143,6 +143,7 @@ function atomic_assert($condition, $message) { $dir = sys_get_temp_dir() . '/wm-atomic-' . bin2hex(random_bytes(5)); mkdir($dir); $file = $dir . '/map.conf'; + $bitmap = imagecreatetruecolor(2, 2); ob_start(); imagepng($bitmap); $validAsset = ob_get_clean(); imagedestroy($bitmap); $original = "WIDTH 600\nHEIGHT 300\nNODE note1\n LABEL Original\n POSITION 50 50\n SET editor_note 1\n"; file_put_contents($file, $original); try { @@ -313,6 +314,13 @@ function atomic_assert($condition, $message) { file_put_contents($rawAssetFile, 'Image fixture'); http_response_code(200); setNodeConfig($file); atomic_assert(http_response_code() === 200 && str_contains(file_get_contents($file), $rawAsset), 'raw node override accepts an existing managed icon'); unlink($rawAssetFile); file_put_contents($file, $original); $GLOBALS['__test_nfilter_request'] = []; + + $copyAsset = 'images/objects/copy-asset-' . bin2hex(random_bytes(4)) . '.png'; $copyAssetFile = $plugin = dirname(__DIR__, 2); $copyAssetFile .= '/' . $copyAsset; + file_put_contents($copyAssetFile, $validAsset); file_put_contents($file, $original . " ICON " . $copyAsset . "\n"); + $copyMap = new \WeatherMap; $copyMap->ReadConfig($file); unlink($copyAssetFile); + atomic_assert(!$copyMap->WriteConfig($dir . '/missing-copy.conf', true) && !is_file($dir . '/missing-copy.conf'), 'create-only copy validates inherited image paths after deletion'); + file_put_contents($copyAssetFile, $validAsset); atomic_assert($copyMap->WriteConfig($dir . '/valid-copy.conf', true), 'copy with existing inherited image succeeds'); + unlink($copyAssetFile); file_put_contents($file, $original); $wrong = \fopen($file, 'rb'); atomic_assert(!\WeathermapConfig\atomicWrite($dir . '/wrong-lock.conf', 'Wrong', false, null, true, $wrong), 'different lock file rejected'); \fclose($wrong); // Revision metadata must describe the same map snapshot as the controls. @@ -364,7 +372,7 @@ function ReadConfig($input, $is_include = false) { unlink($input); $this->config $repairFile = $plugin . '/configs/' . $repairName; $repairImage = $plugin . '/images/backgrounds/' . $repairName . '.png'; $legacy = 'BACKGROUND ' . basename($repairImage) . "\n"; - file_put_contents($repairFile, $legacy); file_put_contents($repairImage, 'fixture'); + file_put_contents($repairFile, $legacy); file_put_contents($repairImage, $validAsset); $oldConfig = $GLOBALS['config']; $GLOBALS['config']['base_path'] = dirname($plugin, 2); $GLOBALS['__test_db_fetch_assoc'] = fn ($sql) => [['configfile' => $repairName]]; @@ -393,7 +401,13 @@ function ReadConfig($input, $is_include = false) { unlink($input); $this->config } $stageSource = $plugin . '/legacy-stage-' . bin2hex(random_bytes(4)) . '.png'; $stageTarget = $plugin . '/images/backgrounds/' . basename($stageSource); - file_put_contents($stageSource, 'Legacy asset bytes'); + file_put_contents($stageSource, $validAsset); + + $outsideAsset = $dir . '/outside.png'; file_put_contents($outsideAsset, $validAsset); + atomic_assert(!weathermap_stage_asset_copy($outsideAsset, $stageTarget) && !is_file($stageTarget), 'repair rejects a valid image outside the legacy root'); + $badAsset = $plugin . '/invalid-stage-' . bin2hex(random_bytes(4)) . '.png'; file_put_contents($badAsset, 'not an image'); + atomic_assert(!weathermap_stage_asset_copy($badAsset, $stageTarget) && !is_file($stageTarget), 'repair rejects non-image bytes within the legacy root'); + unlink($badAsset); unlink($outsideAsset); $stageConfig = $plugin . '/configs/' . basename($stageSource) . '.conf'; $stageLegacy = 'BACKGROUND ' . basename($stageSource) . "\n"; file_put_contents($stageConfig, $stageLegacy); @@ -403,7 +417,7 @@ function ReadConfig($input, $is_include = false) { unlink($input); $this->config try { // A staged copy succeeds, but the config CAS must fail after a concurrent save. $staged = weathermap_stage_asset_copy($stageSource, $stageTarget); - atomic_assert($staged && is_file($stageSource) && file_get_contents($stageTarget) === 'Legacy asset bytes', 'staging keeps original and complete destination'); + atomic_assert($staged && is_file($stageSource) && file_get_contents($stageTarget) === $validAsset, 'staging keeps original and complete destination'); $stageNewer = "TITLE Concurrent repair edit\n" . $stageLegacy; $GLOBALS['config_before_lock'] = function() use ($stageConfig, $stageNewer) { unset($GLOBALS['config_before_lock']); file_put_contents($stageConfig, $stageNewer); }; $GLOBALS['weathermap_error_suppress'] = ['WMPOLL13']; weathermap_repair_maps(); @@ -423,8 +437,8 @@ function ReadConfig($input, $is_include = false) { unlink($input); $this->config $repairPlugin = $repairRoot . '/cacti/plugins/weathermap'; mkdir($repairPlugin . '/configs', 0777, true); mkdir($repairPlugin . '/images'); symlink($plugin . '/lib', $repairPlugin . '/lib'); - file_put_contents($repairPlugin . '/legacy-background.png', 'Background bytes'); - file_put_contents($repairPlugin . '/legacy-icon.png', 'Icon bytes'); + file_put_contents($repairPlugin . '/legacy-background.png', $validAsset); + file_put_contents($repairPlugin . '/legacy-icon.png', $validAsset); $legacyFixture = "BACKGROUND legacy-background.png\nNODE Legacy\n ICON legacy-icon.png\n"; $fixtureConfig = $repairPlugin . '/configs/legacy.conf'; $oldConfig = $GLOBALS['config']; $oldSuppress = $GLOBALS['weathermap_error_suppress'] ?? []; From 5b4098a19843443836609691a2f6db717227c950 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:40:05 +1030 Subject: [PATCH 30/41] Preserve every dialog submit action during placement and Enter saves --- js/editor.js | 14 +++++++++----- tests/Browser/EditorPropertySubmitTest.cjs | 3 +++ tests/Support/ConfigWriteAtomicRegression.php | 7 +++++++ 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/js/editor.js b/js/editor.js index 79d64dee..4ebb0ba2 100644 --- a/js/editor.js +++ b/js/editor.js @@ -892,14 +892,17 @@ function form_submit(event) { tb_node_submit: ['set_node_properties', '#node_name'], tb_link_submit: ['set_link_properties', '#link_name'], tb_map_submit: ['set_map_properties'], - tb_mapstyle_submit: ['set_map_style'] + tb_mapstyle_submit: ['set_map_style'], + tb_editorsettings_submit: ['editor_settings'], + tb_note_submit: ['set_note_properties', '#note_name'], + tb_textedit_submit: [$('#dlgTextEdit').data('wm-config-action')] }; - if (button === 'frmMain') { - var focused = $(document.activeElement).closest('.ui-dialog-content').attr('id'); - var dialogs = {dlgNodeProperties: 'tb_node_submit', dlgLinkProperties: 'tb_link_submit', dlgMapProperties: 'tb_map_submit', dlgMapStyle: 'tb_mapstyle_submit'}; + if (button === 'frmMain' || !button) { + var focused = $(button === 'frmMain' ? document.activeElement : event && event.currentTarget).closest('.ui-dialog-content').attr('id'); + var dialogs = {dlgNodeProperties: 'tb_node_submit', dlgLinkProperties: 'tb_link_submit', dlgMapProperties: 'tb_map_submit', dlgMapStyle: 'tb_mapstyle_submit', dlgEditorSettings: 'tb_editorsettings_submit', dlgNoteProperties: 'tb_note_submit', dlgTextEdit: 'tb_textedit_submit'}; button = dialogs[focused]; } - if (sources[button]) { + if (sources[button] && sources[button][0]) { $('#action').val(sources[button][0]); if (sources[button][1]) $('#param').val($(sources[button][1]).val()); mapmode('existing'); @@ -1004,6 +1007,7 @@ function show_itemtext(itemtype,name) { $('#action').val('set_link_config'); } + $('#dlgTextEdit').data('wm-config-action', $('#action').val()); show_dialog('dlgTextEdit'); $.ajax({ diff --git a/tests/Browser/EditorPropertySubmitTest.cjs b/tests/Browser/EditorPropertySubmitTest.cjs index db135a05..a82d83ee 100644 --- a/tests/Browser/EditorPropertySubmitTest.cjs +++ b/tests/Browser/EditorPropertySubmitTest.cjs @@ -11,4 +11,7 @@ for(const [id,action] of ids.map((id,index)=>[id,['set_node_properties','set_lin $('#'+id+' input')[0].focus();$('#action').val('place_legend');$('#frmMain').trigger('submit');assert.equal(new URLSearchParams(request.data).get('action'),action); } $('#action').val('move_node');w.form_submit();assert.equal(new URLSearchParams(request.data).get('action'),'move_node'); +for (const [id,action] of [['dlgEditorSettings','editor_settings'],['dlgNoteProperties','set_note_properties'],['dlgTextEdit','set_node_config']]) { + const dialog=$('').attr('id',id).addClass('ui-dialog-content').appendTo('#frmMain');const button=$('').appendTo(dialog);$('#dlgTextEdit').data('wm-config-action','set_node_config');$('#action').val('place_stamp');w.form_submit({currentTarget:button[0]});assert.equal(new URLSearchParams(request.data).get('action'),action); +} dom.window.close();console.log('PASS: modeless dialog buttons and implicit form submission restore property actions'); diff --git a/tests/Support/ConfigWriteAtomicRegression.php b/tests/Support/ConfigWriteAtomicRegression.php index 5d35f9fe..c735d001 100644 --- a/tests/Support/ConfigWriteAtomicRegression.php +++ b/tests/Support/ConfigWriteAtomicRegression.php @@ -321,6 +321,13 @@ function atomic_assert($condition, $message) { atomic_assert(!$copyMap->WriteConfig($dir . '/missing-copy.conf', true) && !is_file($dir . '/missing-copy.conf'), 'create-only copy validates inherited image paths after deletion'); file_put_contents($copyAssetFile, $validAsset); atomic_assert($copyMap->WriteConfig($dir . '/valid-copy.conf', true), 'copy with existing inherited image succeeds'); unlink($copyAssetFile); file_put_contents($file, $original); + + $numericLocale = setlocale(LC_NUMERIC, 0); setlocale(LC_NUMERIC, 'fr_FR.UTF-8', 'fr_FR.utf8', 'fr_FR'); + try { + $largeScale = new \WeatherMap; $largeScale->ReadConfig("SCALE large 1500 2500 0 255 0\n"); + atomic_assert($largeScale->WriteConfig($dir . '/large-scale.conf'), 'large scale saves under alternate locale'); + $largeText = file_get_contents($dir . '/large-scale.conf'); atomic_assert(str_contains($largeText, '1.5K') && str_contains($largeText, '2.5K'), 'unit-suffixed ranges retain parser-compatible decimal points'); + } finally { setlocale(LC_NUMERIC, $numericLocale); } $wrong = \fopen($file, 'rb'); atomic_assert(!\WeathermapConfig\atomicWrite($dir . '/wrong-lock.conf', 'Wrong', false, null, true, $wrong), 'different lock file rejected'); \fclose($wrong); // Revision metadata must describe the same map snapshot as the controls. From aa0a3db6b80ebd78e89d6002609da6db46c775ca Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:24:27 +1030 Subject: [PATCH 31/41] Validate existing repair assets and inherited clone images --- lib/WeatherMap.class.php | 20 +++++++++---- lib/editor.actions.php | 4 +-- lib/poller-common.php | 30 +++++++++++++------ tests/Support/ConfigWriteAtomicRegression.php | 16 ++++++++++ tests/Support/MapDuplicatePayload.php | 3 ++ weathermap-cacti-plugin-mgmt.php | 8 ++++- 6 files changed, 64 insertions(+), 17 deletions(-) diff --git a/lib/WeatherMap.class.php b/lib/WeatherMap.class.php index b0ee1f2c..374943c8 100644 --- a/lib/WeatherMap.class.php +++ b/lib/WeatherMap.class.php @@ -2444,17 +2444,27 @@ function DrawTitle($image, $font, $colour) { } /** - * Collect managed image references for commit-time existence checks. + * Collect managed image references from proposed serialized output. + * @param string $serialized Proposed output, including raw overrides. + * @return string[] Paths relative to the plugin directory. + */ + static function ManagedImagePathsFromConfig($serialized) { + $paths = []; + if (preg_match_all('/^\s*(?:ICON|BACKGROUND)\s+(?:[0-9]+\s+[0-9]+\s+)?(.+?)\s*$/mi', $serialized, $matches)) $paths = $matches[1]; + return array_values(array_unique(array_filter($paths, function ($path) { + return preg_match('#^images/(objects|backgrounds)/#', $path); + }))); + } + + /** + * Collect managed image references from map objects or proposed output. * @param string|null $serialized Proposed output, including raw overrides. * @return string[] Paths relative to the plugin directory. */ function ManagedImagePaths($serialized = null) { $paths = [$this->background]; foreach ($this->nodes as $node) $paths[] = $node->iconfile; - if ($serialized !== null) { - $paths = []; - if (preg_match_all('/^\s*(?:ICON|BACKGROUND)\s+(?:[0-9]+\s+[0-9]+\s+)?(.+?)\s*$/mi', $serialized, $matches)) $paths = $matches[1]; - } + if ($serialized !== null) return self::ManagedImagePathsFromConfig($serialized); return array_values(array_unique(array_filter($paths, function ($path) { return is_string($path) && preg_match('#^images/(objects|backgrounds)/#', $path); }))); diff --git a/lib/editor.actions.php b/lib/editor.actions.php index f31f0414..7efd69da 100644 --- a/lib/editor.actions.php +++ b/lib/editor.actions.php @@ -98,7 +98,7 @@ function wm_editor_write_config($map, $mapfile, $createOnly = false) { } /** - * @return void + * @return bool * @param mixed $mapfile */ function newMap($mapfile) { @@ -113,7 +113,7 @@ function newMap($mapfile) { /** * @param mixed $mapfile - * @return void + * @return bool */ function newMapCopy($mapfile) { global $mapdir; diff --git a/lib/poller-common.php b/lib/poller-common.php index 0af3dca7..ebdaabce 100644 --- a/lib/poller-common.php +++ b/lib/poller-common.php @@ -111,6 +111,24 @@ function weathermap_check_cron($time, $string) { return ($matched); } +/** + * Read a regular managed raster only after successful decoding. + * @param string $path Asset filename. + * @return string|false Validated image bytes, or false. + */ +function weathermap_read_raster($path) { + if (is_link($path) || !is_file($path) || !in_array(strtolower(pathinfo($path, PATHINFO_EXTENSION)), ['png', 'jpg', 'jpeg', 'gif'], true)) return false; + $contents = @file_get_contents($path); + if ($contents === false) return false; + $info = @getimagesizefromstring($contents); + if ($info === false || !in_array($info[2], [IMAGETYPE_PNG, IMAGETYPE_JPEG, IMAGETYPE_GIF], true)) return false; + set_error_handler(function () { return true; }, E_WARNING); + try { $image = imagecreatefromstring($contents); } finally { restore_error_handler(); } + if ($image === false) return false; + imagedestroy($image); + return $contents; +} + /** * Stage a complete legacy asset copy while keeping every old map reference usable. * @@ -129,14 +147,8 @@ function weathermap_stage_asset_copy($old, $new) { if (!str_starts_with($source, $root . '/') || !in_array($destination, [$root . '/images/objects', $root . '/images/backgrounds'], true) || !in_array(strtolower(pathinfo($source, PATHINFO_EXTENSION)), ['png', 'jpg', 'jpeg', 'gif'], true)) return false; - $contents = @file_get_contents($source); + $contents = weathermap_read_raster($source); if ($contents === false) return false; - $info = @getimagesizefromstring($contents); - if ($info === false || !in_array($info[2], [IMAGETYPE_PNG, IMAGETYPE_JPEG, IMAGETYPE_GIF], true)) return false; - set_error_handler(function () { return true; }, E_WARNING); - try { $image = imagecreatefromstring($contents); } finally { restore_error_handler(); } - if ($image === false) return false; - imagedestroy($image); return \WeathermapConfig\atomicWrite($new, $contents, false, null, true); } @@ -190,7 +202,7 @@ function weathermap_repair_maps() { if (strpos($bgfile, 'images/backgrounds/') === false) { wm_debug('Found background file reference in old location: ' . $bgfile); - if (file_exists($mydir . 'images/backgrounds/' . basename($bgfile))) { + if (weathermap_read_raster($mydir . 'images/backgrounds/' . basename($bgfile)) !== false) { wm_debug('Background file found in new location! Updating path only.'); $line = 'BACKGROUND images/backgrounds/' . basename($bgfile); @@ -258,7 +270,7 @@ function weathermap_repair_maps() { if (strpos($objfile, 'images/objects/') === false) { wm_debug('Found icon file reference in old location: ' . $objfile); - if (file_exists($mydir . 'images/objects/' . basename($objfile))) { + if (weathermap_read_raster($mydir . 'images/objects/' . basename($objfile)) !== false) { wm_debug('Found file already in new location! Updating path only.'); $line = "\tICON images/objects/" . basename($objfile); diff --git a/tests/Support/ConfigWriteAtomicRegression.php b/tests/Support/ConfigWriteAtomicRegression.php index 335cc502..a3811413 100644 --- a/tests/Support/ConfigWriteAtomicRegression.php +++ b/tests/Support/ConfigWriteAtomicRegression.php @@ -417,6 +417,17 @@ function ReadConfig($input, $is_include = false) { unlink($input); $this->config $GLOBALS['config_fault'] = ''; weathermap_repair_maps(); atomic_assert(file_get_contents($repairFile) === 'BACKGROUND images/backgrounds/' . basename($repairImage), 'repair uses complete atomic replacement'); + foreach (['corrupt', 'directory', 'symlink'] as $invalidTarget) { + unlink($repairImage); file_put_contents($repairFile, $legacy); + if ($invalidTarget === 'directory') mkdir($repairImage); + elseif ($invalidTarget === 'symlink') symlink($repairFile, $repairImage); + else file_put_contents($repairImage, 'corrupt image'); + $GLOBALS['weathermap_error_suppress'] = ['WMPOLL10']; + try { weathermap_repair_maps(); } finally { $GLOBALS['weathermap_error_suppress'] = $oldSuppress; } + atomic_assert(file_get_contents($repairFile) === $legacy, 'repair retains legacy background for invalid existing target: ' . $invalidTarget); + if (is_dir($repairImage)) rmdir($repairImage); else unlink($repairImage); + file_put_contents($repairImage, $validAsset); + } file_put_contents($repairFile, $legacy); $newerRepair = 'TITLE Concurrent editor save' . "\n" . $legacy; $GLOBALS['config_before_lock'] = function() use ($repairFile, $newerRepair) { @@ -544,6 +555,11 @@ function ReadConfig($input, $is_include = false) { unlink($input); $this->config $GLOBALS['__test_db_fetch_cell'] = fn () => 0; $GLOBALS['__test_sql_save'] = function($record) { $GLOBALS['atomic_sql_saves'][] = $record; return 1; }; $GLOBALS['config_fault'] = 'rename'; try { + $GLOBALS['config_fault'] = ''; + file_put_contents($plugin . '/configs/' . $duplicateSource, "TITLE Source\nNODE A\n POSITION 10 10\n ICON images/objects/missing-clone-asset.png\n"); + map_duplicate(1, 'Clone', 'clone'); + atomic_assert($GLOBALS['atomic_sql_saves'] === [] && !is_file($plugin . '/configs/clone.conf'), 'management clone rejects missing inherited managed image before registration'); + file_put_contents($plugin . '/configs/' . $duplicateSource, "TITLE Source\n"); $GLOBALS['config_fault'] = 'rename'; map_duplicate(1, 'Clone', 'clone'); atomic_assert($GLOBALS['__test_db_calls'] === [] && $GLOBALS['atomic_sql_saves'] === [], 'failed clone never registers auth or settings or invokes runner'); } finally { unset($GLOBALS['__test_db_fetch_row_prepared'], $GLOBALS['__test_sql_save'], $GLOBALS['__test_db_fetch_cell']); unlink($plugin . '/configs/' . $duplicateSource); $GLOBALS['config_fault'] = ''; } diff --git a/tests/Support/MapDuplicatePayload.php b/tests/Support/MapDuplicatePayload.php index 3e6dce8d..2be19b25 100644 --- a/tests/Support/MapDuplicatePayload.php +++ b/tests/Support/MapDuplicatePayload.php @@ -6,6 +6,9 @@ +-------------------------------------------------------------------------+ */ +chdir(dirname(__DIR__, 2)); +require dirname(__DIR__, 2) . '/lib/WeatherMap.class.php'; + // Run the actual function without executing the management page dispatcher. $source = file_get_contents(__DIR__ . '/../../weathermap-cacti-plugin-mgmt.php'); $start = strpos($source, 'function map_duplicate('); diff --git a/weathermap-cacti-plugin-mgmt.php b/weathermap-cacti-plugin-mgmt.php index e78652a5..2288870c 100644 --- a/weathermap-cacti-plugin-mgmt.php +++ b/weathermap-cacti-plugin-mgmt.php @@ -2016,7 +2016,13 @@ function map_duplicate($id, $titlecache, $configfile = null) { $contents = is_file($oldfile) ? file_get_contents($oldfile) : false; if ($contents === false) throw new RuntimeException(__('The new Map with the name %s was unable to locate the config file %s to copy', $save['titlecache'], $map['configfile'], 'weathermap')); $contents = str_replace("TITLE {$map['titlecache']}", "TITLE {$save['titlecache']}", $contents); - if (!\WeathermapConfig\atomicWrite($newfile, $contents, false, null, true, $lock)) throw new RuntimeException(__('Could not save the map configuration.', 'weathermap')); + $validateAssets = function () use ($contents) { + foreach (WeatherMap::ManagedImagePathsFromConfig($contents) as $asset) { + if (!preg_match('/\{[^}]+\}/', $asset) && !is_file(__DIR__ . '/' . $asset)) return false; + } + return true; + }; + if (!\WeathermapConfig\atomicWrite($newfile, $contents, false, null, true, $lock, [], $validateAssets)) throw new RuntimeException(__('Could not save the map configuration.', 'weathermap')); $created = true; $newid = sql_save($save, 'weathermap_maps'); if (!$newid) throw new RuntimeException(__('Could not register the new map.', 'weathermap')); From 34d9d840be8478d26dc27a6176bb30a71a43c841 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:51:03 +1030 Subject: [PATCH 32/41] Publish complete uploads and validate included clone images --- lib/WeatherMap.class.php | 2 +- lib/editor.assets.php | 18 ++++++------- lib/poller-common.php | 10 ++++--- tests/Support/ConfigWriteAtomicRegression.php | 16 +++++++++++ .../Support/EditorImageFormatsRegression.php | 17 ++++++++++++ weathermap-cacti-plugin-mgmt.php | 27 ++++++++++++------- 6 files changed, 67 insertions(+), 23 deletions(-) diff --git a/lib/WeatherMap.class.php b/lib/WeatherMap.class.php index 374943c8..0d60904d 100644 --- a/lib/WeatherMap.class.php +++ b/lib/WeatherMap.class.php @@ -3894,7 +3894,7 @@ function WriteConfig($filename, $createOnly = false, $expectedRevision = null, $ $this->config_asset_conflict = false; $savedAssets = $contents !== false ? $this->ManagedImagePaths($contents) : []; $sameDestination = $this->configfile !== '' && $filename === $this->configfile; - $newAssets = ($createOnly || !$sameDestination) ? $savedAssets : array_diff($savedAssets, $this->config_managed_assets); + $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; diff --git a/lib/editor.assets.php b/lib/editor.assets.php index c995d400..d7826c00 100644 --- a/lib/editor.assets.php +++ b/lib/editor.assets.php @@ -212,20 +212,20 @@ function wm_asset_store_image($kind, $source, $original_name) { $stem = preg_replace('/[^A-Za-z0-9_-]/', '_', pathinfo(basename($original_name), PATHINFO_FILENAME)); $name = substr($stem ?: 'image', 0, 60) . '-' . bin2hex(random_bytes(16)) . '.' . $extension; $path = wm_asset_image_path($kind, $name); - $handle = @fopen($path, 'xb'); - if (!$handle) { imagedestroy($image); throw new RuntimeException(__('Could not reserve an image filename.', 'weathermap')); } - $written = false; + $temporary = @tempnam($dir, '.wm-upload-'); + $handle = $temporary !== false ? @fopen($temporary, 'wb') : false; + if (!$handle) { if ($temporary !== false) unlink($temporary); imagedestroy($image); throw new RuntimeException(__('Could not reserve an image filename.', 'weathermap')); } try { - // Re-encode decoded pixels; never publish the original uploaded bytes. + // Keep encoded bytes outside the library until flush, close and permissions succeed. $encoded = $size[2] === IMAGETYPE_JPEG ? $encode($image, $handle, 90) : $encode($image, $handle); $written = $encoded && fflush($handle); - } finally { $closed = fclose($handle); - $written = $written && $closed; + $handle = false; + if (!$written || !$closed || !chmod($temporary, 0644) || !@link($temporary, $path)) throw new RuntimeException(__('Could not save the image.', 'weathermap')); + } finally { + if (is_resource($handle)) fclose($handle); imagedestroy($image); - if (!$written) unlink($path); + unlink($temporary); } - if (!$written) throw new RuntimeException(__('Could not save the image.', 'weathermap')); - chmod($path, 0644); return $name; } diff --git a/lib/poller-common.php b/lib/poller-common.php index ebdaabce..2b17a3d3 100644 --- a/lib/poller-common.php +++ b/lib/poller-common.php @@ -264,8 +264,10 @@ function weathermap_repair_maps() { $dirgood = false; $attribs = preg_split('/\s+/', trim($parts[1])); - if (cacti_sizeof($attribs) == 1) { - $objfile = trim($parts[1]); + if (cacti_sizeof($attribs) == 1 || (cacti_sizeof($attribs) == 3 && ctype_digit($attribs[0]) && ctype_digit($attribs[1]))) { + $objfile = end($attribs); + $dimensions = cacti_sizeof($attribs) == 3 ? $attribs[0] . ' ' . $attribs[1] . ' ' : ''; + if (in_array(strtolower($objfile), ['box', 'rbox', 'round', 'inpie', 'outpie', 'gauge', 'nink', 'nlink', 'none'], true)) { $outcontents[] = rtrim($line); continue; } if (strpos($objfile, 'images/objects/') === false) { wm_debug('Found icon file reference in old location: ' . $objfile); @@ -273,7 +275,7 @@ function weathermap_repair_maps() { if (weathermap_read_raster($mydir . 'images/objects/' . basename($objfile)) !== false) { wm_debug('Found file already in new location! Updating path only.'); - $line = "\tICON images/objects/" . basename($objfile); + $line = "\tICON " . $dimensions . "images/objects/" . basename($objfile); $changes++; } else { @@ -292,7 +294,7 @@ function weathermap_repair_maps() { if (is_readable($mydir . $objfile)) { if (is_readable($old) && is_writeable(dirname($new))) { if (weathermap_stage_asset_copy($old, $new)) { - $line = "\tICON images/objects/" . basename($objfile); + $line = "\tICON " . $dimensions . "images/objects/" . basename($objfile); $changes++; } else { wm_warn(sprintf('Unable to move ICONFILE %s to %s, Permission error [WMPOLL11]', $old, $new)); diff --git a/tests/Support/ConfigWriteAtomicRegression.php b/tests/Support/ConfigWriteAtomicRegression.php index a3811413..fc1773ed 100644 --- a/tests/Support/ConfigWriteAtomicRegression.php +++ b/tests/Support/ConfigWriteAtomicRegression.php @@ -338,6 +338,13 @@ function atomic_assert($condition, $message) { file_put_contents($copyAssetFile, $validAsset); atomic_assert($copyMap->WriteConfig($dir . '/valid-copy.conf', true), 'copy with existing inherited image succeeds'); unlink($copyAssetFile); file_put_contents($file, $original); + $includeAsset = $dir . '/image-include.conf'; + file_put_contents($includeAsset, "NODE Inherited\n POSITION 20 20\n ICON images/objects/missing-included-image.png\n"); + file_put_contents($file, "INCLUDE " . $includeAsset . "\n" . $original); + $includedImageMap = new \WeatherMap; $includedImageMap->ReadConfig($file); + atomic_assert(!$includedImageMap->WriteConfig($dir . '/included-image-copy.conf', true) && !is_file($dir . '/included-image-copy.conf'), 'create-only copy checks effective image references inherited through INCLUDE'); + file_put_contents($file, $original); + $dynamicIcon = 'images/objects/updown_{node:this:bandwidth_in}.png'; file_put_contents($file, $original . " ICON " . $dynamicIcon . "\n"); $dynamicMap = new \WeatherMap; $dynamicMap->ReadConfig($file); @@ -496,6 +503,12 @@ function ReadConfig($input, $is_include = false) { unlink($input); $this->config file_put_contents($fixtureConfig, $legacyFixture); weathermap_repair_maps(); atomic_assert(file_get_contents($fixtureConfig) === $repairedFixture, 'existing complete copies support path-only repair'); weathermap_repair_maps(); atomic_assert(file_get_contents($fixtureConfig) === $repairedFixture, 'correct paths require no rewrite'); + $sizedLegacy = "NODE Sized\n ICON 100 80 legacy-icon.png\nNODE Shape\n ICON 30 40 box\n"; + file_put_contents($fixtureConfig, $sizedLegacy); weathermap_repair_maps(); + atomic_assert(str_contains(file_get_contents($fixtureConfig), 'ICON 100 80 images/objects/legacy-icon.png') && str_contains(file_get_contents($fixtureConfig), 'ICON 30 40 box'), 'sized legacy icon repair preserves dimensions and built-in shapes'); + unlink($repairPlugin . '/images/objects/legacy-icon.png'); file_put_contents($fixtureConfig, $sizedLegacy); weathermap_repair_maps(); + atomic_assert(str_contains(file_get_contents($fixtureConfig), 'ICON 100 80 images/objects/legacy-icon.png') && is_file($repairPlugin . '/images/objects/legacy-icon.png'), 'sized legacy icon is staged into managed library'); + unlink($repairPlugin . '/images/backgrounds/legacy-background.png'); unlink($repairPlugin . '/images/objects/legacy-icon.png'); file_put_contents($fixtureConfig, $legacyFixture); $GLOBALS['config_fault'] = 'rename'; weathermap_repair_maps(); atomic_assert(file_get_contents($fixtureConfig) === $legacyFixture && is_file($repairPlugin . '/legacy-background.png') && is_file($repairPlugin . '/legacy-icon.png'), 'failed background and icon staging preserves usable original references'); @@ -559,6 +572,9 @@ function ReadConfig($input, $is_include = false) { unlink($input); $this->config file_put_contents($plugin . '/configs/' . $duplicateSource, "TITLE Source\nNODE A\n POSITION 10 10\n ICON images/objects/missing-clone-asset.png\n"); map_duplicate(1, 'Clone', 'clone'); atomic_assert($GLOBALS['atomic_sql_saves'] === [] && !is_file($plugin . '/configs/clone.conf'), 'management clone rejects missing inherited managed image before registration'); + file_put_contents($plugin . '/configs/' . $duplicateSource, "TITLE Source\nINCLUDE " . $includeAsset . "\n"); + map_duplicate(1, 'Clone', 'clone'); + atomic_assert($GLOBALS['atomic_sql_saves'] === [] && !is_file($plugin . '/configs/clone.conf'), 'management clone rejects missing image inherited through INCLUDE before registration'); file_put_contents($plugin . '/configs/' . $duplicateSource, "TITLE Source\n"); $GLOBALS['config_fault'] = 'rename'; map_duplicate(1, 'Clone', 'clone'); atomic_assert($GLOBALS['__test_db_calls'] === [] && $GLOBALS['atomic_sql_saves'] === [], 'failed clone never registers auth or settings or invokes runner'); diff --git a/tests/Support/EditorImageFormatsRegression.php b/tests/Support/EditorImageFormatsRegression.php index 2feaf393..84da5465 100644 --- a/tests/Support/EditorImageFormatsRegression.php +++ b/tests/Support/EditorImageFormatsRegression.php @@ -66,6 +66,23 @@ function image_format_assert($value, $message) { image_format_assert(in_array(basename($config), wm_asset_image_users($name), true), 'reference protection applies to every format'); } } + $uploadSource = $temporary . '/publication.png'; $bitmap = imagecreatetruecolor(12, 8); imagepng($bitmap, $uploadSource); imagedestroy($bitmap); + $storeSource = file_get_contents($root . '/lib/editor.assets.php'); $start = strpos($storeSource, 'function wm_asset_store_image('); + $instrumented = substr($storeSource, $start); + $instrumented = str_replace(['function wm_asset_store_image(', "'imagepng'"], ['function publication_store_image(', "'publication_encode'"], $instrumented); + eval($instrumented); + function publication_encode($image, $handle) { + $visible = array_filter(wm_asset_image_list(), fn ($item) => str_starts_with($item['name'], 'publication-')); + image_format_assert($visible === [], 'encoding image is not selectable before publication'); + if ($GLOBALS['publication_fail']) { fwrite($handle, 'partial bytes'); return false; } + return imagepng($image, $handle); + } + $GLOBALS['publication_fail'] = true; + try { publication_store_image('objects', $uploadSource, 'publication.png'); throw new RuntimeException('accepted failed encoding'); } catch (RuntimeException $expected) { image_format_assert(str_contains($expected->getMessage(), 'Could not save'), 'encoding failure reported'); } + image_format_assert(glob($root . '/images/objects/publication-*.png') === [] && glob($root . '/images/objects/.wm-upload-*') === [], 'failed encoding leaves no library file or staging file'); + $GLOBALS['publication_fail'] = false; + $published = publication_store_image('objects', $uploadSource, 'publication.png'); $created[] = wm_asset_image_path('objects', $published); + image_format_assert(getimagesize(end($created))[0] === 12 && (fileperms(end($created)) & 0777) === 0644 && glob($root . '/images/objects/.wm-upload-*') === [], 'successful upload publishes complete permitted image and removes staging file'); file_put_contents($config, "ICON images/objects/core-router.png\n# router.png is merely a comment\n"); image_format_assert(wm_asset_image_users('router.png', 'objects') === [], 'substring and comment do not count as references'); file_put_contents($config, "ICON images/objects/ROUTER.PNG\n"); diff --git a/weathermap-cacti-plugin-mgmt.php b/weathermap-cacti-plugin-mgmt.php index 2288870c..27f92c81 100644 --- a/weathermap-cacti-plugin-mgmt.php +++ b/weathermap-cacti-plugin-mgmt.php @@ -2015,14 +2015,23 @@ function map_duplicate($id, $titlecache, $configfile = null) { try { $contents = is_file($oldfile) ? file_get_contents($oldfile) : false; if ($contents === false) throw new RuntimeException(__('The new Map with the name %s was unable to locate the config file %s to copy', $save['titlecache'], $map['configfile'], 'weathermap')); + $previousDirectory = getcwd(); + try { + chdir(__DIR__); + $assetMap = new WeatherMap; + if (!$assetMap->ReadConfig($oldfile) || $assetMap->config_revision !== hash('sha256', $contents)) throw new RuntimeException(__('Could not save the map configuration.', 'weathermap')); + } finally { chdir($previousDirectory); } + $dependencies = $assetMap->config_dependencies; + $dependencies[realpath($oldfile)] = $assetMap->config_revision; $contents = str_replace("TITLE {$map['titlecache']}", "TITLE {$save['titlecache']}", $contents); - $validateAssets = function () use ($contents) { - foreach (WeatherMap::ManagedImagePathsFromConfig($contents) as $asset) { + $assets = array_unique(array_merge($assetMap->ManagedImagePaths(), WeatherMap::ManagedImagePathsFromConfig($contents))); + $validateAssets = function () use ($assets) { + foreach ($assets as $asset) { if (!preg_match('/\{[^}]+\}/', $asset) && !is_file(__DIR__ . '/' . $asset)) return false; } return true; }; - if (!\WeathermapConfig\atomicWrite($newfile, $contents, false, null, true, $lock, [], $validateAssets)) throw new RuntimeException(__('Could not save the map configuration.', 'weathermap')); + if (!\WeathermapConfig\atomicWrite($newfile, $contents, false, null, true, $lock, $dependencies, $validateAssets)) throw new RuntimeException(__('Could not save the map configuration.', 'weathermap')); $created = true; $newid = sql_save($save, 'weathermap_maps'); if (!$newid) throw new RuntimeException(__('Could not register the new map.', 'weathermap')); @@ -3451,16 +3460,16 @@ function newMap($mapfile, $sourcemapfile = '', $title = '') { if ($sourcemapfile != '') { if (file_exists($sourcemapfile) && is_readable($sourcemapfile)) { if (!$map->ReadConfig($sourcemapfile) || !is_string($map->config_revision)) { - raise_message('map_message', __('The Source Map File name is not readable or does not exist!', 'weathermap'), MESSAGE_LEVEL_ERROR); - return; - } + raise_message('map_message', __('The Source Map File name is not readable or does not exist!', 'weathermap'), MESSAGE_LEVEL_ERROR); + return; + } if ($title !== '') { $map->title = $title; } if (!$map->WriteConfig($mapfile, true)) { - raise_message('map_message', __('Could not save the map configuration.', 'weathermap'), MESSAGE_LEVEL_ERROR); - return; - } + raise_message('map_message', __('Could not save the map configuration.', 'weathermap'), MESSAGE_LEVEL_ERROR); + return; + } raise_message('map_message', __('New Map file %s created from %s', basename($mapfile), basename($sourcemapfile), 'weathermap'), MESSAGE_LEVEL_INFO); } else { raise_message('map_message', __('The Source Map File name is not readable or does not exist!', 'weathermap'), MESSAGE_LEVEL_ERROR); From 0110a1abb7265792f74653cf99b74e0f99018669 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:14:42 +1030 Subject: [PATCH 33/41] Require mutation revisions and keep clone diagnostics server-side --- lib/editor.actions.php | 45 ++++++++-------- locales/po/cacti.pot | 6 ++- .../BackgroundSizingEngineRegression.php | 11 +++- tests/Support/ConfigWriteAtomicRegression.php | 51 ++++++++++++++----- tests/Support/MapDuplicatePayload.php | 2 + tests/Unit/EditorSettingsCookieTest.php | 2 +- weathermap-cacti-plugin-mgmt.php | 5 +- 7 files changed, 84 insertions(+), 38 deletions(-) diff --git a/lib/editor.actions.php b/lib/editor.actions.php index 7efd69da..2630e9e4 100644 --- a/lib/editor.actions.php +++ b/lib/editor.actions.php @@ -55,9 +55,10 @@ * * @param WeatherMap $map Engine receiving the configuration. * @param string $mapfile Configuration path. + * @param bool $requireRevision Require the loaded page token for mutation paths. * @return bool Whether the map was loaded. */ -function wm_editor_read_config($map, $mapfile) { +function wm_editor_read_config($map, $mapfile, $requireRevision = false) { if (!is_file($mapfile) || !is_readable($mapfile)) { http_response_code(404); return false; @@ -71,7 +72,7 @@ function wm_editor_read_config($map, $mapfile) { return false; } $revision = get_nfilter_request_var('config_revision'); - if ($revision !== null && $revision !== '' && (!is_string($revision) || !hash_equals(\WeathermapConfig\snapshotRevision($map->config_revision, $map->config_dependencies), $revision))) { + if (($requireRevision && (!is_string($revision) || $revision === '')) || ($revision !== null && $revision !== '' && (!is_string($revision) || !hash_equals(\WeathermapConfig\snapshotRevision($map->config_revision, $map->config_dependencies), $revision)))) { http_response_code(409); return false; } @@ -279,7 +280,7 @@ function setNodeConfig($mapfile) { $map->context = 'editor'; - if (!wm_editor_read_config($map, $mapfile)) return; + if (!wm_editor_read_config($map, $mapfile, true)) return; $node_name = get_nfilter_request_var('node_name'); $node_config = get_nfilter_request_var('item_configtext'); @@ -300,7 +301,7 @@ function setLinkConfig($mapfile) { $map->context = 'editor'; - if (!wm_editor_read_config($map, $mapfile)) return; + if (!wm_editor_read_config($map, $mapfile, true)) return; $link_name = get_nfilter_request_var('link_name'); $link_config = get_nfilter_request_var('item_configtext'); @@ -321,7 +322,7 @@ function setNodeProperties($mapfile) { $map->context = 'editor'; - if (!wm_editor_read_config($map, $mapfile)) return; + if (!wm_editor_read_config($map, $mapfile, true)) return; $node_name = get_nfilter_request_var('node_name'); $new_node_name = get_nfilter_request_var('node_new_name'); @@ -410,7 +411,7 @@ function setLinkProperties($mapfile) { $map->context = 'editor'; - if (!wm_editor_read_config($map, $mapfile)) return; + if (!wm_editor_read_config($map, $mapfile, true)) return; $link_name = get_nfilter_request_var('link_name'); @@ -491,7 +492,7 @@ function setMapProperties($mapfile) { $map->context = 'editor'; - if (!wm_editor_read_config($map, $mapfile)) return; + if (!wm_editor_read_config($map, $mapfile, true)) return; $map->title = wm_editor_sanitize_string(get_nfilter_request_var('map_title')); $map->keytext['DEFAULT'] = wm_editor_sanitize_string(get_nfilter_request_var('map_legend')); @@ -576,7 +577,7 @@ function setMapStyle($mapfile) { $map->context = 'editor'; - if (!wm_editor_read_config($map, $mapfile)) return; + if (!wm_editor_read_config($map, $mapfile, true)) return; if (wm_editor_validate_one_of(get_nfilter_request_var('mapstyle_htmlstyle'), ['static', 'overlib'], false)) { $map->htmlstyle = strtolower(get_nfilter_request_var('mapstyle_htmlstyle')); @@ -613,7 +614,7 @@ function addLink($mapfile) { $map->context = 'editor'; - if (!wm_editor_read_config($map, $mapfile)) return; + if (!wm_editor_read_config($map, $mapfile, true)) return; $a = get_nfilter_request_var('param'); $b = get_nfilter_request_var('param2'); @@ -664,7 +665,7 @@ function placeLegend($mapfile, $grid_snap_value) { $scalename = wm_editor_sanitize_name(get_nfilter_request_var('param')); - if (!wm_editor_read_config($map, $mapfile)) return; + if (!wm_editor_read_config($map, $mapfile, true)) return; $map->keyx[$scalename] = $x; $map->keyy[$scalename] = $y; @@ -685,7 +686,7 @@ function placeStamp($mapfile, $grid_snap_value) { $x = snap(intval(get_nfilter_request_var('x')), $grid_snap_value); $y = snap(intval(get_nfilter_request_var('y')), $grid_snap_value); - if (!wm_editor_read_config($map, $mapfile)) return; + if (!wm_editor_read_config($map, $mapfile, true)) return; $map->timex = $x; $map->timey = $y; @@ -707,7 +708,7 @@ function viaLink($mapfile) { $link_name = wm_editor_sanitize_name(get_nfilter_request_var('link_name')); - if (!wm_editor_read_config($map, $mapfile)) return; + if (!wm_editor_read_config($map, $mapfile, true)) return; if (isset($map->links[$link_name])) { $map->links[$link_name]->vialist = [[0 =>$x, 1=>$y]]; @@ -730,7 +731,7 @@ function moveNode($mapfile, $grid_snap_value) { $node_name = wm_editor_sanitize_name(get_nfilter_request_var('node_name')); - if (!wm_editor_read_config($map, $mapfile)) return; + if (!wm_editor_read_config($map, $mapfile, true)) return; if (!isset($map->nodes[$node_name])) { http_response_code(404); @@ -841,7 +842,7 @@ function linkTidy($mapfile) { $map->context = 'editor'; - if (!wm_editor_read_config($map, $mapfile)) return; + if (!wm_editor_read_config($map, $mapfile, true)) return; $target = wm_editor_sanitize_name(get_nfilter_request_var('param')); @@ -864,7 +865,7 @@ function reTidy($mapfile) { $map->context = 'editor'; - if (!wm_editor_read_config($map, $mapfile)) return; + if (!wm_editor_read_config($map, $mapfile, true)) return; // draw a map and throw it away, to calculate all the bounding boxes $map->DrawMap('null'); @@ -882,7 +883,7 @@ function reTidyAll($mapfile) { $map->context = 'editor'; - if (!wm_editor_read_config($map, $mapfile)) return; + if (!wm_editor_read_config($map, $mapfile, true)) return; // draw a map and throw it away, to calculate all the bounding boxes $map->DrawMap('null'); @@ -900,7 +901,7 @@ function unTidy($mapfile) { $map->context = 'editor'; - if (!wm_editor_read_config($map, $mapfile)) return; + if (!wm_editor_read_config($map, $mapfile, true)) return; // draw a map and throw it away, to calculate all the bounding boxes $map->DrawMap('null'); @@ -918,7 +919,7 @@ function deleteLink($mapfile) { $map->context = 'editor'; - if (!wm_editor_read_config($map, $mapfile)) return; + if (!wm_editor_read_config($map, $mapfile, true)) return; $target = wm_editor_sanitize_name(get_nfilter_request_var('param')); $log = 'delete link ' . $target; @@ -943,7 +944,7 @@ function addNode($mapfile, $grid_snap_value) { $x = snap(intval(get_nfilter_request_var('x')), $grid_snap_value); $y = snap(intval(get_nfilter_request_var('y')), $grid_snap_value); - if (!wm_editor_read_config($map, $mapfile)) return; + if (!wm_editor_read_config($map, $mapfile, true)) return; $newnodename = sprintf('node%05d', time() % 10000); @@ -987,7 +988,7 @@ function editorSettings($mapfile) { $map->context = 'editor'; // have to do this, otherwise the editor will be unresponsive afterwards - not actually going to change anything! - if (!wm_editor_read_config($map, $mapfile)) return; + if (!wm_editor_read_config($map, $mapfile, true)) return; $use_overlay = (isset_request_var('editorsettings_showvias') ? intval(get_nfilter_request_var('editorsettings_showvias')) : false); $use_relative_overlay = (isset_request_var('editorsettings_showrelative') ? intval(get_nfilter_request_var('editorsettings_showrelative')) : false); @@ -1006,7 +1007,7 @@ function deleteNode($mapfile) { $map->context = 'editor'; - if (!wm_editor_read_config($map, $mapfile)) return; + if (!wm_editor_read_config($map, $mapfile, true)) return; $target = wm_editor_sanitize_name(get_nfilter_request_var('param')); @@ -1040,7 +1041,7 @@ function cloneNode($mapfile) { $map->context = 'editor'; - if (!wm_editor_read_config($map, $mapfile)) return; + if (!wm_editor_read_config($map, $mapfile, true)) return; $target = wm_editor_sanitize_name(get_nfilter_request_var('param')); diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index f631e36b..8c85bd62 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti 1.7\n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-13 23:15+1030\n" +"POT-Creation-Date: 2026-10-14 23:12+1030\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" @@ -1974,6 +1974,10 @@ msgstr "" msgid "Could not register the new map." msgstr "" +#: weathermap-cacti-plugin-mgmt.php +msgid "Could not create the map copy. Please try again." +msgstr "" + #: weathermap-cacti-plugin-mgmt.php #, php-format msgid "The new Map with the name %s was created using config file %s" diff --git a/tests/Support/BackgroundSizingEngineRegression.php b/tests/Support/BackgroundSizingEngineRegression.php index 00b8457b..c7aff2b4 100644 --- a/tests/Support/BackgroundSizingEngineRegression.php +++ b/tests/Support/BackgroundSizingEngineRegression.php @@ -1,4 +1,13 @@ ReadConfig($file); + $GLOBALS['__test_nfilter_request']['config_revision'] = \WeathermapConfig\snapshotRevision($map->config_revision, $map->config_dependencies); + } + return $callback(); +} + /* +-------------------------------------------------------------------------+ | Copyright (C) 2004-2026 The Cacti Group, Howard Jones | @@ -59,7 +68,7 @@ function wm_engine_assert($value,$message) { 'map_bgfile' => $directory . '/background.png', 'map_background_sizing' => $mode, 'map_linkdefaultwidth' => '7', 'map_linkdefaultbwin' => '100M', 'map_linkdefaultbwout' => '100M', ]; - setMapProperties($directory . '/' . $mode . '.conf'); + wm_fixture_revision($directory . '/' . $mode . '.conf', fn () => setMapProperties($directory . '/' . $mode . '.conf')); $saved = new WeatherMap(); $saved->ReadConfig($directory . '/' . $mode . '.conf'); wm_engine_assert($saved->get_hint('background_sizing') === ($mode === 'invalid' ? 'image' : $mode), $mode . ' editor persistence'); diff --git a/tests/Support/ConfigWriteAtomicRegression.php b/tests/Support/ConfigWriteAtomicRegression.php index fc1773ed..3ba43bf2 100644 --- a/tests/Support/ConfigWriteAtomicRegression.php +++ b/tests/Support/ConfigWriteAtomicRegression.php @@ -122,6 +122,15 @@ function rename($source, $target) { } } namespace { +/** Submit the page revision expected by production mutation routes. */ +function wm_fixture_revision($file, $callback) { + if (!array_key_exists('config_revision', $GLOBALS['__test_nfilter_request'] ?? []) && is_file($file)) { + $map = new \WeatherMap; $map->ReadConfig($file); + $GLOBALS['__test_nfilter_request']['config_revision'] = \WeathermapConfig\snapshotRevision($map->config_revision, $map->config_dependencies); + } + return $callback(); +} + require dirname(__DIR__, 2) . '/lib/poller-common.php'; require dirname(__DIR__) . '/bootstrap-unit.php'; chdir(dirname(__DIR__, 2)); @@ -216,7 +225,7 @@ function atomic_assert($condition, $message) { $first->title = 'Next edit'; atomic_assert($first->WriteConfig($file), 'successful save updates its revision for subsequent writes'); $GLOBALS['__test_nfilter_request'] = ['config_revision' => hash('sha256', $original), 'node_name' => 'note1', 'x' => 90, 'y' => 90]; - http_response_code(200); moveNode($file, 0); + http_response_code(200); wm_fixture_revision($file, fn () => moveNode($file, 0)); atomic_assert(http_response_code() === 409 && file_get_contents($file) !== $original, 'stale loaded page cannot overwrite another user save'); $GLOBALS['__test_nfilter_request'] = ['config_revision' => hash_file('sha256', $file)]; $matching = new \WeatherMap; @@ -325,9 +334,9 @@ function atomic_assert($condition, $message) { $rawAsset = 'images/objects/raw-asset-' . bin2hex(random_bytes(5)) . '.png'; $rawAssetFile = dirname(__DIR__, 2) . '/' . $rawAsset; $GLOBALS['__test_nfilter_request'] = ['node_name' => 'note1', 'item_configtext' => "NODE note1\n LABEL Raw\n POSITION 50 50\n ICON 40 40 " . $rawAsset . "\n"]; - file_put_contents($file, $original); http_response_code(200); setNodeConfig($file); + file_put_contents($file, $original); http_response_code(200); wm_fixture_revision($file, fn () => setNodeConfig($file)); atomic_assert(http_response_code() === 409 && file_get_contents($file) === $original, 'raw node override cannot commit a missing managed icon'); - file_put_contents($rawAssetFile, 'Image fixture'); http_response_code(200); setNodeConfig($file); + file_put_contents($rawAssetFile, 'Image fixture'); http_response_code(200); wm_fixture_revision($file, fn () => setNodeConfig($file)); atomic_assert(http_response_code() === 200 && str_contains(file_get_contents($file), $rawAsset), 'raw node override accepts an existing managed icon'); unlink($rawAssetFile); file_put_contents($file, $original); $GLOBALS['__test_nfilter_request'] = []; @@ -350,10 +359,24 @@ function atomic_assert($condition, $message) { $dynamicMap = new \WeatherMap; $dynamicMap->ReadConfig($file); atomic_assert($dynamicMap->WriteConfig($dir . '/dynamic-copy.conf', true), 'create-only copy retains tokenized managed icons'); $GLOBALS['__test_nfilter_request'] = ['node_name' => 'note1', 'item_configtext' => "NODE note1\n LABEL Dynamic\n POSITION 50 50\n ICON " . $dynamicIcon . "\n"]; - file_put_contents($file, $original); http_response_code(200); setNodeConfig($file); + file_put_contents($file, $original); http_response_code(200); wm_fixture_revision($file, fn () => setNodeConfig($file)); atomic_assert(http_response_code() === 200 && str_contains(file_get_contents($file), $dynamicIcon), 'raw edit accepts renderer tokenized icon paths'); $GLOBALS['__test_nfilter_request'] = []; file_put_contents($file, $original); + $GLOBALS['__test_nfilter_request'] = []; + atomic_assert(wm_editor_read_config(new \WeatherMap, $file), 'read/render paths accept absent page revisions'); + foreach ([null, '', [], 'invalid'] as $missingRevision) { + $GLOBALS['__test_nfilter_request'] = ['config_revision' => $missingRevision]; http_response_code(200); + atomic_assert(!wm_editor_read_config(new \WeatherMap, $file, true) && http_response_code() === 409, 'mutation requires valid nonempty loaded revision'); + } + $GLOBALS['__test_nfilter_request'] = []; $beforeMissingRevision = file_get_contents($file); http_response_code(200); + moveNode($file, 0); + atomic_assert(http_response_code() === 409 && file_get_contents($file) === $beforeMissingRevision, 'missing mutation token cannot alter configuration'); + $revisionMap = new \WeatherMap; $revisionMap->ReadConfig($file); + $GLOBALS['__test_nfilter_request'] = ['config_revision' => \WeathermapConfig\snapshotRevision($revisionMap->config_revision, $revisionMap->config_dependencies)]; + atomic_assert(wm_editor_read_config(new \WeatherMap, $file, true), 'matching mutation revision remains accepted'); + $GLOBALS['__test_nfilter_request'] = []; + $numericLocale = setlocale(LC_NUMERIC, 0); setlocale(LC_NUMERIC, 'fr_FR.UTF-8', 'fr_FR.utf8', 'fr_FR'); try { $largeScale = new \WeatherMap; $largeScale->ReadConfig("SCALE large 1500 2500 0 255 0\n"); @@ -585,7 +608,7 @@ function ReadConfig($input, $is_include = false) { unlink($input); $this->config foreach (['node', 'link'] as $kind) { file_put_contents($rawFile, $rawConfig); $GLOBALS['__test_nfilter_request'] = ['config_revision' => hash_file('sha256', $rawFile), 'node_name' => 'A', 'link_name' => 'AB', 'item_configtext' => $kind === 'node' ? "NODE A\n LABEL Updated\n POSITION 10 10\n" : "LINK AB\n NODES A B\n WIDTH 12\n"]; - http_response_code(200); if ($kind === 'node') setNodeConfig($rawFile); else setLinkConfig($rawFile); + http_response_code(200); if ($kind === 'node') wm_fixture_revision($rawFile, fn () => setNodeConfig($rawFile)); else wm_fixture_revision($rawFile, fn () => setLinkConfig($rawFile)); atomic_assert(http_response_code() === 200 && file_get_contents($rawFile) !== $rawConfig, 'raw ' . $kind . ' edit saves without false conflict'); file_put_contents($rawFile, $rawConfig); http_response_code(200); $controller = file_get_contents($plugin . '/weathermap-cacti-plugin-editor.php'); @@ -622,7 +645,11 @@ function ReadConfig($input, $is_include = false) { unlink($input); $this->config if ((str_contains($sql, 'INSERT INTO weathermap_auth') && $failure === 'auth-throw') || (str_contains($sql, 'INSERT INTO weathermap_settings') && $failure === 'settings-throw')) throw new RuntimeException('insert failed'); return !((str_contains($sql, 'INSERT INTO weathermap_auth') && $failure === 'auth-false') || (str_contains($sql, 'INSERT INTO weathermap_settings') && $failure === 'settings-false')); }; + $cloneMessages = []; $GLOBALS['__test_raise_message'] = function ($id, $text) use (&$cloneMessages) { $cloneMessages[] = $text; }; + $GLOBALS['__test_cacti_log'] = []; map_duplicate(1, 'Clone', 'clone'); + unset($GLOBALS['__test_raise_message']); + atomic_assert($cloneMessages === ['Could not create the map copy. Please try again.'] && $GLOBALS['__test_cacti_log'] !== [], 'clone failures show only generic guidance and log original diagnostics'); atomic_assert(!is_file($plugin . '/configs/clone.conf'), 'failed registration removes its own reserved configuration'); if (str_ends_with($failure, '-false') || str_ends_with($failure, '-throw')) atomic_assert(cacti_count(array_filter($GLOBALS['__test_db_calls'], fn ($call) => str_starts_with($call['sql'], 'DELETE'))) === 3, 'partial registration removes map/auth/settings records'); } @@ -636,25 +663,25 @@ function ReadConfig($input, $is_include = false) { unlink($input); $this->config atomic_assert(!$map->WriteConfig($file) && file_get_contents($file) === $original, 'engine failure preserves original'); $GLOBALS['__test_nfilter_request'] = ['map_title' => 'Failed 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']; $GLOBALS['__test_db_calls'] = []; - http_response_code(200); setMapProperties($file); + http_response_code(200); wm_fixture_revision($file, fn () => setMapProperties($file)); atomic_assert(http_response_code() === 500 && $GLOBALS['__test_db_calls'] === [], 'failed properties save cannot change titlecache'); - $GLOBALS['config_fault'] = ''; http_response_code(200); setMapProperties($file); + $GLOBALS['config_fault'] = ''; http_response_code(200); wm_fixture_revision($file, fn () => setMapProperties($file)); atomic_assert(http_response_code() === 200 && cacti_count($GLOBALS['__test_db_calls']) === 1, 'successful properties save updates titlecache'); file_put_contents($file, $original); $GLOBALS['config_fault'] = 'rename'; $GLOBALS['__test_nfilter_request'] = ['node_name' => 'note1', 'param' => 'note1', 'x' => '70', 'y' => '80']; { - http_response_code(200); moveNode($file, 0); + http_response_code(200); wm_fixture_revision($file, fn () => moveNode($file, 0)); atomic_assert(http_response_code() === 500 && file_get_contents($file) === $original, 'failed move surfaces error without changing file'); - http_response_code(200); deleteNode($file); + http_response_code(200); wm_fixture_revision($file, fn () => deleteNode($file)); atomic_assert(http_response_code() === 500 && file_get_contents($file) === $original, 'failed delete surfaces error without changing file'); } $GLOBALS['config_fault'] = ''; $GLOBALS['__test_nfilter_request'] = ['node_name' => 'missing', 'param' => 'missing', 'x' => '70', 'y' => '80']; - http_response_code(200); moveNode($file, 0); + http_response_code(200); wm_fixture_revision($file, fn () => moveNode($file, 0)); atomic_assert(http_response_code() === 404 && file_get_contents($file) === $original, 'missing move target is an error'); - http_response_code(200); deleteNode($file); + http_response_code(200); wm_fixture_revision($file, fn () => deleteNode($file)); atomic_assert(http_response_code() === 404 && file_get_contents($file) === $original, 'missing delete target is an error'); - unlink($file); http_response_code(200); moveNode($file, 0); + unlink($file); http_response_code(200); wm_fixture_revision($file, fn () => moveNode($file, 0)); atomic_assert(http_response_code() === 404 && !file_exists($file), 'deleted map cannot report a successful edit'); echo "Atomic configuration write and locking regression PASS\n"; } finally { diff --git a/tests/Support/MapDuplicatePayload.php b/tests/Support/MapDuplicatePayload.php index 2be19b25..2c432758 100644 --- a/tests/Support/MapDuplicatePayload.php +++ b/tests/Support/MapDuplicatePayload.php @@ -101,6 +101,8 @@ function sql_save($payload, $table) { /** Ignore expected registration failure notices in this schema-only fixture. */ function raise_message($id, $text = '', $level = 0) {} +/** Capture server-only clone diagnostics in this fixture. */ +function cacti_log($text, ...$args) { $GLOBALS['clone_log'][] = $text; } /** Return the untranslated fixture string. */ function __($text, ...$args) { return $text; } define('MESSAGE_LEVEL_ERROR', 3); diff --git a/tests/Unit/EditorSettingsCookieTest.php b/tests/Unit/EditorSettingsCookieTest.php index 77298149..b8f9d685 100644 --- a/tests/Unit/EditorSettingsCookieTest.php +++ b/tests/Unit/EditorSettingsCookieTest.php @@ -41,7 +41,7 @@ function cacti_cookie_set($name, $value) { $GLOBALS['wm_test_cookies'][$name] = file_put_contents($file, "WIDTH 100\nHEIGHT 100\n"); try { foreach ([[1,1,10,'1:1:10'], [0,1,5,'0:1:5'], [0,0,'NO','0:0:0']] as [$via,$relative,$grid,$expected]) { - $GLOBALS['__test_nfilter_request'] = ['editorsettings_showvias'=>$via, 'editorsettings_showrelative'=>$relative, 'editorsettings_gridsnap'=>$grid]; + $GLOBALS['__test_nfilter_request'] = ['config_revision'=>hash_file('sha256', $file), 'editorsettings_showvias'=>$via, 'editorsettings_showrelative'=>$relative, 'editorsettings_gridsnap'=>$grid]; $GLOBALS['__test_request'] = $GLOBALS['__test_nfilter_request']; editorSettings($file); self::assertSame($expected, $GLOBALS['wm_test_cookies']['wmeditor']); diff --git a/weathermap-cacti-plugin-mgmt.php b/weathermap-cacti-plugin-mgmt.php index 27f92c81..35b40a22 100644 --- a/weathermap-cacti-plugin-mgmt.php +++ b/weathermap-cacti-plugin-mgmt.php @@ -2054,8 +2054,11 @@ function map_duplicate($id, $titlecache, $configfile = null) { db_execute_prepared('DELETE FROM weathermap_settings WHERE mapid = ?', [$newid]); db_execute_prepared('DELETE FROM weathermap_maps WHERE id = ?', [$newid]); } + } catch (Throwable $cleanupError) { + cacti_log('Weathermap clone cleanup failed: ' . $cleanupError->getMessage(), false, 'WEATHERMAP'); } finally { if ($created) unlink($newfile); } - raise_message('map_message', $error->getMessage(), MESSAGE_LEVEL_ERROR); + cacti_log('Weathermap map clone failed: ' . $error->getMessage(), false, 'WEATHERMAP'); + raise_message('map_message', __('Could not create the map copy. Please try again.', 'weathermap'), MESSAGE_LEVEL_ERROR); return; } finally { \WeathermapConfig\release($lock); From ac277695d3068320a20e99a809b1df9777062262 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:41:42 +1030 Subject: [PATCH 34/41] Bind new map copies to source snapshots --- lib/WeatherMap.class.php | 21 ++++++++++++---- lib/WeatherMap.config.php | 25 ++++++++++++++++--- locales/po/cacti.pot | 2 +- tests/Support/ConfigWriteAtomicRegression.php | 25 ++++++++++++++++++- weathermap-cacti-plugin-mgmt.php | 6 ++--- 5 files changed, 66 insertions(+), 13 deletions(-) diff --git a/lib/WeatherMap.class.php b/lib/WeatherMap.class.php index 0d60904d..6891f9ab 100644 --- a/lib/WeatherMap.class.php +++ b/lib/WeatherMap.class.php @@ -2507,7 +2507,8 @@ function ReadConfig($input, $is_include = false) { $filename = $input; if ($is_include) { - $dependencyPath = realpath($filename) ?: (realpath(dirname($filename)) ?: dirname($filename)) . '/' . basename($filename); + $dependencyLogicalPath = (realpath(dirname($filename)) ?: dirname($filename)) . '/' . basename($filename); + $dependencyPath = realpath($filename) ?: $dependencyLogicalPath; if (!array_key_exists($dependencyPath, $this->config_dependencies)) $this->config_dependencies[$dependencyPath] = null; wm_debug('ReadConfig Detected that this is an INCLUDED config filename.'); @@ -2540,7 +2541,10 @@ function ReadConfig($input, $is_include = false) { fclose($fd); $loadedRevision = hash_final($revision); if (!$is_include) $this->config_revision = $loadedRevision; - else $this->config_dependencies[$dependencyPath] = $loadedRevision; + else { + $this->config_dependencies[$dependencyPath] = $loadedRevision; + if ($dependencyLogicalPath !== $dependencyPath) $this->config_dependencies[$dependencyLogicalPath] = ['target' => $dependencyPath, 'revision' => $loadedRevision]; + } } } } @@ -3893,7 +3897,7 @@ function WriteConfig($filename, $createOnly = false, $expectedRevision = null, $ $this->config_asset_conflict = false; $savedAssets = $contents !== false ? $this->ManagedImagePaths($contents) : []; - $sameDestination = $this->configfile !== '' && $filename === $this->configfile; + $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) { @@ -3905,8 +3909,15 @@ function WriteConfig($filename, $createOnly = false, $expectedRevision = null, $ } return true; }; - $sameFile = $this->configfile !== '' && $filename === $this->configfile; - $saved = $write_ok && $contents !== false && $closed && \WeathermapConfig\atomicWrite($filename, $contents, $sameFile, $expectedRevision ?? ($sameFile ? $this->config_revision : null), $createOnly, $heldLock, $this->config_dependencies, $validate); + $sameFile = \WeathermapConfig\sameFile($filename, $this->configfile); + $dependencies = $this->config_dependencies; + if (!$sameFile && $this->configfile !== '' && is_string($this->config_revision)) { + $logicalSource = (realpath(dirname($this->configfile)) ?: dirname($this->configfile)) . '/' . basename($this->configfile); + $physicalSource = realpath($this->configfile) ?: $logicalSource; + $dependencies[$physicalSource] = $this->config_revision; + if ($logicalSource !== $physicalSource) $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); return $saved; diff --git a/lib/WeatherMap.config.php b/lib/WeatherMap.config.php index 9a0fa861..854dff04 100644 --- a/lib/WeatherMap.config.php +++ b/lib/WeatherMap.config.php @@ -77,12 +77,31 @@ function writeAll($stream, $contents) { /** * Compare every included configuration against the bytes used by the snapshot. - * @param array $dependencies Included filenames and hashes. + * @param array $dependencies Included filenames and hashes. * @return bool Whether all dependencies still match. */ + +/** + * Compare filesystem destinations as well as identical path strings. + * @param string $left First filename. + * @param string $right Second filename. + * @return bool Whether both names identify the same file. + */ +function sameFile($left, $right) { + if ($left === '' || $right === '') return false; + if ($left === $right) return true; + $resolved = realpath($left); + return $resolved !== false && $resolved === realpath($right); +} + function dependenciesMatch($dependencies) { foreach ($dependencies as $path => $revision) { clearstatcache(true, $path); + if (is_array($revision)) { + if (realpath($path) !== $revision['target']) return false; + $revision = $revision['revision']; + } + if (!is_file($path)) return false; if (!is_string($revision) || @hash_file('sha256', $path) !== $revision) return false; } return true; @@ -91,7 +110,7 @@ function dependenciesMatch($dependencies) { /** * Bind an editor token to its root configuration and included dependencies. * @param string|null $root Root configuration hash. - * @param array $dependencies Included filenames and hashes. + * @param array $dependencies Included filenames and hashes. * @return string|null Snapshot token. */ function snapshotRevision($root, $dependencies) { @@ -110,7 +129,7 @@ function snapshotRevision($root, $dependencies) { * @param bool $createOnly Refuse to replace an existing destination. * @param resource|null $heldLock Optional caller-owned mutex for this destination directory. * - * @param array $dependencies Included configuration snapshots. + * @param array $dependencies Included configuration snapshots. * @param callable|null $validate Additional commit-time validation. * @return bool Success; failures preserve the existing configuration. */ diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 8c85bd62..a520f17a 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti 1.7\n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-14 23:12+1030\n" +"POT-Creation-Date: 2026-10-14 23:40+1030\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" diff --git a/tests/Support/ConfigWriteAtomicRegression.php b/tests/Support/ConfigWriteAtomicRegression.php index 3ba43bf2..addfa02b 100644 --- a/tests/Support/ConfigWriteAtomicRegression.php +++ b/tests/Support/ConfigWriteAtomicRegression.php @@ -377,6 +377,27 @@ function atomic_assert($condition, $message) { atomic_assert(wm_editor_read_config(new \WeatherMap, $file, true), 'matching mutation revision remains accepted'); $GLOBALS['__test_nfilter_request'] = []; + $aliasSource = $dir . '/source-alias.conf'; symlink($file, $aliasSource); + $aliasLoaded = new \WeatherMap; $aliasLoaded->ReadConfig($aliasSource); + file_put_contents($file, "TITLE New direct winner\n"); + atomic_assert(!$aliasLoaded->WriteConfig($file) && file_get_contents($file) === "TITLE New direct winner\n", 'canonical destination identity preserves CAS across source alias'); + file_put_contents($file, $original); + $copySource = new \WeatherMap; $copySource->ReadConfig($file); + file_put_contents($file, "TITLE New copy source winner\n"); + atomic_assert(!$copySource->WriteConfig($dir . '/stale-source-copy.conf', true) && !is_file($dir . '/stale-source-copy.conf'), 'create-only copy validates source root revision'); + file_put_contents($file, $original); $copySource = new \WeatherMap; $copySource->ReadConfig($file); unlink($file); + atomic_assert(!$copySource->WriteConfig($dir . '/deleted-source-copy.conf', true), 'deleted source cannot be copied from stale memory'); + file_put_contents($file, $original); $copySource = new \WeatherMap; $copySource->ReadConfig($aliasSource); + atomic_assert($copySource->WriteConfig($dir . '/fresh-alias-copy.conf', true), 'unchanged alias source copies with logical and physical dependencies'); + unlink($aliasSource); + $retargetA = $dir . '/retarget-a.conf'; $retargetB = $dir . '/retarget-b.conf'; $retargetAlias = $dir . '/retarget-alias.conf'; + file_put_contents($retargetA, "TITLE Same include bytes\n"); file_put_contents($retargetB, "TITLE Same include bytes\n"); symlink($retargetA, $retargetAlias); + file_put_contents($includeRoot, "WIDTH 600\nHEIGHT 300\nINCLUDE " . $retargetAlias . "\n"); + $retargetMap = new \WeatherMap; $retargetMap->ReadConfig($includeRoot); unlink($retargetAlias); symlink($retargetB, $retargetAlias); clearstatcache(); + atomic_assert(!$retargetMap->WriteConfig($includeRoot), 'retargeted INCLUDE is rejected even when replacement bytes are identical'); + $retargetMap = new \WeatherMap; $retargetMap->ReadConfig($includeRoot); atomic_assert($retargetMap->WriteConfig($includeRoot), 'freshly loaded retargeted INCLUDE succeeds'); + unlink($retargetAlias); unlink($retargetA); unlink($retargetB); + $numericLocale = setlocale(LC_NUMERIC, 0); setlocale(LC_NUMERIC, 'fr_FR.UTF-8', 'fr_FR.utf8', 'fr_FR'); try { $largeScale = new \WeatherMap; $largeScale->ReadConfig("SCALE large 1500 2500 0 255 0\n"); @@ -627,7 +648,7 @@ function ReadConfig($input, $is_include = false) { unlink($input); $this->config $GLOBALS['__test_db_fetch_cell'] = fn () => 0; $GLOBALS['weathermap_confdir'] = $plugin . '/configs'; try { - foreach (['false', 'throw', 'auth-false', 'settings-false', 'auth-throw', 'settings-throw'] as $failure) { + foreach (['false', 'throw', 'auth-false', 'settings-false', 'auth-throw', 'settings-throw', 'cleanup-false'] as $failure) { $GLOBALS['__test_sql_save'] = function($record) use ($plugin, $failure) { atomic_assert(is_file($plugin . '/configs/' . $record['configfile']), 'reserved file exists during registration'); if (function_exists('pcntl_fork')) { @@ -643,11 +664,13 @@ function ReadConfig($input, $is_include = false) { unlink($input); $this->config $GLOBALS['__test_db_calls'][] = ['sql' => $sql, 'params' => $params]; if (str_starts_with($sql,'INSERT')) atomic_assert(!str_contains($sql, '987') && $params === [987,1], 'clone IDs are bound as prepared parameters'); if ((str_contains($sql, 'INSERT INTO weathermap_auth') && $failure === 'auth-throw') || (str_contains($sql, 'INSERT INTO weathermap_settings') && $failure === 'settings-throw')) throw new RuntimeException('insert failed'); + if ($failure === 'cleanup-false') return false; return !((str_contains($sql, 'INSERT INTO weathermap_auth') && $failure === 'auth-false') || (str_contains($sql, 'INSERT INTO weathermap_settings') && $failure === 'settings-false')); }; $cloneMessages = []; $GLOBALS['__test_raise_message'] = function ($id, $text) use (&$cloneMessages) { $cloneMessages[] = $text; }; $GLOBALS['__test_cacti_log'] = []; map_duplicate(1, 'Clone', 'clone'); + if ($failure === 'cleanup-false') atomic_assert(cacti_count(array_filter($GLOBALS['__test_cacti_log'], fn ($message) => str_contains($message, 'cleanup incomplete'))) === 3, 'all failed cleanup DELETE results are logged'); unset($GLOBALS['__test_raise_message']); atomic_assert($cloneMessages === ['Could not create the map copy. Please try again.'] && $GLOBALS['__test_cacti_log'] !== [], 'clone failures show only generic guidance and log original diagnostics'); atomic_assert(!is_file($plugin . '/configs/clone.conf'), 'failed registration removes its own reserved configuration'); diff --git a/weathermap-cacti-plugin-mgmt.php b/weathermap-cacti-plugin-mgmt.php index 35b40a22..10a6e3cd 100644 --- a/weathermap-cacti-plugin-mgmt.php +++ b/weathermap-cacti-plugin-mgmt.php @@ -2050,9 +2050,9 @@ function map_duplicate($id, $titlecache, $configfile = null) { // Only this attempt's records and successfully reserved file may be removed. try { if ($newid) { - db_execute_prepared('DELETE FROM weathermap_auth WHERE mapid = ?', [$newid]); - db_execute_prepared('DELETE FROM weathermap_settings WHERE mapid = ?', [$newid]); - db_execute_prepared('DELETE FROM weathermap_maps WHERE id = ?', [$newid]); + if (db_execute_prepared('DELETE FROM weathermap_auth WHERE mapid = ?', [$newid]) === false) cacti_log('Weathermap clone cleanup incomplete: weathermap_auth record removal failed for map ' . $newid, false, 'WEATHERMAP'); + if (db_execute_prepared('DELETE FROM weathermap_settings WHERE mapid = ?', [$newid]) === false) cacti_log('Weathermap clone cleanup incomplete: weathermap_settings record removal failed for map ' . $newid, false, 'WEATHERMAP'); + if (db_execute_prepared('DELETE FROM weathermap_maps WHERE id = ?', [$newid]) === false) cacti_log('Weathermap clone cleanup incomplete: weathermap_maps record removal failed for map ' . $newid, false, 'WEATHERMAP'); } } catch (Throwable $cleanupError) { cacti_log('Weathermap clone cleanup failed: ' . $cleanupError->getMessage(), false, 'WEATHERMAP'); From b2193d3a360e31f3c88234fd65d9f13f0b299b31 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:04:29 +1030 Subject: [PATCH 35/41] Preserve root identity and recoverable clone configuration --- lib/WeatherMap.class.php | 22 +++++++-- lib/WeatherMap.config.php | 15 +++--- lib/editor.actions.php | 6 +-- locales/po/cacti.pot | 2 +- .../BackgroundSizingEngineRegression.php | 2 +- tests/Support/ConfigWriteAtomicRegression.php | 48 ++++++++++++++++--- weathermap-cacti-plugin-mgmt.php | 25 ++++++---- 7 files changed, 88 insertions(+), 32 deletions(-) diff --git a/lib/WeatherMap.class.php b/lib/WeatherMap.class.php index 6891f9ab..99063638 100644 --- a/lib/WeatherMap.class.php +++ b/lib/WeatherMap.class.php @@ -343,6 +343,8 @@ class WeatherMap extends WeatherMapBase { var $configfile; var $config_revision = null; var $config_dependencies = []; + /** @var array Loaded root paths, physical targets and revisions. */ + var $config_root_identity = []; var $config_managed_assets = []; var $config_asset_conflict = false; var $editor_config_revision = null; @@ -2477,7 +2479,7 @@ function ManagedImagePaths($serialized = null) { */ function ReadConfig($input, $is_include = false) { global $config, $weathermap_error_suppress; - if (!$is_include) { $this->config_revision = null; $this->config_dependencies = []; } + if (!$is_include) { $this->config_revision = null; if (strpos($input, "\n") === false && strpos($input, "\r") === false) { $this->config_dependencies = []; $this->config_root_identity = []; } } $curnode = null; $curlink = null; @@ -2505,6 +2507,8 @@ function ReadConfig($input, $is_include = false) { wm_debug('ReadConfig Detected that this is a config filename.'); $filename = $input; + $rootLogicalPath = (realpath(dirname($filename)) ?: dirname($filename)) . '/' . basename($filename); + $rootPhysicalPath = realpath($filename); if ($is_include) { $dependencyLogicalPath = (realpath(dirname($filename)) ?: dirname($filename)) . '/' . basename($filename); @@ -2540,7 +2544,10 @@ function ReadConfig($input, $is_include = false) { fclose($fd); $loadedRevision = hash_final($revision); - if (!$is_include) $this->config_revision = $loadedRevision; + if (!$is_include) { + $this->config_revision = $loadedRevision; + $this->config_root_identity[$rootLogicalPath] = ['target' => $rootPhysicalPath, 'revision' => $loadedRevision]; + } else { $this->config_dependencies[$dependencyPath] = $loadedRevision; if ($dependencyLogicalPath !== $dependencyPath) $this->config_dependencies[$dependencyLogicalPath] = ['target' => $dependencyPath, 'revision' => $loadedRevision]; @@ -3911,15 +3918,22 @@ function WriteConfig($filename, $createOnly = false, $expectedRevision = null, $ }; $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 = (realpath(dirname($this->configfile)) ?: dirname($this->configfile)) . '/' . basename($this->configfile); $physicalSource = realpath($this->configfile) ?: $logicalSource; - $dependencies[$physicalSource] = $this->config_revision; - if ($logicalSource !== $physicalSource) $dependencies[$logicalSource] = ['target' => $physicalSource, 'revision' => $this->config_revision]; + 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"); diff --git a/lib/WeatherMap.config.php b/lib/WeatherMap.config.php index 854dff04..cdf190f4 100644 --- a/lib/WeatherMap.config.php +++ b/lib/WeatherMap.config.php @@ -75,12 +75,6 @@ function writeAll($stream, $contents) { return true; } -/** - * Compare every included configuration against the bytes used by the snapshot. - * @param array $dependencies Included filenames and hashes. - * @return bool Whether all dependencies still match. - */ - /** * Compare filesystem destinations as well as identical path strings. * @param string $left First filename. @@ -94,6 +88,11 @@ function sameFile($left, $right) { return $resolved !== false && $resolved === realpath($right); } +/** + * Compare every included configuration against the bytes used by the snapshot. + * @param array $dependencies Included filenames and hashes. + * @return bool Whether all dependencies still match. + */ function dependenciesMatch($dependencies) { foreach ($dependencies as $path => $revision) { clearstatcache(true, $path); @@ -111,9 +110,11 @@ function dependenciesMatch($dependencies) { * Bind an editor token to its root configuration and included dependencies. * @param string|null $root Root configuration hash. * @param array $dependencies Included filenames and hashes. + * @param array $rootIdentity Loaded root path identities. * @return string|null Snapshot token. */ -function snapshotRevision($root, $dependencies) { +function snapshotRevision($root, $dependencies, $rootIdentity = []) { + foreach ($rootIdentity as $logical => $state) if ($logical !== $state['target']) $dependencies[$logical] = $state; if (!$dependencies) return $root; ksort($dependencies); return hash('sha256', serialize([$root, $dependencies])); diff --git a/lib/editor.actions.php b/lib/editor.actions.php index 2630e9e4..2dc2a25a 100644 --- a/lib/editor.actions.php +++ b/lib/editor.actions.php @@ -72,7 +72,7 @@ function wm_editor_read_config($map, $mapfile, $requireRevision = false) { return false; } $revision = get_nfilter_request_var('config_revision'); - if (($requireRevision && (!is_string($revision) || $revision === '')) || ($revision !== null && $revision !== '' && (!is_string($revision) || !hash_equals(\WeathermapConfig\snapshotRevision($map->config_revision, $map->config_dependencies), $revision)))) { + if (($requireRevision && (!is_string($revision) || $revision === '')) || ($revision !== null && $revision !== '' && (!is_string($revision) || !hash_equals(\WeathermapConfig\snapshotRevision($map->config_revision, $map->config_dependencies, $map->config_root_identity), $revision)))) { http_response_code(409); return false; } @@ -91,7 +91,7 @@ function wm_editor_read_config($map, $mapfile, $requireRevision = false) { function wm_editor_write_config($map, $mapfile, $createOnly = false) { if (!$map->WriteConfig($mapfile, $createOnly, $map->editor_config_revision)) { $current = is_file($mapfile) ? @hash_file('sha256', $mapfile) : false; - $stale = $map->config_asset_conflict || !\WeathermapConfig\dependenciesMatch($map->config_dependencies) || $map->editor_config_revision !== null && $current !== false && $current !== $map->editor_config_revision; + $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); return false; } @@ -159,7 +159,7 @@ function getMapJavaScript($mapfile, $map = null) { } print $map->asJS(); - print 'var WMConfigRevision = ' . json_encode(\WeathermapConfig\snapshotRevision($map->config_revision, $map->config_dependencies)) . ';'; + print 'var WMConfigRevision = ' . json_encode(\WeathermapConfig\snapshotRevision($map->config_revision, $map->config_dependencies, $map->config_root_identity)) . ';'; } /** diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index a520f17a..a7d75956 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti 1.7\n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-14 23:40+1030\n" +"POT-Creation-Date: 2026-10-15 00:01+1030\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" diff --git a/tests/Support/BackgroundSizingEngineRegression.php b/tests/Support/BackgroundSizingEngineRegression.php index c7aff2b4..515e077f 100644 --- a/tests/Support/BackgroundSizingEngineRegression.php +++ b/tests/Support/BackgroundSizingEngineRegression.php @@ -3,7 +3,7 @@ function wm_fixture_revision($file, $callback) { if (!array_key_exists('config_revision', $GLOBALS['__test_nfilter_request'] ?? []) && is_file($file)) { $map = new \WeatherMap; $map->ReadConfig($file); - $GLOBALS['__test_nfilter_request']['config_revision'] = \WeathermapConfig\snapshotRevision($map->config_revision, $map->config_dependencies); + $GLOBALS['__test_nfilter_request']['config_revision'] = \WeathermapConfig\snapshotRevision($map->config_revision, $map->config_dependencies, $map->config_root_identity); } return $callback(); } diff --git a/tests/Support/ConfigWriteAtomicRegression.php b/tests/Support/ConfigWriteAtomicRegression.php index addfa02b..f7c1b32d 100644 --- a/tests/Support/ConfigWriteAtomicRegression.php +++ b/tests/Support/ConfigWriteAtomicRegression.php @@ -122,11 +122,16 @@ function rename($source, $target) { } } namespace { +/** Simulate failed configuration cleanup without changing production code. */ +function atomic_clone_unlink($path) { + return !($GLOBALS['atomic_clone_unlink_fail'] ?? false) && unlink($path); +} + /** Submit the page revision expected by production mutation routes. */ function wm_fixture_revision($file, $callback) { if (!array_key_exists('config_revision', $GLOBALS['__test_nfilter_request'] ?? []) && is_file($file)) { $map = new \WeatherMap; $map->ReadConfig($file); - $GLOBALS['__test_nfilter_request']['config_revision'] = \WeathermapConfig\snapshotRevision($map->config_revision, $map->config_dependencies); + $GLOBALS['__test_nfilter_request']['config_revision'] = \WeathermapConfig\snapshotRevision($map->config_revision, $map->config_dependencies, $map->config_root_identity); } return $callback(); } @@ -282,7 +287,7 @@ function atomic_assert($condition, $message) { $includeConfig = "WIDTH 600\nHEIGHT 300\nINCLUDE " . $includeFile . "\n"; file_put_contents($includeRoot, $includeConfig); $dependent = new \WeatherMap; $dependent->ReadConfig($includeRoot); - $token = \WeathermapConfig\snapshotRevision($dependent->config_revision, $dependent->config_dependencies); + $token = \WeathermapConfig\snapshotRevision($dependent->config_revision, $dependent->config_dependencies, $dependent->config_root_identity); file_put_contents($includeFile, "TITLE Included winner\n"); $dependent->title = 'Stale edit'; atomic_assert(!$dependent->WriteConfig($includeRoot) && file_get_contents($includeRoot) === $includeConfig, 'included file changes reject stale root persistence'); $GLOBALS['__test_nfilter_request'] = ['config_revision' => $token]; http_response_code(200); @@ -373,7 +378,7 @@ function atomic_assert($condition, $message) { moveNode($file, 0); atomic_assert(http_response_code() === 409 && file_get_contents($file) === $beforeMissingRevision, 'missing mutation token cannot alter configuration'); $revisionMap = new \WeatherMap; $revisionMap->ReadConfig($file); - $GLOBALS['__test_nfilter_request'] = ['config_revision' => \WeathermapConfig\snapshotRevision($revisionMap->config_revision, $revisionMap->config_dependencies)]; + $GLOBALS['__test_nfilter_request'] = ['config_revision' => \WeathermapConfig\snapshotRevision($revisionMap->config_revision, $revisionMap->config_dependencies, $revisionMap->config_root_identity)]; atomic_assert(wm_editor_read_config(new \WeatherMap, $file, true), 'matching mutation revision remains accepted'); $GLOBALS['__test_nfilter_request'] = []; @@ -398,6 +403,26 @@ function atomic_assert($condition, $message) { $retargetMap = new \WeatherMap; $retargetMap->ReadConfig($includeRoot); atomic_assert($retargetMap->WriteConfig($includeRoot), 'freshly loaded retargeted INCLUDE succeeds'); unlink($retargetAlias); unlink($retargetA); unlink($retargetB); + $rootA = $dir . '/root-a.conf'; $rootB = $dir . '/root-b.conf'; $rootAlias = $dir . '/root-alias.conf'; + $rootBytes = "WIDTH 100\nHEIGHT 100\nTITLE Identical roots\n"; + file_put_contents($rootA, $rootBytes); file_put_contents($rootB, $rootBytes); symlink($rootA, $rootAlias); + $rootMap = new \WeatherMap; $rootMap->ReadConfig($rootAlias); + $rootToken = \WeathermapConfig\snapshotRevision($rootMap->config_revision, $rootMap->config_dependencies, $rootMap->config_root_identity); + unlink($rootAlias); symlink($rootB, $rootAlias); clearstatcache(true, $rootAlias); + $rootMap->title = 'Unexpected write'; + atomic_assert(!$rootMap->WriteConfig($rootAlias) && file_get_contents($rootA) === $rootBytes && file_get_contents($rootB) === $rootBytes, 'root retargeting cannot overwrite another identical-content file'); + http_response_code(200); + atomic_assert(!wm_editor_write_config($rootMap, $rootAlias) && http_response_code() === 409, 'commit-time root retarget conflicts return HTTP 409'); + $GLOBALS['__test_nfilter_request'] = ['config_revision'=>$rootToken]; http_response_code(200); + atomic_assert(!wm_editor_read_config(new \WeatherMap, $rootAlias, true) && http_response_code() === 409, 'rendered page revision binds root alias identity before mutation dispatch'); + $GLOBALS['__test_nfilter_request'] = []; + $freshRoot = new \WeatherMap; $freshRoot->ReadConfig($rootAlias); $freshRoot->title = 'Fresh root save'; + atomic_assert($freshRoot->WriteConfig($rootAlias), 'freshly loaded root alias saves'); + $freshRoot->title = 'Second root save'; atomic_assert($freshRoot->WriteConfig($rootAlias), 'successful saves refresh root identity revisions'); + $freshRoot->ReadConfig("TITLE Fragment save\n"); + atomic_assert($freshRoot->config_root_identity !== [], 'raw fragments retain captured root identity'); + unlink($rootAlias); unlink($rootA); unlink($rootB); + $numericLocale = setlocale(LC_NUMERIC, 0); setlocale(LC_NUMERIC, 'fr_FR.UTF-8', 'fr_FR.utf8', 'fr_FR'); try { $largeScale = new \WeatherMap; $largeScale->ReadConfig("SCALE large 1500 2500 0 255 0\n"); @@ -604,7 +629,7 @@ function ReadConfig($input, $is_include = false) { unlink($input); $this->config $start = strpos($management, 'function map_get_next_name('); $end = strpos($management, "\n/**", $start); eval(str_replace('__DIR__', var_export($plugin, true), substr($management, $start, $end - $start))); $start = strpos($management, 'function map_duplicate('); $end = strpos($management, "\n/**", $start); - eval(str_replace('__DIR__', var_export($plugin, true), substr($management, $start, $end - $start))); + eval(str_replace(['__DIR__', 'unlink('], [var_export($plugin, true), 'atomic_clone_unlink('], substr($management, $start, $end - $start))); $duplicateSource = 'atomic-duplicate-' . bin2hex(random_bytes(4)) . '.conf'; file_put_contents($plugin . '/configs/' . $duplicateSource, "TITLE Source\n"); $GLOBALS['__test_db_fetch_row_prepared'] = fn () => ['configfile' => $duplicateSource, 'titlecache' => 'Source', 'group_id' => 1, 'active' => 'on', 'thumb_height' => 10, 'thumb_width' => 10, 'schedule' => '', 'archiving' => '',]; @@ -648,7 +673,7 @@ function ReadConfig($input, $is_include = false) { unlink($input); $this->config $GLOBALS['__test_db_fetch_cell'] = fn () => 0; $GLOBALS['weathermap_confdir'] = $plugin . '/configs'; try { - foreach (['false', 'throw', 'auth-false', 'settings-false', 'auth-throw', 'settings-throw', 'cleanup-false'] as $failure) { + foreach (['false', 'throw', 'auth-false', 'settings-false', 'auth-throw', 'settings-throw', 'cleanup-false', 'cleanup-auth-throw', 'cleanup-map-throw', 'unlink-failed'] as $failure) { $GLOBALS['__test_sql_save'] = function($record) use ($plugin, $failure) { atomic_assert(is_file($plugin . '/configs/' . $record['configfile']), 'reserved file exists during registration'); if (function_exists('pcntl_fork')) { @@ -665,15 +690,26 @@ function ReadConfig($input, $is_include = false) { unlink($input); $this->config if (str_starts_with($sql,'INSERT')) atomic_assert(!str_contains($sql, '987') && $params === [987,1], 'clone IDs are bound as prepared parameters'); if ((str_contains($sql, 'INSERT INTO weathermap_auth') && $failure === 'auth-throw') || (str_contains($sql, 'INSERT INTO weathermap_settings') && $failure === 'settings-throw')) throw new RuntimeException('insert failed'); if ($failure === 'cleanup-false') return false; + if (str_starts_with($failure, 'cleanup-') || $failure === 'unlink-failed') { + if (str_starts_with($sql, 'INSERT')) return false; + if (($failure === 'cleanup-auth-throw' && str_contains($sql, 'DELETE FROM weathermap_auth')) || ($failure === 'cleanup-map-throw' && str_contains($sql, 'DELETE FROM weathermap_maps'))) throw new RuntimeException('cleanup database details'); + } + return !((str_contains($sql, 'INSERT INTO weathermap_auth') && $failure === 'auth-false') || (str_contains($sql, 'INSERT INTO weathermap_settings') && $failure === 'settings-false')); }; $cloneMessages = []; $GLOBALS['__test_raise_message'] = function ($id, $text) use (&$cloneMessages) { $cloneMessages[] = $text; }; $GLOBALS['__test_cacti_log'] = []; + $GLOBALS['atomic_clone_unlink_fail'] = $failure === 'unlink-failed'; map_duplicate(1, 'Clone', 'clone'); + unset($GLOBALS['atomic_clone_unlink_fail']); if ($failure === 'cleanup-false') atomic_assert(cacti_count(array_filter($GLOBALS['__test_cacti_log'], fn ($message) => str_contains($message, 'cleanup incomplete'))) === 3, 'all failed cleanup DELETE results are logged'); unset($GLOBALS['__test_raise_message']); atomic_assert($cloneMessages === ['Could not create the map copy. Please try again.'] && $GLOBALS['__test_cacti_log'] !== [], 'clone failures show only generic guidance and log original diagnostics'); - atomic_assert(!is_file($plugin . '/configs/clone.conf'), 'failed registration removes its own reserved configuration'); + $retained = in_array($failure, ['cleanup-false', 'cleanup-map-throw', 'unlink-failed'], true); + atomic_assert(is_file($plugin . '/configs/clone.conf') === $retained, 'failed cleanup retains recoverable config until map row removal and file unlink both succeed'); + if ($failure === 'unlink-failed') atomic_assert(cacti_count(array_filter($GLOBALS['__test_cacti_log'], fn ($message) => str_contains($message, 'config removal failed'))) === 1, 'failed unlink is explicitly logged'); + if ($retained) unlink($plugin . '/configs/clone.conf'); + if (str_ends_with($failure, '-false') || str_ends_with($failure, '-throw')) atomic_assert(cacti_count(array_filter($GLOBALS['__test_db_calls'], fn ($call) => str_starts_with($call['sql'], 'DELETE'))) === 3, 'partial registration removes map/auth/settings records'); } } finally { unset($GLOBALS['__test_db_fetch_row_prepared'], $GLOBALS['__test_sql_save'], $GLOBALS['__test_db_fetch_cell'], $GLOBALS['__test_db_execute_prepared']); unlink($plugin . '/configs/' . $duplicateSource); } diff --git a/weathermap-cacti-plugin-mgmt.php b/weathermap-cacti-plugin-mgmt.php index 10a6e3cd..b47ac718 100644 --- a/weathermap-cacti-plugin-mgmt.php +++ b/weathermap-cacti-plugin-mgmt.php @@ -2022,7 +2022,8 @@ function map_duplicate($id, $titlecache, $configfile = null) { if (!$assetMap->ReadConfig($oldfile) || $assetMap->config_revision !== hash('sha256', $contents)) throw new RuntimeException(__('Could not save the map configuration.', 'weathermap')); } finally { chdir($previousDirectory); } $dependencies = $assetMap->config_dependencies; - $dependencies[realpath($oldfile)] = $assetMap->config_revision; + foreach ($assetMap->config_root_identity as $logical => $state) $dependencies[$logical] = $state; + if (!isset($dependencies[realpath($oldfile)])) $dependencies[realpath($oldfile)] = $assetMap->config_revision; $contents = str_replace("TITLE {$map['titlecache']}", "TITLE {$save['titlecache']}", $contents); $assets = array_unique(array_merge($assetMap->ManagedImagePaths(), WeatherMap::ManagedImagePathsFromConfig($contents))); $validateAssets = function () use ($assets) { @@ -2047,16 +2048,20 @@ function map_duplicate($id, $titlecache, $configfile = null) { FROM weathermap_settings WHERE mapid = ?", [$newid, $id]) === false) throw new RuntimeException(__('Could not register the new map.', 'weathermap')); } catch (Throwable $error) { - // Only this attempt's records and successfully reserved file may be removed. - try { - if ($newid) { - if (db_execute_prepared('DELETE FROM weathermap_auth WHERE mapid = ?', [$newid]) === false) cacti_log('Weathermap clone cleanup incomplete: weathermap_auth record removal failed for map ' . $newid, false, 'WEATHERMAP'); - if (db_execute_prepared('DELETE FROM weathermap_settings WHERE mapid = ?', [$newid]) === false) cacti_log('Weathermap clone cleanup incomplete: weathermap_settings record removal failed for map ' . $newid, false, 'WEATHERMAP'); - if (db_execute_prepared('DELETE FROM weathermap_maps WHERE id = ?', [$newid]) === false) cacti_log('Weathermap clone cleanup incomplete: weathermap_maps record removal failed for map ' . $newid, false, 'WEATHERMAP'); + // Keep a recoverable config whenever removal of its registration is uncertain. + $mapRowRemoved = !$newid; + if ($newid) { + foreach (['weathermap_auth' => 'mapid', 'weathermap_settings' => 'mapid', 'weathermap_maps' => 'id'] as $table => $column) { + try { + $removed = db_execute_prepared("DELETE FROM $table WHERE $column = ?", [$newid]) !== false; + if ($table === 'weathermap_maps') $mapRowRemoved = $removed; + if (!$removed) cacti_log('Weathermap clone cleanup incomplete: ' . $table . ' record removal failed for map ' . $newid, false, 'WEATHERMAP'); + } catch (Throwable $cleanupError) { + cacti_log('Weathermap clone cleanup incomplete: ' . $table . ': ' . $cleanupError->getMessage(), false, 'WEATHERMAP'); + } } - } catch (Throwable $cleanupError) { - cacti_log('Weathermap clone cleanup failed: ' . $cleanupError->getMessage(), false, 'WEATHERMAP'); - } finally { if ($created) unlink($newfile); } + } + if ($created && $mapRowRemoved && !@unlink($newfile)) cacti_log('Weathermap clone cleanup incomplete: config removal failed: ' . $newfile, false, 'WEATHERMAP'); cacti_log('Weathermap map clone failed: ' . $error->getMessage(), false, 'WEATHERMAP'); raise_message('map_message', __('Could not create the map copy. Please try again.', 'weathermap'), MESSAGE_LEVEL_ERROR); return; From 6ce7fffb3fa8958001811d170ed30f526de7aaa3 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:14:29 +1030 Subject: [PATCH 36/41] Validate image mutation revisions and filter unsupported library files --- js/editor.js | 2 + lib/editor.actions.php | 10 +++ lib/editor.assets.php | 1 + locales/po/cacti.pot | 24 +++----- tests/Browser/EditorImagesTest.cjs | 8 +-- .../Support/EditorImageFormatsRegression.php | 61 +++++++++++++++---- 6 files changed, 76 insertions(+), 30 deletions(-) diff --git a/js/editor.js b/js/editor.js index c187c7ee..5085d5f2 100644 --- a/js/editor.js +++ b/js/editor.js @@ -1829,6 +1829,7 @@ function wmDecodeEditorUrl(value) { } function wmAssetRequest(action, extra, callback, errorId) { var values = {action: action, mapname: $('#mapname').val()}; + if (action !== 'image_data' && typeof WMConfigRevision !== 'undefined') values.config_revision = WMConfigRevision; if (typeof csrfMagicToken !== 'undefined') values.__csrf_magic = csrfMagicToken; $.ajax({url: editor_url, type: action === 'image_data' ? 'GET' : 'POST', dataType: 'json', data: $.extend(values, extra), success: callback, error: function(xhr) { $('#' + errorId).text(xhr.responseJSON && xhr.responseJSON.error || wmEditorText.imageText3); }}); @@ -1879,6 +1880,7 @@ function wmOpenImages() { var data = new FormData(); data.append('action', 'upload_image'); data.append('mapname', $('#mapname').val()); data.append('image_kind', kind); data.append('image_file', file); + if (typeof WMConfigRevision !== 'undefined') data.append('config_revision', WMConfigRevision); if (typeof csrfMagicToken !== 'undefined') data.append('__csrf_magic', csrfMagicToken); button.prop('disabled', true); $.ajax({url: editor_url, type: 'POST', data: data, dataType: 'json', contentType: false, processData: false, diff --git a/lib/editor.actions.php b/lib/editor.actions.php index a245b8ac..7518aca9 100644 --- a/lib/editor.actions.php +++ b/lib/editor.actions.php @@ -1209,6 +1209,16 @@ function wm_editor_assets($mapfile, $action) { header('Content-Type: application/json'); try { if ($action !== 'image_data' && ($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') throw new InvalidArgumentException(__('Use POST to save changes.', 'weathermap')); + if ($action !== 'image_data') { + $map = new WeatherMap; + $map->context = 'editor'; + if (!wm_editor_read_config($map, $mapfile, true)) { + echo json_encode(['error' => http_response_code() === 409 + ? __('This map has changed since you opened it. Reload the editor before saving your changes.', 'weathermap') + : __('Cannot read the map configuration.', 'weathermap')]); + return; + } + } if ($action === 'image_data') { $result = ['images' => wm_asset_image_list()]; } elseif ($action === 'upload_image') { diff --git a/lib/editor.assets.php b/lib/editor.assets.php index d7826c00..997fe3dd 100644 --- a/lib/editor.assets.php +++ b/lib/editor.assets.php @@ -180,6 +180,7 @@ function wm_asset_image_list($references = null) { $path = $dir . '/' . $name; if (!is_file($path) || is_link($path) || !is_readable($path) || !preg_match('/\.(png|gif|jpe?g)$/iD', $name)) continue; $size = @getimagesize($path); + if (!$size || !in_array($size[2], [IMAGETYPE_PNG, IMAGETYPE_JPEG, IMAGETYPE_GIF], true)) continue; $users = wm_asset_image_users($name, $kind, $references); $result[] = ['width' => $size ? $size[0] : null, 'height' => $size ? $size[1] : null, 'users' => $users, 'kind' => $kind, 'name' => $name, 'url' => 'images/' . $kind . '/' . rawurlencode($name), 'deletable' => !$users && (bool) preg_match('/^[A-Za-z0-9_.-]+\.(png|gif|jpe?g)$/iD', $name) && strpos($name, '..') === false]; } diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 630bb944..3d471751 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti 1.7\n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-15 00:01+1030\n" +"POT-Creation-Date: 2026-10-15 02:12+1030\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" @@ -26,6 +26,14 @@ msgstr "" msgid "Use POST to save changes." msgstr "" +#: lib/editor.actions.php lib/editor.inc.php +msgid "This map has changed since you opened it. Reload the editor before saving your changes." +msgstr "" + +#: lib/editor.actions.php +msgid "Cannot read the map configuration." +msgstr "" + #: lib/editor.actions.php lib/editor.assets.php #: weathermap-cacti-plugin-mgmt.php msgid "Could not lock map configurations." @@ -261,7 +269,7 @@ msgstr "" msgid "No gradient" msgstr "" -#: lib/editor.inc.php +#: lib/editor.inc.php weathermap-cacti-plugin-editor.php msgid "Custom colour" msgstr "" @@ -331,10 +339,6 @@ msgstr "" msgid "No matching icons" msgstr "" -#: lib/editor.inc.php -msgid "This map has changed since you opened it. Reload the editor before saving your changes." -msgstr "" - #: lib/editor.inc.php weathermap-cacti-plugin-editor.php msgid "Unable to save changes. Please try again." msgstr "" @@ -1271,10 +1275,6 @@ msgstr "" msgid "White" msgstr "" -#: weathermap-cacti-plugin-editor.php -msgid "Custom colour" -msgstr "" - #: weathermap-cacti-plugin-editor.php msgid "Save" msgstr "" @@ -2054,10 +2054,6 @@ msgstr "" msgid "(No Title)" msgstr "" -#: weathermap-cacti-plugin-mgmt.php -msgid "Could not lock map configurations." -msgstr "" - #: weathermap-cacti-plugin-mgmt.php #, php-format msgid "The new Map with the name %s was unable to locate the config file %s to copy" diff --git a/tests/Browser/EditorImagesTest.cjs b/tests/Browser/EditorImagesTest.cjs index 20ef2da7..5df0d916 100644 --- a/tests/Browser/EditorImagesTest.cjs +++ b/tests/Browser/EditorImagesTest.cjs @@ -5,7 +5,7 @@ const fs=require('node:fs'); const path=require('node:path'); const assert=require('node:assert/strict'); const dom=new JSDOM('Label OnlyNo background',{runScripts:'outside-only',pretendToBeVisual:true}); -const w=dom.window,$=jquery(w);w.$=w.jQuery=$;w.editor_url='/editor';w.csrfMagicToken='fixture';$.fn.dialog=function(){return this;}; +const w=dom.window,$=jquery(w);w.$=w.jQuery=$;w.editor_url='/editor';w.csrfMagicToken='fixture';w.WMConfigRevision='a'.repeat(64);$.fn.dialog=function(){return this;}; const inc=fs.readFileSync(path.join(__dirname,'../../lib/editor.inc.php'),'utf8');w.wmEditorText={}; for(const m of inc.matchAll(/'(imageText[0-9]+|imageUsed|imageUnused)' => __\('((?:\\.|[^'])+)'/g))w.wmEditorText[m[1]]=m[2].replace(/\\'/g,"'"); w.wmEditorText.imageUnused='Non utilisée'; @@ -28,9 +28,9 @@ for (const [id, value] of [['node_iconfilename','images/objects/custom folder/si assert.equal(select.find('option').filter(function(){return this.value===value;}).length,1); } let request;$.ajax=opts=>request=opts;w.confirm=()=>false;$('#wm-images-backgrounds .wm-image-delete').trigger('click');assert.equal(request,undefined); -w.confirm=()=>true;$('#wm-images-backgrounds .wm-image-delete').trigger('click');assert.equal(request.type,'POST');assert.equal(request.data.action,'delete_image');assert.equal(request.data.image_kind,'backgrounds');assert.equal(request.data.__csrf_magic,'fixture'); +w.confirm=()=>true;$('#wm-images-backgrounds .wm-image-delete').trigger('click');assert.equal(request.type,'POST');assert.equal(request.data.action,'delete_image');assert.equal(request.data.image_kind,'backgrounds');assert.equal(request.data.__csrf_magic,'fixture');assert.equal(request.data.config_revision,w.WMConfigRevision); request.error({responseJSON:{error:'In use'}});assert.equal($('#wm-images-error').text(),'In use'); -w.wmOpenImages();assert.equal(request.type,'GET');assert.equal(request.data.action,'image_data');request.success({images}); +w.wmOpenImages();assert.equal(request.type,'GET');assert.equal(request.data.action,'image_data');assert.equal('config_revision' in request.data,false);request.success({images}); $('.wm-image-upload').trigger('click');assert.match($('#wm-images-error').text(),/Choose a PNG/); -Object.defineProperty($('#wm-image-file-objects')[0],'files',{value:[new w.File(['image'],'test.png',{type:'image/png'})]});$('.wm-image-upload').trigger('click');assert.equal(request.data.get('action'),'upload_image');assert.equal(request.data.get('image_kind'),'objects');assert.equal(request.data.get('__csrf_magic'),'fixture');assert.equal($('.wm-image-upload').prop('disabled'),true);request.complete();assert.equal($('.wm-image-upload').prop('disabled'),false); +Object.defineProperty($('#wm-image-file-objects')[0],'files',{value:[new w.File(['image'],'test.png',{type:'image/png'})]});$('.wm-image-upload').trigger('click');assert.equal(request.data.get('action'),'upload_image');assert.equal(request.data.get('image_kind'),'objects');assert.equal(request.data.get('config_revision'),w.WMConfigRevision);assert.equal(request.data.get('__csrf_magic'),'fixture');assert.equal($('.wm-image-upload').prop('disabled'),true);request.complete();assert.equal($('.wm-image-upload').prop('disabled'),false); dom.window.close();console.log('PASS: image metadata, translations, protected delete, confirmation, request errors and upload wiring'); diff --git a/tests/Support/EditorImageFormatsRegression.php b/tests/Support/EditorImageFormatsRegression.php index 84da5465..2c4c9cdb 100644 --- a/tests/Support/EditorImageFormatsRegression.php +++ b/tests/Support/EditorImageFormatsRegression.php @@ -22,6 +22,9 @@ +-------------------------------------------------------------------------+ */ require dirname(__DIR__) . "/bootstrap-unit.php"; +chdir(dirname(__DIR__, 2)); +require dirname(__DIR__, 2) . "/setup.php"; +require dirname(__DIR__, 2) . "/lib/WeatherMap.class.php"; require dirname(__DIR__, 2) . '/lib/editor.assets.php'; require dirname(__DIR__, 2) . '/lib/editor.actions.php'; require dirname(__DIR__, 2) . '/lib/editor.inc.php'; @@ -38,6 +41,16 @@ function image_format_assert($value, $message) { throw new RuntimeException($message); } } +/** + * Supply the real loaded snapshot for valid image mutation fixtures. + * + * @param string $file Configuration path. + * @return void + */ +function image_format_revision($file) { + $map = new WeatherMap; $map->ReadConfig($file); + $GLOBALS['__test_nfilter_request']['config_revision'] = \WeathermapConfig\snapshotRevision($map->config_revision, $map->config_dependencies, $map->config_root_identity); +} $root = dirname(__DIR__, 2); $temporary = $root . '/tests/wm-image-formats-' . bin2hex(random_bytes(4)); mkdir($temporary); @@ -83,9 +96,9 @@ function publication_encode($image, $handle) { $GLOBALS['publication_fail'] = false; $published = publication_store_image('objects', $uploadSource, 'publication.png'); $created[] = wm_asset_image_path('objects', $published); image_format_assert(getimagesize(end($created))[0] === 12 && (fileperms(end($created)) & 0777) === 0644 && glob($root . '/images/objects/.wm-upload-*') === [], 'successful upload publishes complete permitted image and removes staging file'); - file_put_contents($config, "ICON images/objects/core-router.png\n# router.png is merely a comment\n"); + file_put_contents($config, "NODE Fixture\n ICON images/objects/core-router.png\n# router.png is merely a comment\n"); image_format_assert(wm_asset_image_users('router.png', 'objects') === [], 'substring and comment do not count as references'); - file_put_contents($config, "ICON images/objects/ROUTER.PNG\n"); + file_put_contents($config, "NODE Fixture\n ICON images/objects/ROUTER.PNG\n"); image_format_assert(wm_asset_image_users('router.png', 'objects') !== [], 'case-insensitive paths protect in-use images'); image_format_assert(wm_asset_normalize_path('images\\objects\\Router.PNG', 'C:\\Cacti\\plugins\\weathermap') === 'C:/Cacti/plugins/weathermap/images/objects/Router.PNG', 'Windows relative path canonicalised'); image_format_assert(wm_asset_normalize_path('D:\\assets\\router.png', 'C:/Cacti') === 'D:/assets/router.png', 'Windows absolute drive path retained'); @@ -93,12 +106,12 @@ function publication_encode($image, $handle) { $jpegPath = $root . '/images/backgrounds/format-reload-' . bin2hex(random_bytes(4)) . '.jpeg'; copy($created[3], $jpegPath); $created[] = $jpegPath; image_format_assert(in_array('images/backgrounds/' . basename($jpegPath), get_imagelist('backgrounds'), true), 'existing JPEG background survives selector reload'); - file_put_contents($config, "ICON /outside/router.png\n"); + file_put_contents($config, "NODE Fixture\n ICON /outside/router.png\n"); image_format_assert(wm_asset_image_users('router.png', 'objects') === [], 'complete paths distinguish external images'); - file_put_contents($config, "ICON 32 32 images/objects/updown_{node:this:bandwidth_in}.png\n"); + file_put_contents($config, "NODE Fixture\n ICON 32 32 images/objects/updown_{node:this:bandwidth_in}.png\n"); image_format_assert(in_array(basename($config), wm_asset_image_users('updown_0.png', 'objects'), true), 'templated icon cannot be deleted'); image_format_assert(wm_asset_image_users('updown_0.png', 'backgrounds') === [], 'library kinds are distinct'); - file_put_contents($config, "ICON images/objects/" . basename($created[0]) . "\n"); + file_put_contents($config, "NODE Fixture\n ICON images/objects/" . basename($created[0]) . "\n"); $listed = array_values(array_filter(wm_asset_image_list(), function($item) use ($created) { return $item['kind'] === 'objects' && $item['name'] === basename($created[0]); })); image_format_assert($listed[0]['width'] === 12 && $listed[0]['height'] === 8 && !$listed[0]['deletable'] && $listed[0]['users'], 'dimensions and usage disable referenced image deletion'); image_format_assert(wm_editor_sanitize_file('images/backgrounds/legacy.JPG', ['png', 'jpg', 'gif', 'jpeg']) === 'images/backgrounds/legacy.JPG', 'uppercase background save retains path'); @@ -149,7 +162,10 @@ function publication_encode($image, $handle) { $_SERVER['REQUEST_METHOD'] = 'POST'; $_FILES['image_file'] = ['error' => UPLOAD_ERR_OK, 'tmp_name' => $source, 'name' => 'must-not-persist.png']; $GLOBALS['__test_nfilter_request'] = ['image_kind' => 'objects']; - ob_start(); wm_editor_assets($config, 'upload_image'); $response = json_decode(ob_get_clean(), true); + $oldSuppress = $GLOBALS['weathermap_error_suppress'] ?? []; + $GLOBALS['weathermap_error_suppress'] = ['']; // The deliberately broken INCLUDE emits an untagged warning. + ob_start(); image_format_revision($config); wm_editor_assets($config, 'upload_image'); $response = json_decode(ob_get_clean(), true); + $GLOBALS['weathermap_error_suppress'] = $oldSuppress; image_format_assert(str_contains($response['error'], 'Cannot check configuration references'), 'configuration failure precedes upload persistence'); image_format_assert(glob($root . '/images/objects/must-not-persist-*') === [], 'reference error cannot create an uploaded image'); unlink($broken); unset($_FILES['image_file']); @@ -160,27 +176,48 @@ function publication_encode($image, $handle) { image_format_assert(isset($response['images']), 'library JSON returned'); $deletePath = $created[0]; $deleteName = basename($deletePath); $GLOBALS['__test_nfilter_request'] = ['image_kind' => 'objects', 'image_name' => $deleteName]; - ob_start(); wm_editor_assets($config, 'delete_image'); $response = json_decode(ob_get_clean(), true); + ob_start(); image_format_revision($config); wm_editor_assets($config, 'delete_image'); $response = json_decode(ob_get_clean(), true); image_format_assert(isset($response['error']) && is_file($deletePath), 'GET cannot delete an image'); $_SERVER['REQUEST_METHOD'] = 'POST'; - file_put_contents($config, "ICON images/objects/" . $deleteName . "\n"); - ob_start(); wm_editor_assets($config, 'delete_image'); $response = json_decode(ob_get_clean(), true); + file_put_contents($config, "NODE Fixture\n ICON images/objects/" . $deleteName . "\n"); + ob_start(); image_format_revision($config); wm_editor_assets($config, 'delete_image'); $response = json_decode(ob_get_clean(), true); image_format_assert(isset($response['error']) && is_file($deletePath), 'referenced image cannot be deleted'); file_put_contents($config, "# no image reference\n"); $externalDir = $temporary . '/reference-source'; mkdir($externalDir); $externalFile = $externalDir . '/map.conf'; file_put_contents($externalFile, "# External source\n"); $externalAlias = $root . '/configs/alias-' . bin2hex(random_bytes(4)) . '.conf'; symlink($externalFile, $externalAlias); $created[] = $externalAlias; $externalLock = \WeathermapConfig\acquire($externalDir); - ob_start(); wm_editor_assets($config, 'delete_image'); $response = json_decode(ob_get_clean(), true); + ob_start(); image_format_revision($config); wm_editor_assets($config, 'delete_image'); $response = json_decode(ob_get_clean(), true); image_format_assert(http_response_code() === 500 && isset($response['error']) && is_file($deletePath), 'image deletion cannot bypass a physical configuration mutex'); \WeathermapConfig\release($externalLock); file_put_contents($externalFile, "ICON images/objects/" . $deleteName . "\n"); - ob_start(); wm_editor_assets($config, 'delete_image'); $response = json_decode(ob_get_clean(), true); + ob_start(); image_format_revision($config); wm_editor_assets($config, 'delete_image'); $response = json_decode(ob_get_clean(), true); image_format_assert(isset($response['error']) && is_file($deletePath), 'external symlink references remain protected after acquiring shared locks'); unlink($externalAlias); unlink($externalFile); unlink($externalDir . '/.weathermap-config.lock'); rmdir($externalDir); - ob_start(); wm_editor_assets($config, 'delete_image'); $response = json_decode(ob_get_clean(), true); + ob_start(); image_format_revision($config); wm_editor_assets($config, 'delete_image'); $response = json_decode(ob_get_clean(), true); image_format_assert(isset($response['images']) && !is_file($deletePath), 'delete removes the actual server file'); + // Content detection, not a filename suffix, decides what selectors can render. + foreach (['objects', 'backgrounds'] as $kind) { + foreach (['broken'=>'not an image', 'unsupported'=>"BM" . pack('VvvV', 58, 0, 0, 54) . pack('VllvvVVllVV', 40, 1, 1, 1, 24, 0, 4, 0, 0, 0, 0) . "\0\0\0\0"] as $name => $bytes) { + $fake = $root . '/images/' . $kind . '/test-' . $name . '-' . bin2hex(random_bytes(4)) . '.png'; + file_put_contents($fake, $bytes); $created[] = $fake; + if ($name === 'unsupported') image_format_assert(getimagesize($fake)[2] === IMAGETYPE_BMP, 'fixture really detects an unsupported raster'); + image_format_assert(!array_filter(wm_asset_image_list(), fn ($item) => $item['kind'] === $kind && $item['name'] === basename($fake)), 'unsupported or invalid image omitted from library'); + } + } + // Stale, missing and nonexistent map tokens fail before upload validation or deletion. + $protected = $created[1]; + $protectedBytes = file_get_contents($protected); + foreach (['upload_image', 'delete_image'] as $action) { + foreach (['missing', 'stale', 'absent-map'] as $failure) { + $GLOBALS['__test_nfilter_request'] = ['image_kind'=>'backgrounds','image_name'=>basename($protected)]; + if ($failure !== 'missing') $GLOBALS['__test_nfilter_request']['config_revision'] = str_repeat('0',64); + http_response_code(200); ob_start(); wm_editor_assets($failure === 'absent-map' ? $config . '.missing' : $config, $action); $blocked = json_decode(ob_get_clean(), true); + image_format_assert(http_response_code() === ($failure === 'absent-map' ? 404 : 409) && isset($blocked['error']), 'image mutation rejects ' . $failure); + image_format_assert(file_get_contents($protected) === $protectedBytes, 'rejected mutation preserves library file'); + } + } echo "Editor image formats regression PASS\n"; } finally { foreach ($created as $path) if (is_file($path)) unlink($path); From f07f47ade20f1fb2d82618e342f41a5125f948bb Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:56:40 +1030 Subject: [PATCH 37/41] Validate raster contents in initial image selectors --- lib/editor.inc.php | 4 +++- locales/po/cacti.pot | 2 +- tests/Support/EditorImageFormatsRegression.php | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/editor.inc.php b/lib/editor.inc.php index f886f7e8..6779d9ee 100644 --- a/lib/editor.inc.php +++ b/lib/editor.inc.php @@ -646,7 +646,9 @@ function get_imagelist($imagedir) { $realfile = $imdir . '/' . $file; $uri = "images/$imagedir/$file"; - if (is_readable($realfile) && (preg_match('/\.(gif|jpe?g|png)$/i', $file))) { + if (is_file($realfile) && !is_link($realfile) && is_readable($realfile) && (preg_match('/\.(gif|jpe?g|png)$/i', $file))) { + $size = @getimagesize($realfile); + if (!$size || !in_array($size[2], [IMAGETYPE_PNG, IMAGETYPE_JPEG, IMAGETYPE_GIF], true)) continue; $imagelist[] = $uri; $n++; } diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 3d471751..2d662562 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti 1.7\n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-15 02:12+1030\n" +"POT-Creation-Date: 2026-10-15 02:26+1030\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" diff --git a/tests/Support/EditorImageFormatsRegression.php b/tests/Support/EditorImageFormatsRegression.php index 2c4c9cdb..f2ab93ab 100644 --- a/tests/Support/EditorImageFormatsRegression.php +++ b/tests/Support/EditorImageFormatsRegression.php @@ -204,6 +204,7 @@ function publication_encode($image, $handle) { file_put_contents($fake, $bytes); $created[] = $fake; if ($name === 'unsupported') image_format_assert(getimagesize($fake)[2] === IMAGETYPE_BMP, 'fixture really detects an unsupported raster'); image_format_assert(!array_filter(wm_asset_image_list(), fn ($item) => $item['kind'] === $kind && $item['name'] === basename($fake)), 'unsupported or invalid image omitted from library'); + image_format_assert(!in_array('images/' . $kind . '/' . basename($fake), get_imagelist($kind), true), 'initial selector also excludes unsupported or invalid images'); } } // Stale, missing and nonexistent map tokens fail before upload validation or deletion. From 13ef8f22bc6ea5d00566492d6f2c83772c6ec410 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Thu, 15 Oct 2026 07:58:20 +1030 Subject: [PATCH 38/41] Align image identities, include locks and symlink selectors --- lib/WeatherMap.class.php | 24 ++++-- lib/editor.assets.php | 51 ++++++++++- lib/editor.inc.php | 2 +- locales/po/cacti.pot | 2 +- .../Support/EditorImageFormatsRegression.php | 85 ++++++++++++++++++- 5 files changed, 152 insertions(+), 12 deletions(-) diff --git a/lib/WeatherMap.class.php b/lib/WeatherMap.class.php index 378e3e9a..5a04efd2 100644 --- a/lib/WeatherMap.class.php +++ b/lib/WeatherMap.class.php @@ -2449,29 +2449,41 @@ function DrawTitle($image, $font, $colour) { /** * Collect managed image references from proposed serialized output. + * * @param string $serialized Proposed output, including raw overrides. + * * @return string[] Paths relative to the plugin directory. */ static function ManagedImagePathsFromConfig($serialized) { $paths = []; if (preg_match_all('/^\s*(?:ICON|BACKGROUND)\s+(?:[0-9]+\s+[0-9]+\s+)?(.+?)\s*$/mi', $serialized, $matches)) $paths = $matches[1]; - return array_values(array_unique(array_filter($paths, function ($path) { - return preg_match('#^images/(objects|backgrounds)/#', $path); - }))); + require_once __DIR__ . '/editor.assets.php'; + $identities = []; + foreach ($paths as $path) { + $identity = wm_asset_managed_path($path, dirname(__DIR__)); + if ($identity !== null) $identities[] = $identity; + } + return array_values(array_unique($identities)); } /** * Collect managed image references from map objects or proposed output. + * * @param string|null $serialized Proposed output, including raw overrides. + * * @return string[] Paths relative to the plugin directory. */ function ManagedImagePaths($serialized = null) { $paths = [$this->background]; foreach ($this->nodes as $node) $paths[] = $node->iconfile; if ($serialized !== null) return self::ManagedImagePathsFromConfig($serialized); - return array_values(array_unique(array_filter($paths, function ($path) { - return is_string($path) && preg_match('#^images/(objects|backgrounds)/#', $path); - }))); + require_once __DIR__ . '/editor.assets.php'; + $identities = []; + foreach ($paths as $path) { + $identity = wm_asset_managed_path($path, dirname(__DIR__)); + if ($identity !== null) $identities[] = $identity; + } + return array_values(array_unique($identities)); } /** diff --git a/lib/editor.assets.php b/lib/editor.assets.php index 997fe3dd..3b373773 100644 --- a/lib/editor.assets.php +++ b/lib/editor.assets.php @@ -76,9 +76,47 @@ function wm_asset_normalize_path($path, $root) { } return $prefix . implode('/', $parts); } +/** + * Make a renderer path absolute without collapsing symlinks or parent components. + * + * @param string $path Original directive path. + * @param string $root Renderer working directory for relative paths. + * + * @return string Absolute path with filesystem resolution semantics preserved. + */ +function wm_asset_absolute_path($path, $root) { + if (substr($path, 0, 1) === '/' || substr($path, 0, 1) === '\\' || preg_match('~^[A-Za-z]:[/\\\\]~', $path)) return $path; + return rtrim($root, '/\\') . '/' . $path; +} +/** + * Resolve a managed image identity even when the final file does not exist. + * + * @param mixed $path Original image path, retaining any renderer tokens. + * @param string $root Plugin directory. + * + * @return string|null Library-relative identity, or null for an unmanaged path. + */ +function wm_asset_managed_path($path, $root) { + if (!is_string($path) || $path === '') return null; + clearstatcache(true); + $parent = wm_asset_absolute_path($path, $root); + $tail = []; + while (($resolved = realpath($parent)) === false) { + $next = dirname($parent); + if ($next === $parent) return null; + array_unshift($tail, basename($parent)); + $parent = $next; + } + $identity = wm_asset_normalize_path($resolved . ($tail ? '/' . implode('/', $tail) : ''), $root); + $base = rtrim(str_replace('\\', '/', realpath($root) ?: $root), '/') . '/'; + if (substr($identity, 0, strlen($base)) !== $base) return null; + $relative = substr($identity, strlen($base)); + return preg_match('#^images/(objects|backgrounds)/#', $relative) ? $relative : null; +} /** * Read configuration files and includes once to index image references. * + * @param array|null $directories Logical and physical parent directories to stabilise. * * @return array The reference patterns and source filenames. */ @@ -103,14 +141,17 @@ function wm_asset_image_reference_index(&$directories = null) { if ($text === false) throw new RuntimeException(__('Cannot check configuration references.', 'weathermap')); if (preg_match_all('/^\s*(ICON|BACKGROUND)\s+(?:[0-9]+\s+[0-9]+\s+)?(.+?)\s*$/mi', $text, $directives, PREG_SET_ORDER)) { foreach ($directives as $directive) { - $image = wm_asset_normalize_path(trim($directive[2]), $root); + $rawImage = trim($directive[2]); + $image = wm_asset_normalize_path(wm_asset_managed_path($rawImage, $root) ?? $rawImage, $root); // Dynamic token values are unknown here: protect all possible paths. $pattern = implode('.*', array_map(function($part) { return preg_quote($part, '~'); }, preg_split('/\{[^}]+\}/', $image))); $references[] = ['pattern' => '~^' . $pattern . '$~iD', 'user' => basename($real)]; } } if (preg_match_all('/^\s*INCLUDE\s+(.+)$/mi', $text, $matches)) foreach ($matches[1] as $include) { - $include = wm_asset_normalize_path(trim($include), $root); + $include = wm_asset_absolute_path(trim($include), $root); + // A writer can create a currently missing include while holding its parent lock. + $directories[dirname($include)] = true; // INCLUDE is a literal filename in the renderer, including glob metacharacters. if (file_exists($include) || is_link($include)) $queue[] = $include; } @@ -119,8 +160,10 @@ function wm_asset_image_reference_index(&$directories = null) { } /** * Lock every physical reference source before deciding whether deletion is safe. + * * @param string $rootDirectory Already locked logical config directory. * @param array $locks Additional locks, released by the caller. + * * @return array The stable reference index. */ function wm_asset_locked_reference_index($rootDirectory, &$locks) { @@ -130,11 +173,13 @@ function wm_asset_locked_reference_index($rootDirectory, &$locks) { $added = false; foreach (array_keys($directories) as $directory) { $directory = realpath($directory); + if ($directory === false) throw new RuntimeException(__('Could not lock map configurations.', 'weathermap')); if (isset($covered[$directory])) continue; $lock = \WeathermapConfig\acquire($directory, false); - if (!$lock && (is_writable($directory) || file_exists($directory . '/.weathermap-config.lock'))) throw new RuntimeException(__('Could not lock map configurations.', 'weathermap')); + if (!$lock) throw new RuntimeException(__('Could not lock map configurations.', 'weathermap')); if ($lock) $locks[] = $lock; $covered[$directory] = true; + if (count($covered) > 1000) throw new RuntimeException(__('Too many included configurations to check.', 'weathermap')); $added = true; } if (!$added) return $references; diff --git a/lib/editor.inc.php b/lib/editor.inc.php index 6779d9ee..ab016745 100644 --- a/lib/editor.inc.php +++ b/lib/editor.inc.php @@ -646,7 +646,7 @@ function get_imagelist($imagedir) { $realfile = $imdir . '/' . $file; $uri = "images/$imagedir/$file"; - if (is_file($realfile) && !is_link($realfile) && is_readable($realfile) && (preg_match('/\.(gif|jpe?g|png)$/i', $file))) { + if (is_file($realfile) && is_readable($realfile) && (preg_match('/\.(gif|jpe?g|png)$/i', $file))) { $size = @getimagesize($realfile); if (!$size || !in_array($size[2], [IMAGETYPE_PNG, IMAGETYPE_JPEG, IMAGETYPE_GIF], true)) continue; $imagelist[] = $uri; diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 2d662562..c27bbe58 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti 1.7\n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-15 02:26+1030\n" +"POT-Creation-Date: 2026-10-15 07:57+1030\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" diff --git a/tests/Support/EditorImageFormatsRegression.php b/tests/Support/EditorImageFormatsRegression.php index f2ab93ab..876ebecb 100644 --- a/tests/Support/EditorImageFormatsRegression.php +++ b/tests/Support/EditorImageFormatsRegression.php @@ -51,6 +51,20 @@ function image_format_revision($file) { $map = new WeatherMap; $map->ReadConfig($file); $GLOBALS['__test_nfilter_request']['config_revision'] = \WeathermapConfig\snapshotRevision($map->config_revision, $map->config_dependencies, $map->config_root_identity); } +/** + * Inject creation of an optional include between reference scans. + * + * @param array|null $directories Parent directories discovered by the real scanner. + * + * @return array Reference index preceding the injected writer. + */ +function image_format_scan(&$directories = null) { + $result = wm_asset_image_reference_index($directories); + if (++$GLOBALS['image_format_scans'] === 1) { + image_format_assert(\WeathermapConfig\atomicWrite($GLOBALS['image_format_optional'], "BACKGROUND " . $GLOBALS['image_format_reference'] . "\n", false, null, true), 'cooperating writer creates missing include'); + } + return $result; +} $root = dirname(__DIR__, 2); $temporary = $root . '/tests/wm-image-formats-' . bin2hex(random_bytes(4)); mkdir($temporary); @@ -219,10 +233,79 @@ function publication_encode($image, $handle) { image_format_assert(file_get_contents($protected) === $protectedBytes, 'rejected mutation preserves library file'); } } + // Existing raster symlinks remain selectable but never manageable. + $backgroundAlias = $root . '/images/backgrounds/alias-' . bin2hex(random_bytes(4)) . '.png'; + symlink($protected, $backgroundAlias); $created[] = $backgroundAlias; + image_format_assert(in_array('images/backgrounds/' . basename($backgroundAlias), get_imagelist('backgrounds'), true), 'readable raster symlink remains in initial background selector'); + image_format_assert(!array_filter(wm_asset_image_list(), fn ($item) => $item['name'] === basename($backgroundAlias)), 'symlink stays outside managed library operations'); + try { wm_asset_image_path('backgrounds', basename($backgroundAlias)); throw new RuntimeException('managed symlink accepted'); } catch (InvalidArgumentException $expected) {} + // Missing include destinations contribute parent locks before the final rescan. + $optionalDir = $temporary . '/optional'; mkdir($optionalDir); $optional = $optionalDir . '/optional.inc'; + file_put_contents($config, "INCLUDE $optional\n"); + wm_asset_image_reference_index($directories); + image_format_assert(isset($directories[$optionalDir]), 'missing literal INCLUDE parent indexed'); + $parentLock = \WeathermapConfig\acquire($optionalDir); $locks = []; + try { wm_asset_locked_reference_index($root . '/configs', $locks); throw new RuntimeException('missing include mutex bypassed'); } catch (RuntimeException $expected) { + image_format_assert(str_contains($expected->getMessage(), 'Could not lock'), 'held missing include destination lock blocks deletion'); + } finally { foreach ($locks as $held) \WeathermapConfig\release($held); \WeathermapConfig\release($parentLock); } + // Inject a cooperating writer after the first index: the subsequent locked scan must see it. + $assetSource = file_get_contents($root . '/lib/editor.assets.php'); + $begin = strpos($assetSource, 'function wm_asset_locked_reference_index('); + $end = strpos($assetSource, "\n/**", $begin); + $instrumentedLockIndex = substr($assetSource, $begin, $end - $begin); + eval(str_replace(['wm_asset_locked_reference_index(', 'wm_asset_image_reference_index('], ['image_format_locked_index(', 'image_format_scan('], $instrumentedLockIndex)); + $GLOBALS['image_format_optional'] = $optional; $GLOBALS['image_format_reference'] = 'images/backgrounds/' . basename($protected); $GLOBALS['image_format_scans'] = 0; + $locks = []; + try { + $lockedReferences = image_format_locked_index($root . '/configs', $locks); + image_format_assert($GLOBALS['image_format_scans'] >= 2 && wm_asset_image_users(basename($protected), 'backgrounds', $lockedReferences), 'new include references are detected by locked rescan'); + image_format_assert(\WeathermapConfig\acquire($optionalDir, false) === false, 'deletion retains missing include parent mutex through unlink decision'); + } finally { foreach ($locks as $held) \WeathermapConfig\release($held); } + file_put_contents($config, "INCLUDE " . $optionalDir . "/absent-parent/file.inc\n"); $locks = []; + try { wm_asset_locked_reference_index($root . '/configs', $locks); throw new RuntimeException('unstable include parent accepted'); } catch (RuntimeException $expected) { + image_format_assert(str_contains($expected->getMessage(), 'Could not lock'), 'unresolvable include destination fails safely'); + } finally { foreach ($locks as $held) \WeathermapConfig\release($held); } + // Filesystem symlink/.. semantics must match the renderer, not lexical path collapse. + $target = $temporary . '/target'; mkdir($target); mkdir($target . '/sub'); + $linked = $temporary . '/linked'; symlink($target . '/sub', $linked); + file_put_contents($target . '/include.conf', "BACKGROUND images/backgrounds/" . basename($protected) . "\n"); + file_put_contents($config, "INCLUDE $linked/../include.conf\n"); + image_format_assert(wm_asset_image_users(basename($protected), 'backgrounds') !== [], 'symlink-parent INCLUDE references protected'); + unlink($linked); unlink($target . '/include.conf'); rmdir($target . '/sub'); rmdir($target); + unlink($optional); unlink($optionalDir . '/.weathermap-config.lock'); rmdir($optionalDir); + // Both collectors canonicalise alternate renderer spellings, including missing paths. + file_put_contents($config, "WIDTH 100\nHEIGHT 100\nNODE A\n POSITION 20 20\n"); + $missingName = 'missing-' . bin2hex(random_bytes(4)) . '.png'; + foreach (['./images/objects/' . $missingName, 'images/./objects/' . $missingName, $root . '/images/objects/' . $missingName] as $spelling) { + $assetMap = new WeatherMap; $assetMap->ReadConfig($config); $assetMap->nodes['A']->iconfile = $spelling; + image_format_assert($assetMap->ManagedImagePaths() === ['images/objects/' . $missingName], 'object collector normalizes missing path'); + image_format_assert(WeatherMap::ManagedImagePathsFromConfig("ICON 32 32 $spelling\n") === ['images/objects/' . $missingName], 'raw collector normalizes missing path'); + $before = file_get_contents($config); + image_format_assert(!$assetMap->WriteConfig($config) && $assetMap->config_asset_conflict && file_get_contents($config) === $before, 'alternate missing-image spelling cannot commit'); + $assetMap->nodes['A']->iconfile = ''; + $assetMap->nodes['A']->config_override = "NODE A\n POSITION 20 20\n ICON $spelling\n"; + image_format_assert(!$assetMap->WriteConfig($config), 'raw override cannot bypass missing-image validation'); + } + $missingInclude = $temporary . '/missing-asset.inc'; + file_put_contents($missingInclude, "BACKGROUND ./images/backgrounds/$missingName\n"); + file_put_contents($config, "INCLUDE $missingInclude\n"); + $copyMap = new WeatherMap; $copyMap->ReadConfig($config); + image_format_assert(!$copyMap->WriteConfig($temporary . '/copy.conf', true) && !is_file($temporary . '/copy.conf'), 'copy rejects inherited missing image in normalized spelling'); + file_put_contents($config, "WIDTH 100\nHEIGHT 100\nNODE A\n POSITION 20 20\n"); + $baseline = new WeatherMap; $baseline->ReadConfig($config); + $baseline->background = './images/backgrounds/' . basename($protected); + image_format_assert($baseline->WriteConfig($config), 'existing alternate image spelling saves'); + $baseline = new WeatherMap; $baseline->ReadConfig($config); + image_format_assert($baseline->config_managed_assets === ['images/backgrounds/' . basename($protected)], 'loaded image baseline uses same canonical identity'); + image_format_assert(wm_asset_managed_path('outside.png', $root) === null && wm_asset_managed_path(null, $root) === null, 'unmanaged image paths excluded'); + image_format_assert(str_contains(wm_asset_managed_path('images/objects/dynamic_{node:this:name}.png', $root), '{node:this:name}'), 'renderer tokens preserved in identity'); echo "Editor image formats regression PASS\n"; } finally { foreach ($created as $path) if (is_file($path)) unlink($path); if (is_file($config)) unlink($config); - foreach (glob($temporary . '/*') as $path) unlink($path); + $cleanup = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($temporary, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST); + foreach ($cleanup as $entry) { + if ($entry->isDir() && !$entry->isLink()) rmdir($entry->getPathname()); else unlink($entry->getPathname()); + } rmdir($temporary); } From a4bdb07ca5d2dbc64430039453e138c464625f56 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:18:27 +1030 Subject: [PATCH 39/41] Remove dangling image fixture symlinks during cleanup --- locales/po/cacti.pot | 2 +- tests/Support/EditorImageFormatsRegression.php | 2 +- tests/Unit/EditorImageFormatsTest.php | 3 +++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index c27bbe58..036f79ad 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti 1.7\n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-15 07:57+1030\n" +"POT-Creation-Date: 2026-10-15 07:58+1030\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" diff --git a/tests/Support/EditorImageFormatsRegression.php b/tests/Support/EditorImageFormatsRegression.php index 876ebecb..05bfd3d2 100644 --- a/tests/Support/EditorImageFormatsRegression.php +++ b/tests/Support/EditorImageFormatsRegression.php @@ -301,7 +301,7 @@ function publication_encode($image, $handle) { image_format_assert(str_contains(wm_asset_managed_path('images/objects/dynamic_{node:this:name}.png', $root), '{node:this:name}'), 'renderer tokens preserved in identity'); echo "Editor image formats regression PASS\n"; } finally { - foreach ($created as $path) if (is_file($path)) unlink($path); + foreach ($created as $path) if (is_file($path) || is_link($path)) unlink($path); if (is_file($config)) unlink($config); $cleanup = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($temporary, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST); foreach ($cleanup as $entry) { diff --git a/tests/Unit/EditorImageFormatsTest.php b/tests/Unit/EditorImageFormatsTest.php index efdd770a..40a089bf 100644 --- a/tests/Unit/EditorImageFormatsTest.php +++ b/tests/Unit/EditorImageFormatsTest.php @@ -34,6 +34,8 @@ final class EditorImageFormatsTest extends TestCase { #[RunInSeparateProcess] #[PreserveGlobalState(false)] public function testRealEngineRegression(): void { + $plugin = dirname(__DIR__, 2); + $beforeLibraries = [glob($plugin . '/images/objects/*'), glob($plugin . '/images/backgrounds/*')]; ob_start(); try { require dirname(__DIR__) . '/Support/EditorImageFormatsRegression.php'; @@ -42,5 +44,6 @@ public function testRealEngineRegression(): void { ob_end_clean(); } self::assertSame("Editor image formats regression PASS\n", $result); + self::assertSame($beforeLibraries, [glob($plugin . '/images/objects/*'), glob($plugin . '/images/backgrounds/*')], 'Fixture cleanup leaves both libraries unchanged, including dangling symlinks'); } } From ae1ada05e1ab1d8dffa296baf1c269b5a5af0741 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:33:22 +1030 Subject: [PATCH 40/41] Keep image and include directive parsing within individual lines --- lib/WeatherMap.class.php | 2 +- lib/editor.assets.php | 5 +++-- locales/po/cacti.pot | 2 +- tests/Support/EditorImageFormatsRegression.php | 15 +++++++++++++++ 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/lib/WeatherMap.class.php b/lib/WeatherMap.class.php index 5a04efd2..752de1c0 100644 --- a/lib/WeatherMap.class.php +++ b/lib/WeatherMap.class.php @@ -2456,7 +2456,7 @@ function DrawTitle($image, $font, $colour) { */ static function ManagedImagePathsFromConfig($serialized) { $paths = []; - if (preg_match_all('/^\s*(?:ICON|BACKGROUND)\s+(?:[0-9]+\s+[0-9]+\s+)?(.+?)\s*$/mi', $serialized, $matches)) $paths = $matches[1]; + if (preg_match_all('/^[ \t]*(?:ICON|BACKGROUND)[ \t]+(?:[0-9]+[ \t]+[0-9]+[ \t]+)?([^\r\n]+?)[ \t]*\r?$/mi', $serialized, $matches)) $paths = $matches[1]; require_once __DIR__ . '/editor.assets.php'; $identities = []; foreach ($paths as $path) { diff --git a/lib/editor.assets.php b/lib/editor.assets.php index 3b373773..8fdcf783 100644 --- a/lib/editor.assets.php +++ b/lib/editor.assets.php @@ -139,7 +139,7 @@ function wm_asset_image_reference_index(&$directories = null) { if (cacti_count($seen) > 1000) throw new RuntimeException(__('Too many included configurations to check.', 'weathermap')); $text = file_get_contents($real); if ($text === false) throw new RuntimeException(__('Cannot check configuration references.', 'weathermap')); - if (preg_match_all('/^\s*(ICON|BACKGROUND)\s+(?:[0-9]+\s+[0-9]+\s+)?(.+?)\s*$/mi', $text, $directives, PREG_SET_ORDER)) { + if (preg_match_all('/^[ \t]*(ICON|BACKGROUND)[ \t]+(?:[0-9]+[ \t]+[0-9]+[ \t]+)?([^\r\n]+?)[ \t]*\r?$/mi', $text, $directives, PREG_SET_ORDER)) { foreach ($directives as $directive) { $rawImage = trim($directive[2]); $image = wm_asset_normalize_path(wm_asset_managed_path($rawImage, $root) ?? $rawImage, $root); @@ -148,7 +148,8 @@ function wm_asset_image_reference_index(&$directories = null) { $references[] = ['pattern' => '~^' . $pattern . '$~iD', 'user' => basename($real)]; } } - if (preg_match_all('/^\s*INCLUDE\s+(.+)$/mi', $text, $matches)) foreach ($matches[1] as $include) { + if (preg_match_all('/^[ \t]*INCLUDE[ \t]+([^\r\n]+?)[ \t]*\r?$/mi', $text, $matches)) foreach ($matches[1] as $include) { + if (trim($include) === '') continue; $include = wm_asset_absolute_path(trim($include), $root); // A writer can create a currently missing include while holding its parent lock. $directories[dirname($include)] = true; diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 036f79ad..4521da50 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti 1.7\n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-15 07:58+1030\n" +"POT-Creation-Date: 2026-10-15 08:30+1030\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" diff --git a/tests/Support/EditorImageFormatsRegression.php b/tests/Support/EditorImageFormatsRegression.php index 05bfd3d2..fe601307 100644 --- a/tests/Support/EditorImageFormatsRegression.php +++ b/tests/Support/EditorImageFormatsRegression.php @@ -299,6 +299,21 @@ function publication_encode($image, $handle) { image_format_assert($baseline->config_managed_assets === ['images/backgrounds/' . basename($protected)], 'loaded image baseline uses same canonical identity'); image_format_assert(wm_asset_managed_path('outside.png', $root) === null && wm_asset_managed_path(null, $root) === null, 'unmanaged image paths excluded'); image_format_assert(str_contains(wm_asset_managed_path('images/objects/dynamic_{node:this:name}.png', $root), '{node:this:name}'), 'renderer tokens preserved in identity'); + $lineInclude = $temporary . '/line-include.inc'; + $missingLineAsset = 'images/objects/line-missing-' . bin2hex(random_bytes(4)) . '.png'; + foreach (["\n", "\r\n"] as $eol) { + file_put_contents($lineInclude, 'BACKGROUND ' . $eol . 'BACKGROUND images/backgrounds/' . basename($protected) . $eol); + file_put_contents($config, 'INCLUDE ' . $eol . 'INCLUDE ' . $lineInclude . $eol . 'ICON ' . $eol . 'ICON 32 32 images/backgrounds/' . basename($protected) . $eol); + $lineReferences = wm_asset_image_reference_index(); + image_format_assert(wm_asset_image_users(basename($protected), 'backgrounds', $lineReferences) !== [], 'empty LF/CRLF directives do not hide referenced images'); + image_format_assert(in_array(basename($lineInclude), wm_asset_image_users(basename($protected), 'backgrounds', $lineReferences), true), 'empty LF/CRLF INCLUDE does not hide the following literal include'); + $raw = 'NODE A' . $eol . ' ICON ' . $eol . ' ICON 32 32 ' . $missingLineAsset . $eol; + image_format_assert(WeatherMap::ManagedImagePathsFromConfig($raw) === [$missingLineAsset], 'raw image collection stays within LF/CRLF lines'); + file_put_contents($config, "WIDTH 100\nHEIGHT 100\nNODE A\n POSITION 20 20\n"); + $lineMap = new WeatherMap; $lineMap->ReadConfig($config); $lineMap->nodes['A']->config_override = $raw; + $beforeLineSave = file_get_contents($config); + image_format_assert(!$lineMap->WriteConfig($config) && $lineMap->config_asset_conflict && file_get_contents($config) === $beforeLineSave, 'empty LF/CRLF raw ICON cannot bypass missing-image validation'); + } echo "Editor image formats regression PASS\n"; } finally { foreach ($created as $path) if (is_file($path) || is_link($path)) unlink($path); From 596b66591f36ee0d39eaa7209c65bec92ad5f127 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:35:46 +1030 Subject: [PATCH 41/41] Reject non-regular configuration reference sources before reading --- lib/editor.assets.php | 2 +- locales/po/cacti.pot | 2 +- .../Support/EditorImageFormatsRegression.php | 31 +++++++++++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/lib/editor.assets.php b/lib/editor.assets.php index 8fdcf783..b8146c7c 100644 --- a/lib/editor.assets.php +++ b/lib/editor.assets.php @@ -131,7 +131,7 @@ function wm_asset_image_reference_index(&$directories = null) { while ($queue) { $path = array_pop($queue); $real = realpath($path); - if ($real === false || !is_readable($real)) throw new RuntimeException(__('Cannot check configuration references.', 'weathermap')); + if ($real === false || !is_file($real) || !is_readable($real)) throw new RuntimeException(__('Cannot check configuration references.', 'weathermap')); if (isset($seen[$real])) continue; $directories[dirname($path)] = true; $directories[dirname($real)] = true; diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 4521da50..4bf37ad7 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti 1.7\n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-15 08:30+1030\n" +"POT-Creation-Date: 2026-10-15 09:32+1030\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" diff --git a/tests/Support/EditorImageFormatsRegression.php b/tests/Support/EditorImageFormatsRegression.php index fe601307..fcbbf55e 100644 --- a/tests/Support/EditorImageFormatsRegression.php +++ b/tests/Support/EditorImageFormatsRegression.php @@ -65,6 +65,17 @@ function image_format_scan(&$directories = null) { } return $result; } +/** + * Make the non-regular source regression fail safely instead of blocking on a FIFO. + * + * @param string $path Scanner-selected source. + * + * @return string|false Contents of a regular source. + */ +function image_format_regular_read($path) { + if (!is_file($path)) throw new RuntimeException('Unexpected non-regular read'); + return file_get_contents($path); +} $root = dirname(__DIR__, 2); $temporary = $root . '/tests/wm-image-formats-' . bin2hex(random_bytes(4)); mkdir($temporary); @@ -314,6 +325,26 @@ function publication_encode($image, $handle) { $beforeLineSave = file_get_contents($config); image_format_assert(!$lineMap->WriteConfig($config) && $lineMap->config_asset_conflict && file_get_contents($config) === $beforeLineSave, 'empty LF/CRLF raw ICON cannot bypass missing-image validation'); } + // Non-regular include targets must fail before the scanner calls a reader. + $referenceSource = file_get_contents($root . '/lib/editor.assets.php'); + $scanStart = strpos($referenceSource, 'function wm_asset_image_reference_index('); + $scanEnd = strpos($referenceSource, "\n/**", $scanStart); + $guardedScan = str_replace('dirname(__DIR__)', var_export($root, true), substr($referenceSource, $scanStart, $scanEnd - $scanStart)); + eval(str_replace(['wm_asset_image_reference_index(', 'file_get_contents('], ['image_format_nonregular_index(', 'image_format_regular_read('], $guardedScan)); + $nonregular = [$temporary]; + if (is_readable('/dev/null')) $nonregular[] = '/dev/null'; + if (function_exists('posix_mkfifo')) { $fifo = $temporary . '/fifo.inc'; image_format_assert(posix_mkfifo($fifo, 0600), 'FIFO fixture created'); $nonregular[] = $fifo; } + foreach ($nonregular as $target) { + file_put_contents($config, "INCLUDE $target\n"); + if (!isset($fifo) || $target !== $fifo) { + try { wm_asset_image_reference_index(); throw new RuntimeException('non-regular include accepted'); } catch (RuntimeException $expected) { + image_format_assert(str_contains($expected->getMessage(), 'Cannot check configuration references'), 'production scanner rejects non-regular source'); + } + } + try { image_format_nonregular_index(); throw new RuntimeException('non-regular include accepted'); } catch (RuntimeException $expected) { + image_format_assert(str_contains($expected->getMessage(), 'Cannot check configuration references'), 'non-regular include rejected before read: ' . $target . ' ' . $expected->getMessage()); + } + } echo "Editor image formats regression PASS\n"; } finally { foreach ($created as $path) if (is_file($path) || is_link($path)) unlink($path);
Nothing in here works yet.
').text('No images in this library yet.')); + $('#wm-images-' + kind + '-count').text((count === 1 ? wmEditorText.imageText11 : wmEditorText.imageText12).replace('%s', String(count))); + if (!count) $('#wm-images-' + kind).append($('
').text(wmEditorText.imageText1)); }); // Rebuild the existing image selectors so uploads are available without reloading the editor. - [{id: 'node_iconfilename', kind: 'objects', label: 'Label Only'}, {id: 'map_bgfile', kind: 'backgrounds', label: 'No background'}].forEach(function(spec) { + [{id: 'node_iconfilename', kind: 'objects', label: wmEditorText.imageText14}, {id: 'map_bgfile', kind: 'backgrounds', label: wmEditorText.imageText15}].forEach(function(spec) { var select = $('#' + spec.id), old = select.val(); var slick = select.data('ddslick'); if (slick) { old = slick.selectedData && slick.selectedData.value; select.ddslick('destroy'); select = $('#' + spec.id); } @@ -1706,15 +1708,15 @@ function wmOpenImages() { $('.wm-image-upload').off('click').on('click', function() { var button = $(this), kind = button.attr('data-kind'), input = $('#wm-image-file-' + kind); var file = input[0].files[0]; - if (!file) { $('#wm-images-error').text('Choose a PNG, JPG or GIF to upload.'); return; } + if (!file) { $('#wm-images-error').text(wmEditorText.imageText2); return; } var data = new FormData(); data.append('action', 'upload_image'); data.append('mapname', $('#mapname').val()); data.append('image_kind', kind); data.append('image_file', file); if (typeof csrfMagicToken !== 'undefined') data.append('__csrf_magic', csrfMagicToken); button.prop('disabled', true); $.ajax({url: editor_url, type: 'POST', data: data, dataType: 'json', contentType: false, processData: false, - success: function(result) { $('#wm-images-error').text('Uploaded ' + result.name); input.val(''); wmAssetImages(result.images); }, - error: function(xhr) { $('#wm-images-error').text(xhr.responseJSON && xhr.responseJSON.error || 'Upload failed.'); }, + success: function(result) { $('#wm-images-error').text(wmEditorText.imageText13.replace('%s', result.name)); input.val(''); wmAssetImages(result.images); }, + error: function(xhr) { $('#wm-images-error').text(xhr.responseJSON && xhr.responseJSON.error || wmEditorText.imageText4); }, complete: function() { button.prop('disabled', false); }}); }); } diff --git a/lib/editor.actions.php b/lib/editor.actions.php index daed6afa..7f4a0b61 100644 --- a/lib/editor.actions.php +++ b/lib/editor.actions.php @@ -1137,28 +1137,28 @@ function getImageURL($mapname, $selected) { } function wm_asset_upload_image($kind, $file) { - if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || !is_uploaded_file($file['tmp_name'] ?? '')) throw new InvalidArgumentException('Choose a PNG, JPG or GIF file up to 5 MB.'); + if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || !is_uploaded_file($file['tmp_name'] ?? '')) throw new InvalidArgumentException(__('Choose a PNG, JPG or GIF file up to 5 MB.', 'weathermap')); return wm_asset_store_image($kind, $file['tmp_name'], $file['name']); } function wm_editor_assets($mapfile, $action) { - header('Content-Type: application/json'); - try { - if ($action !== 'image_data' && ($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') throw new InvalidArgumentException('Use POST to save changes.'); - if ($action === 'image_data') { - $result = ['images' => wm_asset_image_list()]; - } elseif ($action === 'upload_image') { - $name = wm_asset_upload_image(get_nfilter_request_var('image_kind'), $_FILES['image_file'] ?? []); - $result = ['name' => $name, 'images' => wm_asset_image_list()]; - } else { - $name = get_nfilter_request_var('image_name'); - $path = wm_asset_image_path(get_nfilter_request_var('image_kind'), $name); - if (wm_asset_image_users($name)) throw new InvalidArgumentException('This image is still referenced by a map configuration.'); - if (!is_file($path) || !unlink($path)) throw new RuntimeException('Could not delete the image.'); - $result = ['images' => wm_asset_image_list()]; - } - echo json_encode($result); - } catch (Throwable $error) { - http_response_code(400); - echo json_encode(['error' => $error->getMessage()]); - } + header('Content-Type: application/json'); + try { + if ($action !== 'image_data' && ($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') throw new InvalidArgumentException(__('Use POST to save changes.', 'weathermap')); + if ($action === 'image_data') { + $result = ['images' => wm_asset_image_list()]; + } elseif ($action === 'upload_image') { + $name = wm_asset_upload_image(get_nfilter_request_var('image_kind'), $_FILES['image_file'] ?? []); + $result = ['name' => $name, 'images' => wm_asset_image_list()]; + } else { + $name = get_nfilter_request_var('image_name'); + $path = wm_asset_image_path(get_nfilter_request_var('image_kind'), $name); + if (wm_asset_image_users($name, get_nfilter_request_var('image_kind'))) throw new InvalidArgumentException(__('This image is still referenced by a map configuration.', 'weathermap')); + if (!is_file($path) || !unlink($path)) throw new RuntimeException(__('Could not delete the image.', 'weathermap')); + $result = ['images' => wm_asset_image_list()]; + } + echo json_encode($result); + } catch (Throwable $error) { + http_response_code(400); + echo json_encode(['error' => $error->getMessage()]); + } } diff --git a/lib/editor.assets.php b/lib/editor.assets.php index f5e4158f..06943b8b 100644 --- a/lib/editor.assets.php +++ b/lib/editor.assets.php @@ -24,17 +24,17 @@ /** Editor image library management. */ function wm_asset_directory($kind) { - if (!in_array($kind, ['objects', 'backgrounds'], true)) throw new InvalidArgumentException('Invalid image category.'); + if (!in_array($kind, ['objects', 'backgrounds'], true)) throw new InvalidArgumentException(__('Invalid image category.', 'weathermap')); return dirname(__DIR__) . '/images/' . $kind; } function wm_asset_image_path($kind, $name) { $dir = wm_asset_directory($kind); - if (!is_string($name) || !preg_match('/^[A-Za-z0-9_.-]+\.(png|gif|jpe?g)$/iD', $name) || strpos($name, '..') !== false) throw new InvalidArgumentException('Invalid image filename.'); + if (!is_string($name) || !preg_match('/^[A-Za-z0-9_.-]+\.(png|gif|jpe?g)$/iD', $name) || strpos($name, '..') !== false) throw new InvalidArgumentException(__('Invalid image filename.', 'weathermap')); $path = $dir . '/' . $name; - if (is_link($dir) || is_link($path)) throw new InvalidArgumentException('Symbolic links cannot be managed here.'); + if (is_link($dir) || is_link($path)) throw new InvalidArgumentException(__('Symbolic links cannot be managed here.', 'weathermap')); return $path; } -function wm_asset_image_users($name) { +function wm_asset_image_users($name, $kind = null) { $users = []; $queue = []; $root = dirname(__DIR__); @@ -44,16 +44,35 @@ function wm_asset_image_users($name) { while ($queue) { $path = array_pop($queue); $real = realpath($path); - if ($real === false || !is_readable($real)) throw new RuntimeException('Cannot check configuration references.'); + if ($real === false || !is_readable($real)) throw new RuntimeException(__('Cannot check configuration references.', 'weathermap')); if (isset($seen[$real])) continue; $seen[$real] = true; - if (count($seen) > 1000) throw new RuntimeException('Too many included configurations to check.'); + if (count($seen) > 1000) throw new RuntimeException(__('Too many included configurations to check.', 'weathermap')); $text = file_get_contents($real); - if ($text === false) throw new RuntimeException('Cannot check configuration references.'); - if (strpos($text, $name) !== false) $users[] = basename($real); + if ($text === false) throw new RuntimeException(__('Cannot check configuration references.', 'weathermap')); + if (preg_match_all('/^\s*(ICON|BACKGROUND)\s+(?:[0-9]+\s+[0-9]+\s+)?(.+?)\s*$/mi', $text, $directives, PREG_SET_ORDER)) { + foreach ($directives as $directive) { + $image = trim($directive[2]); + $image = strpos($image, '/') === 0 ? $image : $root . '/' . $image; + $parts = []; + foreach (explode('/', $image) as $part) { + if ($part === '' || $part === '.') continue; + if ($part === '..') array_pop($parts); else $parts[] = $part; + } + $image = '/' . implode('/', $parts); + // Dynamic token values are unknown here: protect all possible paths. + $pattern = implode('.*', array_map(function($part) { return preg_quote($part, '~'); }, preg_split('/\{[^}]+\}/', $image))); + foreach ($kind === null ? ['objects', 'backgrounds'] : [$kind] as $library) { + if (preg_match('~^' . $pattern . '$~D', $root . '/images/' . $library . '/' . $name)) { $users[] = basename($real); break 2; } + } + } + } if (preg_match_all('/^\s*INCLUDE\s+(.+)$/mi', $text, $matches)) foreach ($matches[1] as $include) { $include = trim($include); - $queue[] = strpos($include, '/') === 0 ? $include : $root . '/' . $include; + $include = strpos($include, '/') === 0 ? $include : $root . '/' . $include; + $matches = glob($include); + if (!$matches) $queue[] = $include; + else foreach ($matches as $match) $queue[] = $match; } } return $users; @@ -65,33 +84,41 @@ function wm_asset_image_list() { foreach (scandir($dir) ?: [] as $name) { $path = $dir . '/' . $name; if (!is_file($path) || is_link($path) || !is_readable($path) || !preg_match('/\.(png|gif|jpe?g)$/iD', $name)) continue; - $result[] = ['kind' => $kind, 'name' => $name, 'url' => 'images/' . $kind . '/' . rawurlencode($name), 'deletable' => (bool) preg_match('/^[A-Za-z0-9_.-]+\.(png|gif|jpe?g)$/iD', $name) && strpos($name, '..') === false]; + $size = @getimagesize($path); + $users = wm_asset_image_users($name, $kind); + $result[] = ['width' => $size ? $size[0] : null, 'height' => $size ? $size[1] : null, 'users' => $users, 'kind' => $kind, 'name' => $name, 'url' => 'images/' . $kind . '/' . rawurlencode($name), 'deletable' => !$users && (bool) preg_match('/^[A-Za-z0-9_.-]+\.(png|gif|jpe?g)$/iD', $name) && strpos($name, '..') === false]; } } return $result; } function wm_asset_store_image($kind, $source, $original_name) { $dir = wm_asset_directory($kind); - if (is_link($dir) || !is_writable($dir)) throw new RuntimeException('The image directory is not writable.'); - if (!is_file($source) || filesize($source) > 5 * 1024 * 1024) throw new InvalidArgumentException('Choose a PNG, JPG or GIF file up to 5 MB.'); + if (is_link($dir) || !is_writable($dir)) throw new RuntimeException(__('The image directory is not writable.', 'weathermap')); + if (!is_file($source) || filesize($source) > 5 * 1024 * 1024) throw new InvalidArgumentException(__('Choose a PNG, JPG or GIF file up to 5 MB.', 'weathermap')); $size = @getimagesize($source); $formats = [IMAGETYPE_PNG => ['png', 'imagecreatefrompng', 'imagepng'], IMAGETYPE_JPEG => ['jpg', 'imagecreatefromjpeg', 'imagejpeg'], IMAGETYPE_GIF => ['gif', 'imagecreatefromgif', 'imagegif']]; - if (!$size || !isset($formats[$size[2]]) || $size[0] * $size[1] > 4000000) throw new InvalidArgumentException('Choose a PNG, JPG or GIF image up to 4 million pixels.'); + if (!$size || !isset($formats[$size[2]]) || $size[0] * $size[1] > 4000000) throw new InvalidArgumentException(__('Choose a PNG, JPG or GIF image up to 4 million pixels.', 'weathermap')); [$extension, $decode, $encode] = $formats[$size[2]]; $image = @$decode($source); - if (!$image) throw new InvalidArgumentException('The image could not be read.'); + if (!$image) throw new InvalidArgumentException(__('The image could not be read.', 'weathermap')); if ($size[2] === IMAGETYPE_PNG) { imagealphablending($image, false); imagesavealpha($image, true); } $stem = preg_replace('/[^A-Za-z0-9_-]/', '_', pathinfo(basename($original_name), PATHINFO_FILENAME)); - $name = substr($stem ?: 'image', 0, 60) . '-' . bin2hex(random_bytes(4)) . '.' . $extension; + $name = substr($stem ?: 'image', 0, 60) . '-' . bin2hex(random_bytes(16)) . '.' . $extension; $path = wm_asset_image_path($kind, $name); + $handle = @fopen($path, 'x'); + if (!$handle) { imagedestroy($image); throw new RuntimeException(__('Could not reserve an image filename.', 'weathermap')); } try { // Re-encode decoded pixels; never publish the original uploaded bytes. - $written = $size[2] === IMAGETYPE_JPEG ? $encode($image, $path, 90) : $encode($image, $path); - if (!$written) throw new RuntimeException('Could not save the image.'); + $written = $size[2] === IMAGETYPE_JPEG ? $encode($image, $handle, 90) : $encode($image, $handle); + if (!$written) throw new RuntimeException(__('Could not save the image.', 'weathermap')); + if (!fflush($handle)) throw new RuntimeException(__('Could not save the image.', 'weathermap')); chmod($path, 0644); - } finally { imagedestroy($image); } + } catch (Throwable $error) { + unlink($path); + throw $error; + } finally { fclose($handle); imagedestroy($image); } return $name; } diff --git a/lib/editor.inc.php b/lib/editor.inc.php index ba03d20b..26201ebd 100644 --- a/lib/editor.inc.php +++ b/lib/editor.inc.php @@ -1012,6 +1012,25 @@ function getEditorJs() { var addNodeHelp = ''; var wmEditorText = __('−', 'weathermap'), + 'imageText1' => __('No images in this library yet.', 'weathermap'), + 'imageText2' => __('Choose a PNG, JPG or GIF to upload.', 'weathermap'), + 'imageText3' => __('The request failed. Please try again.', 'weathermap'), + 'imageText4' => __('Upload failed.', 'weathermap'), + 'imageText5' => __('Choose a PNG, JPG or GIF file up to 5 MB.', 'weathermap'), + 'imageText6' => __('No gradient', 'weathermap'), + 'imageText7' => __('Custom colour', 'weathermap'), + 'imageText8' => __('Remove range', 'weathermap'), + 'imageText9' => __('Delete %s', '%s', 'weathermap'), + 'imageText10' => __('Delete image "%s" from the server?', '%s', 'weathermap'), + 'imageText11' => __('%s image', '%s', 'weathermap'), + 'imageText12' => __('%s images', '%s', 'weathermap'), + 'imageText13' => __('Uploaded %s', '%s', 'weathermap'), + 'imageText14' => __('Label Only', 'weathermap'), + 'imageText15' => __('No background', 'weathermap'), + 'imageUsed' => __('Used by: %s', '%s', 'weathermap'), + 'imageUnused' => __('Not used by a map', 'weathermap'), + 'currentInterface' => __('Current interface', 'weathermap'), 'advanced' => __('Advanced settings', 'weathermap'), 'hideAdvanced' => __('Hide advanced settings', 'weathermap'), diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 6963759a..3df11429 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -6,9 +6,9 @@ #, fuzzy msgid "" msgstr "" -"Project-Id-Version: Cacti\n" +"Project-Id-Version: Cacti 1.7\n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-11 10:30+1030\n" +"POT-Creation-Date: 2026-10-12 00:51+1030\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" @@ -18,6 +18,62 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" +#: lib/editor.actions.php lib/editor.assets.php lib/editor.inc.php +msgid "Choose a PNG, JPG or GIF file up to 5 MB." +msgstr "" + +#: lib/editor.actions.php +msgid "Use POST to save changes." +msgstr "" + +#: lib/editor.actions.php +msgid "This image is still referenced by a map configuration." +msgstr "" + +#: lib/editor.actions.php +msgid "Could not delete the image." +msgstr "" + +#: lib/editor.assets.php +msgid "Invalid image category." +msgstr "" + +#: lib/editor.assets.php +msgid "Invalid image filename." +msgstr "" + +#: lib/editor.assets.php +msgid "Symbolic links cannot be managed here." +msgstr "" + +#: lib/editor.assets.php +msgid "Cannot check configuration references." +msgstr "" + +#: lib/editor.assets.php +msgid "Too many included configurations to check." +msgstr "" + +#: lib/editor.assets.php +msgid "The image directory is not writable." +msgstr "" + +#: lib/editor.assets.php +msgid "Choose a PNG, JPG or GIF image up to 4 million pixels." +msgstr "" + +#: lib/editor.assets.php +msgid "The image could not be read." +msgstr "" + +#: lib/editor.assets.php +msgid "Could not reserve an image filename." +msgstr "" + +#: lib/editor.assets.php +msgid "Could not save the image." +msgstr "" + #: lib/editor.comment-style.php #, php-format msgid "%s — %s pt (font %s)" @@ -89,6 +145,16 @@ msgstr "" msgid "Link Comment Colour" msgstr "" +#: lib/editor.config-delete.php +#, php-format +msgid "Delete configuration file %s" +msgstr "" + +#: lib/editor.config-delete.php +#, php-format +msgid "Are you sure you want to permanently delete the configuration file \"%s\"?" +msgstr "" + #: lib/editor.inc.php msgid "Next results" msgstr "" @@ -166,6 +232,80 @@ msgstr "" msgid "Click on the map where you would like to add a new node." msgstr "" +#: lib/editor.inc.php +msgid "−" +msgstr "" + +#: lib/editor.inc.php +msgid "No images in this library yet." +msgstr "" + +#: lib/editor.inc.php +msgid "Choose a PNG, JPG or GIF to upload." +msgstr "" + +#: lib/editor.inc.php +msgid "The request failed. Please try again." +msgstr "" + +#: lib/editor.inc.php +msgid "Upload failed." +msgstr "" + +#: lib/editor.inc.php +msgid "No gradient" +msgstr "" + +#: lib/editor.inc.php +msgid "Custom colour" +msgstr "" + +#: lib/editor.inc.php +msgid "Remove range" +msgstr "" + +#: lib/editor.inc.php +#, php-format +msgid "Delete %s" +msgstr "" + +#: lib/editor.inc.php +#, php-format +msgid "Delete image \"%s\" from the server?" +msgstr "" + +#: lib/editor.inc.php +#, php-format +msgid "%s image" +msgstr "" + +#: lib/editor.inc.php +#, php-format +msgid "%s images" +msgstr "" + +#: lib/editor.inc.php +#, php-format +msgid "Uploaded %s" +msgstr "" + +#: lib/editor.inc.php +msgid "Label Only" +msgstr "" + +#: lib/editor.inc.php +msgid "No background" +msgstr "" + +#: lib/editor.inc.php +#, php-format +msgid "Used by: %s" +msgstr "" + +#: lib/editor.inc.php +msgid "Not used by a map" +msgstr "" + #: lib/editor.inc.php msgid "Current interface" msgstr "" @@ -1282,6 +1422,50 @@ msgstr "" msgid "Link Hover Height (pixels)" msgstr "" +#: weathermap-cacti-plugin-editor.php +msgid "Manage Images" +msgstr "" + +#: weathermap-cacti-plugin-editor.php +msgid "Node Icons" +msgstr "" + +#: weathermap-cacti-plugin-editor.php +msgid "Upload Node Icon" +msgstr "" + +#: weathermap-cacti-plugin-editor.php +msgid "Upload node icon" +msgstr "" + +#: weathermap-cacti-plugin-editor.php +msgid "Existing node icons" +msgstr "" + +#: weathermap-cacti-plugin-editor.php +msgid "Background Images" +msgstr "" + +#: weathermap-cacti-plugin-editor.php +msgid "Upload Background" +msgstr "" + +#: weathermap-cacti-plugin-editor.php +msgid "Upload background" +msgstr "" + +#: weathermap-cacti-plugin-editor.php +msgid "Existing background images" +msgstr "" + +#: weathermap-cacti-plugin-editor.php +msgid "Close" +msgstr "" + +#: weathermap-cacti-plugin-editor.php +msgid "PNG, JPG/JPEG or GIF files up to 5 MB and 4 million pixels. GIF uploads use the first frame. Uploads are saved immediately. Images referenced by a map configuration cannot be deleted." +msgstr "" + #: weathermap-cacti-plugin-mgmt.php msgid "Duplicate" msgstr "" @@ -2009,6 +2193,10 @@ msgstr "" msgid "Unable to delete the last remaining Map Group." msgstr "" +#: weathermap-cacti-plugin-mgmt.php +msgid "Map title is too long. Use at most 4088 bytes after escaping." +msgstr "" + #: weathermap-cacti-plugin-mgmt.php #, php-format msgid "The New Map File name %s already exists!" @@ -2142,7 +2330,3 @@ msgstr "" #: weathermap-cacti-plugin.php msgid "Paused" msgstr "" - -#: weathermap-cacti-plugin-mgmt.php -msgid "Map title is too long. Use at most 4088 bytes after escaping." -msgstr "" diff --git a/tests/Browser/EditorImagesTest.cjs b/tests/Browser/EditorImagesTest.cjs new file mode 100644 index 00000000..b70c2438 --- /dev/null +++ b/tests/Browser/EditorImagesTest.cjs @@ -0,0 +1,23 @@ +// Verify library metadata, guarded deletion and upload/request wiring. +const {JSDOM}=require('jsdom'); +const jquery=require('jquery'); +const fs=require('node:fs'); +const path=require('node:path'); +const assert=require('node:assert/strict'); +const dom=new JSDOM('Label OnlyNo background',{runScripts:'outside-only',pretendToBeVisual:true}); +const w=dom.window,$=jquery(w);w.$=w.jQuery=$;w.editor_url='/editor';w.csrfMagicToken='fixture';$.fn.dialog=function(){return this;}; +const inc=fs.readFileSync(path.join(__dirname,'../../lib/editor.inc.php'),'utf8');w.wmEditorText={}; +for(const m of inc.matchAll(/'(imageText[0-9]+|imageUsed|imageUnused)' => __\('((?:\\.|[^'])+)'/g))w.wmEditorText[m[1]]=m[2].replace(/\\'/g,"'"); +w.wmEditorText.imageUnused='Non utilisée'; +const source=fs.readFileSync(path.join(__dirname,'../../js/editor.js'),'utf8');w.eval(source.slice(source.indexOf('function wmAssetRequest('))); +const images=[{kind:'objects',name:'hospital.png',url:'images/objects/hospital.png',width:96,height:96,users:['map.conf'],deletable:false},{kind:'backgrounds',name:'background.jpg',url:'images/backgrounds/background.jpg',width:800,height:600,users:[],deletable:true}]; +w.wmAssetImages(images); +assert.equal($('#wm-images-objects .wm-image-delete').length,0);assert.match($('#wm-images-objects').text(),/96 × 96 px/);assert.match($('#wm-images-objects').text(),/map.conf/);assert.match($('#wm-images-backgrounds').text(),/Non utilisée/); +assert.equal($('#node_iconfilename option[value="images/objects/hospital.png"]').length,1); +let request;$.ajax=opts=>request=opts;w.confirm=()=>false;$('#wm-images-backgrounds .wm-image-delete').trigger('click');assert.equal(request,undefined); +w.confirm=()=>true;$('#wm-images-backgrounds .wm-image-delete').trigger('click');assert.equal(request.type,'POST');assert.equal(request.data.action,'delete_image');assert.equal(request.data.image_kind,'backgrounds');assert.equal(request.data.__csrf_magic,'fixture'); +request.error({responseJSON:{error:'In use'}});assert.equal($('#wm-images-error').text(),'In use'); +w.wmOpenImages();assert.equal(request.type,'GET');assert.equal(request.data.action,'image_data');request.success({images}); +$('.wm-image-upload').trigger('click');assert.match($('#wm-images-error').text(),/Choose a PNG/); +Object.defineProperty($('#wm-image-file-objects')[0],'files',{value:[new w.File(['image'],'test.png',{type:'image/png'})]});$('.wm-image-upload').trigger('click');assert.equal(request.data.get('action'),'upload_image');assert.equal(request.data.get('image_kind'),'objects');assert.equal(request.data.get('__csrf_magic'),'fixture');assert.equal($('.wm-image-upload').prop('disabled'),true);request.complete();assert.equal($('.wm-image-upload').prop('disabled'),false); +dom.window.close();console.log('PASS: image metadata, translations, protected delete, confirmation, request errors and upload wiring'); diff --git a/tests/Support/EditorImageFormatsRegression.php b/tests/Support/EditorImageFormatsRegression.php index c504718a..af528a6c 100644 --- a/tests/Support/EditorImageFormatsRegression.php +++ b/tests/Support/EditorImageFormatsRegression.php @@ -1,4 +1,26 @@ 'objects', 'image_name' => $deleteName]; - ob_start(); wm_editor_assets($config, 'delete_image'); $response = json_decode(ob_get_clean(), true); - image_format_assert(isset($response['error']) && is_file($deletePath), 'GET cannot delete an image'); - $_SERVER['REQUEST_METHOD'] = 'POST'; - file_put_contents($config, "ICON images/objects/" . $deleteName . "\n"); - ob_start(); wm_editor_assets($config, 'delete_image'); $response = json_decode(ob_get_clean(), true); - image_format_assert(isset($response['error']) && is_file($deletePath), 'referenced image cannot be deleted'); - file_put_contents($config, "# no image reference\n"); - ob_start(); wm_editor_assets($config, 'delete_image'); $response = json_decode(ob_get_clean(), true); - image_format_assert(isset($response['images']) && !is_file($deletePath), 'delete removes the actual server file'); + $included = $temporary . '/included.conf'; + file_put_contents($included, "BACKGROUND images/backgrounds/included-image.png\n"); + file_put_contents($config, "INCLUDE " . $included . "\n"); + image_format_assert(in_array(basename($included), wm_asset_image_users('included-image.png'), true), 'included configurations protect images from deletion'); + // Read-only listings, reference protection and the final server unlink. + $_SERVER['REQUEST_METHOD'] = 'GET'; + ob_start(); wm_editor_assets($config, 'image_data'); $response = json_decode(ob_get_clean(), true); + image_format_assert(isset($response['images']), 'library JSON returned'); + $deletePath = $created[0]; $deleteName = basename($deletePath); + $GLOBALS['__test_nfilter_request'] = ['image_kind' => 'objects', 'image_name' => $deleteName]; + ob_start(); wm_editor_assets($config, 'delete_image'); $response = json_decode(ob_get_clean(), true); + image_format_assert(isset($response['error']) && is_file($deletePath), 'GET cannot delete an image'); + $_SERVER['REQUEST_METHOD'] = 'POST'; + file_put_contents($config, "ICON images/objects/" . $deleteName . "\n"); + ob_start(); wm_editor_assets($config, 'delete_image'); $response = json_decode(ob_get_clean(), true); + image_format_assert(isset($response['error']) && is_file($deletePath), 'referenced image cannot be deleted'); + file_put_contents($config, "# no image reference\n"); + ob_start(); wm_editor_assets($config, 'delete_image'); $response = json_decode(ob_get_clean(), true); + image_format_assert(isset($response['images']) && !is_file($deletePath), 'delete removes the actual server file'); echo "Editor image formats regression PASS\n"; } finally { foreach ($created as $path) if (is_file($path)) unlink($path); diff --git a/tests/Unit/EditorImageFormatsTest.php b/tests/Unit/EditorImageFormatsTest.php index d3f7d225..4304d156 100644 --- a/tests/Unit/EditorImageFormatsTest.php +++ b/tests/Unit/EditorImageFormatsTest.php @@ -1,19 +1,41 @@ - + '> - Node Icons - Upload node iconUpload Node Icon - - Background Images - Upload backgroundUpload Background - + + + '> + + + '> - Close - PNG, JPG/JPEG or GIF files up to 5 MB and 4 million pixels. GIF uploads use the first frame. Uploads are saved immediately. Images referenced by a map configuration cannot be deleted. + + From d74b3eb521a8cafb1583189fc7bcba10c522b3ec Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:56:05 +1030 Subject: [PATCH 04/41] Clean up incomplete image writes in the finalizer --- lib/editor.assets.php | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/lib/editor.assets.php b/lib/editor.assets.php index 06943b8b..28783664 100644 --- a/lib/editor.assets.php +++ b/lib/editor.assets.php @@ -110,15 +110,17 @@ function wm_asset_store_image($kind, $source, $original_name) { $path = wm_asset_image_path($kind, $name); $handle = @fopen($path, 'x'); if (!$handle) { imagedestroy($image); throw new RuntimeException(__('Could not reserve an image filename.', 'weathermap')); } + $written = false; try { // Re-encode decoded pixels; never publish the original uploaded bytes. - $written = $size[2] === IMAGETYPE_JPEG ? $encode($image, $handle, 90) : $encode($image, $handle); - if (!$written) throw new RuntimeException(__('Could not save the image.', 'weathermap')); - if (!fflush($handle)) throw new RuntimeException(__('Could not save the image.', 'weathermap')); - chmod($path, 0644); - } catch (Throwable $error) { - unlink($path); - throw $error; - } finally { fclose($handle); imagedestroy($image); } + $encoded = $size[2] === IMAGETYPE_JPEG ? $encode($image, $handle, 90) : $encode($image, $handle); + $written = $encoded && fflush($handle); + } finally { + fclose($handle); + imagedestroy($image); + if (!$written) unlink($path); + } + if (!$written) throw new RuntimeException(__('Could not save the image.', 'weathermap')); + chmod($path, 0644); return $name; } From 4548ed4e73be8f4fc53c89e37487c83610165acb Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:30:27 +1030 Subject: [PATCH 05/41] Guard image library parents and reuse configuration reference indexes --- CHANGELOG.md | 2 + js/editor.js | 2 +- lib/editor.assets.php | 39 +++++++++++++------ lib/editor.inc.php | 3 +- locales/po/cacti.pot | 13 ++----- .../Support/EditorImageFormatsRegression.php | 23 +++++++++++ 6 files changed, 59 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d13427ee..22ae57b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ IMPORTANT NOTE: This version only works on CACTI 1.x++! --- develop --- +* Add separate icon and background image upload and management libraries + * issue#299: Keep editor picker results open when browsing and allow changing an existing selection * feature#298: Fit or stretch background images within the map canvas diff --git a/js/editor.js b/js/editor.js index f260bf5e..3a04ecdf 100644 --- a/js/editor.js +++ b/js/editor.js @@ -1685,7 +1685,7 @@ function wmAssetImages(images) { }); ['objects', 'backgrounds'].forEach(function(kind) { var count = images.filter(function(item) { return item.kind === kind; }).length; - $('#wm-images-' + kind + '-count').text((count === 1 ? wmEditorText.imageText11 : wmEditorText.imageText12).replace('%s', String(count))); + $('#wm-images-' + kind + '-count').text(wmEditorText.imageText11.replace('%s', String(count))); if (!count) $('#wm-images-' + kind).append($('').text(wmEditorText.imageText1)); }); // Rebuild the existing image selectors so uploads are available without reloading the editor. diff --git a/lib/editor.assets.php b/lib/editor.assets.php index 28783664..f1963858 100644 --- a/lib/editor.assets.php +++ b/lib/editor.assets.php @@ -25,7 +25,10 @@ /** Editor image library management. */ function wm_asset_directory($kind) { if (!in_array($kind, ['objects', 'backgrounds'], true)) throw new InvalidArgumentException(__('Invalid image category.', 'weathermap')); - return dirname(__DIR__) . '/images/' . $kind; + $parent = dirname(__DIR__) . '/images'; + $dir = $parent . '/' . $kind; + if (is_link($parent) || is_link($dir)) throw new InvalidArgumentException(__('Symbolic links cannot be managed here.', 'weathermap')); + return $dir; } function wm_asset_image_path($kind, $name) { $dir = wm_asset_directory($kind); @@ -34,8 +37,8 @@ function wm_asset_image_path($kind, $name) { if (is_link($dir) || is_link($path)) throw new InvalidArgumentException(__('Symbolic links cannot be managed here.', 'weathermap')); return $path; } -function wm_asset_image_users($name, $kind = null) { - $users = []; +function wm_asset_image_reference_index() { + $references = []; $queue = []; $root = dirname(__DIR__); $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root . '/configs', FilesystemIterator::SKIP_DOTS)); @@ -62,30 +65,43 @@ function wm_asset_image_users($name, $kind = null) { $image = '/' . implode('/', $parts); // Dynamic token values are unknown here: protect all possible paths. $pattern = implode('.*', array_map(function($part) { return preg_quote($part, '~'); }, preg_split('/\{[^}]+\}/', $image))); - foreach ($kind === null ? ['objects', 'backgrounds'] : [$kind] as $library) { - if (preg_match('~^' . $pattern . '$~D', $root . '/images/' . $library . '/' . $name)) { $users[] = basename($real); break 2; } - } + $references[] = ['pattern' => '~^' . $pattern . '$~D', 'user' => basename($real)]; } } if (preg_match_all('/^\s*INCLUDE\s+(.+)$/mi', $text, $matches)) foreach ($matches[1] as $include) { $include = trim($include); $include = strpos($include, '/') === 0 ? $include : $root . '/' . $include; $matches = glob($include); - if (!$matches) $queue[] = $include; - else foreach ($matches as $match) $queue[] = $match; + // The renderer tolerates absent optional includes; unreadable matches still fail closed. + foreach ($matches ?: [] as $match) $queue[] = $match; + } + } + return $references; +} +function wm_asset_image_users($name, $kind = null, $references = null) { + $references = $references ?? wm_asset_image_reference_index(); + $users = []; + $root = dirname(__DIR__); + foreach ($references as $reference) { + foreach ($kind === null ? ['objects', 'backgrounds'] : [$kind] as $library) { + if (preg_match($reference['pattern'], $root . '/images/' . $library . '/' . $name)) { + $users[] = $reference['user']; + break; + } } } - return $users; + return array_values(array_unique($users)); } function wm_asset_image_list() { $result = []; + $references = wm_asset_image_reference_index(); foreach (['objects', 'backgrounds'] as $kind) { $dir = wm_asset_directory($kind); foreach (scandir($dir) ?: [] as $name) { $path = $dir . '/' . $name; if (!is_file($path) || is_link($path) || !is_readable($path) || !preg_match('/\.(png|gif|jpe?g)$/iD', $name)) continue; $size = @getimagesize($path); - $users = wm_asset_image_users($name, $kind); + $users = wm_asset_image_users($name, $kind, $references); $result[] = ['width' => $size ? $size[0] : null, 'height' => $size ? $size[1] : null, 'users' => $users, 'kind' => $kind, 'name' => $name, 'url' => 'images/' . $kind . '/' . rawurlencode($name), 'deletable' => !$users && (bool) preg_match('/^[A-Za-z0-9_.-]+\.(png|gif|jpe?g)$/iD', $name) && strpos($name, '..') === false]; } } @@ -116,7 +132,8 @@ function wm_asset_store_image($kind, $source, $original_name) { $encoded = $size[2] === IMAGETYPE_JPEG ? $encode($image, $handle, 90) : $encode($image, $handle); $written = $encoded && fflush($handle); } finally { - fclose($handle); + $closed = fclose($handle); + $written = $written && $closed; imagedestroy($image); if (!$written) unlink($path); } diff --git a/lib/editor.inc.php b/lib/editor.inc.php index 26201ebd..843e6ad0 100644 --- a/lib/editor.inc.php +++ b/lib/editor.inc.php @@ -1023,8 +1023,7 @@ function getEditorJs() { 'imageText8' => __('Remove range', 'weathermap'), 'imageText9' => __('Delete %s', '%s', 'weathermap'), 'imageText10' => __('Delete image "%s" from the server?', '%s', 'weathermap'), - 'imageText11' => __('%s image', '%s', 'weathermap'), - 'imageText12' => __('%s images', '%s', 'weathermap'), + 'imageText11' => __('Images: %s', '%s', 'weathermap'), 'imageText13' => __('Uploaded %s', '%s', 'weathermap'), 'imageText14' => __('Label Only', 'weathermap'), 'imageText15' => __('No background', 'weathermap'), diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 3df11429..070b8c88 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti 1.7\n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-12 00:51+1030\n" +"POT-Creation-Date: 2026-10-12 01:28+1030\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" @@ -39,11 +39,11 @@ msgid "Invalid image category." msgstr "" #: lib/editor.assets.php -msgid "Invalid image filename." +msgid "Symbolic links cannot be managed here." msgstr "" #: lib/editor.assets.php -msgid "Symbolic links cannot be managed here." +msgid "Invalid image filename." msgstr "" #: lib/editor.assets.php @@ -276,12 +276,7 @@ msgstr "" #: lib/editor.inc.php #, php-format -msgid "%s image" -msgstr "" - -#: lib/editor.inc.php -#, php-format -msgid "%s images" +msgid "Images: %s" msgstr "" #: lib/editor.inc.php diff --git a/tests/Support/EditorImageFormatsRegression.php b/tests/Support/EditorImageFormatsRegression.php index af528a6c..5eb1c758 100644 --- a/tests/Support/EditorImageFormatsRegression.php +++ b/tests/Support/EditorImageFormatsRegression.php @@ -78,6 +78,29 @@ function image_format_assert($value, $message) { if (!$value) throw new RuntimeE file_put_contents($included, "BACKGROUND images/backgrounds/included-image.png\n"); file_put_contents($config, "INCLUDE " . $included . "\n"); image_format_assert(in_array(basename($included), wm_asset_image_users('included-image.png'), true), 'included configurations protect images from deletion'); + // Missing optional includes must not disable the library, and one index serves all names. + file_put_contents($config, "INCLUDE " . $temporary . "/missing-*.conf\nINCLUDE " . $included . "\n"); + $index = wm_asset_image_reference_index(); + file_put_contents($included, "# changed after indexing\n"); + image_format_assert(in_array(basename($included), wm_asset_image_users('included-image.png', 'backgrounds', $index), true), 'reference snapshot reused without rescanning configurations'); + image_format_assert(wm_asset_image_users('included-image.png', 'backgrounds') === [], 'fresh request reflects changed configuration'); + image_format_assert(is_array(wm_asset_image_list()), 'unmatched optional include permits listing'); + // Test symlink rejection only inside this isolated fixture, and always restore its library. + $library = $root . '/images'; + $originalLibrary = $temporary . '/library'; + image_format_assert(rename($library, $originalLibrary), 'reserve fixture library'); + try { + image_format_assert(symlink($originalLibrary, $library), 'create linked fixture library'); + foreach (['objects', 'backgrounds'] as $kind) { + try { wm_asset_directory($kind); throw new RuntimeException('accepted symlinked library parent'); } catch (InvalidArgumentException $expected) {} + } + try { wm_asset_image_list(); throw new RuntimeException('listed linked library'); } catch (InvalidArgumentException $expected) {} + try { wm_asset_store_image('objects', $source, 'linked.png'); throw new RuntimeException('uploaded to linked library'); } catch (InvalidArgumentException $expected) {} + try { wm_asset_image_path('objects', basename($created[0])); throw new RuntimeException('accepted deletion path in linked library'); } catch (InvalidArgumentException $expected) {} + } finally { + if (is_link($library)) unlink($library); + rename($originalLibrary, $library); + } // Read-only listings, reference protection and the final server unlink. $_SERVER['REQUEST_METHOD'] = 'GET'; ob_start(); wm_editor_assets($config, 'image_data'); $response = json_decode(ob_get_clean(), true); From 1d14e0c98b4c154a81994a3dec6c921acce90939 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:33:38 +1030 Subject: [PATCH 06/41] Reuse the image reference snapshot after deletion --- lib/editor.actions.php | 5 +++-- lib/editor.assets.php | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/editor.actions.php b/lib/editor.actions.php index 7f4a0b61..3463ecda 100644 --- a/lib/editor.actions.php +++ b/lib/editor.actions.php @@ -1152,9 +1152,10 @@ function wm_editor_assets($mapfile, $action) { } else { $name = get_nfilter_request_var('image_name'); $path = wm_asset_image_path(get_nfilter_request_var('image_kind'), $name); - if (wm_asset_image_users($name, get_nfilter_request_var('image_kind'))) throw new InvalidArgumentException(__('This image is still referenced by a map configuration.', 'weathermap')); + $references = wm_asset_image_reference_index(); + if (wm_asset_image_users($name, get_nfilter_request_var('image_kind'), $references)) throw new InvalidArgumentException(__('This image is still referenced by a map configuration.', 'weathermap')); if (!is_file($path) || !unlink($path)) throw new RuntimeException(__('Could not delete the image.', 'weathermap')); - $result = ['images' => wm_asset_image_list()]; + $result = ['images' => wm_asset_image_list($references)]; } echo json_encode($result); } catch (Throwable $error) { diff --git a/lib/editor.assets.php b/lib/editor.assets.php index f1963858..66abbce1 100644 --- a/lib/editor.assets.php +++ b/lib/editor.assets.php @@ -92,9 +92,9 @@ function wm_asset_image_users($name, $kind = null, $references = null) { } return array_values(array_unique($users)); } -function wm_asset_image_list() { +function wm_asset_image_list($references = null) { $result = []; - $references = wm_asset_image_reference_index(); + $references = $references ?? wm_asset_image_reference_index(); foreach (['objects', 'backgrounds'] as $kind) { $dir = wm_asset_directory($kind); foreach (scandir($dir) ?: [] as $name) { From b1f1e2a2b2f75b1aa1d30452f9c31936292aad00 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:08:24 +1030 Subject: [PATCH 07/41] Validate references before persisting image uploads --- lib/editor.actions.php | 19 +++++++- lib/editor.assets.php | 47 ++++++++++++++++++- locales/po/cacti.pot | 2 +- .../Support/EditorImageFormatsRegression.php | 12 +++++ 4 files changed, 77 insertions(+), 3 deletions(-) diff --git a/lib/editor.actions.php b/lib/editor.actions.php index 3463ecda..73e55656 100644 --- a/lib/editor.actions.php +++ b/lib/editor.actions.php @@ -1136,10 +1136,26 @@ function getImageURL($mapname, $selected) { return $imageurl; } +/** + * Validate a PHP upload and store its decoded image. + * + * @param string $kind The destination library category. + * @param array $file The PHP upload metadata from $_FILES. + * + * @return string The stored image filename. + */ function wm_asset_upload_image($kind, $file) { if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || !is_uploaded_file($file['tmp_name'] ?? '')) throw new InvalidArgumentException(__('Choose a PNG, JPG or GIF file up to 5 MB.', 'weathermap')); return wm_asset_store_image($kind, $file['tmp_name'], $file['name']); } +/** + * Read or mutate an image library and emit a JSON response. + * + * @param string $mapfile The current validated map path. + * @param string $action The library operation requested by the editor. + * + * @return void Errors are returned as HTTP 400 JSON responses. + */ function wm_editor_assets($mapfile, $action) { header('Content-Type: application/json'); try { @@ -1147,8 +1163,9 @@ function wm_editor_assets($mapfile, $action) { if ($action === 'image_data') { $result = ['images' => wm_asset_image_list()]; } elseif ($action === 'upload_image') { + $references = wm_asset_image_reference_index(); $name = wm_asset_upload_image(get_nfilter_request_var('image_kind'), $_FILES['image_file'] ?? []); - $result = ['name' => $name, 'images' => wm_asset_image_list()]; + $result = ['name' => $name, 'images' => wm_asset_image_list($references)]; } else { $name = get_nfilter_request_var('image_name'); $path = wm_asset_image_path(get_nfilter_request_var('image_kind'), $name); diff --git a/lib/editor.assets.php b/lib/editor.assets.php index 66abbce1..1b33f802 100644 --- a/lib/editor.assets.php +++ b/lib/editor.assets.php @@ -22,7 +22,13 @@ +-------------------------------------------------------------------------+ */ -/** Editor image library management. */ +/** + * Resolve a managed image library, rejecting symlinked directories. + * + * @param string $kind The objects or backgrounds library. + * + * @return string The library directory. + */ function wm_asset_directory($kind) { if (!in_array($kind, ['objects', 'backgrounds'], true)) throw new InvalidArgumentException(__('Invalid image category.', 'weathermap')); $parent = dirname(__DIR__) . '/images'; @@ -30,6 +36,14 @@ function wm_asset_directory($kind) { if (is_link($parent) || is_link($dir)) throw new InvalidArgumentException(__('Symbolic links cannot be managed here.', 'weathermap')); return $dir; } +/** + * Validate a filename and resolve its non-symlinked library path. + * + * @param string $kind The library category. + * @param mixed $name The filename to validate. + * + * @return string The validated image path. + */ function wm_asset_image_path($kind, $name) { $dir = wm_asset_directory($kind); if (!is_string($name) || !preg_match('/^[A-Za-z0-9_.-]+\.(png|gif|jpe?g)$/iD', $name) || strpos($name, '..') !== false) throw new InvalidArgumentException(__('Invalid image filename.', 'weathermap')); @@ -37,6 +51,12 @@ function wm_asset_image_path($kind, $name) { if (is_link($dir) || is_link($path)) throw new InvalidArgumentException(__('Symbolic links cannot be managed here.', 'weathermap')); return $path; } +/** + * Read configuration files and includes once to index image references. + * + * + * @return array The reference patterns and source filenames. + */ function wm_asset_image_reference_index() { $references = []; $queue = []; @@ -78,6 +98,15 @@ function wm_asset_image_reference_index() { } return $references; } +/** + * Find map configurations referencing an image. + * + * @param string $name The image filename. + * @param string|null $kind One library category, or both when null. + * @param array|null $references A request-local reference index; null builds an index. + * + * @return string[] Unique configuration filenames using the image. + */ function wm_asset_image_users($name, $kind = null, $references = null) { $references = $references ?? wm_asset_image_reference_index(); $users = []; @@ -92,6 +121,13 @@ function wm_asset_image_users($name, $kind = null, $references = null) { } return array_values(array_unique($users)); } +/** + * List image metadata using one shared configuration reference index. + * + * @param array|null $references A precomputed index, or null to read configurations. + * + * @return array The image dimensions, usage and deletion permissions. + */ function wm_asset_image_list($references = null) { $result = []; $references = $references ?? wm_asset_image_reference_index(); @@ -107,6 +143,15 @@ function wm_asset_image_list($references = null) { } return $result; } +/** + * Validate and re-encode an upload under an exclusively reserved filename. + * + * @param string $kind The destination library category. + * @param string $source The temporary source image path. + * @param string $original_name The original name used to derive a safe filename. + * + * @return string The stored filename; invalid images or write failures raise exceptions. + */ function wm_asset_store_image($kind, $source, $original_name) { $dir = wm_asset_directory($kind); if (is_link($dir) || !is_writable($dir)) throw new RuntimeException(__('The image directory is not writable.', 'weathermap')); diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 070b8c88..df8dac5e 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti 1.7\n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-12 01:28+1030\n" +"POT-Creation-Date: 2026-10-12 09:02+1030\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" diff --git a/tests/Support/EditorImageFormatsRegression.php b/tests/Support/EditorImageFormatsRegression.php index 5eb1c758..46f5bb32 100644 --- a/tests/Support/EditorImageFormatsRegression.php +++ b/tests/Support/EditorImageFormatsRegression.php @@ -101,6 +101,18 @@ function image_format_assert($value, $message) { if (!$value) throw new RuntimeE if (is_link($library)) unlink($library); rename($originalLibrary, $library); } + // A reference-scan failure must be reported before upload validation or persistence. + $broken = $temporary . '/broken.conf'; + symlink($temporary . '/missing-target.conf', $broken); + file_put_contents($config, "INCLUDE " . $broken . "\n"); + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_FILES['image_file'] = ['error' => UPLOAD_ERR_OK, 'tmp_name' => $source, 'name' => 'must-not-persist.png']; + $GLOBALS['__test_nfilter_request'] = ['image_kind' => 'objects']; + ob_start(); wm_editor_assets($config, 'upload_image'); $response = json_decode(ob_get_clean(), true); + image_format_assert(str_contains($response['error'], 'Cannot check configuration references'), 'configuration failure precedes upload persistence'); + image_format_assert(glob($root . '/images/objects/must-not-persist-*') === [], 'reference error cannot create an uploaded image'); + unlink($broken); unset($_FILES['image_file']); + file_put_contents($config, "# no image references\n"); // Read-only listings, reference protection and the final server unlink. $_SERVER['REQUEST_METHOD'] = 'GET'; ob_start(); wm_editor_assets($config, 'image_data'); $response = json_decode(ob_get_clean(), true); From 7db90e4d3b903d0df8c9a34ebe34ab723ef98f36 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:09:56 +1030 Subject: [PATCH 08/41] Synchronize image deletion with portable atomic configuration saves --- js/editor.js | 2 +- lib/WeatherMap.class.php | 53 +++-- lib/WeatherMap.config.php | 107 ++++++++++ lib/editor.actions.php | 13 +- lib/editor.assets.php | 46 +++-- lib/editor.inc.php | 2 +- locales/po/cacti.pot | 14 +- tests/Browser/EditorImagesTest.cjs | 2 + tests/Support/ConfigWriteAtomicRegression.php | 193 ++++++++++++++++++ .../Support/EditorImageFormatsRegression.php | 25 ++- tests/Unit/ConfigWriteAtomicTest.php | 41 ++++ weathermap-cacti-plugin-mgmt.php | 21 +- 12 files changed, 453 insertions(+), 66 deletions(-) create mode 100644 lib/WeatherMap.config.php create mode 100644 tests/Support/ConfigWriteAtomicRegression.php create mode 100644 tests/Unit/ConfigWriteAtomicTest.php diff --git a/js/editor.js b/js/editor.js index 3a04ecdf..8fa055a8 100644 --- a/js/editor.js +++ b/js/editor.js @@ -1693,7 +1693,7 @@ function wmAssetImages(images) { var select = $('#' + spec.id), old = select.val(); var slick = select.data('ddslick'); if (slick) { old = slick.selectedData && slick.selectedData.value; select.ddslick('destroy'); select = $('#' + spec.id); } - select.find('option').filter(function() { return /^images\/.*\.(png|gif|jpe?g)$/i.test(this.value); }).remove(); + select.find('option').filter(function() { return this.value.indexOf('images/' + spec.kind + '/') === 0 && /\.(png|gif|jpe?g)$/i.test(this.value); }).remove(); if (!select.find('option[value="--NONE--"]').length) select.prepend($('').val('--NONE--').text(spec.label)); images.filter(function(item) { return item.kind === spec.kind; }).forEach(function(item) { select.append($('').val('images/' + item.kind + '/' + item.name).text(item.name).attr({'data-imagesrc': item.url, 'data-description': item.name})); diff --git a/lib/WeatherMap.class.php b/lib/WeatherMap.class.php index b78c8849..9e2f630d 100644 --- a/lib/WeatherMap.class.php +++ b/lib/WeatherMap.class.php @@ -3607,21 +3607,28 @@ function WriteDataFile($filename) { * @return bool */ function WriteConfig($filename) { - $fd = false; - - if (is_writable(dirname($filename)) || (file_exists($filename) && is_writable($filename))) { - $fd = fopen($filename, 'w'); - assert($fd !== false); - } + require_once __DIR__ . '/WeatherMap.config.php'; + $fd = fopen('php://temp', 'w+b'); $output = ''; $weathermap_version = plugin_weathermap_numeric_version(); if ($fd) { + $write_ok = true; + $write = function($data) use ($fd, &$write_ok) { + if (!$write_ok) return; + $length = strlen($data); + $offset = 0; + while ($offset < $length) { + $count = @fwrite($fd, substr($data, $offset)); + if ($count === false || $count === 0) { $write_ok = false; return; } + $offset += $count; + } + }; $output .= "# Automatically generated by php-weathermap v$weathermap_version\n\n"; - if (count($this->fonts) > 0) { + if (cacti_count($this->fonts) > 0) { foreach ($this->fonts as $fontnumber => $font) { if ($font->type == 'truetype') { $output .= sprintf("FONTDEFINE %d %s %d\n", $fontnumber, $font->file, $font->size); @@ -3782,18 +3789,18 @@ function WriteConfig($filename) { $output .= "\n# End of global section\n\n"; - fwrite($fd, $output); + $write($output); // # fwrite($fd,$this->nodes['DEFAULT']->WriteConfig()); // # fwrite($fd,$this->links['DEFAULT']->WriteConfig()); - // fwrite($fd, "\n\n# Node definitions:\n"); + // $write("\n\n# Node definitions:\n"); foreach (['template', 'normal'] as $which) { if ($which == 'template') { - fwrite($fd, "\n# TEMPLATE-only NODEs:\n"); + $write("\n# TEMPLATE-only NODEs:\n"); } elseif ($which == 'normal') { - fwrite($fd, "\n# regular NODEs:\n"); + $write("\n# regular NODEs:\n"); } foreach ($this->nodes as $node_name => $node) { @@ -3801,21 +3808,21 @@ function WriteConfig($filename) { if ($node->defined_in == $this->configfile || $node_name == 'DEFAULT') { if ($which == 'template' && $node_name == 'DEFAULT') { wm_debug('TEMPLATE Node DEFAULT'); - fwrite($fd, $node->WriteConfig()); + $write($node->WriteConfig()); } elseif ($which == 'template' && $node->x === null) { wm_debug('TEMPLATE Node NON-DEFAULT'); - fwrite($fd, $node->WriteConfig()); + $write($node->WriteConfig()); } elseif ($which == 'normal' && $node->x !== null) { - fwrite($fd, $node->WriteConfig()); + $write($node->WriteConfig()); } } } } if ($which == 'template') { - fwrite($fd, "\n# TEMPLATE-only LINKs:\n"); + $write("\n# TEMPLATE-only LINKs:\n"); } elseif ($which == 'normal') { - fwrite($fd, "\n# regular LINKs:\n"); + $write("\n# regular LINKs:\n"); } foreach ($this->links as $link_name => $link) { @@ -3823,22 +3830,24 @@ function WriteConfig($filename) { if ($link->defined_in == $this->configfile || $link_name == 'DEFAULT') { if ($which == 'template' && $link_name == 'DEFAULT') { wm_debug('TEMPLATE Link DEFAULT'); - fwrite($fd, $link->WriteConfig()); + $write($link->WriteConfig()); } elseif ($which == 'template' && $link->a === null) { wm_debug('TEMPLATE Link NON-DEFAULT'); - fwrite($fd, $link->WriteConfig()); + $write($link->WriteConfig()); } elseif ($which == 'normal' && $link->a !== null) { - fwrite($fd, $link->WriteConfig()); + $write($link->WriteConfig()); } } } } } - fwrite($fd, "\n\n# That's All Folks!\n"); - fclose($fd); + $write("\n\n# That's All Folks!\n"); + rewind($fd); + $contents = stream_get_contents($fd); + $closed = fclose($fd); - return (true); + return $write_ok && $contents !== false && $closed && \WeathermapConfig\atomicWrite($filename, $contents); } else { wm_warn("Couldn't open config file $filename for writing"); diff --git a/lib/WeatherMap.config.php b/lib/WeatherMap.config.php new file mode 100644 index 00000000..543d3240 --- /dev/null +++ b/lib/WeatherMap.config.php @@ -0,0 +1,107 @@ + wm_asset_image_list($references)]; } echo json_encode($result); diff --git a/lib/editor.assets.php b/lib/editor.assets.php index 1b33f802..b25a1136 100644 --- a/lib/editor.assets.php +++ b/lib/editor.assets.php @@ -51,6 +51,30 @@ function wm_asset_image_path($kind, $name) { if (is_link($dir) || is_link($path)) throw new InvalidArgumentException(__('Symbolic links cannot be managed here.', 'weathermap')); return $path; } +/** + * Canonicalise directive paths using portable forward-slash separators. + * + * @param string $path The image or include path. + * @param string $root The base for relative paths. + * + * @return string The normalised absolute path. + */ +function wm_asset_normalize_path($path, $root) { + $path = str_replace('\\', '/', $path); + $root = str_replace('\\', '/', $root); + if (substr($path, 0, 1) !== '/' && !preg_match('~^[A-Za-z]:/~', $path)) $path = rtrim($root, '/') . '/' . $path; + $prefix = substr($path, 0, 2) === '//' ? '//' : '/'; + if (preg_match('~^([A-Za-z]:)/~', $path, $drive)) { + $prefix = $drive[1] . '/'; + $path = substr($path, 3); + } + $parts = []; + foreach (explode('/', $path) as $part) { + if ($part === '' || $part === '.') continue; + if ($part === '..') array_pop($parts); else $parts[] = $part; + } + return $prefix . implode('/', $parts); +} /** * Read configuration files and includes once to index image references. * @@ -62,7 +86,7 @@ function wm_asset_image_reference_index() { $queue = []; $root = dirname(__DIR__); $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root . '/configs', FilesystemIterator::SKIP_DOTS)); - foreach ($iterator as $file) if ($file->isFile()) $queue[] = $file->getPathname(); + foreach ($iterator as $file) if ($file->isFile() && $file->getFilename() !== '.weathermap-config.lock' && strpos($file->getFilename(), '.wm-config-') !== 0) $queue[] = $file->getPathname(); $seen = []; while ($queue) { $path = array_pop($queue); @@ -70,27 +94,19 @@ function wm_asset_image_reference_index() { if ($real === false || !is_readable($real)) throw new RuntimeException(__('Cannot check configuration references.', 'weathermap')); if (isset($seen[$real])) continue; $seen[$real] = true; - if (count($seen) > 1000) throw new RuntimeException(__('Too many included configurations to check.', 'weathermap')); + if (cacti_count($seen) > 1000) throw new RuntimeException(__('Too many included configurations to check.', 'weathermap')); $text = file_get_contents($real); if ($text === false) throw new RuntimeException(__('Cannot check configuration references.', 'weathermap')); if (preg_match_all('/^\s*(ICON|BACKGROUND)\s+(?:[0-9]+\s+[0-9]+\s+)?(.+?)\s*$/mi', $text, $directives, PREG_SET_ORDER)) { foreach ($directives as $directive) { - $image = trim($directive[2]); - $image = strpos($image, '/') === 0 ? $image : $root . '/' . $image; - $parts = []; - foreach (explode('/', $image) as $part) { - if ($part === '' || $part === '.') continue; - if ($part === '..') array_pop($parts); else $parts[] = $part; - } - $image = '/' . implode('/', $parts); + $image = wm_asset_normalize_path(trim($directive[2]), $root); // Dynamic token values are unknown here: protect all possible paths. $pattern = implode('.*', array_map(function($part) { return preg_quote($part, '~'); }, preg_split('/\{[^}]+\}/', $image))); - $references[] = ['pattern' => '~^' . $pattern . '$~D', 'user' => basename($real)]; + $references[] = ['pattern' => '~^' . $pattern . '$~iD', 'user' => basename($real)]; } } if (preg_match_all('/^\s*INCLUDE\s+(.+)$/mi', $text, $matches)) foreach ($matches[1] as $include) { - $include = trim($include); - $include = strpos($include, '/') === 0 ? $include : $root . '/' . $include; + $include = wm_asset_normalize_path(trim($include), $root); $matches = glob($include); // The renderer tolerates absent optional includes; unreadable matches still fail closed. foreach ($matches ?: [] as $match) $queue[] = $match; @@ -113,7 +129,7 @@ function wm_asset_image_users($name, $kind = null, $references = null) { $root = dirname(__DIR__); foreach ($references as $reference) { foreach ($kind === null ? ['objects', 'backgrounds'] : [$kind] as $library) { - if (preg_match($reference['pattern'], $root . '/images/' . $library . '/' . $name)) { + if (preg_match($reference['pattern'], wm_asset_normalize_path('images/' . $library . '/' . $name, $root))) { $users[] = $reference['user']; break; } @@ -169,7 +185,7 @@ function wm_asset_store_image($kind, $source, $original_name) { $stem = preg_replace('/[^A-Za-z0-9_-]/', '_', pathinfo(basename($original_name), PATHINFO_FILENAME)); $name = substr($stem ?: 'image', 0, 60) . '-' . bin2hex(random_bytes(16)) . '.' . $extension; $path = wm_asset_image_path($kind, $name); - $handle = @fopen($path, 'x'); + $handle = @fopen($path, 'xb'); if (!$handle) { imagedestroy($image); throw new RuntimeException(__('Could not reserve an image filename.', 'weathermap')); } $written = false; try { diff --git a/lib/editor.inc.php b/lib/editor.inc.php index 843e6ad0..a4a58999 100644 --- a/lib/editor.inc.php +++ b/lib/editor.inc.php @@ -645,7 +645,7 @@ function get_imagelist($imagedir) { $realfile = $imdir . '/' . $file; $uri = "images/$imagedir/$file"; - if (is_readable($realfile) && (preg_match('/\.(gif|jpg|png)$/i', $file))) { + if (is_readable($realfile) && (preg_match('/\.(gif|jpe?g|png)$/i', $file))) { $imagelist[] = $uri; $n++; } diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index df8dac5e..0bf5b072 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti 1.7\n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-12 09:02+1030\n" +"POT-Creation-Date: 2026-10-12 11:05+1030\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" @@ -26,6 +26,10 @@ msgstr "" msgid "Use POST to save changes." msgstr "" +#: lib/editor.actions.php +msgid "Could not lock map configurations." +msgstr "" + #: lib/editor.actions.php msgid "This image is still referenced by a map configuration." msgstr "" @@ -1906,13 +1910,7 @@ msgid "The new Map with the name %s was created using config file %s" msgstr "" #: weathermap-cacti-plugin-mgmt.php -#, php-format -msgid "The new Map %s was copied, but its title could not be updated in the config file %s" -msgstr "" - -#: weathermap-cacti-plugin-mgmt.php -#, php-format -msgid "The new Map with the name %s was unable to create the config file %s" +msgid "Could not save the map configuration." msgstr "" #: weathermap-cacti-plugin-mgmt.php diff --git a/tests/Browser/EditorImagesTest.cjs b/tests/Browser/EditorImagesTest.cjs index b70c2438..46c9dc8e 100644 --- a/tests/Browser/EditorImagesTest.cjs +++ b/tests/Browser/EditorImagesTest.cjs @@ -11,7 +11,9 @@ for(const m of inc.matchAll(/'(imageText[0-9]+|imageUsed|imageUnused)' => __\('( w.wmEditorText.imageUnused='Non utilisée'; const source=fs.readFileSync(path.join(__dirname,'../../js/editor.js'),'utf8');w.eval(source.slice(source.indexOf('function wmAssetRequest('))); const images=[{kind:'objects',name:'hospital.png',url:'images/objects/hospital.png',width:96,height:96,users:['map.conf'],deletable:false},{kind:'backgrounds',name:'background.jpg',url:'images/backgrounds/background.jpg',width:800,height:600,users:[],deletable:true}]; +$('#node_iconfilename').append('External icon').val('images/icons/external.png'); w.wmAssetImages(images); +assert.equal($('#node_iconfilename').val(),'images/icons/external.png');assert.equal($('#node_iconfilename option[value="images/icons/external.png"]').length,1); assert.equal($('#wm-images-objects .wm-image-delete').length,0);assert.match($('#wm-images-objects').text(),/96 × 96 px/);assert.match($('#wm-images-objects').text(),/map.conf/);assert.match($('#wm-images-backgrounds').text(),/Non utilisée/); assert.equal($('#node_iconfilename option[value="images/objects/hospital.png"]').length,1); let request;$.ajax=opts=>request=opts;w.confirm=()=>false;$('#wm-images-backgrounds .wm-image-delete').trigger('click');assert.equal(request,undefined); diff --git a/tests/Support/ConfigWriteAtomicRegression.php b/tests/Support/ConfigWriteAtomicRegression.php new file mode 100644 index 00000000..91c3a38b --- /dev/null +++ b/tests/Support/ConfigWriteAtomicRegression.php @@ -0,0 +1,193 @@ +ReadConfig($file); $map->title = 'Changed'; + atomic_assert(!$map->WriteConfig($file) && file_get_contents($file) === $original, 'engine failure preserves original'); + $GLOBALS['__test_nfilter_request'] = ['node_name' => 'note1', 'param' => 'note1', 'x' => '70', 'y' => '80']; + if (function_exists('wm_editor_save_note')) { + http_response_code(200); moveNode($file, 0); + atomic_assert(http_response_code() === 500 && file_get_contents($file) === $original, 'failed move surfaces error without changing file'); + http_response_code(200); deleteNode($file); + atomic_assert(http_response_code() === 500 && file_get_contents($file) === $original, 'failed delete surfaces error without changing file'); + } + echo "Atomic configuration write and locking regression PASS\n"; + } finally { + $GLOBALS['config_fault'] = ''; + foreach (glob($dir . '/*') as $path) unlink($path); + foreach (glob($dir . '/.wm-config-*') as $path) unlink($path); + rmdir($dir); + } +} diff --git a/tests/Support/EditorImageFormatsRegression.php b/tests/Support/EditorImageFormatsRegression.php index 46f5bb32..92eae132 100644 --- a/tests/Support/EditorImageFormatsRegression.php +++ b/tests/Support/EditorImageFormatsRegression.php @@ -24,7 +24,20 @@ require dirname(__DIR__) . "/bootstrap-unit.php"; require dirname(__DIR__, 2) . '/lib/editor.assets.php'; require dirname(__DIR__, 2) . '/lib/editor.actions.php'; -function image_format_assert($value, $message) { if (!$value) throw new RuntimeException($message); } +require dirname(__DIR__, 2) . '/lib/editor.inc.php'; +/** + * Assert an image-format regression condition. + * + * @param bool $value Whether the condition holds. + * @param string $message The failure description. + * + * @return void + */ +function image_format_assert($value, $message) { + if (!$value) { + throw new RuntimeException($message); + } +} $root = dirname(__DIR__, 2); $temporary = $root . '/tests/wm-image-formats-' . bin2hex(random_bytes(4)); mkdir($temporary); @@ -48,13 +61,21 @@ function image_format_assert($value, $message) { if (!$value) throw new RuntimeE image_format_assert($size[0] === 12 && $size[1] === 8 && $size[2] === $type, 'decoded format and dimensions preserved'); image_format_assert(strpos(file_get_contents($path), 'UNTRUSTED_TRAILING_BYTES') === false, 'uploaded trailing bytes removed'); $listed = array_values(array_filter(wm_asset_image_list(), function($item) use ($kind, $name) { return $item['kind'] === $kind && $item['name'] === $name; })); - image_format_assert(count($listed) === 1 && $listed[0]['deletable'], 'each format listed and deletable'); + image_format_assert(cacti_count($listed) === 1 && $listed[0]['deletable'], 'each format listed and deletable'); file_put_contents($config, 'BACKGROUND images/' . $kind . '/' . $name . "\n"); image_format_assert(in_array(basename($config), wm_asset_image_users($name), true), 'reference protection applies to every format'); } } file_put_contents($config, "ICON images/objects/core-router.png\n# router.png is merely a comment\n"); image_format_assert(wm_asset_image_users('router.png', 'objects') === [], 'substring and comment do not count as references'); + file_put_contents($config, "ICON images/objects/ROUTER.PNG\n"); + image_format_assert(wm_asset_image_users('router.png', 'objects') !== [], 'case-insensitive paths protect in-use images'); + image_format_assert(wm_asset_normalize_path('images\\objects\\Router.PNG', 'C:\\Cacti\\plugins\\weathermap') === 'C:/Cacti/plugins/weathermap/images/objects/Router.PNG', 'Windows relative path canonicalised'); + image_format_assert(wm_asset_normalize_path('D:\\assets\\router.png', 'C:/Cacti') === 'D:/assets/router.png', 'Windows absolute drive path retained'); + image_format_assert(wm_asset_normalize_path('\\\\server\\share\\router.png', 'C:/Cacti') === '//server/share/router.png', 'UNC absolute path retained'); + $jpegPath = $root . '/images/backgrounds/format-reload-' . bin2hex(random_bytes(4)) . '.jpeg'; + copy($created[3], $jpegPath); $created[] = $jpegPath; + image_format_assert(in_array('images/backgrounds/' . basename($jpegPath), get_imagelist('backgrounds'), true), 'existing JPEG background survives selector reload'); file_put_contents($config, "ICON /outside/router.png\n"); image_format_assert(wm_asset_image_users('router.png', 'objects') === [], 'complete paths distinguish external images'); file_put_contents($config, "ICON 32 32 images/objects/updown_{node:this:bandwidth_in}.png\n"); diff --git a/tests/Unit/ConfigWriteAtomicTest.php b/tests/Unit/ConfigWriteAtomicTest.php new file mode 100644 index 00000000..48736556 --- /dev/null +++ b/tests/Unit/ConfigWriteAtomicTest.php @@ -0,0 +1,41 @@ + Date: Mon, 12 Oct 2026 11:12:09 +1030 Subject: [PATCH 09/41] Verify atomic saves preserve template and traffic objects --- tests/Support/ConfigWriteAtomicRegression.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/Support/ConfigWriteAtomicRegression.php b/tests/Support/ConfigWriteAtomicRegression.php index 91c3a38b..056c2c09 100644 --- a/tests/Support/ConfigWriteAtomicRegression.php +++ b/tests/Support/ConfigWriteAtomicRegression.php @@ -173,6 +173,12 @@ function atomic_assert($condition, $message) { pcntl_waitpid($child, $status); \WeathermapConfig\release($lock); atomic_assert(pcntl_wexitstatus($status) === 0, 'shared lock excludes concurrent configuration writers'); } + $templateFile = $dir . '/templates.conf'; + file_put_contents($templateFile, "NODE templateNode\n LABEL Template\nNODE A\n POSITION 10 10\nNODE B\n POSITION 50 50\nLINK templateLink\n WIDTH 8\nLINK trafficLink\n NODES A B\n"); + $templateMap = new \WeatherMap; $templateMap->ReadConfig($templateFile); + atomic_assert($templateMap->WriteConfig($templateFile), 'atomic save supports templates and traffic links'); + $reloaded = new \WeatherMap; $reloaded->ReadConfig($templateFile); + atomic_assert(isset($reloaded->nodes['templateNode'], $reloaded->links['templateLink'], $reloaded->links['trafficLink']), 'template and real objects survive atomic roundtrip'); $GLOBALS['config_fault'] = 'rename'; $map = new \WeatherMap; $map->ReadConfig($file); $map->title = 'Changed'; atomic_assert(!$map->WriteConfig($file) && file_get_contents($file) === $original, 'engine failure preserves original'); From 075099e8773509d3e467fe0d76e0a31566f5e486 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:21:32 +1030 Subject: [PATCH 10/41] Keep atomic-save regression checks portable across platforms --- tests/Support/ConfigWriteAtomicRegression.php | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/Support/ConfigWriteAtomicRegression.php b/tests/Support/ConfigWriteAtomicRegression.php index 056c2c09..6090fa12 100644 --- a/tests/Support/ConfigWriteAtomicRegression.php +++ b/tests/Support/ConfigWriteAtomicRegression.php @@ -144,7 +144,7 @@ function atomic_assert($condition, $message) { $original = "WIDTH 600\nHEIGHT 300\nNODE note1\n LABEL Original\n POSITION 50 50\n SET editor_note 1\n"; file_put_contents($file, $original); try { - foreach (['lock-open', 'lock', 'temp-name', 'temp-open', 'write-false', 'write-zero', 'flush', 'close', 'group', 'mode', 'rename'] as $fault) { + foreach (array_filter(['lock-open', 'lock', 'temp-name', 'temp-open', 'write-false', 'write-zero', 'flush', 'close', 'group', 'mode', 'rename'], function($fault) { return PHP_OS_FAMILY !== 'Windows' || $fault !== 'group'; }) as $fault) { $GLOBALS['config_fault'] = $fault; atomic_assert(\WeathermapConfig\atomicWrite($file, 'Replacement') === false, 'failure propagated: ' . $fault); atomic_assert(file_get_contents($file) === $original, 'original retained: ' . $fault); @@ -153,15 +153,17 @@ function atomic_assert($condition, $message) { $GLOBALS['config_fault'] = 'short-write'; atomic_assert(\WeathermapConfig\atomicWrite($file, 'Complete replacement'), 'short writes completed'); atomic_assert(file_get_contents($file) === 'Complete replacement', 'complete bytes retained'); - $GLOBALS['config_fault'] = 'lock-read'; + $GLOBALS['config_fault'] = PHP_OS_FAMILY === 'Windows' ? '' : 'lock-read'; atomic_assert(\WeathermapConfig\atomicWrite($file, $original), 'readable lock supports another writer account'); $GLOBALS['config_fault'] = ''; atomic_assert(!\WeathermapConfig\atomicWrite('notewritefail://map/config', 'Replacement'), 'unsupported stream rejected'); atomic_assert(!\WeathermapConfig\atomicWrite($dir . '/missing/map.conf', 'Replacement'), 'missing directory rejected'); atomic_assert(!\WeathermapConfig\atomicWrite($dir, 'Replacement'), 'directory target rejected'); - $link = $dir . '/link.conf'; symlink($file, $link); - atomic_assert(\WeathermapConfig\atomicWrite($link, $original), 'configuration symlink target preserved'); - atomic_assert(is_link($link), 'link itself retained'); unlink($link); + $link = $dir . '/link.conf'; + if (@symlink($file, $link)) { + atomic_assert(\WeathermapConfig\atomicWrite($link, $original), 'configuration symlink target preserved'); + atomic_assert(is_link($link), 'link itself retained'); unlink($link); + } if (function_exists('pcntl_fork')) { $lock = \WeathermapConfig\acquire(); $child = pcntl_fork(); From ad6a77bf73e0e8c6495d08fa90677bf1796b3edd Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:40:28 +1030 Subject: [PATCH 11/41] Accept uppercase raster extensions when saving backgrounds --- lib/editor.inc.php | 2 +- tests/Support/EditorImageFormatsRegression.php | 1 + tests/Unit/EditorImageFormatsTest.php | 5 +++++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/editor.inc.php b/lib/editor.inc.php index a4a58999..4503b9db 100644 --- a/lib/editor.inc.php +++ b/lib/editor.inc.php @@ -337,7 +337,7 @@ function wm_editor_sanitize_file($filename,$allowed_exts = []) { foreach ($allowed_exts as $ext) { $match = '.' . $ext; - if (substr($filename, -strlen($match),strlen($match)) == $match) { + if (strcasecmp(substr($filename, -strlen($match), strlen($match)), $match) === 0) { $ok = true; } } diff --git a/tests/Support/EditorImageFormatsRegression.php b/tests/Support/EditorImageFormatsRegression.php index 92eae132..3d292fdc 100644 --- a/tests/Support/EditorImageFormatsRegression.php +++ b/tests/Support/EditorImageFormatsRegression.php @@ -84,6 +84,7 @@ function image_format_assert($value, $message) { file_put_contents($config, "ICON images/objects/" . basename($created[0]) . "\n"); $listed = array_values(array_filter(wm_asset_image_list(), function($item) use ($created) { return $item['kind'] === 'objects' && $item['name'] === basename($created[0]); })); image_format_assert($listed[0]['width'] === 12 && $listed[0]['height'] === 8 && !$listed[0]['deletable'] && $listed[0]['users'], 'dimensions and usage disable referenced image deletion'); + image_format_assert(wm_editor_sanitize_file('images/backgrounds/legacy.JPG', ['png', 'jpg', 'gif', 'jpeg']) === 'images/backgrounds/legacy.JPG', 'uppercase background save retains path'); foreach (['safe.png', 'safe.jpg', 'safe.jpeg', 'safe.gif', 'safe.JPG'] as $name) image_format_assert(str_ends_with(wm_asset_image_path('backgrounds', $name), $name), 'accepted filename'); foreach (['../outside.jpg', 'bad.php', 'bad.svg', 'bad.jpg.php', 'bad..gif'] as $name) { try { wm_asset_image_path('objects', $name); throw new RuntimeException('accepted invalid filename'); } catch (InvalidArgumentException $expected) {} diff --git a/tests/Unit/EditorImageFormatsTest.php b/tests/Unit/EditorImageFormatsTest.php index 4304d156..efdd770a 100644 --- a/tests/Unit/EditorImageFormatsTest.php +++ b/tests/Unit/EditorImageFormatsTest.php @@ -26,6 +26,11 @@ use PHPUnit\Framework\TestCase; final class EditorImageFormatsTest extends TestCase { + /** + * Exercise raster image selection, validation and save compatibility. + * + * @return void + */ #[RunInSeparateProcess] #[PreserveGlobalState(false)] public function testRealEngineRegression(): void { From 1303b624211c6821e13af1897d020e89a3c4f825 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:23:09 +1030 Subject: [PATCH 12/41] Synchronize repairs and config deletion with destination directory locks --- lib/WeatherMap.class.php | 2 +- lib/WeatherMap.config.php | 22 +++++-- lib/editor.config-delete.php | 65 ++++++++++--------- lib/poller-common.php | 5 +- .../BackgroundSizingEngineRegression.php | 4 +- tests/Support/ConfigWriteAtomicRegression.php | 61 ++++++++++++++++- tests/Support/GalleryPreviewRender.php | 1 + .../Support/NewMapPresetEngineRegression.php | 4 +- tests/Support/NewMapTitleEngineRegression.php | 4 +- .../Support/SavedPresetFallbackRegression.php | 1 + tests/Unit/ConfigFileDeleteTest.php | 8 ++- tests/Unit/ConfigRegistrationLockTest.php | 1 + tests/Unit/WeathermapUpgradeTest.php | 5 ++ tests/bootstrap-unit.php | 9 +-- weathermap-cacti-plugin-mgmt.php | 64 ++++++++++-------- 15 files changed, 178 insertions(+), 78 deletions(-) diff --git a/lib/WeatherMap.class.php b/lib/WeatherMap.class.php index 9e2f630d..01c46ae3 100644 --- a/lib/WeatherMap.class.php +++ b/lib/WeatherMap.class.php @@ -3847,7 +3847,7 @@ function WriteConfig($filename) { $contents = stream_get_contents($fd); $closed = fclose($fd); - return $write_ok && $contents !== false && $closed && \WeathermapConfig\atomicWrite($filename, $contents); + return $write_ok && $contents !== false && $closed && \WeathermapConfig\atomicWrite($filename, $contents, $this->configfile !== '' && $filename === $this->configfile); } else { wm_warn("Couldn't open config file $filename for writing"); diff --git a/lib/WeatherMap.config.php b/lib/WeatherMap.config.php index 543d3240..7d104001 100644 --- a/lib/WeatherMap.config.php +++ b/lib/WeatherMap.config.php @@ -25,15 +25,20 @@ namespace WeathermapConfig; /** - * Acquire the lock shared by configuration writers and image deletion. + * Acquire a persistent directory lock shared by config writers and deletion. + * + * @param string|null $directory Destination directory; null selects plugin configs. + * @param bool $blocking Wait for the lock, or return false when it is busy. * * @return resource|false The exclusive lock handle, or false on failure. */ -function acquire() { - $path = dirname(__DIR__) . '/configs/.weathermap-config.lock'; +function acquire($directory = null, $blocking = true) { + $directory = realpath($directory ?? dirname(__DIR__) . '/configs'); + if ($directory === false) return false; + $path = $directory . '/.weathermap-config.lock'; $handle = @fopen($path, 'c+b'); if (!$handle) $handle = @fopen($path, 'rb'); - if ($handle && flock($handle, LOCK_EX)) return $handle; + if ($handle && flock($handle, LOCK_EX | ($blocking ? 0 : LOCK_NB))) return $handle; if ($handle) fclose($handle); return false; } @@ -74,19 +79,24 @@ function writeAll($stream, $contents) { * * @param string $filename The destination configuration path. * @param string $contents The complete new configuration. + * @param bool $mustExist Refuse to recreate an existing map deleted during editing. * * @return bool Success; failures preserve the existing configuration. */ -function atomicWrite($filename, $contents) { +function atomicWrite($filename, $contents, $mustExist = false) { if (strpos($filename, '://') !== false) return false; if (is_link($filename)) $filename = realpath($filename); if ($filename === false || !is_dir(dirname($filename)) || !is_writable(dirname($filename))) return false; if (file_exists($filename) && (!is_file($filename) || !is_writable($filename))) return false; - $lock = acquire(); + $mustExist = $mustExist || file_exists($filename); + $lock = acquire(dirname($filename)); if (!$lock) return false; $temp = false; $stream = false; try { + clearstatcache(true, $filename); + if ($mustExist && !is_file($filename)) return false; + if (file_exists($filename) && (!is_file($filename) || !is_writable($filename))) return false; $temp = @tempnam(dirname($filename), '.wm-config-'); if ($temp === false) return false; $stream = @fopen($temp, 'wb'); diff --git a/lib/editor.config-delete.php b/lib/editor.config-delete.php index b438f7e1..9b3b529a 100644 --- a/lib/editor.config-delete.php +++ b/lib/editor.config-delete.php @@ -90,45 +90,52 @@ function wm_config_delete($directory, $file) { strpos($file, chr(92)) !== false || preg_match('/[\x00-\x1f\x7f]/', $file) || substr($file, -5) !== '.conf' || !$directory) { return 'invalid'; } - $path = $directory . '/' . $file; - $resolved = realpath($path); - - if (is_link($path) || !is_file($path) || $resolved === false || dirname($resolved) !== $directory) { - return 'invalid'; - } - - $lock = wm_config_lock($path); - if ($lock === false) { - return 'failed'; - } + require_once __DIR__ . '/WeatherMap.config.php'; + $directory_lock = \WeathermapConfig\acquire($directory); + if (!$directory_lock) return 'failed'; try { - $usage = db_fetch_cell_prepared('SELECT COUNT(*) FROM weathermap_maps WHERE configfile IN (?, ?)', [$file, $path]); - - if ($usage === false || $usage === null) { - return 'failed'; - } + $path = $directory . '/' . $file; + $resolved = realpath($path); - if ($usage) { - return 'used'; + if (is_link($path) || !is_file($path) || $resolved === false || dirname($resolved) !== $directory) { + return 'invalid'; } - // Existing registrations may use ./, absolute paths or other aliases. - $maps = db_fetch_assoc_prepared('SELECT configfile FROM weathermap_maps', []); - if (!is_array($maps)) { + $lock = wm_config_lock($path); + if ($lock === false) { return 'failed'; } - foreach ($maps as $map) { - $registered = $map['configfile']; - $registered_path = realpath(wm_config_registered_path($directory, $registered)); - if ($registered_path === $resolved) { + try { + $usage = db_fetch_cell_prepared('SELECT COUNT(*) FROM weathermap_maps WHERE configfile IN (?, ?)', [$file, $path]); + + if ($usage === false || $usage === null) { + return 'failed'; + } + + if ($usage) { return 'used'; } - } - return @unlink($path) ? 'deleted' : 'failed'; + // Existing registrations may use ./, absolute paths or other aliases. + $maps = db_fetch_assoc_prepared('SELECT configfile FROM weathermap_maps', []); + if (!is_array($maps)) { + return 'failed'; + } + foreach ($maps as $map) { + $registered = $map['configfile']; + $registered_path = realpath(wm_config_registered_path($directory, $registered)); + if ($registered_path === $resolved) { + return 'used'; + } + } + + return @unlink($path) ? 'deleted' : 'failed'; + } finally { + flock($lock, LOCK_UN); + fclose($lock); + } } finally { - flock($lock, LOCK_UN); - fclose($lock); + \WeathermapConfig\release($directory_lock); } } /** diff --git a/lib/poller-common.php b/lib/poller-common.php index f5f75103..ca63f097 100644 --- a/lib/poller-common.php +++ b/lib/poller-common.php @@ -271,7 +271,10 @@ function weathermap_repair_maps() { if ($changes) { wm_debug('Writing updated file to: ' . $configfile); - file_put_contents($configfile, implode("\n", $outcontents), LOCK_EX); + require_once __DIR__ . '/WeatherMap.config.php'; + if (!\WeathermapConfig\atomicWrite($configfile, implode("\n", $outcontents), true)) { + wm_warn('Unable to save repaired configuration: ' . $configfile . ' [WMPOLL13]'); + } } else { wm_debug('File has correct BACKGROUND and ICON paths: ' . $configfile); } diff --git a/tests/Support/BackgroundSizingEngineRegression.php b/tests/Support/BackgroundSizingEngineRegression.php index 98ad1a20..00b8457b 100644 --- a/tests/Support/BackgroundSizingEngineRegression.php +++ b/tests/Support/BackgroundSizingEngineRegression.php @@ -100,5 +100,7 @@ public function run($map) { } finally { foreach (glob($directory . '/*') as $file) { unlink($file); - }rmdir($directory); + } + if (is_file($directory . '/.weathermap-config.lock')) unlink($directory . '/.weathermap-config.lock'); + rmdir($directory); } diff --git a/tests/Support/ConfigWriteAtomicRegression.php b/tests/Support/ConfigWriteAtomicRegression.php index 6090fa12..75b7989b 100644 --- a/tests/Support/ConfigWriteAtomicRegression.php +++ b/tests/Support/ConfigWriteAtomicRegression.php @@ -121,6 +121,7 @@ function rename($source, $target) { } } namespace { + require dirname(__DIR__, 2) . '/lib/poller-common.php'; require dirname(__DIR__) . '/bootstrap-unit.php'; chdir(dirname(__DIR__, 2)); require dirname(__DIR__, 2) . '/setup.php'; @@ -165,22 +166,77 @@ function atomic_assert($condition, $message) { atomic_assert(is_link($link), 'link itself retained'); unlink($link); } if (function_exists('pcntl_fork')) { - $lock = \WeathermapConfig\acquire(); + $lock = \WeathermapConfig\acquire($dir); $child = pcntl_fork(); if ($child === 0) { - $probe = \fopen(dirname(__DIR__, 2) . '/configs/.weathermap-config.lock', 'rb'); + $probe = \fopen($dir . '/.weathermap-config.lock', 'rb'); $blocked = !\flock($probe, LOCK_EX | LOCK_NB); \fclose($probe); exit($blocked ? 0 : 1); } pcntl_waitpid($child, $status); \WeathermapConfig\release($lock); atomic_assert(pcntl_wexitstatus($status) === 0, 'shared lock excludes concurrent configuration writers'); } + // Deletion waits for the same stable directory mutex used by atomic replacement. + if (function_exists('pcntl_fork')) { + require_once dirname(__DIR__, 2) . '/lib/editor.config-delete.php'; + $GLOBALS['__test_db_fetch_cell_prepared'] = fn () => 0; + $GLOBALS['__test_db_fetch_assoc_prepared'] = fn () => []; + $lock = \WeathermapConfig\acquire($dir); + $ready = $dir . '/ready'; $done = $dir . '/done'; + $child = pcntl_fork(); + if ($child === 0) { + \fclose($lock); + file_put_contents($ready, 'ready'); + $result = wm_config_delete($dir, 'map.conf'); + file_put_contents($done, $result); + exit($result === 'deleted' ? 0 : 1); + } + for ($wait = 0; $wait < 100 && !is_file($ready); $wait++) usleep(10000); + usleep(50000); + $blocked = !is_file($done) && is_file($file); + \WeathermapConfig\release($lock); + pcntl_waitpid($child, $status); + atomic_assert($blocked && pcntl_wexitstatus($status) === 0, 'config deletion cannot unlink during a directory-locked write'); + unlink($ready); unlink($done); + unset($GLOBALS['__test_db_fetch_cell_prepared'], $GLOBALS['__test_db_fetch_assoc_prepared']); + file_put_contents($file, $original); + } + atomic_assert(!\WeathermapConfig\acquire($dir . '/missing'), 'missing lock directory rejected'); + atomic_assert(!\WeathermapConfig\atomicWrite($dir . '/deleted.conf', 'Replacement', true), 'deleted existing maps are not recreated'); + atomic_assert(is_file($dir . '/.weathermap-config.lock'), 'arbitrary output uses its destination directory lock'); $templateFile = $dir . '/templates.conf'; file_put_contents($templateFile, "NODE templateNode\n LABEL Template\nNODE A\n POSITION 10 10\nNODE B\n POSITION 50 50\nLINK templateLink\n WIDTH 8\nLINK trafficLink\n NODES A B\n"); $templateMap = new \WeatherMap; $templateMap->ReadConfig($templateFile); atomic_assert($templateMap->WriteConfig($templateFile), 'atomic save supports templates and traffic links'); $reloaded = new \WeatherMap; $reloaded->ReadConfig($templateFile); atomic_assert(isset($reloaded->nodes['templateNode'], $reloaded->links['templateLink'], $reloaded->links['trafficLink']), 'template and real objects survive atomic roundtrip'); + $plugin = dirname(__DIR__, 2); + $repairName = 'atomic-repair-' . bin2hex(random_bytes(4)) . '.conf'; + $repairFile = $plugin . '/configs/' . $repairName; + $repairImage = $plugin . '/images/backgrounds/' . $repairName . '.png'; + $legacy = 'BACKGROUND ' . basename($repairImage) . "\n"; + file_put_contents($repairFile, $legacy); file_put_contents($repairImage, 'fixture'); + $oldConfig = $GLOBALS['config']; + $GLOBALS['config']['base_path'] = dirname($plugin, 2); + $GLOBALS['__test_db_fetch_assoc'] = fn ($sql) => [['configfile' => $repairName]]; + try { + $GLOBALS['config_fault'] = 'rename'; + $oldSuppress = $GLOBALS['weathermap_error_suppress'] ?? []; + $GLOBALS['weathermap_error_suppress'] = ['WMPOLL13']; + try { weathermap_repair_maps(); } finally { $GLOBALS['weathermap_error_suppress'] = $oldSuppress; } + atomic_assert(file_get_contents($repairFile) === $legacy, 'failed repair preserves original config'); + $GLOBALS['config_fault'] = ''; + weathermap_repair_maps(); + atomic_assert(file_get_contents($repairFile) === 'BACKGROUND images/backgrounds/' . basename($repairImage), 'repair uses complete atomic replacement'); + } finally { + $GLOBALS['config'] = $oldConfig; + unset($GLOBALS['__test_db_fetch_assoc']); + unlink($repairFile); unlink($repairImage); + } + $deletedMap = new \WeatherMap; $deletedMap->ReadConfig($file); + unlink($file); + atomic_assert(!$deletedMap->WriteConfig($file) && !file_exists($file), 'saving a previously loaded deleted map fails without resurrection'); + file_put_contents($file, $original); $GLOBALS['config_fault'] = 'rename'; $map = new \WeatherMap; $map->ReadConfig($file); $map->title = 'Changed'; atomic_assert(!$map->WriteConfig($file) && file_get_contents($file) === $original, 'engine failure preserves original'); @@ -196,6 +252,7 @@ function atomic_assert($condition, $message) { $GLOBALS['config_fault'] = ''; foreach (glob($dir . '/*') as $path) unlink($path); foreach (glob($dir . '/.wm-config-*') as $path) unlink($path); + if (is_file($dir . '/.weathermap-config.lock')) unlink($dir . '/.weathermap-config.lock'); rmdir($dir); } } diff --git a/tests/Support/GalleryPreviewRender.php b/tests/Support/GalleryPreviewRender.php index 467a2084..553e8a91 100644 --- a/tests/Support/GalleryPreviewRender.php +++ b/tests/Support/GalleryPreviewRender.php @@ -143,5 +143,6 @@ function weathermap_tabs($group) { unlink($file); } rmdir($directory . '/output'); + if (is_file($directory . '/.weathermap-config.lock')) unlink($directory . '/.weathermap-config.lock'); rmdir($directory); } diff --git a/tests/Support/NewMapPresetEngineRegression.php b/tests/Support/NewMapPresetEngineRegression.php index 6323b6e1..d738d431 100644 --- a/tests/Support/NewMapPresetEngineRegression.php +++ b/tests/Support/NewMapPresetEngineRegression.php @@ -86,5 +86,7 @@ function wm_engine_assert($value,$message) { } finally { foreach (glob($directory . '/*') as $file) { unlink($file); - }rmdir($directory); + } + if (is_file($directory . '/.weathermap-config.lock')) unlink($directory . '/.weathermap-config.lock'); + rmdir($directory); } diff --git a/tests/Support/NewMapTitleEngineRegression.php b/tests/Support/NewMapTitleEngineRegression.php index 636f6d7c..3410b656 100644 --- a/tests/Support/NewMapTitleEngineRegression.php +++ b/tests/Support/NewMapTitleEngineRegression.php @@ -112,5 +112,7 @@ function wm_engine_assert($value,$message) { } finally { foreach (glob($directory . '/*') as $file) { unlink($file); - }rmdir($directory); + } + if (is_file($directory . '/.weathermap-config.lock')) unlink($directory . '/.weathermap-config.lock'); + rmdir($directory); } diff --git a/tests/Support/SavedPresetFallbackRegression.php b/tests/Support/SavedPresetFallbackRegression.php index a0738740..993321d6 100644 --- a/tests/Support/SavedPresetFallbackRegression.php +++ b/tests/Support/SavedPresetFallbackRegression.php @@ -70,5 +70,6 @@ function clean_up_name($value) { foreach (glob($directory . '/*') as $file) { unlink($file); } + if (is_file($directory . '/.weathermap-config.lock')) unlink($directory . '/.weathermap-config.lock'); rmdir($directory); } diff --git a/tests/Unit/ConfigFileDeleteTest.php b/tests/Unit/ConfigFileDeleteTest.php index 57ab72b7..e4a2a249 100644 --- a/tests/Unit/ConfigFileDeleteTest.php +++ b/tests/Unit/ConfigFileDeleteTest.php @@ -54,7 +54,9 @@ foreach (glob($dir . '/*') as $p) { unlink($p); - }rmdir($dir); + } + if (is_file($dir . '/.weathermap-config.lock')) unlink($dir . '/.weathermap-config.lock'); + rmdir($dir); } }); it('ReportsADeletionFailureWithoutClaimingSuccess', function () { @@ -79,6 +81,7 @@ restore_error_handler(); chmod($dir,0700); unlink($dir . '/test.conf'); + if (is_file($dir . '/.weathermap-config.lock')) unlink($dir . '/.weathermap-config.lock'); rmdir($dir); unset($GLOBALS['__test_db_fetch_cell_prepared']); } @@ -120,6 +123,7 @@ foreach (glob($weathermap_confdir . '/*') as $path) { unlink($path); } + if (is_file($weathermap_confdir . '/.weathermap-config.lock')) unlink($weathermap_confdir . '/.weathermap-config.lock'); rmdir($weathermap_confdir); unset($GLOBALS['__test_nfilter_request']['file'], $GLOBALS['__test_raise_message'], $GLOBALS['__test_delete_message'], $GLOBALS['__test_db_fetch_cell_prepared']); @@ -153,7 +157,9 @@ unset($GLOBALS['__test_db_fetch_cell_prepared'], $GLOBALS['__test_db_fetch_assoc_prepared']); unlink($directory . '/Other.conf'); if (file_exists($directory . '/Map.conf')) { unlink($directory . '/Map.conf'); } + if (is_file($directory . '/sub' . '/.weathermap-config.lock')) unlink($directory . '/sub' . '/.weathermap-config.lock'); rmdir($directory . '/sub'); + if (is_file($directory . '/.weathermap-config.lock')) unlink($directory . '/.weathermap-config.lock'); rmdir($directory); } }); diff --git a/tests/Unit/ConfigRegistrationLockTest.php b/tests/Unit/ConfigRegistrationLockTest.php index 6c5b9464..7a15701a 100644 --- a/tests/Unit/ConfigRegistrationLockTest.php +++ b/tests/Unit/ConfigRegistrationLockTest.php @@ -101,6 +101,7 @@ function map_resort() {} $weathermap_confdir = $old_directory; unset($GLOBALS['__test_db_execute_prepared'], $GLOBALS['__test_db_fetch_cell_prepared']); foreach (glob($directory . '/*') as $entry) { unlink($entry); } + if (is_file($directory . '/.weathermap-config.lock')) unlink($directory . '/.weathermap-config.lock'); rmdir($directory); } }); diff --git a/tests/Unit/WeathermapUpgradeTest.php b/tests/Unit/WeathermapUpgradeTest.php index 6800e6ae..24ca2c17 100644 --- a/tests/Unit/WeathermapUpgradeTest.php +++ b/tests/Unit/WeathermapUpgradeTest.php @@ -21,6 +21,11 @@ * suite stubs. */ +// Only upgrade tests stub the filesystem repair; isolated engine tests load it. +if (!function_exists('weathermap_repair_maps')) { + function weathermap_repair_maps() {} +} + beforeAll(function () { require_once __DIR__ . '/../../setup.php'; }); diff --git a/tests/bootstrap-unit.php b/tests/bootstrap-unit.php index 5e4a2190..de6f6e41 100644 --- a/tests/bootstrap-unit.php +++ b/tests/bootstrap-unit.php @@ -67,6 +67,7 @@ function db_execute_prepared($sql, $params = []) { if (!function_exists('db_fetch_assoc')) { function db_fetch_assoc($sql) { + if (isset($GLOBALS['__test_db_fetch_assoc'])) return ($GLOBALS['__test_db_fetch_assoc'])($sql); return []; } } @@ -184,14 +185,6 @@ function test_set_current_page($page) { } } -// Real implementation lives in lib/poller-common.php, which is never included -// at runtime by the test suites (it pulls in WeatherMap.class.php); stub it so -// the upgrade-path test can drive plugin_weathermap_upgrade() safely. -if (!function_exists('weathermap_repair_maps')) { - function weathermap_repair_maps() { - } -} - if (!function_exists('isset_request_var')) { function isset_request_var($n) { return isset($GLOBALS['__test_request'][$n]); diff --git a/weathermap-cacti-plugin-mgmt.php b/weathermap-cacti-plugin-mgmt.php index 4d17ae26..81271715 100644 --- a/weathermap-cacti-plugin-mgmt.php +++ b/weathermap-cacti-plugin-mgmt.php @@ -1752,38 +1752,48 @@ function add_config($file) { print '' . __('Path mismatch', 'weathermap') . ''; } else { require_once __DIR__ . '/lib/editor.config-delete.php'; - $file = basename($file); - $realfile = $weathermap_confdir . '/' . $file; - $lock = wm_config_lock($realfile); - if ($lock === false) { + require_once __DIR__ . '/lib/WeatherMap.config.php'; + $directory_lock = \WeathermapConfig\acquire($weathermap_confdir, false); + if (!$directory_lock) { raise_message('path_missing', __esc('The configuration file is unavailable. Please retry.', 'weathermap'), MESSAGE_LEVEL_ERROR); return; } try { - $title = wmap_get_title($realfile); - - db_execute_prepared("INSERT INTO weathermap_maps - (configfile, titlecache, active, imagefile, htmlfile, filehash, config) - VALUES (?, ?, 'on', '', '', '', '')", - [$file, $title]); - - $last_id = db_fetch_insert_id(); - $myuid = (isset($_SESSION['sess_user_id']) ? intval($_SESSION['sess_user_id']) : 1); - - db_execute_prepared('INSERT INTO weathermap_auth - (mapid, userid) - VALUES (?, ?)', - [$last_id, $myuid]); - - db_execute_prepared('UPDATE weathermap_maps - SET filehash = LEFT(MD5(CONCAT(id, configfile, rand())), 20) - WHERE id = ?', - [$last_id]); - - map_resort(); + $file = basename($file); + $realfile = $weathermap_confdir . '/' . $file; + $lock = wm_config_lock($realfile); + if ($lock === false) { + raise_message('path_missing', __esc('The configuration file is unavailable. Please retry.', 'weathermap'), MESSAGE_LEVEL_ERROR); + return; + } + try { + $title = wmap_get_title($realfile); + + db_execute_prepared("INSERT INTO weathermap_maps + (configfile, titlecache, active, imagefile, htmlfile, filehash, config) + VALUES (?, ?, 'on', '', '', '', '')", + [$file, $title]); + + $last_id = db_fetch_insert_id(); + $myuid = (isset($_SESSION['sess_user_id']) ? intval($_SESSION['sess_user_id']) : 1); + + db_execute_prepared('INSERT INTO weathermap_auth + (mapid, userid) + VALUES (?, ?)', + [$last_id, $myuid]); + + db_execute_prepared('UPDATE weathermap_maps + SET filehash = LEFT(MD5(CONCAT(id, configfile, rand())), 20) + WHERE id = ?', + [$last_id]); + + map_resort(); + } finally { + flock($lock, LOCK_UN); + fclose($lock); + } } finally { - flock($lock, LOCK_UN); - fclose($lock); + \WeathermapConfig\release($directory_lock); } } } From 17a85466d6f4818da386a641b6bf7b3782818d3a Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:25:40 +1030 Subject: [PATCH 13/41] Return a retry when configuration deletion encounters a busy directory --- lib/editor.config-delete.php | 2 +- tests/Support/ConfigWriteAtomicRegression.php | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/editor.config-delete.php b/lib/editor.config-delete.php index 9b3b529a..9d445604 100644 --- a/lib/editor.config-delete.php +++ b/lib/editor.config-delete.php @@ -91,7 +91,7 @@ function wm_config_delete($directory, $file) { return 'invalid'; } require_once __DIR__ . '/WeatherMap.config.php'; - $directory_lock = \WeathermapConfig\acquire($directory); + $directory_lock = \WeathermapConfig\acquire($directory, false); if (!$directory_lock) return 'failed'; try { $path = $directory . '/' . $file; diff --git a/tests/Support/ConfigWriteAtomicRegression.php b/tests/Support/ConfigWriteAtomicRegression.php index 75b7989b..0fe0d0e2 100644 --- a/tests/Support/ConfigWriteAtomicRegression.php +++ b/tests/Support/ConfigWriteAtomicRegression.php @@ -176,7 +176,7 @@ function atomic_assert($condition, $message) { pcntl_waitpid($child, $status); \WeathermapConfig\release($lock); atomic_assert(pcntl_wexitstatus($status) === 0, 'shared lock excludes concurrent configuration writers'); } - // Deletion waits for the same stable directory mutex used by atomic replacement. + // Deletion refuses a busy stable directory mutex rather than blocking the UI. if (function_exists('pcntl_fork')) { require_once dirname(__DIR__, 2) . '/lib/editor.config-delete.php'; $GLOBALS['__test_db_fetch_cell_prepared'] = fn () => 0; @@ -189,14 +189,14 @@ function atomic_assert($condition, $message) { file_put_contents($ready, 'ready'); $result = wm_config_delete($dir, 'map.conf'); file_put_contents($done, $result); - exit($result === 'deleted' ? 0 : 1); + exit($result === 'failed' ? 0 : 1); } - for ($wait = 0; $wait < 100 && !is_file($ready); $wait++) usleep(10000); - usleep(50000); - $blocked = !is_file($done) && is_file($file); + for ($wait = 0; $wait < 100 && !is_file($done); $wait++) usleep(10000); + $blocked = is_file($done) && file_get_contents($done) === 'failed' && is_file($file); \WeathermapConfig\release($lock); pcntl_waitpid($child, $status); atomic_assert($blocked && pcntl_wexitstatus($status) === 0, 'config deletion cannot unlink during a directory-locked write'); + atomic_assert(wm_config_delete($dir, 'map.conf') === 'deleted', 'deletion succeeds after the writer releases its lock'); unlink($ready); unlink($done); unset($GLOBALS['__test_db_fetch_cell_prepared'], $GLOBALS['__test_db_fetch_assoc_prepared']); file_put_contents($file, $original); From 04a44cd1a5366c3b350fc9ae7ed19c583a420e9b Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:06:27 +1030 Subject: [PATCH 14/41] Surface editor save failures and isolate upgrade regression stubs --- CHANGELOG.md | 2 + js/editor.js | 1 + lib/editor.actions.php | 138 ++++++++++++------ lib/editor.inc.php | 1 + locales/po/cacti.pot | 6 +- tests/Browser/EditorSaveFailureTest.cjs | 6 + tests/Support/ConfigWriteAtomicRegression.php | 10 +- tests/Unit/WeathermapUpgradeTest.php | 115 +++++++-------- weathermap-cacti-plugin-editor.php | 2 +- 9 files changed, 171 insertions(+), 110 deletions(-) create mode 100644 tests/Browser/EditorSaveFailureTest.cjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 90352cc0..2e1a8a63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ IMPORTANT NOTE: This version only works on CACTI 1.x++! --- develop --- +* Make configuration saves and repairs atomic; coordinate writes, registration and deletion with destination-directory locks and report failed editor saves + * Add separate icon and background image upload and management libraries * Reduce gallery preview cards by 20% while preserving generated thumbnail resolution * Add a searchable node icon picker and bundled enterprise site icons diff --git a/js/editor.js b/js/editor.js index 41ccd72d..6e5a9bbc 100644 --- a/js/editor.js +++ b/js/editor.js @@ -878,6 +878,7 @@ function form_submit() { type: 'POST', url: editor_url, data: data, + error: function() { window.alert(wmEditorText.saveError); }, success: function(html) { hide_all_dialogs(); diff --git a/lib/editor.actions.php b/lib/editor.actions.php index aab93135..9c89ff5c 100644 --- a/lib/editor.actions.php +++ b/lib/editor.actions.php @@ -48,6 +48,40 @@ * @param mixed $mapfile */ +/** + * Read an existing editor map and reject stale or unreadable targets. + * + * @param WeatherMap $map Engine receiving the configuration. + * @param string $mapfile Configuration path. + * @return bool Whether the map was loaded. + */ +function wm_editor_read_config($map, $mapfile) { + if (!is_file($mapfile) || !is_readable($mapfile)) { + http_response_code(404); + return false; + } + if (!$map->ReadConfig($mapfile)) { + http_response_code(422); + return false; + } + return true; +} + +/** + * Propagate failed editor configuration saves to the AJAX caller. + * + * @param WeatherMap $map Engine containing the edited configuration. + * @param string $mapfile Configuration path. + * @return bool Whether the complete configuration was saved. + */ +function wm_editor_write_config($map, $mapfile) { + if (!$map->WriteConfig($mapfile)) { + http_response_code(500); + return false; + } + return true; +} + /** * @return void * @param mixed $mapfile @@ -59,7 +93,7 @@ function newMap($mapfile) { require_once __DIR__ . '/editor.new-map-preset.php'; wm_new_map_preset($map); - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } /** @@ -86,7 +120,7 @@ function newMapCopy($mapfile) { if (file_exists($sourcemap) && is_readable($sourcemap)) { $map->ReadConfig($sourcemap); - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } } } @@ -100,7 +134,7 @@ function getMapJavaScript($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; print $map->asJS(); } @@ -114,7 +148,7 @@ function getMapAreaData($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; // we need to draw and throw away a map, to get the // dimensions for the imagemap. @@ -141,7 +175,7 @@ function drawMap($mapfile, $selected, $use_overlay, $use_relative_overlay) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; if ($selected != '') { if (substr($selected, 0, 5) == 'NODE:') { @@ -186,7 +220,7 @@ function fetchConfig($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; header('Content-type: text/plain'); @@ -223,7 +257,7 @@ function setNodeConfig($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $node_name = get_nfilter_request_var('node_name'); $node_config = get_nfilter_request_var('item_configtext'); @@ -231,7 +265,7 @@ function setNodeConfig($mapfile) { if (isset($map->nodes[$node_name])) { $map->nodes[$node_name]->config_override = $node_config; - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); // now clear and reload the map object, because the in-memory one is out of sync // - we don't know what changes the user made here, so we just have to reload. @@ -241,7 +275,7 @@ function setNodeConfig($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; } } @@ -254,7 +288,7 @@ function setLinkConfig($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $link_name = get_nfilter_request_var('link_name'); $link_config = get_nfilter_request_var('item_configtext'); @@ -262,7 +296,7 @@ function setLinkConfig($mapfile) { if (isset($map->links[$link_name])) { $map->links[$link_name]->config_override = $link_config; - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); // now clear and reload the map object, because the in-memory one is out of sync // - we don't know what changes the user made here, so we just have to reload. @@ -272,7 +306,7 @@ function setLinkConfig($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; } } @@ -285,7 +319,7 @@ function setNodeProperties($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $node_name = get_nfilter_request_var('node_name'); $new_node_name = get_nfilter_request_var('node_new_name'); @@ -362,7 +396,7 @@ function setNodeProperties($mapfile) { $map->nodes[$new_node_name]->iconfile = $iconfile; } - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } /** @@ -374,7 +408,7 @@ function setLinkProperties($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $link_name = get_nfilter_request_var('link_name'); @@ -442,7 +476,7 @@ function setLinkProperties($mapfile) { // $map->links[$link_name]->SetBandwidth($bwin,$bwout); - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } } @@ -455,7 +489,7 @@ function setMapProperties($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $map->title = wm_editor_sanitize_string(get_nfilter_request_var('map_title')); $map->keytext['DEFAULT'] = wm_editor_sanitize_string(get_nfilter_request_var('map_legend')); @@ -528,7 +562,7 @@ function setMapProperties($mapfile) { } } - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } /** @@ -540,7 +574,7 @@ function setMapStyle($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; if (wm_editor_validate_one_of(get_nfilter_request_var('mapstyle_htmlstyle'), ['static', 'overlib'], false)) { $map->htmlstyle = strtolower(get_nfilter_request_var('mapstyle_htmlstyle')); @@ -565,7 +599,7 @@ function setMapStyle($mapfile) { wm_comment_update_default($map, 'bwfont', wm_comment_resolve_font($map, get_nfilter_request_var('mapstyle_linkfont'))); wm_comment_apply_style($map, get_nfilter_request_var('mapstyle_commentfont'), get_nfilter_request_var('mapstyle_commentcolour')); - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } /** @@ -577,7 +611,7 @@ function addLink($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $a = get_nfilter_request_var('param'); $b = get_nfilter_request_var('param2'); @@ -609,7 +643,7 @@ function addLink($mapfile) { $map->links[$newlinkname] = $newlink; array_push($map->seen_zlayers[$newlink->zorder], $newlink); - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } } @@ -628,12 +662,12 @@ function placeLegend($mapfile, $grid_snap_value) { $scalename = wm_editor_sanitize_name(get_nfilter_request_var('param')); - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $map->keyx[$scalename] = $x; $map->keyy[$scalename] = $y; - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } /** @@ -649,12 +683,12 @@ function placeStamp($mapfile, $grid_snap_value) { $x = snap(intval(get_nfilter_request_var('x')), $grid_snap_value); $y = snap(intval(get_nfilter_request_var('y')), $grid_snap_value); - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $map->timex = $x; $map->timey = $y; - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } /** @@ -671,11 +705,11 @@ function viaLink($mapfile) { $link_name = wm_editor_sanitize_name(get_nfilter_request_var('link_name')); - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; if (isset($map->links[$link_name])) { $map->links[$link_name]->vialist = [[0 =>$x, 1=>$y]]; - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } } @@ -694,8 +728,12 @@ function moveNode($mapfile, $grid_snap_value) { $node_name = wm_editor_sanitize_name(get_nfilter_request_var('node_name')); - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; + if (!isset($map->nodes[$node_name])) { + http_response_code(404); + return; + } if (isset($map->nodes[$node_name])) { // This is a complicated bit. Find out if this node is involved in any // links that have VIAs. If it is, we want to rotate those VIA points @@ -788,7 +826,7 @@ function moveNode($mapfile, $grid_snap_value) { $map->nodes[$node_name]->x = $x; $map->nodes[$node_name]->y = $y; - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } } @@ -801,7 +839,7 @@ function linkTidy($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $target = wm_editor_sanitize_name(get_nfilter_request_var('param')); @@ -811,7 +849,7 @@ function linkTidy($mapfile) { tidy_link($map, $target); - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } } @@ -824,13 +862,13 @@ function reTidy($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; // draw a map and throw it away, to calculate all the bounding boxes $map->DrawMap('null'); retidy_links($map); - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } /** @@ -842,13 +880,13 @@ function reTidyAll($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; // draw a map and throw it away, to calculate all the bounding boxes $map->DrawMap('null'); retidy_links($map,true); - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } /** @@ -860,13 +898,13 @@ function unTidy($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; // draw a map and throw it away, to calculate all the bounding boxes $map->DrawMap('null'); untidy_links($map); - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } /** @@ -878,7 +916,7 @@ function deleteLink($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $target = wm_editor_sanitize_name(get_nfilter_request_var('param')); $log = 'delete link ' . $target; @@ -886,7 +924,7 @@ function deleteLink($mapfile) { if (isset($map->links[$target])) { unset($map->links[$target]); - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } } @@ -903,7 +941,7 @@ function addNode($mapfile, $grid_snap_value) { $x = snap(intval(get_nfilter_request_var('x')), $grid_snap_value); $y = snap(intval(get_nfilter_request_var('y')), $grid_snap_value); - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $newnodename = sprintf('node%05d', time() % 10000); @@ -932,7 +970,7 @@ function addNode($mapfile, $grid_snap_value) { $map->nodes[$node->name] = $node; $log = "added a node called $newnodename at $x,$y to $mapfile"; - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } /** @@ -947,7 +985,7 @@ function editorSettings($mapfile) { $map->context = 'editor'; // have to do this, otherwise the editor will be unresponsive afterwards - not actually going to change anything! - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $use_overlay = (isset_request_var('editorsettings_showvias') ? intval(get_nfilter_request_var('editorsettings_showvias')) : false); $use_relative_overlay = (isset_request_var('editorsettings_showrelative') ? intval(get_nfilter_request_var('editorsettings_showrelative')) : false); @@ -963,10 +1001,14 @@ function deleteNode($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $target = wm_editor_sanitize_name(get_nfilter_request_var('param')); + if (!isset($map->nodes[$target])) { + http_response_code(404); + return; + } if (isset($map->nodes[$target])) { $log = 'delete node ' . $target; @@ -980,7 +1022,7 @@ function deleteNode($mapfile) { unset($map->nodes[$target]); - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } } @@ -993,7 +1035,7 @@ function cloneNode($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $target = wm_editor_sanitize_name(get_nfilter_request_var('param')); @@ -1024,7 +1066,7 @@ function cloneNode($mapfile) { array_push($map->seen_zlayers[$node->zorder], $node); - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } } @@ -1037,7 +1079,7 @@ function displayFontSamples($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; ksort($map->fonts); header('Content-type: image/png'); diff --git a/lib/editor.inc.php b/lib/editor.inc.php index e314229a..19a0cb6a 100644 --- a/lib/editor.inc.php +++ b/lib/editor.inc.php @@ -1036,6 +1036,7 @@ function getEditorJs() { 'iconMatchCount' => __('Matching icons: %s', '%s', 'weathermap'), 'iconNoMatches' => __('No matching icons', 'weathermap'), + 'saveError' => __('Unable to save changes. Please try again.', 'weathermap'), 'currentInterface' => __('Current interface', 'weathermap'), 'advanced' => __('Advanced settings', 'weathermap'), 'hideAdvanced' => __('Hide advanced settings', 'weathermap'), diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index c13cbea6..d326380e 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti 1.7\n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-12 12:29+1030\n" +"POT-Creation-Date: 2026-10-12 13:03+1030\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" @@ -330,6 +330,10 @@ msgstr "" msgid "No matching icons" msgstr "" +#: lib/editor.inc.php +msgid "Unable to save changes. Please try again." +msgstr "" + #: lib/editor.inc.php msgid "Current interface" msgstr "" diff --git a/tests/Browser/EditorSaveFailureTest.cjs b/tests/Browser/EditorSaveFailureTest.cjs new file mode 100644 index 00000000..447a1c29 --- /dev/null +++ b/tests/Browser/EditorSaveFailureTest.cjs @@ -0,0 +1,6 @@ +const {JSDOM}=require('jsdom'),jquery=require('jquery'),fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict'); +const dom=new JSDOM('',{runScripts:'outside-only'}),w=dom.window,$=jquery(w);w.$=w.jQuery=$;w.editor_url='/editor';w.wmEditorText={saveError:'Unable to save changes'}; +let request,message,closed=0,refreshed=0;$.ajax=opts=>request=opts;w.alert=text=>message=text;w.hide_all_dialogs=()=>closed++;$.get=()=>refreshed++; +const source=fs.readFileSync(path.join(__dirname,'../../js/editor.js'),'utf8');w.eval(source.slice(source.indexOf('function form_submit()'),source.indexOf('function map_properties()'))); +w.form_submit();request.error();assert.equal(message,'Unable to save changes');assert.equal(closed,0);assert.equal(refreshed,0);assert.equal($('#action').val(),'set_map_properties'); +w.close();console.log('PASS: save failure reports retry and preserves the form'); diff --git a/tests/Support/ConfigWriteAtomicRegression.php b/tests/Support/ConfigWriteAtomicRegression.php index 0fe0d0e2..df55f377 100644 --- a/tests/Support/ConfigWriteAtomicRegression.php +++ b/tests/Support/ConfigWriteAtomicRegression.php @@ -241,12 +241,20 @@ function atomic_assert($condition, $message) { $map = new \WeatherMap; $map->ReadConfig($file); $map->title = 'Changed'; atomic_assert(!$map->WriteConfig($file) && file_get_contents($file) === $original, 'engine failure preserves original'); $GLOBALS['__test_nfilter_request'] = ['node_name' => 'note1', 'param' => 'note1', 'x' => '70', 'y' => '80']; - if (function_exists('wm_editor_save_note')) { + { http_response_code(200); moveNode($file, 0); atomic_assert(http_response_code() === 500 && file_get_contents($file) === $original, 'failed move surfaces error without changing file'); http_response_code(200); deleteNode($file); atomic_assert(http_response_code() === 500 && file_get_contents($file) === $original, 'failed delete surfaces error without changing file'); } + $GLOBALS['config_fault'] = ''; + $GLOBALS['__test_nfilter_request'] = ['node_name' => 'missing', 'param' => 'missing', 'x' => '70', 'y' => '80']; + http_response_code(200); moveNode($file, 0); + atomic_assert(http_response_code() === 404 && file_get_contents($file) === $original, 'missing move target is an error'); + http_response_code(200); deleteNode($file); + atomic_assert(http_response_code() === 404 && file_get_contents($file) === $original, 'missing delete target is an error'); + unlink($file); http_response_code(200); moveNode($file, 0); + atomic_assert(http_response_code() === 404 && !file_exists($file), 'deleted map cannot report a successful edit'); echo "Atomic configuration write and locking regression PASS\n"; } finally { $GLOBALS['config_fault'] = ''; diff --git a/tests/Unit/WeathermapUpgradeTest.php b/tests/Unit/WeathermapUpgradeTest.php index 24ca2c17..9a3bdad8 100644 --- a/tests/Unit/WeathermapUpgradeTest.php +++ b/tests/Unit/WeathermapUpgradeTest.php @@ -5,66 +5,63 @@ +-------------------------------------------------------------------------+ */ -/* - * Unit coverage for plugin_weathermap_upgrade() in setup.php: the - * page-guard. - * - * The rest of the function (reached on any allowed page) is intentionally - * NOT covered here: it include_once()s lib/poller-common.php, which itself - * include_once()s lib/WeatherMap.class.php - a file this plugin's own - * other test suites (e.g. tests/Handoff) already load directly by a - * different path, and loading it a second time via a different resolved - * path fatals with "Cannot declare class WeatherMapDataSource, because - * the name is already in use". The version-drift branch also calls - * weathermap_repair_maps(), which does a real chdir() and reads/writes - * map config files on disk - a much larger and riskier surface than this - * suite stubs. - */ - -// Only upgrade tests stub the filesystem repair; isolated engine tests load it. -if (!function_exists('weathermap_repair_maps')) { - function weathermap_repair_maps() {} -} - -beforeAll(function () { - require_once __DIR__ . '/../../setup.php'; -}); - -beforeEach(function () { - $GLOBALS['__test_db_calls'] = []; - $GLOBALS['__test_current_page'] = ''; -}); +use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; +use PHPUnit\Framework\Attributes\PreserveGlobalState; +use PHPUnit\Framework\TestCase; -it('does nothing on a page that does not need the version check', function () { - test_set_current_page('graphs.php'); +/** Exercise upgrade dispatch without sharing production-function stubs. */ +#[RunTestsInSeparateProcesses] +#[PreserveGlobalState(false)] +final class WeathermapUpgradeTest extends TestCase { + /** + * Install the upgrade-only repair stub in this isolated process. + * + * @return void + */ + protected function setUp(): void { + require_once __DIR__ . '/../../setup.php'; + if (!function_exists('weathermap_repair_maps')) { + function weathermap_repair_maps() {} + } + $GLOBALS['__test_db_calls'] = []; + $GLOBALS['__test_current_page'] = ''; + } - plugin_weathermap_upgrade(); + /** + * Verify unrelated pages do not trigger upgrade queries. + * + * @return void + */ + public function testUnrelatedPageSkipsUpgrade(): void { + test_set_current_page('graphs.php'); + plugin_weathermap_upgrade(); + self::assertEmpty($GLOBALS['__test_db_calls']); + } - expect($GLOBALS['__test_db_calls'])->toBeEmpty(); -}); - -it('runs the realm/version updates and prune on a version drift', function () { -test_set_current_page('plugins.php'); - -// Sandbox base_path with an empty poller-common stub + minimal INFO so the -// upgrade-time prune runs against a throwaway tree, never the real checkout, -// and the heavy lib include is a no-op. -$restore = $GLOBALS['config']['base_path']; -$base = sys_get_temp_dir() . '/weathermap-upg-' . uniqid(); -mkdir($base . '/plugins/weathermap/lib', 0777, true); -file_put_contents($base . '/plugins/weathermap/lib/poller-common.php', "not->toBeEmpty(); -}); diff --git a/weathermap-cacti-plugin-editor.php b/weathermap-cacti-plugin-editor.php index 8196770b..46efd3c8 100644 --- a/weathermap-cacti-plugin-editor.php +++ b/weathermap-cacti-plugin-editor.php @@ -873,7 +873,7 @@ '> - +
').text(wmEditorText.imageText1)); }); // Rebuild the existing image selectors so uploads are available without reloading the editor. diff --git a/lib/editor.assets.php b/lib/editor.assets.php index 28783664..f1963858 100644 --- a/lib/editor.assets.php +++ b/lib/editor.assets.php @@ -25,7 +25,10 @@ /** Editor image library management. */ function wm_asset_directory($kind) { if (!in_array($kind, ['objects', 'backgrounds'], true)) throw new InvalidArgumentException(__('Invalid image category.', 'weathermap')); - return dirname(__DIR__) . '/images/' . $kind; + $parent = dirname(__DIR__) . '/images'; + $dir = $parent . '/' . $kind; + if (is_link($parent) || is_link($dir)) throw new InvalidArgumentException(__('Symbolic links cannot be managed here.', 'weathermap')); + return $dir; } function wm_asset_image_path($kind, $name) { $dir = wm_asset_directory($kind); @@ -34,8 +37,8 @@ function wm_asset_image_path($kind, $name) { if (is_link($dir) || is_link($path)) throw new InvalidArgumentException(__('Symbolic links cannot be managed here.', 'weathermap')); return $path; } -function wm_asset_image_users($name, $kind = null) { - $users = []; +function wm_asset_image_reference_index() { + $references = []; $queue = []; $root = dirname(__DIR__); $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root . '/configs', FilesystemIterator::SKIP_DOTS)); @@ -62,30 +65,43 @@ function wm_asset_image_users($name, $kind = null) { $image = '/' . implode('/', $parts); // Dynamic token values are unknown here: protect all possible paths. $pattern = implode('.*', array_map(function($part) { return preg_quote($part, '~'); }, preg_split('/\{[^}]+\}/', $image))); - foreach ($kind === null ? ['objects', 'backgrounds'] : [$kind] as $library) { - if (preg_match('~^' . $pattern . '$~D', $root . '/images/' . $library . '/' . $name)) { $users[] = basename($real); break 2; } - } + $references[] = ['pattern' => '~^' . $pattern . '$~D', 'user' => basename($real)]; } } if (preg_match_all('/^\s*INCLUDE\s+(.+)$/mi', $text, $matches)) foreach ($matches[1] as $include) { $include = trim($include); $include = strpos($include, '/') === 0 ? $include : $root . '/' . $include; $matches = glob($include); - if (!$matches) $queue[] = $include; - else foreach ($matches as $match) $queue[] = $match; + // The renderer tolerates absent optional includes; unreadable matches still fail closed. + foreach ($matches ?: [] as $match) $queue[] = $match; + } + } + return $references; +} +function wm_asset_image_users($name, $kind = null, $references = null) { + $references = $references ?? wm_asset_image_reference_index(); + $users = []; + $root = dirname(__DIR__); + foreach ($references as $reference) { + foreach ($kind === null ? ['objects', 'backgrounds'] : [$kind] as $library) { + if (preg_match($reference['pattern'], $root . '/images/' . $library . '/' . $name)) { + $users[] = $reference['user']; + break; + } } } - return $users; + return array_values(array_unique($users)); } function wm_asset_image_list() { $result = []; + $references = wm_asset_image_reference_index(); foreach (['objects', 'backgrounds'] as $kind) { $dir = wm_asset_directory($kind); foreach (scandir($dir) ?: [] as $name) { $path = $dir . '/' . $name; if (!is_file($path) || is_link($path) || !is_readable($path) || !preg_match('/\.(png|gif|jpe?g)$/iD', $name)) continue; $size = @getimagesize($path); - $users = wm_asset_image_users($name, $kind); + $users = wm_asset_image_users($name, $kind, $references); $result[] = ['width' => $size ? $size[0] : null, 'height' => $size ? $size[1] : null, 'users' => $users, 'kind' => $kind, 'name' => $name, 'url' => 'images/' . $kind . '/' . rawurlencode($name), 'deletable' => !$users && (bool) preg_match('/^[A-Za-z0-9_.-]+\.(png|gif|jpe?g)$/iD', $name) && strpos($name, '..') === false]; } } @@ -116,7 +132,8 @@ function wm_asset_store_image($kind, $source, $original_name) { $encoded = $size[2] === IMAGETYPE_JPEG ? $encode($image, $handle, 90) : $encode($image, $handle); $written = $encoded && fflush($handle); } finally { - fclose($handle); + $closed = fclose($handle); + $written = $written && $closed; imagedestroy($image); if (!$written) unlink($path); } diff --git a/lib/editor.inc.php b/lib/editor.inc.php index 26201ebd..843e6ad0 100644 --- a/lib/editor.inc.php +++ b/lib/editor.inc.php @@ -1023,8 +1023,7 @@ function getEditorJs() { 'imageText8' => __('Remove range', 'weathermap'), 'imageText9' => __('Delete %s', '%s', 'weathermap'), 'imageText10' => __('Delete image "%s" from the server?', '%s', 'weathermap'), - 'imageText11' => __('%s image', '%s', 'weathermap'), - 'imageText12' => __('%s images', '%s', 'weathermap'), + 'imageText11' => __('Images: %s', '%s', 'weathermap'), 'imageText13' => __('Uploaded %s', '%s', 'weathermap'), 'imageText14' => __('Label Only', 'weathermap'), 'imageText15' => __('No background', 'weathermap'), diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 3df11429..070b8c88 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti 1.7\n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-12 00:51+1030\n" +"POT-Creation-Date: 2026-10-12 01:28+1030\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" @@ -39,11 +39,11 @@ msgid "Invalid image category." msgstr "" #: lib/editor.assets.php -msgid "Invalid image filename." +msgid "Symbolic links cannot be managed here." msgstr "" #: lib/editor.assets.php -msgid "Symbolic links cannot be managed here." +msgid "Invalid image filename." msgstr "" #: lib/editor.assets.php @@ -276,12 +276,7 @@ msgstr "" #: lib/editor.inc.php #, php-format -msgid "%s image" -msgstr "" - -#: lib/editor.inc.php -#, php-format -msgid "%s images" +msgid "Images: %s" msgstr "" #: lib/editor.inc.php diff --git a/tests/Support/EditorImageFormatsRegression.php b/tests/Support/EditorImageFormatsRegression.php index af528a6c..5eb1c758 100644 --- a/tests/Support/EditorImageFormatsRegression.php +++ b/tests/Support/EditorImageFormatsRegression.php @@ -78,6 +78,29 @@ function image_format_assert($value, $message) { if (!$value) throw new RuntimeE file_put_contents($included, "BACKGROUND images/backgrounds/included-image.png\n"); file_put_contents($config, "INCLUDE " . $included . "\n"); image_format_assert(in_array(basename($included), wm_asset_image_users('included-image.png'), true), 'included configurations protect images from deletion'); + // Missing optional includes must not disable the library, and one index serves all names. + file_put_contents($config, "INCLUDE " . $temporary . "/missing-*.conf\nINCLUDE " . $included . "\n"); + $index = wm_asset_image_reference_index(); + file_put_contents($included, "# changed after indexing\n"); + image_format_assert(in_array(basename($included), wm_asset_image_users('included-image.png', 'backgrounds', $index), true), 'reference snapshot reused without rescanning configurations'); + image_format_assert(wm_asset_image_users('included-image.png', 'backgrounds') === [], 'fresh request reflects changed configuration'); + image_format_assert(is_array(wm_asset_image_list()), 'unmatched optional include permits listing'); + // Test symlink rejection only inside this isolated fixture, and always restore its library. + $library = $root . '/images'; + $originalLibrary = $temporary . '/library'; + image_format_assert(rename($library, $originalLibrary), 'reserve fixture library'); + try { + image_format_assert(symlink($originalLibrary, $library), 'create linked fixture library'); + foreach (['objects', 'backgrounds'] as $kind) { + try { wm_asset_directory($kind); throw new RuntimeException('accepted symlinked library parent'); } catch (InvalidArgumentException $expected) {} + } + try { wm_asset_image_list(); throw new RuntimeException('listed linked library'); } catch (InvalidArgumentException $expected) {} + try { wm_asset_store_image('objects', $source, 'linked.png'); throw new RuntimeException('uploaded to linked library'); } catch (InvalidArgumentException $expected) {} + try { wm_asset_image_path('objects', basename($created[0])); throw new RuntimeException('accepted deletion path in linked library'); } catch (InvalidArgumentException $expected) {} + } finally { + if (is_link($library)) unlink($library); + rename($originalLibrary, $library); + } // Read-only listings, reference protection and the final server unlink. $_SERVER['REQUEST_METHOD'] = 'GET'; ob_start(); wm_editor_assets($config, 'image_data'); $response = json_decode(ob_get_clean(), true); From 1d14e0c98b4c154a81994a3dec6c921acce90939 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:33:38 +1030 Subject: [PATCH 06/41] Reuse the image reference snapshot after deletion --- lib/editor.actions.php | 5 +++-- lib/editor.assets.php | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/editor.actions.php b/lib/editor.actions.php index 7f4a0b61..3463ecda 100644 --- a/lib/editor.actions.php +++ b/lib/editor.actions.php @@ -1152,9 +1152,10 @@ function wm_editor_assets($mapfile, $action) { } else { $name = get_nfilter_request_var('image_name'); $path = wm_asset_image_path(get_nfilter_request_var('image_kind'), $name); - if (wm_asset_image_users($name, get_nfilter_request_var('image_kind'))) throw new InvalidArgumentException(__('This image is still referenced by a map configuration.', 'weathermap')); + $references = wm_asset_image_reference_index(); + if (wm_asset_image_users($name, get_nfilter_request_var('image_kind'), $references)) throw new InvalidArgumentException(__('This image is still referenced by a map configuration.', 'weathermap')); if (!is_file($path) || !unlink($path)) throw new RuntimeException(__('Could not delete the image.', 'weathermap')); - $result = ['images' => wm_asset_image_list()]; + $result = ['images' => wm_asset_image_list($references)]; } echo json_encode($result); } catch (Throwable $error) { diff --git a/lib/editor.assets.php b/lib/editor.assets.php index f1963858..66abbce1 100644 --- a/lib/editor.assets.php +++ b/lib/editor.assets.php @@ -92,9 +92,9 @@ function wm_asset_image_users($name, $kind = null, $references = null) { } return array_values(array_unique($users)); } -function wm_asset_image_list() { +function wm_asset_image_list($references = null) { $result = []; - $references = wm_asset_image_reference_index(); + $references = $references ?? wm_asset_image_reference_index(); foreach (['objects', 'backgrounds'] as $kind) { $dir = wm_asset_directory($kind); foreach (scandir($dir) ?: [] as $name) { From b1f1e2a2b2f75b1aa1d30452f9c31936292aad00 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:08:24 +1030 Subject: [PATCH 07/41] Validate references before persisting image uploads --- lib/editor.actions.php | 19 +++++++- lib/editor.assets.php | 47 ++++++++++++++++++- locales/po/cacti.pot | 2 +- .../Support/EditorImageFormatsRegression.php | 12 +++++ 4 files changed, 77 insertions(+), 3 deletions(-) diff --git a/lib/editor.actions.php b/lib/editor.actions.php index 3463ecda..73e55656 100644 --- a/lib/editor.actions.php +++ b/lib/editor.actions.php @@ -1136,10 +1136,26 @@ function getImageURL($mapname, $selected) { return $imageurl; } +/** + * Validate a PHP upload and store its decoded image. + * + * @param string $kind The destination library category. + * @param array $file The PHP upload metadata from $_FILES. + * + * @return string The stored image filename. + */ function wm_asset_upload_image($kind, $file) { if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || !is_uploaded_file($file['tmp_name'] ?? '')) throw new InvalidArgumentException(__('Choose a PNG, JPG or GIF file up to 5 MB.', 'weathermap')); return wm_asset_store_image($kind, $file['tmp_name'], $file['name']); } +/** + * Read or mutate an image library and emit a JSON response. + * + * @param string $mapfile The current validated map path. + * @param string $action The library operation requested by the editor. + * + * @return void Errors are returned as HTTP 400 JSON responses. + */ function wm_editor_assets($mapfile, $action) { header('Content-Type: application/json'); try { @@ -1147,8 +1163,9 @@ function wm_editor_assets($mapfile, $action) { if ($action === 'image_data') { $result = ['images' => wm_asset_image_list()]; } elseif ($action === 'upload_image') { + $references = wm_asset_image_reference_index(); $name = wm_asset_upload_image(get_nfilter_request_var('image_kind'), $_FILES['image_file'] ?? []); - $result = ['name' => $name, 'images' => wm_asset_image_list()]; + $result = ['name' => $name, 'images' => wm_asset_image_list($references)]; } else { $name = get_nfilter_request_var('image_name'); $path = wm_asset_image_path(get_nfilter_request_var('image_kind'), $name); diff --git a/lib/editor.assets.php b/lib/editor.assets.php index 66abbce1..1b33f802 100644 --- a/lib/editor.assets.php +++ b/lib/editor.assets.php @@ -22,7 +22,13 @@ +-------------------------------------------------------------------------+ */ -/** Editor image library management. */ +/** + * Resolve a managed image library, rejecting symlinked directories. + * + * @param string $kind The objects or backgrounds library. + * + * @return string The library directory. + */ function wm_asset_directory($kind) { if (!in_array($kind, ['objects', 'backgrounds'], true)) throw new InvalidArgumentException(__('Invalid image category.', 'weathermap')); $parent = dirname(__DIR__) . '/images'; @@ -30,6 +36,14 @@ function wm_asset_directory($kind) { if (is_link($parent) || is_link($dir)) throw new InvalidArgumentException(__('Symbolic links cannot be managed here.', 'weathermap')); return $dir; } +/** + * Validate a filename and resolve its non-symlinked library path. + * + * @param string $kind The library category. + * @param mixed $name The filename to validate. + * + * @return string The validated image path. + */ function wm_asset_image_path($kind, $name) { $dir = wm_asset_directory($kind); if (!is_string($name) || !preg_match('/^[A-Za-z0-9_.-]+\.(png|gif|jpe?g)$/iD', $name) || strpos($name, '..') !== false) throw new InvalidArgumentException(__('Invalid image filename.', 'weathermap')); @@ -37,6 +51,12 @@ function wm_asset_image_path($kind, $name) { if (is_link($dir) || is_link($path)) throw new InvalidArgumentException(__('Symbolic links cannot be managed here.', 'weathermap')); return $path; } +/** + * Read configuration files and includes once to index image references. + * + * + * @return array The reference patterns and source filenames. + */ function wm_asset_image_reference_index() { $references = []; $queue = []; @@ -78,6 +98,15 @@ function wm_asset_image_reference_index() { } return $references; } +/** + * Find map configurations referencing an image. + * + * @param string $name The image filename. + * @param string|null $kind One library category, or both when null. + * @param array|null $references A request-local reference index; null builds an index. + * + * @return string[] Unique configuration filenames using the image. + */ function wm_asset_image_users($name, $kind = null, $references = null) { $references = $references ?? wm_asset_image_reference_index(); $users = []; @@ -92,6 +121,13 @@ function wm_asset_image_users($name, $kind = null, $references = null) { } return array_values(array_unique($users)); } +/** + * List image metadata using one shared configuration reference index. + * + * @param array|null $references A precomputed index, or null to read configurations. + * + * @return array The image dimensions, usage and deletion permissions. + */ function wm_asset_image_list($references = null) { $result = []; $references = $references ?? wm_asset_image_reference_index(); @@ -107,6 +143,15 @@ function wm_asset_image_list($references = null) { } return $result; } +/** + * Validate and re-encode an upload under an exclusively reserved filename. + * + * @param string $kind The destination library category. + * @param string $source The temporary source image path. + * @param string $original_name The original name used to derive a safe filename. + * + * @return string The stored filename; invalid images or write failures raise exceptions. + */ function wm_asset_store_image($kind, $source, $original_name) { $dir = wm_asset_directory($kind); if (is_link($dir) || !is_writable($dir)) throw new RuntimeException(__('The image directory is not writable.', 'weathermap')); diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 070b8c88..df8dac5e 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti 1.7\n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-12 01:28+1030\n" +"POT-Creation-Date: 2026-10-12 09:02+1030\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" diff --git a/tests/Support/EditorImageFormatsRegression.php b/tests/Support/EditorImageFormatsRegression.php index 5eb1c758..46f5bb32 100644 --- a/tests/Support/EditorImageFormatsRegression.php +++ b/tests/Support/EditorImageFormatsRegression.php @@ -101,6 +101,18 @@ function image_format_assert($value, $message) { if (!$value) throw new RuntimeE if (is_link($library)) unlink($library); rename($originalLibrary, $library); } + // A reference-scan failure must be reported before upload validation or persistence. + $broken = $temporary . '/broken.conf'; + symlink($temporary . '/missing-target.conf', $broken); + file_put_contents($config, "INCLUDE " . $broken . "\n"); + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_FILES['image_file'] = ['error' => UPLOAD_ERR_OK, 'tmp_name' => $source, 'name' => 'must-not-persist.png']; + $GLOBALS['__test_nfilter_request'] = ['image_kind' => 'objects']; + ob_start(); wm_editor_assets($config, 'upload_image'); $response = json_decode(ob_get_clean(), true); + image_format_assert(str_contains($response['error'], 'Cannot check configuration references'), 'configuration failure precedes upload persistence'); + image_format_assert(glob($root . '/images/objects/must-not-persist-*') === [], 'reference error cannot create an uploaded image'); + unlink($broken); unset($_FILES['image_file']); + file_put_contents($config, "# no image references\n"); // Read-only listings, reference protection and the final server unlink. $_SERVER['REQUEST_METHOD'] = 'GET'; ob_start(); wm_editor_assets($config, 'image_data'); $response = json_decode(ob_get_clean(), true); From 7db90e4d3b903d0df8c9a34ebe34ab723ef98f36 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:09:56 +1030 Subject: [PATCH 08/41] Synchronize image deletion with portable atomic configuration saves --- js/editor.js | 2 +- lib/WeatherMap.class.php | 53 +++-- lib/WeatherMap.config.php | 107 ++++++++++ lib/editor.actions.php | 13 +- lib/editor.assets.php | 46 +++-- lib/editor.inc.php | 2 +- locales/po/cacti.pot | 14 +- tests/Browser/EditorImagesTest.cjs | 2 + tests/Support/ConfigWriteAtomicRegression.php | 193 ++++++++++++++++++ .../Support/EditorImageFormatsRegression.php | 25 ++- tests/Unit/ConfigWriteAtomicTest.php | 41 ++++ weathermap-cacti-plugin-mgmt.php | 21 +- 12 files changed, 453 insertions(+), 66 deletions(-) create mode 100644 lib/WeatherMap.config.php create mode 100644 tests/Support/ConfigWriteAtomicRegression.php create mode 100644 tests/Unit/ConfigWriteAtomicTest.php diff --git a/js/editor.js b/js/editor.js index 3a04ecdf..8fa055a8 100644 --- a/js/editor.js +++ b/js/editor.js @@ -1693,7 +1693,7 @@ function wmAssetImages(images) { var select = $('#' + spec.id), old = select.val(); var slick = select.data('ddslick'); if (slick) { old = slick.selectedData && slick.selectedData.value; select.ddslick('destroy'); select = $('#' + spec.id); } - select.find('option').filter(function() { return /^images\/.*\.(png|gif|jpe?g)$/i.test(this.value); }).remove(); + select.find('option').filter(function() { return this.value.indexOf('images/' + spec.kind + '/') === 0 && /\.(png|gif|jpe?g)$/i.test(this.value); }).remove(); if (!select.find('option[value="--NONE--"]').length) select.prepend($('').val('--NONE--').text(spec.label)); images.filter(function(item) { return item.kind === spec.kind; }).forEach(function(item) { select.append($('').val('images/' + item.kind + '/' + item.name).text(item.name).attr({'data-imagesrc': item.url, 'data-description': item.name})); diff --git a/lib/WeatherMap.class.php b/lib/WeatherMap.class.php index b78c8849..9e2f630d 100644 --- a/lib/WeatherMap.class.php +++ b/lib/WeatherMap.class.php @@ -3607,21 +3607,28 @@ function WriteDataFile($filename) { * @return bool */ function WriteConfig($filename) { - $fd = false; - - if (is_writable(dirname($filename)) || (file_exists($filename) && is_writable($filename))) { - $fd = fopen($filename, 'w'); - assert($fd !== false); - } + require_once __DIR__ . '/WeatherMap.config.php'; + $fd = fopen('php://temp', 'w+b'); $output = ''; $weathermap_version = plugin_weathermap_numeric_version(); if ($fd) { + $write_ok = true; + $write = function($data) use ($fd, &$write_ok) { + if (!$write_ok) return; + $length = strlen($data); + $offset = 0; + while ($offset < $length) { + $count = @fwrite($fd, substr($data, $offset)); + if ($count === false || $count === 0) { $write_ok = false; return; } + $offset += $count; + } + }; $output .= "# Automatically generated by php-weathermap v$weathermap_version\n\n"; - if (count($this->fonts) > 0) { + if (cacti_count($this->fonts) > 0) { foreach ($this->fonts as $fontnumber => $font) { if ($font->type == 'truetype') { $output .= sprintf("FONTDEFINE %d %s %d\n", $fontnumber, $font->file, $font->size); @@ -3782,18 +3789,18 @@ function WriteConfig($filename) { $output .= "\n# End of global section\n\n"; - fwrite($fd, $output); + $write($output); // # fwrite($fd,$this->nodes['DEFAULT']->WriteConfig()); // # fwrite($fd,$this->links['DEFAULT']->WriteConfig()); - // fwrite($fd, "\n\n# Node definitions:\n"); + // $write("\n\n# Node definitions:\n"); foreach (['template', 'normal'] as $which) { if ($which == 'template') { - fwrite($fd, "\n# TEMPLATE-only NODEs:\n"); + $write("\n# TEMPLATE-only NODEs:\n"); } elseif ($which == 'normal') { - fwrite($fd, "\n# regular NODEs:\n"); + $write("\n# regular NODEs:\n"); } foreach ($this->nodes as $node_name => $node) { @@ -3801,21 +3808,21 @@ function WriteConfig($filename) { if ($node->defined_in == $this->configfile || $node_name == 'DEFAULT') { if ($which == 'template' && $node_name == 'DEFAULT') { wm_debug('TEMPLATE Node DEFAULT'); - fwrite($fd, $node->WriteConfig()); + $write($node->WriteConfig()); } elseif ($which == 'template' && $node->x === null) { wm_debug('TEMPLATE Node NON-DEFAULT'); - fwrite($fd, $node->WriteConfig()); + $write($node->WriteConfig()); } elseif ($which == 'normal' && $node->x !== null) { - fwrite($fd, $node->WriteConfig()); + $write($node->WriteConfig()); } } } } if ($which == 'template') { - fwrite($fd, "\n# TEMPLATE-only LINKs:\n"); + $write("\n# TEMPLATE-only LINKs:\n"); } elseif ($which == 'normal') { - fwrite($fd, "\n# regular LINKs:\n"); + $write("\n# regular LINKs:\n"); } foreach ($this->links as $link_name => $link) { @@ -3823,22 +3830,24 @@ function WriteConfig($filename) { if ($link->defined_in == $this->configfile || $link_name == 'DEFAULT') { if ($which == 'template' && $link_name == 'DEFAULT') { wm_debug('TEMPLATE Link DEFAULT'); - fwrite($fd, $link->WriteConfig()); + $write($link->WriteConfig()); } elseif ($which == 'template' && $link->a === null) { wm_debug('TEMPLATE Link NON-DEFAULT'); - fwrite($fd, $link->WriteConfig()); + $write($link->WriteConfig()); } elseif ($which == 'normal' && $link->a !== null) { - fwrite($fd, $link->WriteConfig()); + $write($link->WriteConfig()); } } } } } - fwrite($fd, "\n\n# That's All Folks!\n"); - fclose($fd); + $write("\n\n# That's All Folks!\n"); + rewind($fd); + $contents = stream_get_contents($fd); + $closed = fclose($fd); - return (true); + return $write_ok && $contents !== false && $closed && \WeathermapConfig\atomicWrite($filename, $contents); } else { wm_warn("Couldn't open config file $filename for writing"); diff --git a/lib/WeatherMap.config.php b/lib/WeatherMap.config.php new file mode 100644 index 00000000..543d3240 --- /dev/null +++ b/lib/WeatherMap.config.php @@ -0,0 +1,107 @@ + wm_asset_image_list($references)]; } echo json_encode($result); diff --git a/lib/editor.assets.php b/lib/editor.assets.php index 1b33f802..b25a1136 100644 --- a/lib/editor.assets.php +++ b/lib/editor.assets.php @@ -51,6 +51,30 @@ function wm_asset_image_path($kind, $name) { if (is_link($dir) || is_link($path)) throw new InvalidArgumentException(__('Symbolic links cannot be managed here.', 'weathermap')); return $path; } +/** + * Canonicalise directive paths using portable forward-slash separators. + * + * @param string $path The image or include path. + * @param string $root The base for relative paths. + * + * @return string The normalised absolute path. + */ +function wm_asset_normalize_path($path, $root) { + $path = str_replace('\\', '/', $path); + $root = str_replace('\\', '/', $root); + if (substr($path, 0, 1) !== '/' && !preg_match('~^[A-Za-z]:/~', $path)) $path = rtrim($root, '/') . '/' . $path; + $prefix = substr($path, 0, 2) === '//' ? '//' : '/'; + if (preg_match('~^([A-Za-z]:)/~', $path, $drive)) { + $prefix = $drive[1] . '/'; + $path = substr($path, 3); + } + $parts = []; + foreach (explode('/', $path) as $part) { + if ($part === '' || $part === '.') continue; + if ($part === '..') array_pop($parts); else $parts[] = $part; + } + return $prefix . implode('/', $parts); +} /** * Read configuration files and includes once to index image references. * @@ -62,7 +86,7 @@ function wm_asset_image_reference_index() { $queue = []; $root = dirname(__DIR__); $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root . '/configs', FilesystemIterator::SKIP_DOTS)); - foreach ($iterator as $file) if ($file->isFile()) $queue[] = $file->getPathname(); + foreach ($iterator as $file) if ($file->isFile() && $file->getFilename() !== '.weathermap-config.lock' && strpos($file->getFilename(), '.wm-config-') !== 0) $queue[] = $file->getPathname(); $seen = []; while ($queue) { $path = array_pop($queue); @@ -70,27 +94,19 @@ function wm_asset_image_reference_index() { if ($real === false || !is_readable($real)) throw new RuntimeException(__('Cannot check configuration references.', 'weathermap')); if (isset($seen[$real])) continue; $seen[$real] = true; - if (count($seen) > 1000) throw new RuntimeException(__('Too many included configurations to check.', 'weathermap')); + if (cacti_count($seen) > 1000) throw new RuntimeException(__('Too many included configurations to check.', 'weathermap')); $text = file_get_contents($real); if ($text === false) throw new RuntimeException(__('Cannot check configuration references.', 'weathermap')); if (preg_match_all('/^\s*(ICON|BACKGROUND)\s+(?:[0-9]+\s+[0-9]+\s+)?(.+?)\s*$/mi', $text, $directives, PREG_SET_ORDER)) { foreach ($directives as $directive) { - $image = trim($directive[2]); - $image = strpos($image, '/') === 0 ? $image : $root . '/' . $image; - $parts = []; - foreach (explode('/', $image) as $part) { - if ($part === '' || $part === '.') continue; - if ($part === '..') array_pop($parts); else $parts[] = $part; - } - $image = '/' . implode('/', $parts); + $image = wm_asset_normalize_path(trim($directive[2]), $root); // Dynamic token values are unknown here: protect all possible paths. $pattern = implode('.*', array_map(function($part) { return preg_quote($part, '~'); }, preg_split('/\{[^}]+\}/', $image))); - $references[] = ['pattern' => '~^' . $pattern . '$~D', 'user' => basename($real)]; + $references[] = ['pattern' => '~^' . $pattern . '$~iD', 'user' => basename($real)]; } } if (preg_match_all('/^\s*INCLUDE\s+(.+)$/mi', $text, $matches)) foreach ($matches[1] as $include) { - $include = trim($include); - $include = strpos($include, '/') === 0 ? $include : $root . '/' . $include; + $include = wm_asset_normalize_path(trim($include), $root); $matches = glob($include); // The renderer tolerates absent optional includes; unreadable matches still fail closed. foreach ($matches ?: [] as $match) $queue[] = $match; @@ -113,7 +129,7 @@ function wm_asset_image_users($name, $kind = null, $references = null) { $root = dirname(__DIR__); foreach ($references as $reference) { foreach ($kind === null ? ['objects', 'backgrounds'] : [$kind] as $library) { - if (preg_match($reference['pattern'], $root . '/images/' . $library . '/' . $name)) { + if (preg_match($reference['pattern'], wm_asset_normalize_path('images/' . $library . '/' . $name, $root))) { $users[] = $reference['user']; break; } @@ -169,7 +185,7 @@ function wm_asset_store_image($kind, $source, $original_name) { $stem = preg_replace('/[^A-Za-z0-9_-]/', '_', pathinfo(basename($original_name), PATHINFO_FILENAME)); $name = substr($stem ?: 'image', 0, 60) . '-' . bin2hex(random_bytes(16)) . '.' . $extension; $path = wm_asset_image_path($kind, $name); - $handle = @fopen($path, 'x'); + $handle = @fopen($path, 'xb'); if (!$handle) { imagedestroy($image); throw new RuntimeException(__('Could not reserve an image filename.', 'weathermap')); } $written = false; try { diff --git a/lib/editor.inc.php b/lib/editor.inc.php index 843e6ad0..a4a58999 100644 --- a/lib/editor.inc.php +++ b/lib/editor.inc.php @@ -645,7 +645,7 @@ function get_imagelist($imagedir) { $realfile = $imdir . '/' . $file; $uri = "images/$imagedir/$file"; - if (is_readable($realfile) && (preg_match('/\.(gif|jpg|png)$/i', $file))) { + if (is_readable($realfile) && (preg_match('/\.(gif|jpe?g|png)$/i', $file))) { $imagelist[] = $uri; $n++; } diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index df8dac5e..0bf5b072 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti 1.7\n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-12 09:02+1030\n" +"POT-Creation-Date: 2026-10-12 11:05+1030\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" @@ -26,6 +26,10 @@ msgstr "" msgid "Use POST to save changes." msgstr "" +#: lib/editor.actions.php +msgid "Could not lock map configurations." +msgstr "" + #: lib/editor.actions.php msgid "This image is still referenced by a map configuration." msgstr "" @@ -1906,13 +1910,7 @@ msgid "The new Map with the name %s was created using config file %s" msgstr "" #: weathermap-cacti-plugin-mgmt.php -#, php-format -msgid "The new Map %s was copied, but its title could not be updated in the config file %s" -msgstr "" - -#: weathermap-cacti-plugin-mgmt.php -#, php-format -msgid "The new Map with the name %s was unable to create the config file %s" +msgid "Could not save the map configuration." msgstr "" #: weathermap-cacti-plugin-mgmt.php diff --git a/tests/Browser/EditorImagesTest.cjs b/tests/Browser/EditorImagesTest.cjs index b70c2438..46c9dc8e 100644 --- a/tests/Browser/EditorImagesTest.cjs +++ b/tests/Browser/EditorImagesTest.cjs @@ -11,7 +11,9 @@ for(const m of inc.matchAll(/'(imageText[0-9]+|imageUsed|imageUnused)' => __\('( w.wmEditorText.imageUnused='Non utilisée'; const source=fs.readFileSync(path.join(__dirname,'../../js/editor.js'),'utf8');w.eval(source.slice(source.indexOf('function wmAssetRequest('))); const images=[{kind:'objects',name:'hospital.png',url:'images/objects/hospital.png',width:96,height:96,users:['map.conf'],deletable:false},{kind:'backgrounds',name:'background.jpg',url:'images/backgrounds/background.jpg',width:800,height:600,users:[],deletable:true}]; +$('#node_iconfilename').append('External icon').val('images/icons/external.png'); w.wmAssetImages(images); +assert.equal($('#node_iconfilename').val(),'images/icons/external.png');assert.equal($('#node_iconfilename option[value="images/icons/external.png"]').length,1); assert.equal($('#wm-images-objects .wm-image-delete').length,0);assert.match($('#wm-images-objects').text(),/96 × 96 px/);assert.match($('#wm-images-objects').text(),/map.conf/);assert.match($('#wm-images-backgrounds').text(),/Non utilisée/); assert.equal($('#node_iconfilename option[value="images/objects/hospital.png"]').length,1); let request;$.ajax=opts=>request=opts;w.confirm=()=>false;$('#wm-images-backgrounds .wm-image-delete').trigger('click');assert.equal(request,undefined); diff --git a/tests/Support/ConfigWriteAtomicRegression.php b/tests/Support/ConfigWriteAtomicRegression.php new file mode 100644 index 00000000..91c3a38b --- /dev/null +++ b/tests/Support/ConfigWriteAtomicRegression.php @@ -0,0 +1,193 @@ +ReadConfig($file); $map->title = 'Changed'; + atomic_assert(!$map->WriteConfig($file) && file_get_contents($file) === $original, 'engine failure preserves original'); + $GLOBALS['__test_nfilter_request'] = ['node_name' => 'note1', 'param' => 'note1', 'x' => '70', 'y' => '80']; + if (function_exists('wm_editor_save_note')) { + http_response_code(200); moveNode($file, 0); + atomic_assert(http_response_code() === 500 && file_get_contents($file) === $original, 'failed move surfaces error without changing file'); + http_response_code(200); deleteNode($file); + atomic_assert(http_response_code() === 500 && file_get_contents($file) === $original, 'failed delete surfaces error without changing file'); + } + echo "Atomic configuration write and locking regression PASS\n"; + } finally { + $GLOBALS['config_fault'] = ''; + foreach (glob($dir . '/*') as $path) unlink($path); + foreach (glob($dir . '/.wm-config-*') as $path) unlink($path); + rmdir($dir); + } +} diff --git a/tests/Support/EditorImageFormatsRegression.php b/tests/Support/EditorImageFormatsRegression.php index 46f5bb32..92eae132 100644 --- a/tests/Support/EditorImageFormatsRegression.php +++ b/tests/Support/EditorImageFormatsRegression.php @@ -24,7 +24,20 @@ require dirname(__DIR__) . "/bootstrap-unit.php"; require dirname(__DIR__, 2) . '/lib/editor.assets.php'; require dirname(__DIR__, 2) . '/lib/editor.actions.php'; -function image_format_assert($value, $message) { if (!$value) throw new RuntimeException($message); } +require dirname(__DIR__, 2) . '/lib/editor.inc.php'; +/** + * Assert an image-format regression condition. + * + * @param bool $value Whether the condition holds. + * @param string $message The failure description. + * + * @return void + */ +function image_format_assert($value, $message) { + if (!$value) { + throw new RuntimeException($message); + } +} $root = dirname(__DIR__, 2); $temporary = $root . '/tests/wm-image-formats-' . bin2hex(random_bytes(4)); mkdir($temporary); @@ -48,13 +61,21 @@ function image_format_assert($value, $message) { if (!$value) throw new RuntimeE image_format_assert($size[0] === 12 && $size[1] === 8 && $size[2] === $type, 'decoded format and dimensions preserved'); image_format_assert(strpos(file_get_contents($path), 'UNTRUSTED_TRAILING_BYTES') === false, 'uploaded trailing bytes removed'); $listed = array_values(array_filter(wm_asset_image_list(), function($item) use ($kind, $name) { return $item['kind'] === $kind && $item['name'] === $name; })); - image_format_assert(count($listed) === 1 && $listed[0]['deletable'], 'each format listed and deletable'); + image_format_assert(cacti_count($listed) === 1 && $listed[0]['deletable'], 'each format listed and deletable'); file_put_contents($config, 'BACKGROUND images/' . $kind . '/' . $name . "\n"); image_format_assert(in_array(basename($config), wm_asset_image_users($name), true), 'reference protection applies to every format'); } } file_put_contents($config, "ICON images/objects/core-router.png\n# router.png is merely a comment\n"); image_format_assert(wm_asset_image_users('router.png', 'objects') === [], 'substring and comment do not count as references'); + file_put_contents($config, "ICON images/objects/ROUTER.PNG\n"); + image_format_assert(wm_asset_image_users('router.png', 'objects') !== [], 'case-insensitive paths protect in-use images'); + image_format_assert(wm_asset_normalize_path('images\\objects\\Router.PNG', 'C:\\Cacti\\plugins\\weathermap') === 'C:/Cacti/plugins/weathermap/images/objects/Router.PNG', 'Windows relative path canonicalised'); + image_format_assert(wm_asset_normalize_path('D:\\assets\\router.png', 'C:/Cacti') === 'D:/assets/router.png', 'Windows absolute drive path retained'); + image_format_assert(wm_asset_normalize_path('\\\\server\\share\\router.png', 'C:/Cacti') === '//server/share/router.png', 'UNC absolute path retained'); + $jpegPath = $root . '/images/backgrounds/format-reload-' . bin2hex(random_bytes(4)) . '.jpeg'; + copy($created[3], $jpegPath); $created[] = $jpegPath; + image_format_assert(in_array('images/backgrounds/' . basename($jpegPath), get_imagelist('backgrounds'), true), 'existing JPEG background survives selector reload'); file_put_contents($config, "ICON /outside/router.png\n"); image_format_assert(wm_asset_image_users('router.png', 'objects') === [], 'complete paths distinguish external images'); file_put_contents($config, "ICON 32 32 images/objects/updown_{node:this:bandwidth_in}.png\n"); diff --git a/tests/Unit/ConfigWriteAtomicTest.php b/tests/Unit/ConfigWriteAtomicTest.php new file mode 100644 index 00000000..48736556 --- /dev/null +++ b/tests/Unit/ConfigWriteAtomicTest.php @@ -0,0 +1,41 @@ + Date: Mon, 12 Oct 2026 11:12:09 +1030 Subject: [PATCH 09/41] Verify atomic saves preserve template and traffic objects --- tests/Support/ConfigWriteAtomicRegression.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/Support/ConfigWriteAtomicRegression.php b/tests/Support/ConfigWriteAtomicRegression.php index 91c3a38b..056c2c09 100644 --- a/tests/Support/ConfigWriteAtomicRegression.php +++ b/tests/Support/ConfigWriteAtomicRegression.php @@ -173,6 +173,12 @@ function atomic_assert($condition, $message) { pcntl_waitpid($child, $status); \WeathermapConfig\release($lock); atomic_assert(pcntl_wexitstatus($status) === 0, 'shared lock excludes concurrent configuration writers'); } + $templateFile = $dir . '/templates.conf'; + file_put_contents($templateFile, "NODE templateNode\n LABEL Template\nNODE A\n POSITION 10 10\nNODE B\n POSITION 50 50\nLINK templateLink\n WIDTH 8\nLINK trafficLink\n NODES A B\n"); + $templateMap = new \WeatherMap; $templateMap->ReadConfig($templateFile); + atomic_assert($templateMap->WriteConfig($templateFile), 'atomic save supports templates and traffic links'); + $reloaded = new \WeatherMap; $reloaded->ReadConfig($templateFile); + atomic_assert(isset($reloaded->nodes['templateNode'], $reloaded->links['templateLink'], $reloaded->links['trafficLink']), 'template and real objects survive atomic roundtrip'); $GLOBALS['config_fault'] = 'rename'; $map = new \WeatherMap; $map->ReadConfig($file); $map->title = 'Changed'; atomic_assert(!$map->WriteConfig($file) && file_get_contents($file) === $original, 'engine failure preserves original'); From 075099e8773509d3e467fe0d76e0a31566f5e486 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:21:32 +1030 Subject: [PATCH 10/41] Keep atomic-save regression checks portable across platforms --- tests/Support/ConfigWriteAtomicRegression.php | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/Support/ConfigWriteAtomicRegression.php b/tests/Support/ConfigWriteAtomicRegression.php index 056c2c09..6090fa12 100644 --- a/tests/Support/ConfigWriteAtomicRegression.php +++ b/tests/Support/ConfigWriteAtomicRegression.php @@ -144,7 +144,7 @@ function atomic_assert($condition, $message) { $original = "WIDTH 600\nHEIGHT 300\nNODE note1\n LABEL Original\n POSITION 50 50\n SET editor_note 1\n"; file_put_contents($file, $original); try { - foreach (['lock-open', 'lock', 'temp-name', 'temp-open', 'write-false', 'write-zero', 'flush', 'close', 'group', 'mode', 'rename'] as $fault) { + foreach (array_filter(['lock-open', 'lock', 'temp-name', 'temp-open', 'write-false', 'write-zero', 'flush', 'close', 'group', 'mode', 'rename'], function($fault) { return PHP_OS_FAMILY !== 'Windows' || $fault !== 'group'; }) as $fault) { $GLOBALS['config_fault'] = $fault; atomic_assert(\WeathermapConfig\atomicWrite($file, 'Replacement') === false, 'failure propagated: ' . $fault); atomic_assert(file_get_contents($file) === $original, 'original retained: ' . $fault); @@ -153,15 +153,17 @@ function atomic_assert($condition, $message) { $GLOBALS['config_fault'] = 'short-write'; atomic_assert(\WeathermapConfig\atomicWrite($file, 'Complete replacement'), 'short writes completed'); atomic_assert(file_get_contents($file) === 'Complete replacement', 'complete bytes retained'); - $GLOBALS['config_fault'] = 'lock-read'; + $GLOBALS['config_fault'] = PHP_OS_FAMILY === 'Windows' ? '' : 'lock-read'; atomic_assert(\WeathermapConfig\atomicWrite($file, $original), 'readable lock supports another writer account'); $GLOBALS['config_fault'] = ''; atomic_assert(!\WeathermapConfig\atomicWrite('notewritefail://map/config', 'Replacement'), 'unsupported stream rejected'); atomic_assert(!\WeathermapConfig\atomicWrite($dir . '/missing/map.conf', 'Replacement'), 'missing directory rejected'); atomic_assert(!\WeathermapConfig\atomicWrite($dir, 'Replacement'), 'directory target rejected'); - $link = $dir . '/link.conf'; symlink($file, $link); - atomic_assert(\WeathermapConfig\atomicWrite($link, $original), 'configuration symlink target preserved'); - atomic_assert(is_link($link), 'link itself retained'); unlink($link); + $link = $dir . '/link.conf'; + if (@symlink($file, $link)) { + atomic_assert(\WeathermapConfig\atomicWrite($link, $original), 'configuration symlink target preserved'); + atomic_assert(is_link($link), 'link itself retained'); unlink($link); + } if (function_exists('pcntl_fork')) { $lock = \WeathermapConfig\acquire(); $child = pcntl_fork(); From ad6a77bf73e0e8c6495d08fa90677bf1796b3edd Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:40:28 +1030 Subject: [PATCH 11/41] Accept uppercase raster extensions when saving backgrounds --- lib/editor.inc.php | 2 +- tests/Support/EditorImageFormatsRegression.php | 1 + tests/Unit/EditorImageFormatsTest.php | 5 +++++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/editor.inc.php b/lib/editor.inc.php index a4a58999..4503b9db 100644 --- a/lib/editor.inc.php +++ b/lib/editor.inc.php @@ -337,7 +337,7 @@ function wm_editor_sanitize_file($filename,$allowed_exts = []) { foreach ($allowed_exts as $ext) { $match = '.' . $ext; - if (substr($filename, -strlen($match),strlen($match)) == $match) { + if (strcasecmp(substr($filename, -strlen($match), strlen($match)), $match) === 0) { $ok = true; } } diff --git a/tests/Support/EditorImageFormatsRegression.php b/tests/Support/EditorImageFormatsRegression.php index 92eae132..3d292fdc 100644 --- a/tests/Support/EditorImageFormatsRegression.php +++ b/tests/Support/EditorImageFormatsRegression.php @@ -84,6 +84,7 @@ function image_format_assert($value, $message) { file_put_contents($config, "ICON images/objects/" . basename($created[0]) . "\n"); $listed = array_values(array_filter(wm_asset_image_list(), function($item) use ($created) { return $item['kind'] === 'objects' && $item['name'] === basename($created[0]); })); image_format_assert($listed[0]['width'] === 12 && $listed[0]['height'] === 8 && !$listed[0]['deletable'] && $listed[0]['users'], 'dimensions and usage disable referenced image deletion'); + image_format_assert(wm_editor_sanitize_file('images/backgrounds/legacy.JPG', ['png', 'jpg', 'gif', 'jpeg']) === 'images/backgrounds/legacy.JPG', 'uppercase background save retains path'); foreach (['safe.png', 'safe.jpg', 'safe.jpeg', 'safe.gif', 'safe.JPG'] as $name) image_format_assert(str_ends_with(wm_asset_image_path('backgrounds', $name), $name), 'accepted filename'); foreach (['../outside.jpg', 'bad.php', 'bad.svg', 'bad.jpg.php', 'bad..gif'] as $name) { try { wm_asset_image_path('objects', $name); throw new RuntimeException('accepted invalid filename'); } catch (InvalidArgumentException $expected) {} diff --git a/tests/Unit/EditorImageFormatsTest.php b/tests/Unit/EditorImageFormatsTest.php index 4304d156..efdd770a 100644 --- a/tests/Unit/EditorImageFormatsTest.php +++ b/tests/Unit/EditorImageFormatsTest.php @@ -26,6 +26,11 @@ use PHPUnit\Framework\TestCase; final class EditorImageFormatsTest extends TestCase { + /** + * Exercise raster image selection, validation and save compatibility. + * + * @return void + */ #[RunInSeparateProcess] #[PreserveGlobalState(false)] public function testRealEngineRegression(): void { From 1303b624211c6821e13af1897d020e89a3c4f825 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:23:09 +1030 Subject: [PATCH 12/41] Synchronize repairs and config deletion with destination directory locks --- lib/WeatherMap.class.php | 2 +- lib/WeatherMap.config.php | 22 +++++-- lib/editor.config-delete.php | 65 ++++++++++--------- lib/poller-common.php | 5 +- .../BackgroundSizingEngineRegression.php | 4 +- tests/Support/ConfigWriteAtomicRegression.php | 61 ++++++++++++++++- tests/Support/GalleryPreviewRender.php | 1 + .../Support/NewMapPresetEngineRegression.php | 4 +- tests/Support/NewMapTitleEngineRegression.php | 4 +- .../Support/SavedPresetFallbackRegression.php | 1 + tests/Unit/ConfigFileDeleteTest.php | 8 ++- tests/Unit/ConfigRegistrationLockTest.php | 1 + tests/Unit/WeathermapUpgradeTest.php | 5 ++ tests/bootstrap-unit.php | 9 +-- weathermap-cacti-plugin-mgmt.php | 64 ++++++++++-------- 15 files changed, 178 insertions(+), 78 deletions(-) diff --git a/lib/WeatherMap.class.php b/lib/WeatherMap.class.php index 9e2f630d..01c46ae3 100644 --- a/lib/WeatherMap.class.php +++ b/lib/WeatherMap.class.php @@ -3847,7 +3847,7 @@ function WriteConfig($filename) { $contents = stream_get_contents($fd); $closed = fclose($fd); - return $write_ok && $contents !== false && $closed && \WeathermapConfig\atomicWrite($filename, $contents); + return $write_ok && $contents !== false && $closed && \WeathermapConfig\atomicWrite($filename, $contents, $this->configfile !== '' && $filename === $this->configfile); } else { wm_warn("Couldn't open config file $filename for writing"); diff --git a/lib/WeatherMap.config.php b/lib/WeatherMap.config.php index 543d3240..7d104001 100644 --- a/lib/WeatherMap.config.php +++ b/lib/WeatherMap.config.php @@ -25,15 +25,20 @@ namespace WeathermapConfig; /** - * Acquire the lock shared by configuration writers and image deletion. + * Acquire a persistent directory lock shared by config writers and deletion. + * + * @param string|null $directory Destination directory; null selects plugin configs. + * @param bool $blocking Wait for the lock, or return false when it is busy. * * @return resource|false The exclusive lock handle, or false on failure. */ -function acquire() { - $path = dirname(__DIR__) . '/configs/.weathermap-config.lock'; +function acquire($directory = null, $blocking = true) { + $directory = realpath($directory ?? dirname(__DIR__) . '/configs'); + if ($directory === false) return false; + $path = $directory . '/.weathermap-config.lock'; $handle = @fopen($path, 'c+b'); if (!$handle) $handle = @fopen($path, 'rb'); - if ($handle && flock($handle, LOCK_EX)) return $handle; + if ($handle && flock($handle, LOCK_EX | ($blocking ? 0 : LOCK_NB))) return $handle; if ($handle) fclose($handle); return false; } @@ -74,19 +79,24 @@ function writeAll($stream, $contents) { * * @param string $filename The destination configuration path. * @param string $contents The complete new configuration. + * @param bool $mustExist Refuse to recreate an existing map deleted during editing. * * @return bool Success; failures preserve the existing configuration. */ -function atomicWrite($filename, $contents) { +function atomicWrite($filename, $contents, $mustExist = false) { if (strpos($filename, '://') !== false) return false; if (is_link($filename)) $filename = realpath($filename); if ($filename === false || !is_dir(dirname($filename)) || !is_writable(dirname($filename))) return false; if (file_exists($filename) && (!is_file($filename) || !is_writable($filename))) return false; - $lock = acquire(); + $mustExist = $mustExist || file_exists($filename); + $lock = acquire(dirname($filename)); if (!$lock) return false; $temp = false; $stream = false; try { + clearstatcache(true, $filename); + if ($mustExist && !is_file($filename)) return false; + if (file_exists($filename) && (!is_file($filename) || !is_writable($filename))) return false; $temp = @tempnam(dirname($filename), '.wm-config-'); if ($temp === false) return false; $stream = @fopen($temp, 'wb'); diff --git a/lib/editor.config-delete.php b/lib/editor.config-delete.php index b438f7e1..9b3b529a 100644 --- a/lib/editor.config-delete.php +++ b/lib/editor.config-delete.php @@ -90,45 +90,52 @@ function wm_config_delete($directory, $file) { strpos($file, chr(92)) !== false || preg_match('/[\x00-\x1f\x7f]/', $file) || substr($file, -5) !== '.conf' || !$directory) { return 'invalid'; } - $path = $directory . '/' . $file; - $resolved = realpath($path); - - if (is_link($path) || !is_file($path) || $resolved === false || dirname($resolved) !== $directory) { - return 'invalid'; - } - - $lock = wm_config_lock($path); - if ($lock === false) { - return 'failed'; - } + require_once __DIR__ . '/WeatherMap.config.php'; + $directory_lock = \WeathermapConfig\acquire($directory); + if (!$directory_lock) return 'failed'; try { - $usage = db_fetch_cell_prepared('SELECT COUNT(*) FROM weathermap_maps WHERE configfile IN (?, ?)', [$file, $path]); - - if ($usage === false || $usage === null) { - return 'failed'; - } + $path = $directory . '/' . $file; + $resolved = realpath($path); - if ($usage) { - return 'used'; + if (is_link($path) || !is_file($path) || $resolved === false || dirname($resolved) !== $directory) { + return 'invalid'; } - // Existing registrations may use ./, absolute paths or other aliases. - $maps = db_fetch_assoc_prepared('SELECT configfile FROM weathermap_maps', []); - if (!is_array($maps)) { + $lock = wm_config_lock($path); + if ($lock === false) { return 'failed'; } - foreach ($maps as $map) { - $registered = $map['configfile']; - $registered_path = realpath(wm_config_registered_path($directory, $registered)); - if ($registered_path === $resolved) { + try { + $usage = db_fetch_cell_prepared('SELECT COUNT(*) FROM weathermap_maps WHERE configfile IN (?, ?)', [$file, $path]); + + if ($usage === false || $usage === null) { + return 'failed'; + } + + if ($usage) { return 'used'; } - } - return @unlink($path) ? 'deleted' : 'failed'; + // Existing registrations may use ./, absolute paths or other aliases. + $maps = db_fetch_assoc_prepared('SELECT configfile FROM weathermap_maps', []); + if (!is_array($maps)) { + return 'failed'; + } + foreach ($maps as $map) { + $registered = $map['configfile']; + $registered_path = realpath(wm_config_registered_path($directory, $registered)); + if ($registered_path === $resolved) { + return 'used'; + } + } + + return @unlink($path) ? 'deleted' : 'failed'; + } finally { + flock($lock, LOCK_UN); + fclose($lock); + } } finally { - flock($lock, LOCK_UN); - fclose($lock); + \WeathermapConfig\release($directory_lock); } } /** diff --git a/lib/poller-common.php b/lib/poller-common.php index f5f75103..ca63f097 100644 --- a/lib/poller-common.php +++ b/lib/poller-common.php @@ -271,7 +271,10 @@ function weathermap_repair_maps() { if ($changes) { wm_debug('Writing updated file to: ' . $configfile); - file_put_contents($configfile, implode("\n", $outcontents), LOCK_EX); + require_once __DIR__ . '/WeatherMap.config.php'; + if (!\WeathermapConfig\atomicWrite($configfile, implode("\n", $outcontents), true)) { + wm_warn('Unable to save repaired configuration: ' . $configfile . ' [WMPOLL13]'); + } } else { wm_debug('File has correct BACKGROUND and ICON paths: ' . $configfile); } diff --git a/tests/Support/BackgroundSizingEngineRegression.php b/tests/Support/BackgroundSizingEngineRegression.php index 98ad1a20..00b8457b 100644 --- a/tests/Support/BackgroundSizingEngineRegression.php +++ b/tests/Support/BackgroundSizingEngineRegression.php @@ -100,5 +100,7 @@ public function run($map) { } finally { foreach (glob($directory . '/*') as $file) { unlink($file); - }rmdir($directory); + } + if (is_file($directory . '/.weathermap-config.lock')) unlink($directory . '/.weathermap-config.lock'); + rmdir($directory); } diff --git a/tests/Support/ConfigWriteAtomicRegression.php b/tests/Support/ConfigWriteAtomicRegression.php index 6090fa12..75b7989b 100644 --- a/tests/Support/ConfigWriteAtomicRegression.php +++ b/tests/Support/ConfigWriteAtomicRegression.php @@ -121,6 +121,7 @@ function rename($source, $target) { } } namespace { + require dirname(__DIR__, 2) . '/lib/poller-common.php'; require dirname(__DIR__) . '/bootstrap-unit.php'; chdir(dirname(__DIR__, 2)); require dirname(__DIR__, 2) . '/setup.php'; @@ -165,22 +166,77 @@ function atomic_assert($condition, $message) { atomic_assert(is_link($link), 'link itself retained'); unlink($link); } if (function_exists('pcntl_fork')) { - $lock = \WeathermapConfig\acquire(); + $lock = \WeathermapConfig\acquire($dir); $child = pcntl_fork(); if ($child === 0) { - $probe = \fopen(dirname(__DIR__, 2) . '/configs/.weathermap-config.lock', 'rb'); + $probe = \fopen($dir . '/.weathermap-config.lock', 'rb'); $blocked = !\flock($probe, LOCK_EX | LOCK_NB); \fclose($probe); exit($blocked ? 0 : 1); } pcntl_waitpid($child, $status); \WeathermapConfig\release($lock); atomic_assert(pcntl_wexitstatus($status) === 0, 'shared lock excludes concurrent configuration writers'); } + // Deletion waits for the same stable directory mutex used by atomic replacement. + if (function_exists('pcntl_fork')) { + require_once dirname(__DIR__, 2) . '/lib/editor.config-delete.php'; + $GLOBALS['__test_db_fetch_cell_prepared'] = fn () => 0; + $GLOBALS['__test_db_fetch_assoc_prepared'] = fn () => []; + $lock = \WeathermapConfig\acquire($dir); + $ready = $dir . '/ready'; $done = $dir . '/done'; + $child = pcntl_fork(); + if ($child === 0) { + \fclose($lock); + file_put_contents($ready, 'ready'); + $result = wm_config_delete($dir, 'map.conf'); + file_put_contents($done, $result); + exit($result === 'deleted' ? 0 : 1); + } + for ($wait = 0; $wait < 100 && !is_file($ready); $wait++) usleep(10000); + usleep(50000); + $blocked = !is_file($done) && is_file($file); + \WeathermapConfig\release($lock); + pcntl_waitpid($child, $status); + atomic_assert($blocked && pcntl_wexitstatus($status) === 0, 'config deletion cannot unlink during a directory-locked write'); + unlink($ready); unlink($done); + unset($GLOBALS['__test_db_fetch_cell_prepared'], $GLOBALS['__test_db_fetch_assoc_prepared']); + file_put_contents($file, $original); + } + atomic_assert(!\WeathermapConfig\acquire($dir . '/missing'), 'missing lock directory rejected'); + atomic_assert(!\WeathermapConfig\atomicWrite($dir . '/deleted.conf', 'Replacement', true), 'deleted existing maps are not recreated'); + atomic_assert(is_file($dir . '/.weathermap-config.lock'), 'arbitrary output uses its destination directory lock'); $templateFile = $dir . '/templates.conf'; file_put_contents($templateFile, "NODE templateNode\n LABEL Template\nNODE A\n POSITION 10 10\nNODE B\n POSITION 50 50\nLINK templateLink\n WIDTH 8\nLINK trafficLink\n NODES A B\n"); $templateMap = new \WeatherMap; $templateMap->ReadConfig($templateFile); atomic_assert($templateMap->WriteConfig($templateFile), 'atomic save supports templates and traffic links'); $reloaded = new \WeatherMap; $reloaded->ReadConfig($templateFile); atomic_assert(isset($reloaded->nodes['templateNode'], $reloaded->links['templateLink'], $reloaded->links['trafficLink']), 'template and real objects survive atomic roundtrip'); + $plugin = dirname(__DIR__, 2); + $repairName = 'atomic-repair-' . bin2hex(random_bytes(4)) . '.conf'; + $repairFile = $plugin . '/configs/' . $repairName; + $repairImage = $plugin . '/images/backgrounds/' . $repairName . '.png'; + $legacy = 'BACKGROUND ' . basename($repairImage) . "\n"; + file_put_contents($repairFile, $legacy); file_put_contents($repairImage, 'fixture'); + $oldConfig = $GLOBALS['config']; + $GLOBALS['config']['base_path'] = dirname($plugin, 2); + $GLOBALS['__test_db_fetch_assoc'] = fn ($sql) => [['configfile' => $repairName]]; + try { + $GLOBALS['config_fault'] = 'rename'; + $oldSuppress = $GLOBALS['weathermap_error_suppress'] ?? []; + $GLOBALS['weathermap_error_suppress'] = ['WMPOLL13']; + try { weathermap_repair_maps(); } finally { $GLOBALS['weathermap_error_suppress'] = $oldSuppress; } + atomic_assert(file_get_contents($repairFile) === $legacy, 'failed repair preserves original config'); + $GLOBALS['config_fault'] = ''; + weathermap_repair_maps(); + atomic_assert(file_get_contents($repairFile) === 'BACKGROUND images/backgrounds/' . basename($repairImage), 'repair uses complete atomic replacement'); + } finally { + $GLOBALS['config'] = $oldConfig; + unset($GLOBALS['__test_db_fetch_assoc']); + unlink($repairFile); unlink($repairImage); + } + $deletedMap = new \WeatherMap; $deletedMap->ReadConfig($file); + unlink($file); + atomic_assert(!$deletedMap->WriteConfig($file) && !file_exists($file), 'saving a previously loaded deleted map fails without resurrection'); + file_put_contents($file, $original); $GLOBALS['config_fault'] = 'rename'; $map = new \WeatherMap; $map->ReadConfig($file); $map->title = 'Changed'; atomic_assert(!$map->WriteConfig($file) && file_get_contents($file) === $original, 'engine failure preserves original'); @@ -196,6 +252,7 @@ function atomic_assert($condition, $message) { $GLOBALS['config_fault'] = ''; foreach (glob($dir . '/*') as $path) unlink($path); foreach (glob($dir . '/.wm-config-*') as $path) unlink($path); + if (is_file($dir . '/.weathermap-config.lock')) unlink($dir . '/.weathermap-config.lock'); rmdir($dir); } } diff --git a/tests/Support/GalleryPreviewRender.php b/tests/Support/GalleryPreviewRender.php index 467a2084..553e8a91 100644 --- a/tests/Support/GalleryPreviewRender.php +++ b/tests/Support/GalleryPreviewRender.php @@ -143,5 +143,6 @@ function weathermap_tabs($group) { unlink($file); } rmdir($directory . '/output'); + if (is_file($directory . '/.weathermap-config.lock')) unlink($directory . '/.weathermap-config.lock'); rmdir($directory); } diff --git a/tests/Support/NewMapPresetEngineRegression.php b/tests/Support/NewMapPresetEngineRegression.php index 6323b6e1..d738d431 100644 --- a/tests/Support/NewMapPresetEngineRegression.php +++ b/tests/Support/NewMapPresetEngineRegression.php @@ -86,5 +86,7 @@ function wm_engine_assert($value,$message) { } finally { foreach (glob($directory . '/*') as $file) { unlink($file); - }rmdir($directory); + } + if (is_file($directory . '/.weathermap-config.lock')) unlink($directory . '/.weathermap-config.lock'); + rmdir($directory); } diff --git a/tests/Support/NewMapTitleEngineRegression.php b/tests/Support/NewMapTitleEngineRegression.php index 636f6d7c..3410b656 100644 --- a/tests/Support/NewMapTitleEngineRegression.php +++ b/tests/Support/NewMapTitleEngineRegression.php @@ -112,5 +112,7 @@ function wm_engine_assert($value,$message) { } finally { foreach (glob($directory . '/*') as $file) { unlink($file); - }rmdir($directory); + } + if (is_file($directory . '/.weathermap-config.lock')) unlink($directory . '/.weathermap-config.lock'); + rmdir($directory); } diff --git a/tests/Support/SavedPresetFallbackRegression.php b/tests/Support/SavedPresetFallbackRegression.php index a0738740..993321d6 100644 --- a/tests/Support/SavedPresetFallbackRegression.php +++ b/tests/Support/SavedPresetFallbackRegression.php @@ -70,5 +70,6 @@ function clean_up_name($value) { foreach (glob($directory . '/*') as $file) { unlink($file); } + if (is_file($directory . '/.weathermap-config.lock')) unlink($directory . '/.weathermap-config.lock'); rmdir($directory); } diff --git a/tests/Unit/ConfigFileDeleteTest.php b/tests/Unit/ConfigFileDeleteTest.php index 57ab72b7..e4a2a249 100644 --- a/tests/Unit/ConfigFileDeleteTest.php +++ b/tests/Unit/ConfigFileDeleteTest.php @@ -54,7 +54,9 @@ foreach (glob($dir . '/*') as $p) { unlink($p); - }rmdir($dir); + } + if (is_file($dir . '/.weathermap-config.lock')) unlink($dir . '/.weathermap-config.lock'); + rmdir($dir); } }); it('ReportsADeletionFailureWithoutClaimingSuccess', function () { @@ -79,6 +81,7 @@ restore_error_handler(); chmod($dir,0700); unlink($dir . '/test.conf'); + if (is_file($dir . '/.weathermap-config.lock')) unlink($dir . '/.weathermap-config.lock'); rmdir($dir); unset($GLOBALS['__test_db_fetch_cell_prepared']); } @@ -120,6 +123,7 @@ foreach (glob($weathermap_confdir . '/*') as $path) { unlink($path); } + if (is_file($weathermap_confdir . '/.weathermap-config.lock')) unlink($weathermap_confdir . '/.weathermap-config.lock'); rmdir($weathermap_confdir); unset($GLOBALS['__test_nfilter_request']['file'], $GLOBALS['__test_raise_message'], $GLOBALS['__test_delete_message'], $GLOBALS['__test_db_fetch_cell_prepared']); @@ -153,7 +157,9 @@ unset($GLOBALS['__test_db_fetch_cell_prepared'], $GLOBALS['__test_db_fetch_assoc_prepared']); unlink($directory . '/Other.conf'); if (file_exists($directory . '/Map.conf')) { unlink($directory . '/Map.conf'); } + if (is_file($directory . '/sub' . '/.weathermap-config.lock')) unlink($directory . '/sub' . '/.weathermap-config.lock'); rmdir($directory . '/sub'); + if (is_file($directory . '/.weathermap-config.lock')) unlink($directory . '/.weathermap-config.lock'); rmdir($directory); } }); diff --git a/tests/Unit/ConfigRegistrationLockTest.php b/tests/Unit/ConfigRegistrationLockTest.php index 6c5b9464..7a15701a 100644 --- a/tests/Unit/ConfigRegistrationLockTest.php +++ b/tests/Unit/ConfigRegistrationLockTest.php @@ -101,6 +101,7 @@ function map_resort() {} $weathermap_confdir = $old_directory; unset($GLOBALS['__test_db_execute_prepared'], $GLOBALS['__test_db_fetch_cell_prepared']); foreach (glob($directory . '/*') as $entry) { unlink($entry); } + if (is_file($directory . '/.weathermap-config.lock')) unlink($directory . '/.weathermap-config.lock'); rmdir($directory); } }); diff --git a/tests/Unit/WeathermapUpgradeTest.php b/tests/Unit/WeathermapUpgradeTest.php index 6800e6ae..24ca2c17 100644 --- a/tests/Unit/WeathermapUpgradeTest.php +++ b/tests/Unit/WeathermapUpgradeTest.php @@ -21,6 +21,11 @@ * suite stubs. */ +// Only upgrade tests stub the filesystem repair; isolated engine tests load it. +if (!function_exists('weathermap_repair_maps')) { + function weathermap_repair_maps() {} +} + beforeAll(function () { require_once __DIR__ . '/../../setup.php'; }); diff --git a/tests/bootstrap-unit.php b/tests/bootstrap-unit.php index 5e4a2190..de6f6e41 100644 --- a/tests/bootstrap-unit.php +++ b/tests/bootstrap-unit.php @@ -67,6 +67,7 @@ function db_execute_prepared($sql, $params = []) { if (!function_exists('db_fetch_assoc')) { function db_fetch_assoc($sql) { + if (isset($GLOBALS['__test_db_fetch_assoc'])) return ($GLOBALS['__test_db_fetch_assoc'])($sql); return []; } } @@ -184,14 +185,6 @@ function test_set_current_page($page) { } } -// Real implementation lives in lib/poller-common.php, which is never included -// at runtime by the test suites (it pulls in WeatherMap.class.php); stub it so -// the upgrade-path test can drive plugin_weathermap_upgrade() safely. -if (!function_exists('weathermap_repair_maps')) { - function weathermap_repair_maps() { - } -} - if (!function_exists('isset_request_var')) { function isset_request_var($n) { return isset($GLOBALS['__test_request'][$n]); diff --git a/weathermap-cacti-plugin-mgmt.php b/weathermap-cacti-plugin-mgmt.php index 4d17ae26..81271715 100644 --- a/weathermap-cacti-plugin-mgmt.php +++ b/weathermap-cacti-plugin-mgmt.php @@ -1752,38 +1752,48 @@ function add_config($file) { print '' . __('Path mismatch', 'weathermap') . ''; } else { require_once __DIR__ . '/lib/editor.config-delete.php'; - $file = basename($file); - $realfile = $weathermap_confdir . '/' . $file; - $lock = wm_config_lock($realfile); - if ($lock === false) { + require_once __DIR__ . '/lib/WeatherMap.config.php'; + $directory_lock = \WeathermapConfig\acquire($weathermap_confdir, false); + if (!$directory_lock) { raise_message('path_missing', __esc('The configuration file is unavailable. Please retry.', 'weathermap'), MESSAGE_LEVEL_ERROR); return; } try { - $title = wmap_get_title($realfile); - - db_execute_prepared("INSERT INTO weathermap_maps - (configfile, titlecache, active, imagefile, htmlfile, filehash, config) - VALUES (?, ?, 'on', '', '', '', '')", - [$file, $title]); - - $last_id = db_fetch_insert_id(); - $myuid = (isset($_SESSION['sess_user_id']) ? intval($_SESSION['sess_user_id']) : 1); - - db_execute_prepared('INSERT INTO weathermap_auth - (mapid, userid) - VALUES (?, ?)', - [$last_id, $myuid]); - - db_execute_prepared('UPDATE weathermap_maps - SET filehash = LEFT(MD5(CONCAT(id, configfile, rand())), 20) - WHERE id = ?', - [$last_id]); - - map_resort(); + $file = basename($file); + $realfile = $weathermap_confdir . '/' . $file; + $lock = wm_config_lock($realfile); + if ($lock === false) { + raise_message('path_missing', __esc('The configuration file is unavailable. Please retry.', 'weathermap'), MESSAGE_LEVEL_ERROR); + return; + } + try { + $title = wmap_get_title($realfile); + + db_execute_prepared("INSERT INTO weathermap_maps + (configfile, titlecache, active, imagefile, htmlfile, filehash, config) + VALUES (?, ?, 'on', '', '', '', '')", + [$file, $title]); + + $last_id = db_fetch_insert_id(); + $myuid = (isset($_SESSION['sess_user_id']) ? intval($_SESSION['sess_user_id']) : 1); + + db_execute_prepared('INSERT INTO weathermap_auth + (mapid, userid) + VALUES (?, ?)', + [$last_id, $myuid]); + + db_execute_prepared('UPDATE weathermap_maps + SET filehash = LEFT(MD5(CONCAT(id, configfile, rand())), 20) + WHERE id = ?', + [$last_id]); + + map_resort(); + } finally { + flock($lock, LOCK_UN); + fclose($lock); + } } finally { - flock($lock, LOCK_UN); - fclose($lock); + \WeathermapConfig\release($directory_lock); } } } From 17a85466d6f4818da386a641b6bf7b3782818d3a Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:25:40 +1030 Subject: [PATCH 13/41] Return a retry when configuration deletion encounters a busy directory --- lib/editor.config-delete.php | 2 +- tests/Support/ConfigWriteAtomicRegression.php | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/editor.config-delete.php b/lib/editor.config-delete.php index 9b3b529a..9d445604 100644 --- a/lib/editor.config-delete.php +++ b/lib/editor.config-delete.php @@ -91,7 +91,7 @@ function wm_config_delete($directory, $file) { return 'invalid'; } require_once __DIR__ . '/WeatherMap.config.php'; - $directory_lock = \WeathermapConfig\acquire($directory); + $directory_lock = \WeathermapConfig\acquire($directory, false); if (!$directory_lock) return 'failed'; try { $path = $directory . '/' . $file; diff --git a/tests/Support/ConfigWriteAtomicRegression.php b/tests/Support/ConfigWriteAtomicRegression.php index 75b7989b..0fe0d0e2 100644 --- a/tests/Support/ConfigWriteAtomicRegression.php +++ b/tests/Support/ConfigWriteAtomicRegression.php @@ -176,7 +176,7 @@ function atomic_assert($condition, $message) { pcntl_waitpid($child, $status); \WeathermapConfig\release($lock); atomic_assert(pcntl_wexitstatus($status) === 0, 'shared lock excludes concurrent configuration writers'); } - // Deletion waits for the same stable directory mutex used by atomic replacement. + // Deletion refuses a busy stable directory mutex rather than blocking the UI. if (function_exists('pcntl_fork')) { require_once dirname(__DIR__, 2) . '/lib/editor.config-delete.php'; $GLOBALS['__test_db_fetch_cell_prepared'] = fn () => 0; @@ -189,14 +189,14 @@ function atomic_assert($condition, $message) { file_put_contents($ready, 'ready'); $result = wm_config_delete($dir, 'map.conf'); file_put_contents($done, $result); - exit($result === 'deleted' ? 0 : 1); + exit($result === 'failed' ? 0 : 1); } - for ($wait = 0; $wait < 100 && !is_file($ready); $wait++) usleep(10000); - usleep(50000); - $blocked = !is_file($done) && is_file($file); + for ($wait = 0; $wait < 100 && !is_file($done); $wait++) usleep(10000); + $blocked = is_file($done) && file_get_contents($done) === 'failed' && is_file($file); \WeathermapConfig\release($lock); pcntl_waitpid($child, $status); atomic_assert($blocked && pcntl_wexitstatus($status) === 0, 'config deletion cannot unlink during a directory-locked write'); + atomic_assert(wm_config_delete($dir, 'map.conf') === 'deleted', 'deletion succeeds after the writer releases its lock'); unlink($ready); unlink($done); unset($GLOBALS['__test_db_fetch_cell_prepared'], $GLOBALS['__test_db_fetch_assoc_prepared']); file_put_contents($file, $original); From 04a44cd1a5366c3b350fc9ae7ed19c583a420e9b Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:06:27 +1030 Subject: [PATCH 14/41] Surface editor save failures and isolate upgrade regression stubs --- CHANGELOG.md | 2 + js/editor.js | 1 + lib/editor.actions.php | 138 ++++++++++++------ lib/editor.inc.php | 1 + locales/po/cacti.pot | 6 +- tests/Browser/EditorSaveFailureTest.cjs | 6 + tests/Support/ConfigWriteAtomicRegression.php | 10 +- tests/Unit/WeathermapUpgradeTest.php | 115 +++++++-------- weathermap-cacti-plugin-editor.php | 2 +- 9 files changed, 171 insertions(+), 110 deletions(-) create mode 100644 tests/Browser/EditorSaveFailureTest.cjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 90352cc0..2e1a8a63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ IMPORTANT NOTE: This version only works on CACTI 1.x++! --- develop --- +* Make configuration saves and repairs atomic; coordinate writes, registration and deletion with destination-directory locks and report failed editor saves + * Add separate icon and background image upload and management libraries * Reduce gallery preview cards by 20% while preserving generated thumbnail resolution * Add a searchable node icon picker and bundled enterprise site icons diff --git a/js/editor.js b/js/editor.js index 41ccd72d..6e5a9bbc 100644 --- a/js/editor.js +++ b/js/editor.js @@ -878,6 +878,7 @@ function form_submit() { type: 'POST', url: editor_url, data: data, + error: function() { window.alert(wmEditorText.saveError); }, success: function(html) { hide_all_dialogs(); diff --git a/lib/editor.actions.php b/lib/editor.actions.php index aab93135..9c89ff5c 100644 --- a/lib/editor.actions.php +++ b/lib/editor.actions.php @@ -48,6 +48,40 @@ * @param mixed $mapfile */ +/** + * Read an existing editor map and reject stale or unreadable targets. + * + * @param WeatherMap $map Engine receiving the configuration. + * @param string $mapfile Configuration path. + * @return bool Whether the map was loaded. + */ +function wm_editor_read_config($map, $mapfile) { + if (!is_file($mapfile) || !is_readable($mapfile)) { + http_response_code(404); + return false; + } + if (!$map->ReadConfig($mapfile)) { + http_response_code(422); + return false; + } + return true; +} + +/** + * Propagate failed editor configuration saves to the AJAX caller. + * + * @param WeatherMap $map Engine containing the edited configuration. + * @param string $mapfile Configuration path. + * @return bool Whether the complete configuration was saved. + */ +function wm_editor_write_config($map, $mapfile) { + if (!$map->WriteConfig($mapfile)) { + http_response_code(500); + return false; + } + return true; +} + /** * @return void * @param mixed $mapfile @@ -59,7 +93,7 @@ function newMap($mapfile) { require_once __DIR__ . '/editor.new-map-preset.php'; wm_new_map_preset($map); - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } /** @@ -86,7 +120,7 @@ function newMapCopy($mapfile) { if (file_exists($sourcemap) && is_readable($sourcemap)) { $map->ReadConfig($sourcemap); - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } } } @@ -100,7 +134,7 @@ function getMapJavaScript($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; print $map->asJS(); } @@ -114,7 +148,7 @@ function getMapAreaData($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; // we need to draw and throw away a map, to get the // dimensions for the imagemap. @@ -141,7 +175,7 @@ function drawMap($mapfile, $selected, $use_overlay, $use_relative_overlay) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; if ($selected != '') { if (substr($selected, 0, 5) == 'NODE:') { @@ -186,7 +220,7 @@ function fetchConfig($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; header('Content-type: text/plain'); @@ -223,7 +257,7 @@ function setNodeConfig($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $node_name = get_nfilter_request_var('node_name'); $node_config = get_nfilter_request_var('item_configtext'); @@ -231,7 +265,7 @@ function setNodeConfig($mapfile) { if (isset($map->nodes[$node_name])) { $map->nodes[$node_name]->config_override = $node_config; - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); // now clear and reload the map object, because the in-memory one is out of sync // - we don't know what changes the user made here, so we just have to reload. @@ -241,7 +275,7 @@ function setNodeConfig($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; } } @@ -254,7 +288,7 @@ function setLinkConfig($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $link_name = get_nfilter_request_var('link_name'); $link_config = get_nfilter_request_var('item_configtext'); @@ -262,7 +296,7 @@ function setLinkConfig($mapfile) { if (isset($map->links[$link_name])) { $map->links[$link_name]->config_override = $link_config; - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); // now clear and reload the map object, because the in-memory one is out of sync // - we don't know what changes the user made here, so we just have to reload. @@ -272,7 +306,7 @@ function setLinkConfig($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; } } @@ -285,7 +319,7 @@ function setNodeProperties($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $node_name = get_nfilter_request_var('node_name'); $new_node_name = get_nfilter_request_var('node_new_name'); @@ -362,7 +396,7 @@ function setNodeProperties($mapfile) { $map->nodes[$new_node_name]->iconfile = $iconfile; } - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } /** @@ -374,7 +408,7 @@ function setLinkProperties($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $link_name = get_nfilter_request_var('link_name'); @@ -442,7 +476,7 @@ function setLinkProperties($mapfile) { // $map->links[$link_name]->SetBandwidth($bwin,$bwout); - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } } @@ -455,7 +489,7 @@ function setMapProperties($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $map->title = wm_editor_sanitize_string(get_nfilter_request_var('map_title')); $map->keytext['DEFAULT'] = wm_editor_sanitize_string(get_nfilter_request_var('map_legend')); @@ -528,7 +562,7 @@ function setMapProperties($mapfile) { } } - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } /** @@ -540,7 +574,7 @@ function setMapStyle($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; if (wm_editor_validate_one_of(get_nfilter_request_var('mapstyle_htmlstyle'), ['static', 'overlib'], false)) { $map->htmlstyle = strtolower(get_nfilter_request_var('mapstyle_htmlstyle')); @@ -565,7 +599,7 @@ function setMapStyle($mapfile) { wm_comment_update_default($map, 'bwfont', wm_comment_resolve_font($map, get_nfilter_request_var('mapstyle_linkfont'))); wm_comment_apply_style($map, get_nfilter_request_var('mapstyle_commentfont'), get_nfilter_request_var('mapstyle_commentcolour')); - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } /** @@ -577,7 +611,7 @@ function addLink($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $a = get_nfilter_request_var('param'); $b = get_nfilter_request_var('param2'); @@ -609,7 +643,7 @@ function addLink($mapfile) { $map->links[$newlinkname] = $newlink; array_push($map->seen_zlayers[$newlink->zorder], $newlink); - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } } @@ -628,12 +662,12 @@ function placeLegend($mapfile, $grid_snap_value) { $scalename = wm_editor_sanitize_name(get_nfilter_request_var('param')); - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $map->keyx[$scalename] = $x; $map->keyy[$scalename] = $y; - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } /** @@ -649,12 +683,12 @@ function placeStamp($mapfile, $grid_snap_value) { $x = snap(intval(get_nfilter_request_var('x')), $grid_snap_value); $y = snap(intval(get_nfilter_request_var('y')), $grid_snap_value); - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $map->timex = $x; $map->timey = $y; - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } /** @@ -671,11 +705,11 @@ function viaLink($mapfile) { $link_name = wm_editor_sanitize_name(get_nfilter_request_var('link_name')); - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; if (isset($map->links[$link_name])) { $map->links[$link_name]->vialist = [[0 =>$x, 1=>$y]]; - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } } @@ -694,8 +728,12 @@ function moveNode($mapfile, $grid_snap_value) { $node_name = wm_editor_sanitize_name(get_nfilter_request_var('node_name')); - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; + if (!isset($map->nodes[$node_name])) { + http_response_code(404); + return; + } if (isset($map->nodes[$node_name])) { // This is a complicated bit. Find out if this node is involved in any // links that have VIAs. If it is, we want to rotate those VIA points @@ -788,7 +826,7 @@ function moveNode($mapfile, $grid_snap_value) { $map->nodes[$node_name]->x = $x; $map->nodes[$node_name]->y = $y; - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } } @@ -801,7 +839,7 @@ function linkTidy($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $target = wm_editor_sanitize_name(get_nfilter_request_var('param')); @@ -811,7 +849,7 @@ function linkTidy($mapfile) { tidy_link($map, $target); - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } } @@ -824,13 +862,13 @@ function reTidy($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; // draw a map and throw it away, to calculate all the bounding boxes $map->DrawMap('null'); retidy_links($map); - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } /** @@ -842,13 +880,13 @@ function reTidyAll($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; // draw a map and throw it away, to calculate all the bounding boxes $map->DrawMap('null'); retidy_links($map,true); - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } /** @@ -860,13 +898,13 @@ function unTidy($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; // draw a map and throw it away, to calculate all the bounding boxes $map->DrawMap('null'); untidy_links($map); - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } /** @@ -878,7 +916,7 @@ function deleteLink($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $target = wm_editor_sanitize_name(get_nfilter_request_var('param')); $log = 'delete link ' . $target; @@ -886,7 +924,7 @@ function deleteLink($mapfile) { if (isset($map->links[$target])) { unset($map->links[$target]); - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } } @@ -903,7 +941,7 @@ function addNode($mapfile, $grid_snap_value) { $x = snap(intval(get_nfilter_request_var('x')), $grid_snap_value); $y = snap(intval(get_nfilter_request_var('y')), $grid_snap_value); - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $newnodename = sprintf('node%05d', time() % 10000); @@ -932,7 +970,7 @@ function addNode($mapfile, $grid_snap_value) { $map->nodes[$node->name] = $node; $log = "added a node called $newnodename at $x,$y to $mapfile"; - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } /** @@ -947,7 +985,7 @@ function editorSettings($mapfile) { $map->context = 'editor'; // have to do this, otherwise the editor will be unresponsive afterwards - not actually going to change anything! - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $use_overlay = (isset_request_var('editorsettings_showvias') ? intval(get_nfilter_request_var('editorsettings_showvias')) : false); $use_relative_overlay = (isset_request_var('editorsettings_showrelative') ? intval(get_nfilter_request_var('editorsettings_showrelative')) : false); @@ -963,10 +1001,14 @@ function deleteNode($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $target = wm_editor_sanitize_name(get_nfilter_request_var('param')); + if (!isset($map->nodes[$target])) { + http_response_code(404); + return; + } if (isset($map->nodes[$target])) { $log = 'delete node ' . $target; @@ -980,7 +1022,7 @@ function deleteNode($mapfile) { unset($map->nodes[$target]); - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } } @@ -993,7 +1035,7 @@ function cloneNode($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; $target = wm_editor_sanitize_name(get_nfilter_request_var('param')); @@ -1024,7 +1066,7 @@ function cloneNode($mapfile) { array_push($map->seen_zlayers[$node->zorder], $node); - $map->WriteConfig($mapfile); + wm_editor_write_config($map, $mapfile); } } @@ -1037,7 +1079,7 @@ function displayFontSamples($mapfile) { $map->context = 'editor'; - $map->ReadConfig($mapfile); + if (!wm_editor_read_config($map, $mapfile)) return; ksort($map->fonts); header('Content-type: image/png'); diff --git a/lib/editor.inc.php b/lib/editor.inc.php index e314229a..19a0cb6a 100644 --- a/lib/editor.inc.php +++ b/lib/editor.inc.php @@ -1036,6 +1036,7 @@ function getEditorJs() { 'iconMatchCount' => __('Matching icons: %s', '%s', 'weathermap'), 'iconNoMatches' => __('No matching icons', 'weathermap'), + 'saveError' => __('Unable to save changes. Please try again.', 'weathermap'), 'currentInterface' => __('Current interface', 'weathermap'), 'advanced' => __('Advanced settings', 'weathermap'), 'hideAdvanced' => __('Hide advanced settings', 'weathermap'), diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index c13cbea6..d326380e 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti 1.7\n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-12 12:29+1030\n" +"POT-Creation-Date: 2026-10-12 13:03+1030\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" @@ -330,6 +330,10 @@ msgstr "" msgid "No matching icons" msgstr "" +#: lib/editor.inc.php +msgid "Unable to save changes. Please try again." +msgstr "" + #: lib/editor.inc.php msgid "Current interface" msgstr "" diff --git a/tests/Browser/EditorSaveFailureTest.cjs b/tests/Browser/EditorSaveFailureTest.cjs new file mode 100644 index 00000000..447a1c29 --- /dev/null +++ b/tests/Browser/EditorSaveFailureTest.cjs @@ -0,0 +1,6 @@ +const {JSDOM}=require('jsdom'),jquery=require('jquery'),fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict'); +const dom=new JSDOM('',{runScripts:'outside-only'}),w=dom.window,$=jquery(w);w.$=w.jQuery=$;w.editor_url='/editor';w.wmEditorText={saveError:'Unable to save changes'}; +let request,message,closed=0,refreshed=0;$.ajax=opts=>request=opts;w.alert=text=>message=text;w.hide_all_dialogs=()=>closed++;$.get=()=>refreshed++; +const source=fs.readFileSync(path.join(__dirname,'../../js/editor.js'),'utf8');w.eval(source.slice(source.indexOf('function form_submit()'),source.indexOf('function map_properties()'))); +w.form_submit();request.error();assert.equal(message,'Unable to save changes');assert.equal(closed,0);assert.equal(refreshed,0);assert.equal($('#action').val(),'set_map_properties'); +w.close();console.log('PASS: save failure reports retry and preserves the form'); diff --git a/tests/Support/ConfigWriteAtomicRegression.php b/tests/Support/ConfigWriteAtomicRegression.php index 0fe0d0e2..df55f377 100644 --- a/tests/Support/ConfigWriteAtomicRegression.php +++ b/tests/Support/ConfigWriteAtomicRegression.php @@ -241,12 +241,20 @@ function atomic_assert($condition, $message) { $map = new \WeatherMap; $map->ReadConfig($file); $map->title = 'Changed'; atomic_assert(!$map->WriteConfig($file) && file_get_contents($file) === $original, 'engine failure preserves original'); $GLOBALS['__test_nfilter_request'] = ['node_name' => 'note1', 'param' => 'note1', 'x' => '70', 'y' => '80']; - if (function_exists('wm_editor_save_note')) { + { http_response_code(200); moveNode($file, 0); atomic_assert(http_response_code() === 500 && file_get_contents($file) === $original, 'failed move surfaces error without changing file'); http_response_code(200); deleteNode($file); atomic_assert(http_response_code() === 500 && file_get_contents($file) === $original, 'failed delete surfaces error without changing file'); } + $GLOBALS['config_fault'] = ''; + $GLOBALS['__test_nfilter_request'] = ['node_name' => 'missing', 'param' => 'missing', 'x' => '70', 'y' => '80']; + http_response_code(200); moveNode($file, 0); + atomic_assert(http_response_code() === 404 && file_get_contents($file) === $original, 'missing move target is an error'); + http_response_code(200); deleteNode($file); + atomic_assert(http_response_code() === 404 && file_get_contents($file) === $original, 'missing delete target is an error'); + unlink($file); http_response_code(200); moveNode($file, 0); + atomic_assert(http_response_code() === 404 && !file_exists($file), 'deleted map cannot report a successful edit'); echo "Atomic configuration write and locking regression PASS\n"; } finally { $GLOBALS['config_fault'] = ''; diff --git a/tests/Unit/WeathermapUpgradeTest.php b/tests/Unit/WeathermapUpgradeTest.php index 24ca2c17..9a3bdad8 100644 --- a/tests/Unit/WeathermapUpgradeTest.php +++ b/tests/Unit/WeathermapUpgradeTest.php @@ -5,66 +5,63 @@ +-------------------------------------------------------------------------+ */ -/* - * Unit coverage for plugin_weathermap_upgrade() in setup.php: the - * page-guard. - * - * The rest of the function (reached on any allowed page) is intentionally - * NOT covered here: it include_once()s lib/poller-common.php, which itself - * include_once()s lib/WeatherMap.class.php - a file this plugin's own - * other test suites (e.g. tests/Handoff) already load directly by a - * different path, and loading it a second time via a different resolved - * path fatals with "Cannot declare class WeatherMapDataSource, because - * the name is already in use". The version-drift branch also calls - * weathermap_repair_maps(), which does a real chdir() and reads/writes - * map config files on disk - a much larger and riskier surface than this - * suite stubs. - */ - -// Only upgrade tests stub the filesystem repair; isolated engine tests load it. -if (!function_exists('weathermap_repair_maps')) { - function weathermap_repair_maps() {} -} - -beforeAll(function () { - require_once __DIR__ . '/../../setup.php'; -}); - -beforeEach(function () { - $GLOBALS['__test_db_calls'] = []; - $GLOBALS['__test_current_page'] = ''; -}); +use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; +use PHPUnit\Framework\Attributes\PreserveGlobalState; +use PHPUnit\Framework\TestCase; -it('does nothing on a page that does not need the version check', function () { - test_set_current_page('graphs.php'); +/** Exercise upgrade dispatch without sharing production-function stubs. */ +#[RunTestsInSeparateProcesses] +#[PreserveGlobalState(false)] +final class WeathermapUpgradeTest extends TestCase { + /** + * Install the upgrade-only repair stub in this isolated process. + * + * @return void + */ + protected function setUp(): void { + require_once __DIR__ . '/../../setup.php'; + if (!function_exists('weathermap_repair_maps')) { + function weathermap_repair_maps() {} + } + $GLOBALS['__test_db_calls'] = []; + $GLOBALS['__test_current_page'] = ''; + } - plugin_weathermap_upgrade(); + /** + * Verify unrelated pages do not trigger upgrade queries. + * + * @return void + */ + public function testUnrelatedPageSkipsUpgrade(): void { + test_set_current_page('graphs.php'); + plugin_weathermap_upgrade(); + self::assertEmpty($GLOBALS['__test_db_calls']); + } - expect($GLOBALS['__test_db_calls'])->toBeEmpty(); -}); - -it('runs the realm/version updates and prune on a version drift', function () { -test_set_current_page('plugins.php'); - -// Sandbox base_path with an empty poller-common stub + minimal INFO so the -// upgrade-time prune runs against a throwaway tree, never the real checkout, -// and the heavy lib include is a no-op. -$restore = $GLOBALS['config']['base_path']; -$base = sys_get_temp_dir() . '/weathermap-upg-' . uniqid(); -mkdir($base . '/plugins/weathermap/lib', 0777, true); -file_put_contents($base . '/plugins/weathermap/lib/poller-common.php', "not->toBeEmpty(); -}); diff --git a/weathermap-cacti-plugin-editor.php b/weathermap-cacti-plugin-editor.php index 8196770b..46efd3c8 100644 --- a/weathermap-cacti-plugin-editor.php +++ b/weathermap-cacti-plugin-editor.php @@ -873,7 +873,7 @@ '>