diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bcf087..72c601e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ IMPORTANT NOTE: This version only works on CACTI 1.x++! * feature#298: Fit or stretch background images within the map canvas +* feature#297: Allow setting a map title when creating configuration files + * feature#295: Use a readable landscape preset for new blank maps * feature#294: Expose bundled fonts and link comment colours in Map Style diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 9a61683..7bf44e0 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -1114,7 +1114,7 @@ msgstr "" msgid "Map Properties" msgstr "" -#: weathermap-cacti-plugin-editor.php +#: weathermap-cacti-plugin-editor.php weathermap-cacti-plugin-mgmt.php msgid "Map Title" msgstr "" @@ -1621,6 +1621,10 @@ msgstr "" msgid "Name including .conf" msgstr "" +#: weathermap-cacti-plugin-mgmt.php +msgid "Optional map title" +msgstr "" + #: weathermap-cacti-plugin-mgmt.php msgid "Source Map" msgstr "" @@ -2120,3 +2124,7 @@ 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/Support/NewMapTitleEngineRegression.php b/tests/Support/NewMapTitleEngineRegression.php new file mode 100644 index 0000000..636f6d7 --- /dev/null +++ b/tests/Support/NewMapTitleEngineRegression.php @@ -0,0 +1,116 @@ + & 'title'\nTITLE injected"); + $saved = new WeatherMap(); + $saved->ReadConfig($directory . '/new.conf'); + wm_engine_assert($saved->title === 'My <network> & 'title' TITLE injected','title round trip and line-break normalization'); + $map = new WeatherMap(); + $map->width = 910; + $map->title = 'Source title'; + $map->WriteConfig($directory . '/source.conf'); + $before = file_get_contents($directory . '/source.conf'); + newMap('copy.conf','source.conf',''); + $copy = new WeatherMap(); + $copy->ReadConfig($directory . '/copy.conf'); + wm_engine_assert($copy->title === 'Source title' && $copy->width == 910,'blank title retains source'); + foreach (["\x01", "\x7f", " \r\n\x02 ", null, []] as $index => $empty_title) { + $filename = 'empty-' . $index . '.conf'; + newMap($filename, 'source.conf', $empty_title); + $empty_copy = new WeatherMap(); + $empty_copy->ReadConfig($directory . '/' . $filename); + wm_engine_assert($empty_copy->title === 'Source title' && $empty_copy->width == 910, 'normalized empty override preserves source title and layout'); + } + newMap('blank-control.conf', '', "\x01"); + $blank = new WeatherMap(); + $blank->ReadConfig($directory . '/blank-control.conf'); + $defaults = new WeatherMap(); + wm_engine_assert($blank->title === $defaults->title, 'normalized empty title preserves a new map default'); + newMap('mixed.conf', 'source.conf', " \x01New\x7ftitle "); + $mixed = new WeatherMap(); + $mixed->ReadConfig($directory . '/mixed.conf'); + wm_engine_assert($mixed->title === 'New title', 'mixed control characters normalize before applying a real override'); + newMap('renamed.conf','source.conf','New & copy'); + $renamed = new WeatherMap(); + $renamed->ReadConfig($directory . '/renamed.conf'); + wm_engine_assert($renamed->title === 'New <title> & copy' && $renamed->width == 910,'override title preserves layout'); + wm_engine_assert(file_get_contents($directory . '/source.conf') === $before,'source unchanged'); + + foreach ([str_repeat('T', 4088), str_repeat('é', 2044)] as $index => $boundary_title) { + newMap('boundary-' . $index . '.conf', '', $boundary_title); + $boundary = new WeatherMap(); + $boundary->ReadConfig($directory . '/boundary-' . $index . '.conf'); + wm_engine_assert($boundary->title === $boundary_title, 'maximum byte-length title round-trips intact'); + } + $include = $directory . '/injected.conf'; + file_put_contents($include, "WIDTH 9999\n"); + foreach ([str_repeat('T', 4089), str_repeat('é', 2045), str_repeat('&', 818), str_repeat('T', 4089) . 'INCLUDE ' . $include] as $index => $oversized_title) { + foreach (['', 'source.conf'] as $source_map) { + $filename = 'oversized-' . $index . '-' . ($source_map === '' ? 'blank' : 'copy') . '.conf'; + newMap($filename, $source_map, $oversized_title); + wm_engine_assert(!file_exists($directory . '/' . $filename), 'oversized title cannot create a config or inject directives'); + } + } + wm_engine_assert(file_get_contents($directory . '/source.conf') === $before, 'oversized copied-map override leaves its source untouched'); + + print "PASS\n"; +} finally { + foreach (glob($directory . '/*') as $file) { + unlink($file); + }rmdir($directory); +} diff --git a/tests/Unit/NewMapTitleEngineTest.php b/tests/Unit/NewMapTitleEngineTest.php new file mode 100644 index 0000000..9d143c1 --- /dev/null +++ b/tests/Unit/NewMapTitleEngineTest.php @@ -0,0 +1,29 @@ +<?php +/* + +-------------------------------------------------------------------------+ + | Copyright (C) 2004-2026 The Cacti Group, Howard Jones | + | | + | This program is free software; you can redistribute it and/or | + | modify it under the terms of the GNU General Public License | + | as published by the Free Software Foundation; either version 2 | + | of the License, or (at your option) any later version. | + | | + | This program is distributed in the hope that it will be useful, | + | but WITHOUT ANY WARRANTY; without even the implied warranty of | + | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | + | GNU General Public License for more details. | + +-------------------------------------------------------------------------+ + | Cacti: The Complete RRDtool-based Graphing Solution | + +-------------------------------------------------------------------------+ + | This code is designed, written, and maintained by the Cacti Group. See | + | about.php and/or the AUTHORS file for specific developer information. | + +-------------------------------------------------------------------------+ + | http://www.cacti.net/ | + +-------------------------------------------------------------------------+ +*/ + +it('PersistsAndRendersTheNewMapOptionsUsingTheRealEngine', function () { + $lines = []; + exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(dirname(__DIR__) . '/Support/NewMapTitleEngineRegression.php') . ' 2>&1',$lines,$status); + expect($status)->toBe(0)->and(implode("\n",$lines))->toBe('PASS'); +}); diff --git a/tests/Unit/NewMapTitleEscapingTest.php b/tests/Unit/NewMapTitleEscapingTest.php new file mode 100644 index 0000000..3c9e3e4 --- /dev/null +++ b/tests/Unit/NewMapTitleEscapingTest.php @@ -0,0 +1,37 @@ +<?php +/* + +-------------------------------------------------------------------------+ + | Copyright (C) 2004-2026 The Cacti Group, Howard Jones | + | | + | This program is free software; you can redistribute it and/or | + | modify it under the terms of the GNU General Public License | + | as published by the Free Software Foundation; either version 2 | + | of the License, or (at your option) any later version. | + | | + | This program is distributed in the hope that it will be useful, | + | but WITHOUT ANY WARRANTY; without even the implied warranty of | + | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | + | GNU General Public License for more details. | + +-------------------------------------------------------------------------+ + | Cacti: The Complete RRDtool-based Graphing Solution | + +-------------------------------------------------------------------------+ + | This code is designed, written, and maintained by the Cacti Group. See | + | about.php and/or the AUTHORS file for specific developer information. | + +-------------------------------------------------------------------------+ + | http://www.cacti.net/ | + +-------------------------------------------------------------------------+ +*/ + +it('EscapesTranslatedTitleFieldsInTheActualCreateForm', function () { + $source = file_get_contents(dirname(__DIR__, 2) . '/weathermap-cacti-plugin-mgmt.php'); + $start = strpos($source, "<td><label for='newtitle'>"); + $end = strpos($source, '</td>', strpos($source, "<input id='newtitle'", $start)) + strlen('</td>'); + $fragment = substr($source, $start, $end - $start); + $payload = "Title' autofocus onfocus='alert(1) <script>"; + // Substitute translated output before the escape helper executes. + $fragment = str_replace(["'Map Title'", "'Optional map title'"], var_export($payload, true), $fragment); + ob_start(); + eval('?>' . $fragment); + $html = ob_get_clean(); + expect($html)->toContain(''', '<script>')->not->toContain("placeholder='Title' autofocus", '<script>'); +}); diff --git a/weathermap-cacti-plugin-mgmt.php b/weathermap-cacti-plugin-mgmt.php index e24fee3..f4afe4d 100644 --- a/weathermap-cacti-plugin-mgmt.php +++ b/weathermap-cacti-plugin-mgmt.php @@ -302,9 +302,9 @@ break; case 'newmap': if (isset_request_var('srcmap') && get_nfilter_request_var('srcmap') != '-1') { - newMap(get_nfilter_request_var('newfile'), get_nfilter_request_var('srcmap')); + newMap(get_nfilter_request_var('newfile'), get_nfilter_request_var('srcmap'), get_nfilter_request_var('newtitle')); } else { - newMap(get_nfilter_request_var('newfile')); + newMap(get_nfilter_request_var('newfile'), '', get_nfilter_request_var('newtitle')); } header('Location: weathermap-cacti-plugin-mgmt.php?action=addmap_picker&header=false'); @@ -1349,6 +1349,8 @@ function addmap_filter() { <td> <input id='newfile' class='ui-state-default ui-corner-all' name='newfile' type='text' size='25' value='' placeholder='<?php print __('Name including .conf', 'weathermaps'); ?>'> </td> + <td><label for='newtitle'><?php print __esc('Map Title', 'weathermap'); ?></label></td> + <td><input id='newtitle' class='ui-state-default ui-corner-all' name='newtitle' type='text' size='25' value='' placeholder='<?php print __esc('Optional map title', 'weathermap'); ?>'></td> <td> <?php print __('Source Map', 'weathermaps'); ?> </td> @@ -1418,6 +1420,7 @@ function clearFilter() { var json = { __csrf_magic: csrfMagicToken, newfile: $('#newfile').val(), + newtitle: $('#newtitle').val(), srcmap: $('#srcmap').val() }; @@ -3343,15 +3346,26 @@ function weathermap_group_delete($id) { * @param string $sourcemapfile Optional existing config file to copy * settings from instead of creating a blank * map. + * @param string $title Optional display title for the new map. * * @return void * * @global string $weathermap_confdir The configured path to the * weathermap configs directory. */ -function newMap($mapfile, $sourcemapfile = '') { +function newMap($mapfile, $sourcemapfile = '', $title = '') { global $weathermap_confdir; + require_once __DIR__ . '/lib/editor.inc.php'; + + $title = is_string($title) ? wm_editor_sanitize_string(trim(preg_replace('/[\x00-\x1f\x7f]/', ' ', $title))) : ''; + + // fgets(..., 4096) must read the complete TITLE line, including its newline. + if (strlen($title) > 4088) { + raise_message('map_message', __esc('Map title is too long. Use at most 4088 bytes after escaping.', 'weathermap'), MESSAGE_LEVEL_ERROR); + return; + } + if ($mapfile == basename($mapfile)) { $mapfile = $weathermap_confdir . '/' . clean_up_name(basename($mapfile, '.conf')) . '.conf'; } else { @@ -3378,12 +3392,18 @@ function newMap($mapfile, $sourcemapfile = '') { if ($sourcemapfile != '') { if (file_exists($sourcemapfile) && is_readable($sourcemapfile)) { $map->ReadConfig($sourcemapfile); + if ($title !== '') { + $map->title = $title; + } $map->WriteConfig($mapfile); 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); } } elseif ($mapfile != '') { + if ($title !== '') { + $map->title = $title; + } $map->WriteConfig($mapfile); raise_message('map_message', __('New Map file %s created.', basename($mapfile), 'weathermap'), MESSAGE_LEVEL_INFO); }