From 4d553e4e48731105b8f5c89fe5ec03e59b31e5df Mon Sep 17 00:00:00 2001 From: TheWitness Date: Tue, 6 Oct 2026 13:15:01 -0400 Subject: [PATCH 1/3] Fix fatal redeclare from leftover legacy include/ directory --- CHANGELOG.md | 1 + setup.php | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 002deb3..f161939 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ --- develop --- +* issue: Fix a fatal `Cannot redeclare intropage_get_allowed_devices()` that disabled the plugin on installs upgraded from the old `include/` layout; `intropage_config_arrays()` now repoints any stale `plugin_hooks` row and removes the leftover `include/` directory on the earliest per-page hook, before a stale hook can load a second copy of the library * issue: Load Thold's relocated function library from `includes/functions.php` (with a legacy `thold_functions.php` fallback) and skip the threshold poller when Thold's API is unavailable, so intropage no longer fatals after Thold moved its functions out of the plugin root * dev: Rename the plugin's stylesheet directory from `themes/` to `css/`; the old directory is tombstoned in `manifest.json` and removed on upgrade * dev: Move the plugin's library directory from `include/` to `includes/` and re-register the affected hooks on upgrade (a `plugin_hooks` file repoint in `intropage_upgrade_database()`) so existing installations load them from the new path diff --git a/setup.php b/setup.php index 706108d..54b5b30 100644 --- a/setup.php +++ b/setup.php @@ -111,6 +111,46 @@ function plugin_intropage_uninstall(): void { intropage_drop_database(); } +/** + * Repoints any stale plugin_hooks row still bound to the old include/ + * library path to includes/ and removes the leftover include/ directory + * from disk. The plugin's library directory moved from include/ to + * includes/; on an upgraded install the old directory (and the hook rows + * that reference it) can linger, so Cacti loads include/functions.php via + * a stale hook and then includes/functions.php via the plugin's own code, + * fatally redeclaring every function the two files share. This runs from + * intropage_config_arrays() - the earliest per-page hook, always loaded + * from setup.php - so it executes before any stale graph_buttons/ + * console_after hook can load the old library, and is a cheap no-op on + * healthy installs where include/ no longer exists. + * + * @return void + * + * @global array $config Cacti global configuration array; used to resolve + * the plugin directory. + */ +function intropage_cleanup_legacy_include(): void { + global $config; + + $legacy_dir = $config['base_path'] . '/plugins/intropage/include'; + + // Fast path: nothing to do once the stale directory is gone. + if (!is_dir($legacy_dir)) { + return; + } + + // Repoint stale hook rows so future requests load the library from + // includes/ instead of the removed include/ path. + db_execute("UPDATE plugin_hooks SET file = REPLACE(file, 'include/', 'includes/') WHERE name = 'intropage' AND file LIKE 'include/%'"); + + // Physically remove the stale directory so no stale hook row cached for + // the current request can load a second copy of the library and redeclare + // its functions. + if (!plugin_intropage_rmtree($legacy_dir)) { + cacti_log('WARNING: intropage could not remove the legacy include/ directory (check file/directory permissions)', false, 'INTROPAGE'); + } +} + /** * Config_arrays hook: augments Cacti's role system to grant the * Intropage realms to the Normal User/System Administration roles, and @@ -136,6 +176,10 @@ function plugin_intropage_uninstall(): void { function intropage_config_arrays(): void { global $intropage_intervals, $trend_timespans, $panel_lines; + // Runs before any stale include/ hook can load the old library and + // redeclare functions already defined by includes/. + intropage_cleanup_legacy_include(); + // Core builds $user_auth_roles with __('Normal User') in the core domain // (include/global_arrays.php), and auth_augment_roles() indexes by that // exact string. Adding the intropage domain here would key a new role on From 6098c880e36dd318f839b40dcc81330c31342bc9 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Tue, 6 Oct 2026 13:35:21 -0400 Subject: [PATCH 2/3] Add unit coverage for intropage_cleanup_legacy_include() --- .../IntropageLegacyIncludeCleanupTest.php | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 tests/Unit/IntropageLegacyIncludeCleanupTest.php diff --git a/tests/Unit/IntropageLegacyIncludeCleanupTest.php b/tests/Unit/IntropageLegacyIncludeCleanupTest.php new file mode 100644 index 0000000..3ba8795 --- /dev/null +++ b/tests/Unit/IntropageLegacyIncludeCleanupTest.php @@ -0,0 +1,84 @@ +toBe([]); + expect($GLOBALS['__test_cacti_log'])->toBe([]); +}); + +it('repoints stale hooks and removes the legacy include/ directory', function () { + $base = sys_get_temp_dir() . '/intropage-legacy-' . uniqid(); + $plugin = $base . '/plugins/intropage'; + mkdir($plugin . '/include', 0777, true); + file_put_contents($plugin . '/include/functions.php', "toBeFalse(); + + $sql = implode("\n", array_column($GLOBALS['__test_db_calls'], 'sql')); + expect($sql)->toContain("REPLACE(file, 'include/', 'includes/')"); + expect($GLOBALS['__test_cacti_log'])->toBe([]); +}); + +it('warns when the legacy include/ directory cannot be removed', function () { + $base = sys_get_temp_dir() . '/intropage-legacy-fail-' . uniqid(); + $plugin = $base . '/plugins/intropage'; + mkdir($plugin, 0777, true); + + $target = $base . '/realtarget'; + mkdir($target, 0777, true); + file_put_contents($target . '/functions.php', "toContain('legacy include/ directory'); +}); From c36f7665beed54e097fbb02792736699cfc25194 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Tue, 6 Oct 2026 13:44:43 -0400 Subject: [PATCH 3/3] Repoint stale include/ hook rows before the filesystem check --- setup.php | 17 ++++++++++------- .../Unit/IntropageLegacyIncludeCleanupTest.php | 7 +++++-- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/setup.php b/setup.php index 54b5b30..c02fc38 100644 --- a/setup.php +++ b/setup.php @@ -121,8 +121,7 @@ function plugin_intropage_uninstall(): void { * fatally redeclaring every function the two files share. This runs from * intropage_config_arrays() - the earliest per-page hook, always loaded * from setup.php - so it executes before any stale graph_buttons/ - * console_after hook can load the old library, and is a cheap no-op on - * healthy installs where include/ no longer exists. + * console_after hook can load the old library. * * @return void * @@ -132,17 +131,21 @@ function plugin_intropage_uninstall(): void { function intropage_cleanup_legacy_include(): void { global $config; + // Repoint stale hook rows first - unconditionally, before any filesystem + // check - because the rows can outlive the include/ directory (e.g. a prior + // partial cleanup removed the files but never committed the repoint), and + // while they remain Cacti keeps failing its file-inclusion check for the + // missing path on every request. The LIKE filter makes this a no-op on + // healthy installs. + db_execute("UPDATE plugin_hooks SET file = REPLACE(file, 'include/', 'includes/') WHERE name = 'intropage' AND file LIKE 'include/%'"); + $legacy_dir = $config['base_path'] . '/plugins/intropage/include'; - // Fast path: nothing to do once the stale directory is gone. + // Nothing left to remove once the stale directory is gone. if (!is_dir($legacy_dir)) { return; } - // Repoint stale hook rows so future requests load the library from - // includes/ instead of the removed include/ path. - db_execute("UPDATE plugin_hooks SET file = REPLACE(file, 'include/', 'includes/') WHERE name = 'intropage' AND file LIKE 'include/%'"); - // Physically remove the stale directory so no stale hook row cached for // the current request can load a second copy of the library and redeclare // its functions. diff --git a/tests/Unit/IntropageLegacyIncludeCleanupTest.php b/tests/Unit/IntropageLegacyIncludeCleanupTest.php index 3ba8795..a06a642 100644 --- a/tests/Unit/IntropageLegacyIncludeCleanupTest.php +++ b/tests/Unit/IntropageLegacyIncludeCleanupTest.php @@ -18,7 +18,7 @@ $GLOBALS['__test_cacti_log'] = []; }); -it('is a no-op when no legacy include/ directory exists', function () { +it('repoints stale hooks even when no legacy include/ directory exists', function () { $base = sys_get_temp_dir() . '/intropage-legacy-none-' . uniqid(); mkdir($base . '/plugins/intropage', 0777, true); @@ -31,7 +31,10 @@ $GLOBALS['config']['base_path'] = $restore; } - expect($GLOBALS['__test_db_calls'])->toBe([]); + // The hook-row repoint must run even with no directory to remove, so stale + // rows left behind by an earlier partial cleanup still get healed. + $sql = implode("\n", array_column($GLOBALS['__test_db_calls'], 'sql')); + expect($sql)->toContain("REPLACE(file, 'include/', 'includes/')"); expect($GLOBALS['__test_cacti_log'])->toBe([]); });