From a7d43325469ce7d3bbdfa0b5c50713aaa039e4b3 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 30 Sep 2026 10:49:25 -0400 Subject: [PATCH 01/11] INFO: back off compat to 1.2.29 --- INFO | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/INFO b/INFO index d394f46..8579854 100644 --- a/INFO +++ b/INFO @@ -5,5 +5,5 @@ longname = MySQL/MariaDB Slow Log Viewer author = The Cacti Group email = homepage = http://www.cacti.net -compat = 1.2.32 +compat = 1.2.29 capabilities = online_view:1, online_mgmt:1, offline_view:0, offline_mgmt:0, remote_collect:0 From 90e33db63fdffb668b80c7b4344835db2ef685b4 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 30 Sep 2026 16:04:58 -0400 Subject: [PATCH 02/11] Add manifest.json + upgrade-time file pruning --- .github/copilot-instructions.md | 4 + .github/workflows/plugin-ci-workflow.yml | 3 + {themes => css}/modern/apexcharts.css | 0 manifest.json | 23 +++ setup.php | 158 +++++++++++++++++++++ slowlog.php | 8 +- tests/Unit/PruneFilesTest.php | 173 +++++++++++++++++++++++ tests/Unit/SlowlogCheckUpgradeTest.php | 21 +++ tests/bin/validate-manifest.php | 73 ++++++++++ tests/bootstrap-unit.php | 2 + 10 files changed, 461 insertions(+), 4 deletions(-) rename {themes => css}/modern/apexcharts.css (100%) create mode 100644 manifest.json create mode 100644 tests/Unit/PruneFilesTest.php create mode 100644 tests/bin/validate-manifest.php diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 9736814..c81ac3d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -223,3 +223,7 @@ existing code or adding new code, not just in dedicated cleanup passes: line, `@param` lines, a blank comment line, then `@return`. Infer parameter/return types from actual usage; don't change the function's real type-hints in the same pass (let static analysis flag mismatches separately). Skip vendored third-party library files. + +## File manifest & upgrade pruning + +The plugin ships a root `manifest.json` with three arrays: `tombstones` (files/directories older versions shipped that have since moved or been removed), `expected` (the top-level files and directories that ship today, directories written with a trailing `/`), and `whitelist` (paths holding user data that must never be touched). Keep `expected` current: CI runs `tests/bin/validate-manifest.php`, which fails on any drift between `expected` and the real top-level tree (it ignores `tests/`, `.git*`, and whitelisted paths). Custom customer CSS/theme files belong in `expected`, and stylesheets live in `css/` (not `themes/`). On upgrade, `plugin_slowlog_prune_files()` deletes the tombstoned paths and the dev-only `tests/` tree, leaves `whitelist` and `.git*` alone, and logs (without removing) any top-level entry the manifest does not account for. As a safety measure it refuses any tombstone that resolves outside the plugin directory (a tampered manifest.json) and logs a warning for any file or directory it cannot remove. When you move or delete a shipped file, add its old path to `tombstones` and update `expected` in the same change. diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml index 17aa76b..3f95d94 100644 --- a/.github/workflows/plugin-ci-workflow.yml +++ b/.github/workflows/plugin-ci-workflow.yml @@ -88,6 +88,9 @@ jobs: - name: Check PHP version run: php -v + - name: Validate plugin manifest (expected-file drift) + run: php cacti/plugins/slowlog/tests/bin/validate-manifest.php + - name: Run apt-get update run: sudo apt-get update diff --git a/themes/modern/apexcharts.css b/css/modern/apexcharts.css similarity index 100% rename from themes/modern/apexcharts.css rename to css/modern/apexcharts.css diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..396f277 --- /dev/null +++ b/manifest.json @@ -0,0 +1,23 @@ +{ + "tombstones": [ + "themes/" + ], + "expected": [ + "CHANGELOG.md", + "INFO", + "LICENSE", + "README.md", + "css/", + "images/", + "import_log.php", + "includes/", + "js/", + "keywords.txt", + "locales/", + "manifest.json", + "phpunit.xml", + "setup.php", + "slowlog.php" + ], + "whitelist": [] +} diff --git a/setup.php b/setup.php index 5754194..6b0a663 100644 --- a/setup.php +++ b/setup.php @@ -209,6 +209,8 @@ function slowlog_check_upgrade(): void { // The schema create/refresh lives in includes/database.php (the thold model). slowlog_upgrade_tables(); + + plugin_slowlog_prune_files(); } } @@ -335,3 +337,159 @@ function slowlog_show_tab(): void { } } } + +/** + * Removes files and directories that a previous version of this plugin + * shipped but that have since moved or been deleted, using the tombstone + * and whitelist lists in manifest.json. Whitelisted (user-data) paths and + * any VCS metadata (.git*) are never touched; the dev-only tests/ tree is + * removed. Any path that resolves outside the plugin directory (a tampered + * manifest.json) is refused, and any file/directory that cannot be removed + * (e.g. read-only) is reported to the Cacti log. Any top-level entry that is + * neither expected nor a tombstone nor whitelisted is logged to the Cacti + * log and left in place. Called on a plugin version change. + * + * @return void + * + * @global array $config Cacti global configuration array; used to resolve + * the plugin directory. + */ +function plugin_slowlog_prune_files(): void { + global $config; + + $plugin_dir = $config['base_path'] . '/plugins/slowlog'; + $manifest_path = $plugin_dir . '/manifest.json'; + + if (!is_readable($manifest_path)) { + return; + } + + $manifest = json_decode((string) file_get_contents($manifest_path), true); + + if (!is_array($manifest)) { + cacti_log('WARNING: slowlog manifest.json could not be parsed; skipping file prune', false, 'SLOWLOG'); + + return; + } + + $tombstones = isset($manifest['tombstones']) && is_array($manifest['tombstones']) ? $manifest['tombstones'] : []; + $expected = isset($manifest['expected']) && is_array($manifest['expected']) ? $manifest['expected'] : []; + $whitelist = isset($manifest['whitelist']) && is_array($manifest['whitelist']) ? $manifest['whitelist'] : []; + + $protected = function (string $rel) use ($whitelist): bool { + if (strncmp($rel, '.git', 4) === 0) { + return true; + } + + foreach ($whitelist as $entry) { + $entry = trim((string) $entry, '/'); + + if ($entry !== '' && ($rel === $entry || strncmp($rel, $entry . '/', strlen($entry) + 1) === 0)) { + return true; + } + } + + return false; + }; + + // Security: resolve the plugin directory so a tampered manifest.json + // cannot steer the prune outside of it. + $plugin_real = realpath($plugin_dir); + + // Remove tombstoned (moved/deleted) paths plus the dev-only tests/ tree. + $remove = $tombstones; + $remove[] = 'tests/'; + + foreach ($remove as $rel) { + $rel = trim((string) $rel, '/'); + + if ($rel === '' || $protected($rel)) { + continue; + } + + $path = $plugin_dir . '/' . $rel; + + if (!is_link($path) && !file_exists($path)) { + continue; + } + + // Refuse any path that, after resolving symlinks and ../ segments, + // escapes the plugin directory (protects user data from a tampered + // manifest.json). + $anchor = is_link($path) ? dirname($path) : $path; + $real = realpath($anchor); + + if ($real === false || ($real !== $plugin_real && strncmp($real, $plugin_real . DIRECTORY_SEPARATOR, strlen((string) $plugin_real) + 1) !== 0)) { + cacti_log(sprintf('WARNING: slowlog prune refused to remove %s: path resolves outside the plugin directory (tampered manifest.json?)', $rel), false, 'SLOWLOG'); + + continue; + } + + if (is_dir($path) && !is_link($path)) { + $removed = plugin_slowlog_rmtree($path); + } else { + $removed = @unlink($path); + } + + if (!$removed) { + cacti_log(sprintf('WARNING: slowlog upgrade could not remove %s (check file/directory permissions)', $rel), false, 'SLOWLOG'); + } + } + + // Surface any top-level entry the manifest does not account for. + $known = []; + + foreach (array_merge($expected, $tombstones) as $entry) { + $top = explode('/', trim((string) $entry, '/'))[0]; + + if ($top !== '') { + $known[$top] = true; + } + } + + $entries = scandir($plugin_dir); + + foreach (($entries !== false ? $entries : []) as $entry) { + if ($entry === '.' || $entry === '..' || $entry === 'tests' || $protected($entry) || isset($known[$entry])) { + continue; + } + + cacti_log(sprintf('WARNING: slowlog upgrade found a file/directory not described in manifest.json: %s (left in place)', $entry), false, 'SLOWLOG'); + } +} + +/** + * Recursively deletes a directory and its contents. Symlinks are removed + * without being followed. Helper for plugin_slowlog_prune_files(). + * + * @param string $dir Absolute path to the directory to remove. + * + * @return bool True if the directory and everything under it was removed; + * false if any entry could not be deleted. + */ +function plugin_slowlog_rmtree(string $dir): bool { + $entries = scandir($dir); + $ok = true; + + foreach (($entries !== false ? $entries : []) as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $path = $dir . '/' . $entry; + + if (is_dir($path) && !is_link($path)) { + if (!plugin_slowlog_rmtree($path)) { + $ok = false; + } + } elseif (!@unlink($path)) { + $ok = false; + } + } + + if (!@rmdir($dir)) { + $ok = false; + } + + return $ok; +} diff --git a/slowlog.php b/slowlog.php index 8041aa9..c49f1c0 100644 --- a/slowlog.php +++ b/slowlog.php @@ -343,8 +343,8 @@ function slowlog_import(): void { print get_md5_include_js('plugins/slowlog/js/apexcharts.js'); - if (file_exists($config['base_path'] . "/plugins/slowlog/themes/$selected_theme/apexcharts.css")) { - print get_md5_include_css("plugins/slowlog/themes/$selected_theme/apexcharts.css"); + if (file_exists($config['base_path'] . "/plugins/slowlog/css//apexcharts.css")) { + print get_md5_include_css("plugins/slowlog/css//apexcharts.css"); } else { print ''; } @@ -1036,8 +1036,8 @@ function slowlog_view_charts(string $method): void { print get_md5_include_js('plugins/slowlog/js/apexcharts.js'); - if (file_exists($config['base_path'] . "/plugins/slowlog/themes/$selected_theme/apexcharts.css")) { - print get_md5_include_css("plugins/slowlog/themes/$selected_theme/apexcharts.css"); + if (file_exists($config['base_path'] . "/plugins/slowlog/css//apexcharts.css")) { + print get_md5_include_css("plugins/slowlog/css//apexcharts.css"); } else { print ''; } diff --git a/tests/Unit/PruneFilesTest.php b/tests/Unit/PruneFilesTest.php new file mode 100644 index 0000000..8844362 --- /dev/null +++ b/tests/Unit/PruneFilesTest.php @@ -0,0 +1,173 @@ + ['include/', 'oldfile.php', 'userdata/', 'gone.png'], + 'expected' => ['INFO', 'setup.php', 'includes/', 'manifest.json'], + 'whitelist' => ['userdata/'], + ]; + + $base = slowlog_prune_fixture($manifest); + $plugin = $base . '/plugins/slowlog'; + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = $base; + + try { + plugin_slowlog_prune_files(); + } finally { + $GLOBALS['config']['base_path'] = $restore; + } + + // Tombstone and the dev-only tests/ tree are gone. + expect(is_dir($plugin . '/include'))->toBeFalse(); + expect(is_dir($plugin . '/tests'))->toBeFalse(); + expect(is_file($plugin . '/oldfile.php'))->toBeFalse(); + + // Whitelisted user data, VCS metadata, and expected files are untouched. + // (userdata/ is even listed as a tombstone, but the whitelist wins.) + expect(is_file($plugin . '/userdata/keep.dat'))->toBeTrue(); + expect(is_dir($plugin . '/.git'))->toBeTrue(); + expect(is_file($plugin . '/INFO'))->toBeTrue(); + expect(is_dir($plugin . '/includes'))->toBeTrue(); + + // An unexpected, non-whitelisted stray is left in place but logged. + expect(is_file($plugin . '/stray.php'))->toBeTrue(); + + $logged = implode("\n", $GLOBALS['__test_cacti_log']); + expect($logged)->toContain('stray.php'); + expect($logged)->not->toContain('userdata'); + expect($logged)->not->toContain('.git'); +}); + +it('is a safe no-op when the manifest is missing', function () { + $base = sys_get_temp_dir() . '/slowlog-prune-missing-' . uniqid(); + mkdir($base . '/plugins/slowlog', 0777, true); + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = $base; + + try { + plugin_slowlog_prune_files(); + } finally { + $GLOBALS['config']['base_path'] = $restore; + } + + expect($GLOBALS['__test_cacti_log'])->toBe([]); +}); + +it('logs and skips pruning when the manifest is malformed', function () { + $base = sys_get_temp_dir() . '/slowlog-prune-bad-' . uniqid(); + $plugin = $base . '/plugins/slowlog'; + mkdir($plugin, 0777, true); + file_put_contents($plugin . '/manifest.json', 'not json'); + mkdir($plugin . '/tests', 0777, true); + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = $base; + + try { + plugin_slowlog_prune_files(); + } finally { + $GLOBALS['config']['base_path'] = $restore; + } + + // A malformed manifest must not delete anything. + expect(is_dir($plugin . '/tests'))->toBeTrue(); + expect(implode("\n", $GLOBALS['__test_cacti_log']))->toContain('could not be parsed'); +}); + +it('refuses to remove a tombstone that resolves outside the plugin directory', function () { + $manifest = [ + 'tombstones' => ['../escapee.txt'], + 'expected' => ['manifest.json'], + 'whitelist' => [], + ]; + + $base = slowlog_prune_fixture($manifest); + $plugin = $base . '/plugins/slowlog'; + $outside = $base . '/plugins/escapee.txt'; + file_put_contents($outside, 'precious user data'); + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = $base; + + try { + plugin_slowlog_prune_files(); + } finally { + $GLOBALS['config']['base_path'] = $restore; + } + + // The out-of-tree file is untouched and the refusal is logged. + expect(is_file($outside))->toBeTrue(); + expect(implode("\n", $GLOBALS['__test_cacti_log']))->toContain('outside the plugin directory'); +}); + +it('warns when a tombstoned path cannot be removed', function () { + $manifest = [ + 'tombstones' => ['locked/'], + 'expected' => ['manifest.json'], + 'whitelist' => [], + ]; + + $base = slowlog_prune_fixture($manifest); + $plugin = $base . '/plugins/slowlog'; + mkdir($plugin . '/locked/sub', 0777, true); + file_put_contents($plugin . '/locked/sub/data', 'x'); + chmod($plugin . '/locked/sub', 0500); // read-only dir: its child cannot be unlinked + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = $base; + + set_error_handler(static fn () => true); // swallow the expected unlink warning + + try { + plugin_slowlog_prune_files(); + } finally { + restore_error_handler(); + $GLOBALS['config']['base_path'] = $restore; + @chmod($plugin . '/locked/sub', 0700); + } + + expect(implode("\n", $GLOBALS['__test_cacti_log']))->toContain('could not remove'); +})->skip(function () { + return function_exists('posix_getuid') && posix_getuid() === 0; +}, 'permission checks are bypassed for the root user'); diff --git a/tests/Unit/SlowlogCheckUpgradeTest.php b/tests/Unit/SlowlogCheckUpgradeTest.php index fb6062e..c31fc69 100644 --- a/tests/Unit/SlowlogCheckUpgradeTest.php +++ b/tests/Unit/SlowlogCheckUpgradeTest.php @@ -20,6 +20,9 @@ beforeAll(function () { require_once __DIR__ . '/../../setup.php'; + // Define slowlog_upgrade_tables() from the real checkout so + // slowlog_check_upgrade() runs while base_path is sandboxed below. + require_once __DIR__ . '/../../includes/database.php'; $stubLibraryPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'slowlog-test-lib-stub'; @@ -36,6 +39,24 @@ beforeEach(function () { slowlog_test_reset_db_mocks(); unset($_SERVER['PHP_SELF']); + + // Sandbox base_path so the version-drift branch runs + // plugin_slowlog_prune_files() against a throwaway tree with no + // manifest.json (prune no-ops), never the real checkout. The temp tree + // carries a copy of the real INFO (so slowlog_version() still matches) + // and an empty includes/database.php the top-level require_once can load. + $GLOBALS['__slowlog_base_restore'] = $GLOBALS['config']['base_path']; + $base = sys_get_temp_dir() . '/slowlog-test-' . uniqid(); + mkdir($base . '/plugins/slowlog/includes', 0777, true); + copy(__DIR__ . '/../../INFO', $base . '/plugins/slowlog/INFO'); + file_put_contents($base . '/plugins/slowlog/includes/database.php', " Date: Wed, 30 Sep 2026 19:57:41 -0400 Subject: [PATCH 03/11] Prune phpunit.xml on upgrade and ignore markdown-lint configs phpunit.xml ships in the repo for CI but is a dev-only test artifact, so it is removed from manifest.json 'expected', pruned from installs on upgrade (like tests/), and excluded from the manifest drift check. The retired markdown-lint configs (.mdlrc, .md_style.rb) are deleted, and .md* is now ignored like .git*: protected from pruning and excluded from drift if it reappears. --- manifest.json | 1 - setup.php | 8 +++++--- tests/Unit/PruneFilesTest.php | 8 ++++++++ tests/bin/validate-manifest.php | 8 ++++---- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/manifest.json b/manifest.json index 396f277..989f620 100644 --- a/manifest.json +++ b/manifest.json @@ -15,7 +15,6 @@ "keywords.txt", "locales/", "manifest.json", - "phpunit.xml", "setup.php", "slowlog.php" ], diff --git a/setup.php b/setup.php index 6b0a663..776822a 100644 --- a/setup.php +++ b/setup.php @@ -377,7 +377,7 @@ function plugin_slowlog_prune_files(): void { $whitelist = isset($manifest['whitelist']) && is_array($manifest['whitelist']) ? $manifest['whitelist'] : []; $protected = function (string $rel) use ($whitelist): bool { - if (strncmp($rel, '.git', 4) === 0) { + if (strncmp($rel, '.git', 4) === 0 || strncmp($rel, '.md', 3) === 0) { return true; } @@ -396,9 +396,11 @@ function plugin_slowlog_prune_files(): void { // cannot steer the prune outside of it. $plugin_real = realpath($plugin_dir); - // Remove tombstoned (moved/deleted) paths plus the dev-only tests/ tree. + // Remove tombstoned (moved/deleted) paths plus the dev-only tests/ + // tree and the phpunit.xml test configuration. $remove = $tombstones; $remove[] = 'tests/'; + $remove[] = 'phpunit.xml'; foreach ($remove as $rel) { $rel = trim((string) $rel, '/'); @@ -450,7 +452,7 @@ function plugin_slowlog_prune_files(): void { $entries = scandir($plugin_dir); foreach (($entries !== false ? $entries : []) as $entry) { - if ($entry === '.' || $entry === '..' || $entry === 'tests' || $protected($entry) || isset($known[$entry])) { + if ($entry === '.' || $entry === '..' || $entry === 'tests' || $entry === 'phpunit.xml' || $protected($entry) || isset($known[$entry])) { continue; } diff --git a/tests/Unit/PruneFilesTest.php b/tests/Unit/PruneFilesTest.php index 8844362..a7c3fef 100644 --- a/tests/Unit/PruneFilesTest.php +++ b/tests/Unit/PruneFilesTest.php @@ -27,6 +27,9 @@ function slowlog_prune_fixture(array $manifest): string { file_put_contents($plugin . '/setup.php', "toBeFalse(); expect(is_dir($plugin . '/tests'))->toBeFalse(); expect(is_file($plugin . '/oldfile.php'))->toBeFalse(); + expect(is_file($plugin . '/phpunit.xml'))->toBeFalse(); // Whitelisted user data, VCS metadata, and expected files are untouched. // (userdata/ is even listed as a tombstone, but the whitelist wins.) @@ -68,6 +72,8 @@ function slowlog_prune_fixture(array $manifest): string { expect(is_dir($plugin . '/.git'))->toBeTrue(); expect(is_file($plugin . '/INFO'))->toBeTrue(); expect(is_dir($plugin . '/includes'))->toBeTrue(); + expect(is_file($plugin . '/.mdlrc'))->toBeTrue(); + expect(is_file($plugin . '/.md_style.rb'))->toBeTrue(); // An unexpected, non-whitelisted stray is left in place but logged. expect(is_file($plugin . '/stray.php'))->toBeTrue(); @@ -76,6 +82,8 @@ function slowlog_prune_fixture(array $manifest): string { expect($logged)->toContain('stray.php'); expect($logged)->not->toContain('userdata'); expect($logged)->not->toContain('.git'); + expect($logged)->not->toContain('.mdlrc'); + expect($logged)->not->toContain('.md_style.rb'); }); it('is a safe no-op when the manifest is missing', function () { diff --git a/tests/bin/validate-manifest.php b/tests/bin/validate-manifest.php index aa9858b..c54fab5 100644 --- a/tests/bin/validate-manifest.php +++ b/tests/bin/validate-manifest.php @@ -5,8 +5,8 @@ +-------------------------------------------------------------------------+ | Validates that manifest.json's "expected" array matches the plugin's | | actual top-level tree, so the manifest that drives upgrade-time pruning | - | cannot silently drift. tests/, .git* and whitelisted (user-data) paths | - | are intentionally excluded. Exits non-zero on any drift. | + | cannot silently drift. tests/, phpunit.xml, .git* and .md* are | + | excluded, as are whitelisted (user-data) paths. Non-zero exit on drift. | +-------------------------------------------------------------------------+ */ @@ -39,11 +39,11 @@ $actual = []; foreach (scandir($root) as $entry) { - if ($entry === '.' || $entry === '..' || $entry === 'tests') { + if ($entry === '.' || $entry === '..' || $entry === 'tests' || $entry === 'phpunit.xml') { continue; } - if (strncmp($entry, '.git', 4) === 0 || isset($whitelistTop[$entry])) { + if (strncmp($entry, '.git', 4) === 0 || strncmp($entry, '.md', 3) === 0 || isset($whitelistTop[$entry])) { continue; } From f981314c9580714870716949248149d89453b2b9 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 30 Sep 2026 20:31:22 -0400 Subject: [PATCH 04/11] Move keywords.txt into docs/ The reserved-words seed file moves to docs/; includes/database.php loads it from the new path during table setup, and it is tombstoned so existing installs drop the stale top-level copy on upgrade. --- keywords.txt => docs/keywords.txt | 0 includes/database.php | 4 ++-- manifest.json | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) rename keywords.txt => docs/keywords.txt (100%) diff --git a/keywords.txt b/docs/keywords.txt similarity index 100% rename from keywords.txt rename to docs/keywords.txt diff --git a/includes/database.php b/includes/database.php index d2651ce..63bf23a 100644 --- a/includes/database.php +++ b/includes/database.php @@ -251,8 +251,8 @@ function slowlog_setup_table_new(): void { // The (id, word) primary key doesn't prevent duplicate words on a re-run, since id is // auto-incrementing - only load once, when the table is still empty. - if (file_exists(__DIR__ . '/../keywords.txt') && !db_fetch_cell_prepared('SELECT COUNT(*) FROM plugin_slowlog_reserved_words')) { - $words = file(__DIR__ . '/../keywords.txt') ?: []; + if (file_exists(__DIR__ . '/../docs/keywords.txt') && !db_fetch_cell_prepared('SELECT COUNT(*) FROM plugin_slowlog_reserved_words')) { + $words = file(__DIR__ . '/../docs/keywords.txt') ?: []; if (cacti_sizeof($words)) { foreach ($words as $word) { diff --git a/manifest.json b/manifest.json index 989f620..462bd23 100644 --- a/manifest.json +++ b/manifest.json @@ -1,5 +1,6 @@ { "tombstones": [ + "keywords.txt", "themes/" ], "expected": [ @@ -8,11 +9,11 @@ "LICENSE", "README.md", "css/", + "docs/", "images/", "import_log.php", "includes/", "js/", - "keywords.txt", "locales/", "manifest.json", "setup.php", From b840de5cbc1b94d679d067bade0e2f3fce35a084 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 30 Sep 2026 22:17:46 -0400 Subject: [PATCH 05/11] Harden prune against tampered-manifest path traversal The upgrade-time prune now rejects any tombstone containing '.'/'..' segments (which could escape the plugin directory or resolve to its root) and treats ancestors of whitelist entries as protected, so a tombstone on a parent directory can no longer delete a whitelisted file beneath it. --- setup.php | 14 ++++++++- tests/Unit/PruneFilesTest.php | 59 ++++++++++++++++++++++++++++++++++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/setup.php b/setup.php index 776822a..2bbbd0e 100644 --- a/setup.php +++ b/setup.php @@ -384,7 +384,9 @@ function plugin_slowlog_prune_files(): void { foreach ($whitelist as $entry) { $entry = trim((string) $entry, '/'); - if ($entry !== '' && ($rel === $entry || strncmp($rel, $entry . '/', strlen($entry) + 1) === 0)) { + if ($entry !== '' && ($rel === $entry + || strncmp($rel, $entry . '/', strlen($entry) + 1) === 0 + || strncmp($entry, $rel . '/', strlen($rel) + 1) === 0)) { return true; } } @@ -409,6 +411,16 @@ function plugin_slowlog_prune_files(): void { continue; } + // A tombstone must never contain '.'/'..' segments; a tampered manifest + // could use them to escape the plugin directory or target its root. + $segments = explode('/', $rel); + + if (in_array('.', $segments, true) || in_array('..', $segments, true)) { + cacti_log(sprintf('WARNING: slowlog prune refused to remove %s: path contains a traversal segment (tampered manifest.json?)', $rel), false, 'SLOWLOG'); + + continue; + } + $path = $plugin_dir . '/' . $rel; if (!is_link($path) && !file_exists($path)) { diff --git a/tests/Unit/PruneFilesTest.php b/tests/Unit/PruneFilesTest.php index a7c3fef..b417c54 100644 --- a/tests/Unit/PruneFilesTest.php +++ b/tests/Unit/PruneFilesTest.php @@ -146,7 +146,7 @@ function slowlog_prune_fixture(array $manifest): string { // The out-of-tree file is untouched and the refusal is logged. expect(is_file($outside))->toBeTrue(); - expect(implode("\n", $GLOBALS['__test_cacti_log']))->toContain('outside the plugin directory'); + expect(implode("\n", $GLOBALS['__test_cacti_log']))->toContain('a traversal segment'); }); it('warns when a tombstoned path cannot be removed', function () { @@ -179,3 +179,60 @@ function slowlog_prune_fixture(array $manifest): string { })->skip(function () { return function_exists('posix_getuid') && posix_getuid() === 0; }, 'permission checks are bypassed for the root user'); + +it('refuses a tombstone that escapes through a symlinked directory', function () { + $manifest = [ + 'tombstones' => ['escdir/secret.txt'], + 'expected' => ['manifest.json'], + 'whitelist' => [], + ]; + + $base = slowlog_prune_fixture($manifest); + $plugin = $base . '/plugins/slowlog'; + $outside = $base . '/outside'; + mkdir($outside, 0777, true); + file_put_contents($outside . '/secret.txt', 'precious user data'); + @symlink($outside, $plugin . '/escdir'); + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = $base; + + try { + plugin_slowlog_prune_files(); + } finally { + $GLOBALS['config']['base_path'] = $restore; + } + + // The out-of-tree file reached through the symlink is untouched and logged. + expect(is_file($outside . '/secret.txt'))->toBeTrue(); + expect(implode("\n", $GLOBALS['__test_cacti_log']))->toContain('outside the plugin directory'); +})->skip(function () { + $probe = sys_get_temp_dir() . '/.prune-symlink-probe-' . uniqid(); + $ok = @symlink(__FILE__, $probe); + @unlink($probe); + + return $ok === false; +}, 'symlinks are not supported on this filesystem'); + +it('protects a whitelisted file from a tombstone on its parent directory', function () { + $manifest = [ + 'tombstones' => ['userdata/'], + 'expected' => ['manifest.json'], + 'whitelist' => ['userdata/keep.dat'], + ]; + + $base = slowlog_prune_fixture($manifest); + $plugin = $base . '/plugins/slowlog'; + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = $base; + + try { + plugin_slowlog_prune_files(); + } finally { + $GLOBALS['config']['base_path'] = $restore; + } + + // A whitelisted file shields its parent directory from a tombstone. + expect(is_file($plugin . '/userdata/keep.dat'))->toBeTrue(); +}); From 60f94753791a6099a2f4b9672123a3f02d293d1b Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 30 Sep 2026 22:36:42 -0400 Subject: [PATCH 06/11] docs: update and align the Project Structure block Point the structure tree at the relocated files (libraries now under includes/, data files under docs/, stylesheets under css/) and align the trailing '# ...' comments to a single column so they no longer drift right. --- .github/copilot-instructions.md | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c81ac3d..6526eea 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -20,26 +20,26 @@ When generating code for this repository: ### Key Dependencies - Cacti core framework (`api_plugin_*`, `db_*`) - `js/` chart rendering for slow-query analysis views -- `keywords.txt` reserved-word reference used by the log parser +- `docs/keywords.txt` reserved-word reference used by the log parser ## Project Structure ``` -slowlog/ # Repository root (install to plugins/slowlog/ in Cacti) -├── images/ # UI icons -├── includes/ # Library/helper files, require_once'd from the entry points -│ ├── database.php # Schema management: table defs + create/upgrade/drop helpers -│ └── slowlog_functions.php # Log parsing, import, and charting logic -├── js/ # Chart rendering client-side code -├── locales/ # Internationalization files -├── tests/ # Test suite -├── themes/ # CSS theme overlays -├── import_log.php # CLI slow-query-log importer -├── keywords.txt # SQL reserved-word list used by the parser -├── slowlog.php # Main viewer/administration UI -├── INFO # Plugin metadata (name, version, compat) +slowlog/ # Repository root (install to plugins/slowlog/ in Cacti) +├── images/ # UI icons +├── includes/ # Library/helper files, require_once'd from the entry points +│ ├── database.php # Schema management: table defs + create/upgrade/drop helpers +│ └── slowlog_functions.php # Log parsing, import, and charting logic +├── js/ # Chart rendering client-side code +├── locales/ # Internationalization files +├── tests/ # Test suite +├── docs/ # keywords.txt (SQL reserved-word list used by the parser) +├── css/ # CSS theme overlays +├── import_log.php # CLI slow-query-log importer +├── slowlog.php # Main viewer/administration UI +├── INFO # Plugin metadata (name, version, compat) ├── README.md -└── setup.php # Plugin install/uninstall/upgrade hooks +└── setup.php # Plugin install/uninstall/upgrade hooks ``` ## Naming Conventions @@ -83,7 +83,7 @@ db_execute("DELETE FROM plugin_slowlog WHERE logid = $logid"); ``` ### Log Import Handling -`import_logfile()`/`slowlog_import()` parse arbitrary uploaded/imported slow-query-log text. Treat log contents as untrusted: never `eval()` or directly execute parsed queries, and use `keywords.txt`-driven tokenizing (`is_reserved_word()`) rather than ad hoc regex that could mis-parse crafted input. +`import_logfile()`/`slowlog_import()` parse arbitrary uploaded/imported slow-query-log text. Treat log contents as untrusted: never `eval()` or directly execute parsed queries, and use `docs/keywords.txt`-driven tokenizing (`is_reserved_word()`) rather than ad hoc regex that could mis-parse crafted input. ### Input Validation Use `get_filter_request_var()` / `get_nfilter_request_var()` for request input; never read `$_GET`/`$_POST` directly. From 2dded6cfcd5ef933d64f773310943b738550f919 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 30 Sep 2026 22:47:22 -0400 Subject: [PATCH 07/11] fix: restore theme segment in relocated ApexCharts CSS path The themes/ -> css/ flatten dropped the $selected_theme directory segment, producing "plugins/slowlog/css//apexcharts.css", which never resolves the relocated css/modern/apexcharts.css. Both the import and chart views therefore always fell back to the generic stylesheet. Reference css/$selected_theme/apexcharts.css to match the new layout. --- slowlog.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/slowlog.php b/slowlog.php index c49f1c0..b447d94 100644 --- a/slowlog.php +++ b/slowlog.php @@ -343,8 +343,8 @@ function slowlog_import(): void { print get_md5_include_js('plugins/slowlog/js/apexcharts.js'); - if (file_exists($config['base_path'] . "/plugins/slowlog/css//apexcharts.css")) { - print get_md5_include_css("plugins/slowlog/css//apexcharts.css"); + if (file_exists($config['base_path'] . "/plugins/slowlog/css/$selected_theme/apexcharts.css")) { + print get_md5_include_css("plugins/slowlog/css/$selected_theme/apexcharts.css"); } else { print ''; } @@ -1036,8 +1036,8 @@ function slowlog_view_charts(string $method): void { print get_md5_include_js('plugins/slowlog/js/apexcharts.js'); - if (file_exists($config['base_path'] . "/plugins/slowlog/css//apexcharts.css")) { - print get_md5_include_css("plugins/slowlog/css//apexcharts.css"); + if (file_exists($config['base_path'] . "/plugins/slowlog/css/$selected_theme/apexcharts.css")) { + print get_md5_include_css("plugins/slowlog/css/$selected_theme/apexcharts.css"); } else { print ''; } From f62d919c7056250dbc17a4ae985292c1d04273ef Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 30 Sep 2026 23:01:04 -0400 Subject: [PATCH 08/11] docs/tests: standardize test header and correct manifest-contract notes - Use the full license header (from the plugin's own setup.php) in tests/Unit/PruneFilesTest.php instead of the abbreviated copyright banner. - Document that the manifest drift check and the upgrade-time prune also handle phpunit.xml and .md* files, matching the implemented behavior. --- .github/copilot-instructions.md | 2 +- tests/Unit/PruneFilesTest.php | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 6526eea..10fa6a2 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -226,4 +226,4 @@ existing code or adding new code, not just in dedicated cleanup passes: ## File manifest & upgrade pruning -The plugin ships a root `manifest.json` with three arrays: `tombstones` (files/directories older versions shipped that have since moved or been removed), `expected` (the top-level files and directories that ship today, directories written with a trailing `/`), and `whitelist` (paths holding user data that must never be touched). Keep `expected` current: CI runs `tests/bin/validate-manifest.php`, which fails on any drift between `expected` and the real top-level tree (it ignores `tests/`, `.git*`, and whitelisted paths). Custom customer CSS/theme files belong in `expected`, and stylesheets live in `css/` (not `themes/`). On upgrade, `plugin_slowlog_prune_files()` deletes the tombstoned paths and the dev-only `tests/` tree, leaves `whitelist` and `.git*` alone, and logs (without removing) any top-level entry the manifest does not account for. As a safety measure it refuses any tombstone that resolves outside the plugin directory (a tampered manifest.json) and logs a warning for any file or directory it cannot remove. When you move or delete a shipped file, add its old path to `tombstones` and update `expected` in the same change. +The plugin ships a root `manifest.json` with three arrays: `tombstones` (files/directories older versions shipped that have since moved or been removed), `expected` (the top-level files and directories that ship today, directories written with a trailing `/`), and `whitelist` (paths holding user data that must never be touched). Keep `expected` current: CI runs `tests/bin/validate-manifest.php`, which fails on any drift between `expected` and the real top-level tree (it ignores `tests/`, `phpunit.xml`, `.git*`, `.md*`, and whitelisted paths). Custom customer CSS/theme files belong in `expected`, and stylesheets live in `css/` (not `themes/`). On upgrade, `plugin_slowlog_prune_files()` deletes the tombstoned paths, the dev-only `tests/` tree, and the `phpunit.xml` test config, leaves `whitelist`, `.git*`, and `.md*` alone, and logs (without removing) any top-level entry the manifest does not account for. As a safety measure it refuses any tombstone that resolves outside the plugin directory (a tampered manifest.json) and logs a warning for any file or directory it cannot remove. When you move or delete a shipped file, add its old path to `tombstones` and update `expected` in the same change. diff --git a/tests/Unit/PruneFilesTest.php b/tests/Unit/PruneFilesTest.php index b417c54..23c96e6 100644 --- a/tests/Unit/PruneFilesTest.php +++ b/tests/Unit/PruneFilesTest.php @@ -2,6 +2,23 @@ /* +-------------------------------------------------------------------------+ | Copyright (C) 2004-2026 The Cacti Group | + | | + | 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/ | +-------------------------------------------------------------------------+ */ From f7895bbd6a3696230dfecd296475f82f9c639cb8 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 30 Sep 2026 23:16:28 -0400 Subject: [PATCH 09/11] refactor: rename prune helpers to the plugin-slug function prefix The repository naming contract reserves the plugin__ prefix for plugin lifecycle / hook-registration functions; all other functions use the plain _ prefix. Rename the internal upgrade helpers accordingly: plugin__prune_files() -> _prune_files() plugin__rmtree() -> _rmtree() The call site, unit tests, and the copilot-instructions.md references are updated to match. No behavioral change. --- .github/copilot-instructions.md | 2 +- setup.php | 12 ++++++------ tests/Unit/PruneFilesTest.php | 16 ++++++++-------- tests/Unit/SlowlogCheckUpgradeTest.php | 2 +- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 10fa6a2..485a693 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -226,4 +226,4 @@ existing code or adding new code, not just in dedicated cleanup passes: ## File manifest & upgrade pruning -The plugin ships a root `manifest.json` with three arrays: `tombstones` (files/directories older versions shipped that have since moved or been removed), `expected` (the top-level files and directories that ship today, directories written with a trailing `/`), and `whitelist` (paths holding user data that must never be touched). Keep `expected` current: CI runs `tests/bin/validate-manifest.php`, which fails on any drift between `expected` and the real top-level tree (it ignores `tests/`, `phpunit.xml`, `.git*`, `.md*`, and whitelisted paths). Custom customer CSS/theme files belong in `expected`, and stylesheets live in `css/` (not `themes/`). On upgrade, `plugin_slowlog_prune_files()` deletes the tombstoned paths, the dev-only `tests/` tree, and the `phpunit.xml` test config, leaves `whitelist`, `.git*`, and `.md*` alone, and logs (without removing) any top-level entry the manifest does not account for. As a safety measure it refuses any tombstone that resolves outside the plugin directory (a tampered manifest.json) and logs a warning for any file or directory it cannot remove. When you move or delete a shipped file, add its old path to `tombstones` and update `expected` in the same change. +The plugin ships a root `manifest.json` with three arrays: `tombstones` (files/directories older versions shipped that have since moved or been removed), `expected` (the top-level files and directories that ship today, directories written with a trailing `/`), and `whitelist` (paths holding user data that must never be touched). Keep `expected` current: CI runs `tests/bin/validate-manifest.php`, which fails on any drift between `expected` and the real top-level tree (it ignores `tests/`, `phpunit.xml`, `.git*`, `.md*`, and whitelisted paths). Custom customer CSS/theme files belong in `expected`, and stylesheets live in `css/` (not `themes/`). On upgrade, `slowlog_prune_files()` deletes the tombstoned paths, the dev-only `tests/` tree, and the `phpunit.xml` test config, leaves `whitelist`, `.git*`, and `.md*` alone, and logs (without removing) any top-level entry the manifest does not account for. As a safety measure it refuses any tombstone that resolves outside the plugin directory (a tampered manifest.json) and logs a warning for any file or directory it cannot remove. When you move or delete a shipped file, add its old path to `tombstones` and update `expected` in the same change. diff --git a/setup.php b/setup.php index 2bbbd0e..1b55ab0 100644 --- a/setup.php +++ b/setup.php @@ -210,7 +210,7 @@ function slowlog_check_upgrade(): void { // The schema create/refresh lives in includes/database.php (the thold model). slowlog_upgrade_tables(); - plugin_slowlog_prune_files(); + slowlog_prune_files(); } } @@ -354,7 +354,7 @@ function slowlog_show_tab(): void { * @global array $config Cacti global configuration array; used to resolve * the plugin directory. */ -function plugin_slowlog_prune_files(): void { +function slowlog_prune_files(): void { global $config; $plugin_dir = $config['base_path'] . '/plugins/slowlog'; @@ -440,7 +440,7 @@ function plugin_slowlog_prune_files(): void { } if (is_dir($path) && !is_link($path)) { - $removed = plugin_slowlog_rmtree($path); + $removed = slowlog_rmtree($path); } else { $removed = @unlink($path); } @@ -474,14 +474,14 @@ function plugin_slowlog_prune_files(): void { /** * Recursively deletes a directory and its contents. Symlinks are removed - * without being followed. Helper for plugin_slowlog_prune_files(). + * without being followed. Helper for slowlog_prune_files(). * * @param string $dir Absolute path to the directory to remove. * * @return bool True if the directory and everything under it was removed; * false if any entry could not be deleted. */ -function plugin_slowlog_rmtree(string $dir): bool { +function slowlog_rmtree(string $dir): bool { $entries = scandir($dir); $ok = true; @@ -493,7 +493,7 @@ function plugin_slowlog_rmtree(string $dir): bool { $path = $dir . '/' . $entry; if (is_dir($path) && !is_link($path)) { - if (!plugin_slowlog_rmtree($path)) { + if (!slowlog_rmtree($path)) { $ok = false; } } elseif (!@unlink($path)) { diff --git a/tests/Unit/PruneFilesTest.php b/tests/Unit/PruneFilesTest.php index 23c96e6..f2eb1ae 100644 --- a/tests/Unit/PruneFilesTest.php +++ b/tests/Unit/PruneFilesTest.php @@ -23,7 +23,7 @@ */ /* - * Unit coverage for plugin_slowlog_prune_files(): tombstone/tests removal, + * Unit coverage for slowlog_prune_files(): tombstone/tests removal, * whitelist and .git protection, and logging of unaccounted-for entries. */ @@ -72,7 +72,7 @@ function slowlog_prune_fixture(array $manifest): string { $GLOBALS['config']['base_path'] = $base; try { - plugin_slowlog_prune_files(); + slowlog_prune_files(); } finally { $GLOBALS['config']['base_path'] = $restore; } @@ -111,7 +111,7 @@ function slowlog_prune_fixture(array $manifest): string { $GLOBALS['config']['base_path'] = $base; try { - plugin_slowlog_prune_files(); + slowlog_prune_files(); } finally { $GLOBALS['config']['base_path'] = $restore; } @@ -130,7 +130,7 @@ function slowlog_prune_fixture(array $manifest): string { $GLOBALS['config']['base_path'] = $base; try { - plugin_slowlog_prune_files(); + slowlog_prune_files(); } finally { $GLOBALS['config']['base_path'] = $restore; } @@ -156,7 +156,7 @@ function slowlog_prune_fixture(array $manifest): string { $GLOBALS['config']['base_path'] = $base; try { - plugin_slowlog_prune_files(); + slowlog_prune_files(); } finally { $GLOBALS['config']['base_path'] = $restore; } @@ -185,7 +185,7 @@ function slowlog_prune_fixture(array $manifest): string { set_error_handler(static fn () => true); // swallow the expected unlink warning try { - plugin_slowlog_prune_files(); + slowlog_prune_files(); } finally { restore_error_handler(); $GLOBALS['config']['base_path'] = $restore; @@ -215,7 +215,7 @@ function slowlog_prune_fixture(array $manifest): string { $GLOBALS['config']['base_path'] = $base; try { - plugin_slowlog_prune_files(); + slowlog_prune_files(); } finally { $GLOBALS['config']['base_path'] = $restore; } @@ -245,7 +245,7 @@ function slowlog_prune_fixture(array $manifest): string { $GLOBALS['config']['base_path'] = $base; try { - plugin_slowlog_prune_files(); + slowlog_prune_files(); } finally { $GLOBALS['config']['base_path'] = $restore; } diff --git a/tests/Unit/SlowlogCheckUpgradeTest.php b/tests/Unit/SlowlogCheckUpgradeTest.php index c31fc69..0f82468 100644 --- a/tests/Unit/SlowlogCheckUpgradeTest.php +++ b/tests/Unit/SlowlogCheckUpgradeTest.php @@ -41,7 +41,7 @@ unset($_SERVER['PHP_SELF']); // Sandbox base_path so the version-drift branch runs - // plugin_slowlog_prune_files() against a throwaway tree with no + // slowlog_prune_files() against a throwaway tree with no // manifest.json (prune no-ops), never the real checkout. The temp tree // carries a copy of the real INFO (so slowlog_version() still matches) // and an empty includes/database.php the top-level require_once can load. From f5594d0ee0f625068e7685bcdf428d07d353e991 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 30 Sep 2026 23:29:54 -0400 Subject: [PATCH 10/11] chore: set the Cacti compatibility floor to 1.2.29 Align the compat metadata (and the contributor-guide references) with the team decision to standardize the minimum supported Cacti version at 1.2.29; the previously proposed 1.2.32 floor was not adopted. --- .github/copilot-instructions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 485a693..0033eb3 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -4,7 +4,7 @@ When generating code for this repository: -1. **Version Compatibility**: This is a Cacti plugin (`slowlog`, version 2.1) targeting Cacti 1.2.32+ +1. **Version Compatibility**: This is a Cacti plugin (`slowlog`, version 2.1) targeting Cacti 1.2.29+ 2. **Context Files**: Prioritize patterns and standards defined in this file (`.github/copilot-instructions.md`) 3. **Codebase Patterns**: When context files don't provide specific guidance, scan the codebase for established patterns 4. **Architectural Consistency**: Maintain plugin-based architecture extending Cacti core From a2e7e3ac7a1e6597d25acf10290bcdd56f432afe Mon Sep 17 00:00:00 2001 From: Copilot Date: Thu, 1 Oct 2026 00:08:16 -0400 Subject: [PATCH 11/11] refactor: flatten slowlog CSS to a theme-prefixed filename Replace the per-theme css//apexcharts.css subdirectory layout with a flat css/_apexcharts.css naming scheme (e.g. css/modern_apexcharts.css), repointing both the import and chart views at css/{$selected_theme}_apexcharts.css. --- css/{modern/apexcharts.css => modern_apexcharts.css} | 0 slowlog.php | 8 ++++---- 2 files changed, 4 insertions(+), 4 deletions(-) rename css/{modern/apexcharts.css => modern_apexcharts.css} (100%) diff --git a/css/modern/apexcharts.css b/css/modern_apexcharts.css similarity index 100% rename from css/modern/apexcharts.css rename to css/modern_apexcharts.css diff --git a/slowlog.php b/slowlog.php index b447d94..1b8a9f4 100644 --- a/slowlog.php +++ b/slowlog.php @@ -343,8 +343,8 @@ function slowlog_import(): void { print get_md5_include_js('plugins/slowlog/js/apexcharts.js'); - if (file_exists($config['base_path'] . "/plugins/slowlog/css/$selected_theme/apexcharts.css")) { - print get_md5_include_css("plugins/slowlog/css/$selected_theme/apexcharts.css"); + if (file_exists($config['base_path'] . "/plugins/slowlog/css/{$selected_theme}_apexcharts.css")) { + print get_md5_include_css("plugins/slowlog/css/{$selected_theme}_apexcharts.css"); } else { print ''; } @@ -1036,8 +1036,8 @@ function slowlog_view_charts(string $method): void { print get_md5_include_js('plugins/slowlog/js/apexcharts.js'); - if (file_exists($config['base_path'] . "/plugins/slowlog/css/$selected_theme/apexcharts.css")) { - print get_md5_include_css("plugins/slowlog/css/$selected_theme/apexcharts.css"); + if (file_exists($config['base_path'] . "/plugins/slowlog/css/{$selected_theme}_apexcharts.css")) { + print get_md5_include_css("plugins/slowlog/css/{$selected_theme}_apexcharts.css"); } else { print ''; }