Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions setup.php
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,49 @@ 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.
*
* @return void
*
* @global array $config Cacti global configuration array; used to resolve
* the plugin directory.
*/
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';

// Nothing left to remove once the stale directory is gone.
if (!is_dir($legacy_dir)) {
return;
}

// 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
Expand All @@ -136,6 +179,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
Expand Down
87 changes: 87 additions & 0 deletions tests/Unit/IntropageLegacyIncludeCleanupTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
+-------------------------------------------------------------------------+
*/

/*
* Unit coverage for intropage_cleanup_legacy_include() in setup.php - repoints
* any stale include/ plugin_hooks row to includes/ and removes the leftover
* include/ directory, warning when it cannot be removed.
*/

require_once __DIR__ . '/../../setup.php';

beforeEach(function () {
$GLOBALS['__test_db_calls'] = [];
$GLOBALS['__test_cacti_log'] = [];
});

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);

$restore = $GLOBALS['config']['base_path'];
$GLOBALS['config']['base_path'] = $base;

try {
intropage_cleanup_legacy_include();
} finally {
$GLOBALS['config']['base_path'] = $restore;
}

// 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([]);
});

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', "<?php\n");

$restore = $GLOBALS['config']['base_path'];
$GLOBALS['config']['base_path'] = $base;

try {
intropage_cleanup_legacy_include();
} finally {
$GLOBALS['config']['base_path'] = $restore;
}

expect(is_dir($plugin . '/include'))->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', "<?php\n");

// A symlinked include/ satisfies is_dir() but rmdir() on the link itself
// fails, so plugin_intropage_rmtree() returns false and the warning runs.
symlink($target, $plugin . '/include');

$restore = $GLOBALS['config']['base_path'];
$GLOBALS['config']['base_path'] = $base;

try {
intropage_cleanup_legacy_include();
} finally {
$GLOBALS['config']['base_path'] = $restore;
}

$logged = implode("\n", $GLOBALS['__test_cacti_log']);
expect($logged)->toContain('legacy include/ directory');
});
Loading