diff --git a/CHANGELOG.md b/CHANGELOG.md index 67cb79e2..eae61f01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ IMPORTANT NOTE: This version only works on CACTI 1.x++! * Fix Safari rendering of high-traffic link glows using native SVG filters * 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 * Add editable map notes with alignment and sharp text at logical map dimensions * Fix editor placement reset, destructive action controls and theme contrast * Reduce gallery preview cards by 20% while preserving generated thumbnail resolution diff --git a/css/editor.css b/css/editor.css index 9f85de23..15c23ebd 100644 --- a/css/editor.css +++ b/css/editor.css @@ -346,6 +346,19 @@ body#mainView .ui-dialog textarea.ui-state-default { color: var(--text-color-lighter, #333) !important; } +.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; } + +#dlgImages > .dlgBody { display: block; width: 100%; box-sizing: border-box; } +#dlgImages .wm-editor-section { float: none; } + /* Colour and image libraries. */ #wm-scale-rows input { width: 85px; box-sizing: border-box; } #wm-scale-rows .wm-range-tag { width: 110px; } @@ -362,9 +375,6 @@ body#mainView .ui-dialog textarea.ui-state-default { .wm-traffic-swatch { width: 55px; height: 22px; border: 1px solid #777; } #wm-traffic-advanced > summary { cursor: pointer; margin: 14px 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-colours-error:not(:empty) { color: inherit; border-left: 3px solid #c62828; padding-left: 8px; font-weight: bold; } #dlgColours > .dlgBody { display: block; } diff --git a/js/editor.js b/js/editor.js index 8ffed979..8a49f886 100644 --- a/js/editor.js +++ b/js/editor.js @@ -740,6 +740,7 @@ function manage_images() { $('#action').val('set_image'); show_dialog('dlgImages'); + wmOpenImages(); } function prefs() { @@ -1328,7 +1329,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, @@ -1833,6 +1834,69 @@ function wmFriendlyLinkNames() { function wmDecodeEditorUrl(value) { return String(value || '').replace(/&/g, '&'); } +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); }}); +} +function wmAssetImages(images) { + $('#wm-images-objects, #wm-images-backgrounds').empty(); + images.forEach(function(item) { + var card = $('
').text(wmEditorText.imageText1)); + }); + // Rebuild the existing image selectors so uploads are available without reloading the editor. + [{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); } + 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}}); +} +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(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 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, + 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); }}); + }); +} + var wmAssetPalette = {Black: '#000000', White: '#ffffff', Blue: '#0000ff', Red: '#cc0000', Green: '#008000', Purple: '#800080', Orange: '#b35a00', Grey: '#666666', None: 'none'}; function wmAssetColour(value, allowEmpty, swatches, solid, label) { var box = $(''), select = $('').attr('aria-label', label).appendTo(box); diff --git a/lib/WeatherMap.class.php b/lib/WeatherMap.class.php index 2a890ca9..b7e5c672 100644 --- a/lib/WeatherMap.class.php +++ b/lib/WeatherMap.class.php @@ -347,6 +347,8 @@ class WeatherMap extends WeatherMapBase { var $included_scale_names = []; /** @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; /** @var mixed */ var $imagefile; @@ -2451,6 +2453,45 @@ function DrawTitle($image, $font, $colour) { $this->imap->addArea('Rectangle', 'TITLE', '', [$x, $y, $x + $boxwidth, $y - $boxheight]); } + /** + * 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('/^[ \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) { + $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); + 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)); + } + /** * @param mixed $input * @param bool $is_include @@ -3575,6 +3616,7 @@ function ReadConfig($input, $is_include = false) { wm_debug('Finished Pre-Processing Plugins...'); + if (!$is_include) $this->config_managed_assets = $this->ManagedImagePaths(); return (true); } @@ -3903,6 +3945,20 @@ function WriteConfig($filename, $createOnly = false, $expectedRevision = null, $ $contents = stream_get_contents($fd); $closed = fclose($fd); + $this->config_asset_conflict = false; + $savedAssets = $contents !== false ? $this->ManagedImagePaths($contents) : []; + $sameDestination = \WeathermapConfig\sameFile($filename, $this->configfile); + $newAssets = ($createOnly || !$sameDestination) ? array_unique(array_merge($this->ManagedImagePaths(), $savedAssets)) : array_diff($savedAssets, $this->config_managed_assets); + $validate = function () use ($newAssets) { + foreach ($newAssets as $asset) { + if (preg_match('/\{[^}]+\}/', $asset)) continue; + if (!is_file(dirname(__DIR__) . '/' . $asset)) { + $this->config_asset_conflict = true; + return false; + } + } + return true; + }; $sameFile = \WeathermapConfig\sameFile($filename, $this->configfile); $dependencies = $this->config_dependencies; foreach ($this->config_root_identity as $logical => $state) { @@ -3915,7 +3971,8 @@ function WriteConfig($filename, $createOnly = false, $expectedRevision = null, $ 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); + $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); diff --git a/lib/editor.actions.php b/lib/editor.actions.php index dff28389..a315941a 100644 --- a/lib/editor.actions.php +++ b/lib/editor.actions.php @@ -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 = !\WeathermapConfig\dependenciesMatch(array_merge($map->config_dependencies, $map->config_root_identity)) || $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; } @@ -1200,6 +1200,70 @@ 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 { + 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') { + $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($references)]; + } else { + $name = get_nfilter_request_var('image_name'); + $path = wm_asset_image_path(get_nfilter_request_var('image_kind'), $name); + 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_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($error instanceof InvalidArgumentException ? 400 : 500); + echo json_encode(['error' => $error->getMessage()]); + } +} + /** * Read or save editor colour settings and emit a JSON response. * diff --git a/lib/editor.assets.php b/lib/editor.assets.php new file mode 100644 index 00000000..b8146c7c --- /dev/null +++ b/lib/editor.assets.php @@ -0,0 +1,278 @@ +isFile() && strtolower($file->getExtension()) === 'conf') $queue[] = $file->getPathname(); + $seen = []; + while ($queue) { + $path = array_pop($queue); + $real = realpath($path); + 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; + $seen[$real] = true; + 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('/^[ \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); + // 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('/^[ \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; + // INCLUDE is a literal filename in the renderer, including glob metacharacters. + if (file_exists($include) || is_link($include)) $queue[] = $include; + } + } + 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 ($directory === false) throw new RuntimeException(__('Could not lock map configurations.', 'weathermap')); + if (isset($covered[$directory])) continue; + $lock = \WeathermapConfig\acquire($directory, false); + 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; + } +} + +/** + * 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 = []; + $root = dirname(__DIR__); + foreach ($references as $reference) { + foreach ($kind === null ? ['objects', 'backgrounds'] : [$kind] as $library) { + if (preg_match($reference['pattern'], wm_asset_normalize_path('images/' . $library . '/' . $name, $root))) { + $users[] = $reference['user']; + break; + } + } + } + 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(); + 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); + 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]; + } + } + 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')); + 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.', 'weathermap')); + [$extension, $decode, $encode] = $formats[$size[2]]; + $image = @$decode($source); + 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(16)) . '.' . $extension; + $path = wm_asset_image_path($kind, $name); + $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 { + // 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); + $closed = fclose($handle); + $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); + unlink($temporary); + } + return $name; +} diff --git a/lib/editor.inc.php b/lib/editor.inc.php index 206aefac..ed154775 100644 --- a/lib/editor.inc.php +++ b/lib/editor.inc.php @@ -338,7 +338,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; } } @@ -646,7 +646,9 @@ function get_imagelist($imagedir) { $realfile = $imdir . '/' . $file; $uri = "images/$imagedir/$file"; - if (is_readable($realfile) && (preg_match('/\.(gif|jpg|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; $n++; } @@ -1013,6 +1015,24 @@ 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' => __('Images: %s', '%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'), + 'colourText0' => __('No gradient', 'weathermap'), 'colourText1' => __('Custom colour', 'weathermap'), 'colourText2' => __('−', 'weathermap'), @@ -1041,8 +1061,6 @@ 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'), 'hideAdvanced' => __('Hide advanced settings', 'weathermap'), diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 511a205d..18edb16a 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 09:32+1030\n" +"POT-Creation-Date: 2026-10-15 10:19+1030\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" @@ -18,20 +18,33 @@ 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 weathermap-cacti-plugin-mgmt.php -msgid "Could not lock map configurations." +#: 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.inc.php -msgid "This map has changed since you opened it. Reload the editor before saving your changes." +#: lib/editor.actions.php lib/editor.assets.php +#: weathermap-cacti-plugin-mgmt.php +msgid "Could not lock map configurations." +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.actions.php @@ -42,6 +55,46 @@ msgstr "" msgid "Could not save the map configuration." msgstr "" +#: lib/editor.assets.php +msgid "Invalid image category." +msgstr "" + +#: lib/editor.assets.php +msgid "Symbolic links cannot be managed here." +msgstr "" + +#: lib/editor.assets.php +msgid "Invalid image filename." +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.colours.php msgid "Choose a valid colour." msgstr "" @@ -262,6 +315,26 @@ 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 "" @@ -271,27 +344,48 @@ msgid "Custom colour" msgstr "" #: lib/editor.inc.php -msgid "−" +msgid "Remove range" msgstr "" #: lib/editor.inc.php -msgid "No DEFAULT ranges. See Advanced settings for named scales." +#, php-format +msgid "Delete %s" msgstr "" #: lib/editor.inc.php -msgid "The request failed. Please try again." +#, php-format +msgid "Delete image \"%s\" from the server?" msgstr "" #: lib/editor.inc.php -msgid "Upload failed." +#, php-format +msgid "Images: %s" msgstr "" #: lib/editor.inc.php -msgid "Choose a PNG, JPG or GIF file up to 5 MB." +#, php-format +msgid "Uploaded %s" msgstr "" #: lib/editor.inc.php -msgid "Remove range" +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 "No DEFAULT ranges. See Advanced settings for named scales." msgstr "" #: lib/editor.inc.php @@ -1664,6 +1758,50 @@ msgstr "" msgid "Scale ranges use the units configured for each link or node; traffic links normally use percentages." 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-editor.php msgid "Edit Object Configuration" msgstr "" diff --git a/tests/Browser/EditorImagesTest.cjs b/tests/Browser/EditorImagesTest.cjs new file mode 100644 index 00000000..5df0d916 --- /dev/null +++ b/tests/Browser/EditorImagesTest.cjs @@ -0,0 +1,36 @@ +// 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';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'; +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); +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');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');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('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/ConfigWriteAtomicRegression.php b/tests/Support/ConfigWriteAtomicRegression.php index 2175217b..0267bda2 100644 --- a/tests/Support/ConfigWriteAtomicRegression.php +++ b/tests/Support/ConfigWriteAtomicRegression.php @@ -305,6 +305,15 @@ function atomic_assert($condition, $message) { 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); + $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); + $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'); @@ -328,6 +337,37 @@ function atomic_assert($condition, $message) { atomic_assert($aliasMap->WriteConfig($includeRoot), 'symlink include save succeeds after dependency 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); 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); 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'] = []; + + $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); + + $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); + 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); 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) { @@ -597,6 +637,14 @@ 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\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'); } 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/EditorImageFormatsRegression.php b/tests/Support/EditorImageFormatsRegression.php new file mode 100644 index 00000000..fcbbf55e --- /dev/null +++ b/tests/Support/EditorImageFormatsRegression.php @@ -0,0 +1,357 @@ +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; +} +/** + * 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); +$created = []; +$config = $root . '/configs/image-formats-test-' . bin2hex(random_bytes(4)) . '.conf'; +try { + foreach (['png' => 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(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'); + } + } + $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, "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, "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'); + 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, "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, "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, "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'); + 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'); + $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(); + 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 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 { + foreach (['objects', 'backgrounds'] as $kind) { + try { wm_asset_directory($kind, $fixtureRoot); throw new RuntimeException('accepted symlinked library parent'); } catch (InvalidArgumentException $expected) {} + } + } 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); + 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']; + $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']); + 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); + 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(); 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, "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(); 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(); 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(); 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'); + 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. + $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'); + } + } + // 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'); + $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'); + } + // 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); + if (is_file($config)) unlink($config); + $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); +} diff --git a/tests/Support/MapDuplicatePayload.php b/tests/Support/MapDuplicatePayload.php index 2ff1fa05..2aaf465a 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('); @@ -14,7 +17,7 @@ if ($start === false || $end === false) { throw new RuntimeException('Unable to locate the map duplication function.'); } -$fixture = sys_get_temp_dir() . '/wm-duplicate-' . bin2hex(random_bytes(5)); +$fixture = realpath(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"); @@ -55,6 +58,15 @@ function cacti_sizeof($value) { return count($value); } +/** + * Count effective scale entries for the combined engine fixture. + * + * @param array $value Values to count. + * + * @return int Number of entries. + */ +function cacti_count($value) { return count($value); } + /** * Supply a destination filename without touching the filesystem. * diff --git a/tests/Unit/EditorImageFormatsTest.php b/tests/Unit/EditorImageFormatsTest.php new file mode 100644 index 00000000..40a089bf --- /dev/null +++ b/tests/Unit/EditorImageFormatsTest.php @@ -0,0 +1,49 @@ + - - - - 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. - + '> + + + + + '> + + + '> + + diff --git a/weathermap-cacti-plugin-mgmt.php b/weathermap-cacti-plugin-mgmt.php index 34e4f1b7..b47ac718 100644 --- a/weathermap-cacti-plugin-mgmt.php +++ b/weathermap-cacti-plugin-mgmt.php @@ -2015,8 +2015,24 @@ 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; + 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); - if (!\WeathermapConfig\atomicWrite($newfile, $contents, false, null, true, $lock)) throw new RuntimeException(__('Could not save the map configuration.', 'weathermap')); + $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, $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'));
Nothing in here works yet.