| 1 |
<?php |
| 2 |
defined('ABSPATH') or die('Unauthorized Access'); |
| 3 |
|
| 4 |
/** |
| 5 |
* Troubleshooting Mode (Safemode) — the Health Check killer feature. |
| 6 |
* |
| 7 |
* - Per-user: only the admin who started a session sees plugins disabled |
| 8 |
* (cookie scoped to them). Other visitors see the site normally. |
| 9 |
* - Time-limited: cookies and transients expire after the chosen duration. |
| 10 |
* Default 1 hour, max 4 hours. |
| 11 |
* - Fully reversible: nothing in the database is ever modified. The actual |
| 12 |
* active_plugins option is never touched. Filters do the work at runtime. |
| 13 |
* - mu-plugin drop: installs a tiny must-use plugin at session start so the |
| 14 |
* filter fires before regular plugins load (true code-level isolation). |
| 15 |
* Without the mu-plugin we fall back to "admin-pages only" isolation which |
| 16 |
* is still useful for debugging the dashboard. |
| 17 |
* - Explicit exit: an "End and restore" button kills the cookie + transient. |
| 18 |
* Even if the user closes the browser, the session expires on its own. |
| 19 |
* |
| 20 |
* The famous Health Check bug — "exited troubleshooting and all my plugins |
| 21 |
* stayed disabled" — cannot happen here because we never deactivate plugins |
| 22 |
* in the database. Worst-case (cookie stuck, no UI access) the user clears |
| 23 |
* cookies and everything is back to normal. |
| 24 |
*/ |
| 25 |
class Phpinfo_WP_Safemode { |
| 26 |
|
| 27 |
const COOKIE = 'phpinfowp_safemode'; |
| 28 |
const TRANSIENT_PREFIX = 'phpinfowp_safemode_'; |
| 29 |
const DEFAULT_DURATION = 3600; // 1 hour |
| 30 |
const MAX_DURATION = 14400; // 4 hours |
| 31 |
const MU_FILE = 'phpinfowp-safemode.php'; |
| 32 |
|
| 33 |
public static function register(): void { |
| 34 |
// Apply filters from main plugin too — covers admin pages even when |
| 35 |
// the mu-plugin isn't installed. The mu-plugin gives full coverage. |
| 36 |
$token = self::current_token(); |
| 37 |
if (!$token) return; |
| 38 |
|
| 39 |
$session = self::load_session($token); |
| 40 |
if (!$session) { |
| 41 |
// Stale cookie → clean up to avoid confusing users |
| 42 |
self::clear_cookie(); |
| 43 |
return; |
| 44 |
} |
| 45 |
|
| 46 |
// Make the live session globally accessible for filters + views |
| 47 |
$GLOBALS['phpinfowp_safemode_session'] = $session; |
| 48 |
|
| 49 |
add_filter('option_active_plugins', [self::class, 'filter_active_plugins'], 0); |
| 50 |
add_filter('site_option_active_sitewide_plugins',[self::class, 'filter_sitewide_plugins'], 0); |
| 51 |
|
| 52 |
if (!empty($session['disable_theme'])) { |
| 53 |
add_filter('stylesheet', [self::class, 'default_theme'], 0); |
| 54 |
add_filter('template', [self::class, 'default_theme'], 0); |
| 55 |
} |
| 56 |
|
| 57 |
// Auto-exit on logout — be sure to nuke the cookie when the user |
| 58 |
// logs out of the admin, since their identity check no longer applies. |
| 59 |
add_action('wp_logout', [self::class, 'stop']); |
| 60 |
} |
| 61 |
|
| 62 |
// -- Session lifecycle ------------------------------------------------- |
| 63 |
|
| 64 |
public static function start(array $disabled_plugins, bool $disable_theme = false, int $duration = self::DEFAULT_DURATION): array { |
| 65 |
if (!current_user_can('manage_options')) { |
| 66 |
return ['ok' => false, 'reason' => 'insufficient_caps']; |
| 67 |
} |
| 68 |
|
| 69 |
$duration = max(60, min($duration, self::MAX_DURATION)); |
| 70 |
$token = bin2hex(random_bytes(16)); |
| 71 |
|
| 72 |
$session = [ |
| 73 |
'user_id' => get_current_user_id(), |
| 74 |
'disabled_plugins' => array_values(array_filter(array_map('sanitize_text_field', $disabled_plugins))), |
| 75 |
'disable_theme' => (bool) $disable_theme, |
| 76 |
'started' => time(), |
| 77 |
'expires' => time() + $duration, |
| 78 |
'duration' => $duration, |
| 79 |
]; |
| 80 |
|
| 81 |
set_transient(self::TRANSIENT_PREFIX . $token, $session, $duration); |
| 82 |
self::set_cookie($token, $session['expires']); |
| 83 |
|
| 84 |
$mu = self::install_mu_plugin(); |
| 85 |
return ['ok' => true, 'token' => $token, 'mu' => $mu, 'session' => $session]; |
| 86 |
} |
| 87 |
|
| 88 |
public static function stop(): void { |
| 89 |
$token = self::current_token(); |
| 90 |
if ($token) delete_transient(self::TRANSIENT_PREFIX . $token); |
| 91 |
self::clear_cookie(); |
| 92 |
unset($GLOBALS['phpinfowp_safemode_session']); |
| 93 |
} |
| 94 |
|
| 95 |
public static function is_active(): bool { |
| 96 |
return self::current_session() !== null; |
| 97 |
} |
| 98 |
|
| 99 |
public static function current_session(): ?array { |
| 100 |
$token = self::current_token(); |
| 101 |
if (!$token) return null; |
| 102 |
return self::load_session($token); |
| 103 |
} |
| 104 |
|
| 105 |
private static function current_token(): ?string { |
| 106 |
$t = $_COOKIE[self::COOKIE] ?? ''; |
| 107 |
if (!is_string($t) || !preg_match('/^[a-f0-9]{32}$/', $t)) return null; |
| 108 |
return $t; |
| 109 |
} |
| 110 |
|
| 111 |
private static function load_session(string $token): ?array { |
| 112 |
$session = get_transient(self::TRANSIENT_PREFIX . $token); |
| 113 |
if (!is_array($session)) return null; |
| 114 |
if ((int) ($session['expires'] ?? 0) < time()) { |
| 115 |
delete_transient(self::TRANSIENT_PREFIX . $token); |
| 116 |
return null; |
| 117 |
} |
| 118 |
// If a different user is now logged in, refuse to apply this session. |
| 119 |
// We don't clear the cookie here — the original user might log back in. |
| 120 |
$current_uid = get_current_user_id(); |
| 121 |
if ($current_uid > 0 && (int) ($session['user_id'] ?? 0) !== $current_uid) { |
| 122 |
return null; |
| 123 |
} |
| 124 |
return $session; |
| 125 |
} |
| 126 |
|
| 127 |
// -- Cookie handling --------------------------------------------------- |
| 128 |
|
| 129 |
private static function set_cookie(string $token, int $expires): void { |
| 130 |
$secure = is_ssl(); |
| 131 |
$opts = [ |
| 132 |
'expires' => $expires, |
| 133 |
'path' => COOKIEPATH ?: '/', |
| 134 |
'domain' => COOKIE_DOMAIN ?: '', |
| 135 |
'secure' => $secure, |
| 136 |
'httponly' => true, |
| 137 |
'samesite' => 'Lax', |
| 138 |
]; |
| 139 |
if (!headers_sent()) setcookie(self::COOKIE, $token, $opts); |
| 140 |
$_COOKIE[self::COOKIE] = $token; |
| 141 |
} |
| 142 |
|
| 143 |
private static function clear_cookie(): void { |
| 144 |
if (!headers_sent()) { |
| 145 |
setcookie(self::COOKIE, '', [ |
| 146 |
'expires' => time() - 3600, |
| 147 |
'path' => COOKIEPATH ?: '/', |
| 148 |
'domain' => COOKIE_DOMAIN ?: '', |
| 149 |
]); |
| 150 |
} |
| 151 |
unset($_COOKIE[self::COOKIE]); |
| 152 |
} |
| 153 |
|
| 154 |
// -- Filters ----------------------------------------------------------- |
| 155 |
|
| 156 |
public static function filter_active_plugins($plugins) { |
| 157 |
$s = $GLOBALS['phpinfowp_safemode_session'] ?? null; |
| 158 |
if (!$s || !is_array($plugins)) return $plugins; |
| 159 |
return array_values(array_diff($plugins, (array) ($s['disabled_plugins'] ?? []))); |
| 160 |
} |
| 161 |
|
| 162 |
public static function filter_sitewide_plugins($plugins) { |
| 163 |
$s = $GLOBALS['phpinfowp_safemode_session'] ?? null; |
| 164 |
if (!$s || !is_array($plugins)) return $plugins; |
| 165 |
foreach ((array) ($s['disabled_plugins'] ?? []) as $p) unset($plugins[$p]); |
| 166 |
return $plugins; |
| 167 |
} |
| 168 |
|
| 169 |
public static function default_theme($theme) { |
| 170 |
return defined('WP_DEFAULT_THEME') && WP_DEFAULT_THEME ? WP_DEFAULT_THEME : $theme; |
| 171 |
} |
| 172 |
|
| 173 |
// -- mu-plugin install / uninstall ------------------------------------- |
| 174 |
|
| 175 |
public static function install_mu_plugin(): array { |
| 176 |
$dir = self::mu_dir(); |
| 177 |
if (!file_exists($dir) && !@mkdir($dir, 0755, true)) { |
| 178 |
return ['installed' => false, 'reason' => 'mu-plugins directory could not be created']; |
| 179 |
} |
| 180 |
if (!is_dir($dir) || !is_writable($dir)) { |
| 181 |
return ['installed' => false, 'reason' => 'mu-plugins directory not writable']; |
| 182 |
} |
| 183 |
$target = $dir . '/' . self::MU_FILE; |
| 184 |
$content = self::mu_plugin_source(); |
| 185 |
$ok = (bool) @file_put_contents($target, $content); |
| 186 |
return [ |
| 187 |
'installed' => $ok, |
| 188 |
'path' => $target, |
| 189 |
'reason' => $ok ? 'installed' : 'write failed', |
| 190 |
]; |
| 191 |
} |
| 192 |
|
| 193 |
public static function uninstall_mu_plugin(): bool { |
| 194 |
$target = self::mu_dir() . '/' . self::MU_FILE; |
| 195 |
if (!file_exists($target)) return true; |
| 196 |
return (bool) @unlink($target); |
| 197 |
} |
| 198 |
|
| 199 |
public static function mu_plugin_installed(): bool { |
| 200 |
return file_exists(self::mu_dir() . '/' . self::MU_FILE); |
| 201 |
} |
| 202 |
|
| 203 |
private static function mu_dir(): string { |
| 204 |
return defined('WPMU_PLUGIN_DIR') && WPMU_PLUGIN_DIR |
| 205 |
? WPMU_PLUGIN_DIR |
| 206 |
: WP_CONTENT_DIR . '/mu-plugins'; |
| 207 |
} |
| 208 |
|
| 209 |
/** |
| 210 |
* The mu-plugin runs before regular plugins load. It is harmless when no |
| 211 |
* cookie is present (early return). Hand-rolled to avoid coupling to the |
| 212 |
* full phpinfo() WP runtime — mu-plugins should be minimal & dependency-free. |
| 213 |
*/ |
| 214 |
private static function mu_plugin_source(): string { |
| 215 |
return <<<'PHP' |
| 216 |
<?php |
| 217 |
/** |
| 218 |
* phpinfo() WP — Troubleshooting Safemode mu-plugin |
| 219 |
* |
| 220 |
* Installed automatically by phpinfo() WP. Filters the active plugins list |
| 221 |
* and (optionally) the active theme for users carrying a valid safemode |
| 222 |
* cookie. Harmless when no cookie is set. |
| 223 |
* |
| 224 |
* Safe to leave installed. Removed automatically on plugin uninstall. |
| 225 |
*/ |
| 226 |
if (!defined('ABSPATH')) exit; |
| 227 |
|
| 228 |
$_phpiwp_token = $_COOKIE['phpinfowp_safemode'] ?? ''; |
| 229 |
if (!is_string($_phpiwp_token) || !preg_match('/^[a-f0-9]{32}$/', $_phpiwp_token)) return; |
| 230 |
|
| 231 |
$_phpiwp_apply = function($_phpiwp_token) { |
| 232 |
static $session = null; |
| 233 |
if ($session !== null) return $session ?: null; |
| 234 |
$s = get_transient('phpinfowp_safemode_' . $_phpiwp_token); |
| 235 |
if (!is_array($s) || ((int)($s['expires'] ?? 0)) < time()) { |
| 236 |
$session = false; |
| 237 |
return null; |
| 238 |
} |
| 239 |
$session = $s; |
| 240 |
return $s; |
| 241 |
}; |
| 242 |
|
| 243 |
add_filter('option_active_plugins', function($plugins) use ($_phpiwp_token, $_phpiwp_apply) { |
| 244 |
if (!is_array($plugins)) return $plugins; |
| 245 |
$s = $_phpiwp_apply($_phpiwp_token); |
| 246 |
if (!$s) return $plugins; |
| 247 |
return array_values(array_diff($plugins, (array)($s['disabled_plugins'] ?? []))); |
| 248 |
}, 0); |
| 249 |
|
| 250 |
add_filter('site_option_active_sitewide_plugins', function($plugins) use ($_phpiwp_token, $_phpiwp_apply) { |
| 251 |
if (!is_array($plugins)) return $plugins; |
| 252 |
$s = $_phpiwp_apply($_phpiwp_token); |
| 253 |
if (!$s) return $plugins; |
| 254 |
foreach ((array)($s['disabled_plugins'] ?? []) as $p) unset($plugins[$p]); |
| 255 |
return $plugins; |
| 256 |
}, 0); |
| 257 |
|
| 258 |
add_filter('stylesheet', function($theme) use ($_phpiwp_token, $_phpiwp_apply) { |
| 259 |
$s = $_phpiwp_apply($_phpiwp_token); |
| 260 |
if (!$s || empty($s['disable_theme'])) return $theme; |
| 261 |
return defined('WP_DEFAULT_THEME') && WP_DEFAULT_THEME ? WP_DEFAULT_THEME : $theme; |
| 262 |
}, 0); |
| 263 |
|
| 264 |
add_filter('template', function($theme) use ($_phpiwp_token, $_phpiwp_apply) { |
| 265 |
$s = $_phpiwp_apply($_phpiwp_token); |
| 266 |
if (!$s || empty($s['disable_theme'])) return $theme; |
| 267 |
return defined('WP_DEFAULT_THEME') && WP_DEFAULT_THEME ? WP_DEFAULT_THEME : $theme; |
| 268 |
}, 0); |
| 269 |
PHP; |
| 270 |
} |
| 271 |
} |
| 272 |
|