| @@ -64,8 +64,23 @@ | ||
| 64 | 64 | */ |
| 65 | 65 | const CRON_HOOK = 'metasync_check_debug_limits'; |
| 66 | 66 | |
| 67 | 67 | /** |
| 68 | + * Outcomes of a wp-config.php constant write. | |
| 69 | + * | |
| 70 | + * WRITE_PARTIAL matters: some constants are live on disk, so the caller must not | |
| 71 | + * report "off" - that hides the dashboard widget and stops the auto-disable cron. | |
| 72 | + */ | |
| 73 | + const WRITE_OK = 'ok'; | |
| 74 | + const WRITE_PARTIAL = 'partial'; | |
| 75 | + const WRITE_FAILED = 'failed'; | |
| 76 | + | |
| 77 | + /** | |
| 78 | + * Maximum queued admin notices. | |
| 79 | + */ | |
| 80 | + const MAX_NOTICES = 5; | |
| 81 | + | |
| 82 | + /** | |
| 68 | 83 | * Option key for debug mode settings |
| 69 | 84 | * |
| 70 | 85 | * @var string |
| 71 | 86 | */ |
| @@ -173,8 +188,10 @@ | ||
| 173 | 188 | * @return bool Success status |
| 174 | 189 | */ |
| 175 | 190 | public function enable_debug_mode($indefinite = false) |
| 176 | 191 | { |
| 192 | + $previousSettings = get_option(self::OPTION_KEY); | |
| 193 | + | |
| 177 | 194 | $settings = array( |
| 178 | 195 | 'enabled' => true, |
| 179 | 196 | 'enabled_at' => current_time('timestamp'), |
| 180 | 197 | 'indefinite' => $indefinite, |
| @@ -184,15 +201,33 @@ | ||
| 184 | 201 | $result = update_option(self::OPTION_KEY, $settings); |
| 185 | 202 | |
| 186 | 203 | if ($result) { |
| 187 | 204 | // Enable WP_DEBUG constants via ConfigController |
| 188 | - $this->update_wp_debug_constants(true); | |
| 205 | + $write = $this->update_wp_debug_constants(true); | |
| 189 | 206 | |
| 190 | - // Ensure cron job is scheduled (safety net) | |
| 207 | + // Ensure cron job is scheduled (safety net). Scheduled before the write is | |
| 208 | + // judged, because the partial-write branch below deliberately leaves debug | |
| 209 | + // enabled on the assumption this cron can still clean it up. | |
| 191 | 210 | if (!wp_next_scheduled(self::CRON_HOOK)) { |
| 192 | 211 | wp_schedule_event(time(), 'hourly', self::CRON_HOOK); |
| 193 | 212 | } |
| 194 | 213 | |
| 214 | + if (self::WRITE_OK !== $write) { | |
| 215 | + // Only roll the tracking state back when nothing reached wp-config.php. | |
| 216 | + // After a partial write the constants that landed are live, and a state of | |
| 217 | + // "off" would hide the dashboard widget and make the auto-disable cron | |
| 218 | + // skip - leaving debug on with nothing left to turn it off. | |
| 219 | + if (self::WRITE_FAILED === $write) { | |
| 220 | + if (false !== $previousSettings) { | |
| 221 | + update_option(self::OPTION_KEY, $previousSettings); | |
| 222 | + } else { | |
| 223 | + delete_option(self::OPTION_KEY); | |
| 224 | + } | |
| 225 | + } | |
| 226 | + | |
| 227 | + return false; | |
| 228 | + } | |
| 229 | + | |
| 195 | 230 | // Clear any previous notices |
| 196 | 231 | delete_transient(self::NOTICE_TRANSIENT); |
| 197 | 232 | |
| 198 | 233 | // Log the action |
| @@ -209,8 +244,10 @@ | ||
| 209 | 244 | * @return bool Success status |
| 210 | 245 | */ |
| 211 | 246 | public function disable_debug_mode($reason = 'manual') |
| 212 | 247 | { |
| 248 | + $previousSettings = get_option(self::OPTION_KEY); | |
| 249 | + | |
| 213 | 250 | $settings = $this->get_settings(); |
| 214 | 251 | $settings['enabled'] = false; |
| 215 | 252 | $settings['disabled_at'] = current_time('timestamp'); |
| 216 | 253 | $settings['disabled_reason'] = $reason; |
| @@ -218,10 +255,20 @@ | ||
| 218 | 255 | $result = update_option(self::OPTION_KEY, $settings); |
| 219 | 256 | |
| 220 | 257 | if ($result) { |
| 221 | 258 | // Disable WP_DEBUG constants via ConfigController |
| 222 | - $this->update_wp_debug_constants(false); | |
| 259 | + if (self::WRITE_OK !== $this->update_wp_debug_constants(false)) { | |
| 260 | + // Debug is still on in wp-config.php, wholly or partly. Restore the | |
| 261 | + // enabled state so it matches the file: that keeps the dashboard widget | |
| 262 | + // visible and the auto-disable cron running, which is what will eventually | |
| 263 | + // clear it. Reporting "disabled" here would hide both. | |
| 264 | + if (false !== $previousSettings) { | |
| 265 | + update_option(self::OPTION_KEY, $previousSettings); | |
| 266 | + } | |
| 223 | 267 | |
| 268 | + return false; | |
| 269 | + } | |
| 270 | + | |
| 224 | 271 | // Add admin notice |
| 225 | 272 | $this->add_notice( |
| 226 | 273 | 'Debug mode has been disabled (' . $reason . ').', |
| 227 | 274 | 'info' |
| @@ -306,13 +353,17 @@ | ||
| 306 | 353 | $elapsed_time = $current_time - $enabled_at; |
| 307 | 354 | |
| 308 | 355 | // Check if 24 hours have passed |
| 309 | 356 | if ($elapsed_time >= self::DEBUG_DURATION) { |
| 310 | - $this->disable_debug_mode('auto_expired'); | |
| 311 | - $this->add_notice( | |
| 312 | - 'Debug mode auto-disabled after 24 hours.', | |
| 313 | - 'warning' | |
| 314 | - ); | |
| 357 | + // Only announce the auto-disable if it actually succeeded. This runs hourly, | |
| 358 | + // so claiming it unconditionally would repeat the message every hour on a site | |
| 359 | + // where wp-config.php cannot be written. | |
| 360 | + if ($this->disable_debug_mode('auto_expired')) { | |
| 361 | + $this->add_notice( | |
| 362 | + 'Debug mode auto-disabled after 24 hours.', | |
| 363 | + 'warning' | |
| 364 | + ); | |
| 365 | + } | |
| 315 | 366 | } |
| 316 | 367 | } |
| 317 | 368 | |
| 318 | 369 | /** |
| @@ -397,8 +448,25 @@ | ||
| 397 | 448 | } |
| 398 | 449 | } |
| 399 | 450 | |
| 400 | 451 | /** |
| 452 | + * Check whether debug mode is currently enabled | |
| 453 | + * | |
| 454 | + * Static and side-effect free: reads the option directly rather than going | |
| 455 | + * through get_instance(), which registers hooks as part of construction. | |
| 456 | + * Safe to call from front-end request paths, including ones that run before | |
| 457 | + * the singleton is initialized on 'init'. | |
| 458 | + * | |
| 459 | + * @return bool True when debug mode is on | |
| 460 | + */ | |
| 461 | + public static function is_enabled() | |
| 462 | + { | |
| 463 | + $settings = get_option(self::OPTION_KEY, array()); | |
| 464 | + | |
| 465 | + return is_array($settings) && !empty($settings['enabled']); | |
| 466 | + } | |
| 467 | + | |
| 468 | + /** | |
| 401 | 469 | * Get current debug mode settings |
| 402 | 470 | * |
| 403 | 471 | * @return array Debug mode settings |
| 404 | 472 | */ |
| @@ -461,20 +529,57 @@ | ||
| 461 | 529 | /** |
| 462 | 530 | * Update WP_DEBUG constants via ConfigController |
| 463 | 531 | * |
| 464 | 532 | * @param bool $enable Whether to enable or disable |
| 465 | - * @return void | |
| 533 | + * @return string One of self::WRITE_OK, self::WRITE_PARTIAL or self::WRITE_FAILED. | |
| 466 | 534 | */ |
| 467 | 535 | private function update_wp_debug_constants($enable) |
| 468 | 536 | { |
| 537 | + $previousEnabled = get_option('wp_debug_enabled', 'false'); | |
| 538 | + $previousLog = get_option('wp_debug_log_enabled', 'false'); | |
| 539 | + | |
| 469 | 540 | try { |
| 470 | 541 | update_option('wp_debug_enabled', $enable ? 'true' : 'false'); |
| 471 | 542 | update_option('wp_debug_log_enabled', $enable ? 'true' : 'false'); |
| 472 | 543 | |
| 473 | 544 | $config_controller = new ConfigControllerMetaSync(); |
| 474 | - $config_controller->store(); | |
| 475 | - } catch (Exception $e) { | |
| 545 | + | |
| 546 | + $reason = ''; | |
| 547 | + $partial = false; | |
| 548 | + if (!$config_controller->isReady()) { | |
| 549 | + $reason = $config_controller->getConfigError() !== '' | |
| 550 | + ? $config_controller->getConfigError() | |
| 551 | + : 'the file is not writable.'; | |
| 552 | + } elseif (!$config_controller->store()) { | |
| 553 | + $reason = $config_controller->getConfigError() !== '' | |
| 554 | + ? $config_controller->getConfigError() | |
| 555 | + : 'the write did not complete.'; | |
| 556 | + $partial = $config_controller->hadPartialWrite(); | |
| 557 | + } | |
| 558 | + | |
| 559 | + if ('' !== $reason) { | |
| 560 | + if (!$partial) { | |
| 561 | + // Nothing reached the file, so the flags can safely go back. | |
| 562 | + update_option('wp_debug_enabled', $previousEnabled); | |
| 563 | + update_option('wp_debug_log_enabled', $previousLog); | |
| 564 | + } | |
| 565 | + | |
| 566 | + // admin_notices never fires during a REST request, so the controller's own | |
| 567 | + // notice would be discarded - use the transient notices the admin reads. | |
| 568 | + $this->add_notice('wp-config.php could not be updated: ' . $reason, 'error'); | |
| 569 | + error_log('MetaSync: wp-config.php not updated - ' . $reason); | |
| 570 | + | |
| 571 | + return $partial ? self::WRITE_PARTIAL : self::WRITE_FAILED; | |
| 572 | + } | |
| 573 | + | |
| 574 | + return self::WRITE_OK; | |
| 575 | + } catch (\Throwable $e) { | |
| 576 | + // Throwable rather than Exception: an Error here would be a fatal on what is | |
| 577 | + // an ordinary REST request. | |
| 578 | + update_option('wp_debug_enabled', $previousEnabled); | |
| 579 | + update_option('wp_debug_log_enabled', $previousLog); | |
| 476 | 580 | error_log('MetaSync: Error updating wp-config.php - ' . $e->getMessage()); |
| 581 | + return self::WRITE_FAILED; | |
| 477 | 582 | } |
| 478 | 583 | } |
| 479 | 584 | |
| 480 | 585 | /** |
| @@ -490,13 +595,42 @@ | ||
| 490 | 595 | if (!is_array($notices)) { |
| 491 | 596 | $notices = array(); |
| 492 | 597 | } |
| 493 | 598 | |
| 599 | + // Skip an identical message that is already queued. The limit checks run hourly, | |
| 600 | + // so a persistent failure would otherwise append the same notice every hour - | |
| 601 | + // and set_transient() refreshes the TTL each time, so the queue never expires. | |
| 602 | + foreach ($notices as $existing) { | |
| 603 | + if (isset($existing['message'], $existing['type']) | |
| 604 | + && $existing['message'] === $message | |
| 605 | + && $existing['type'] === $type) { | |
| 606 | + return; | |
| 607 | + } | |
| 608 | + } | |
| 609 | + | |
| 494 | 610 | $notices[] = array( |
| 495 | 611 | 'message' => $message, |
| 496 | 612 | 'type' => $type, |
| 497 | 613 | 'timestamp' => current_time('timestamp') |
| 498 | 614 | ); |
| 615 | + | |
| 616 | + // Keep the queue bounded so the admin screen cannot be flooded. Drop informational | |
| 617 | + // notices first: evicting oldest-first would discard an error about wp-config.php | |
| 618 | + // not being writable in favour of routine status messages. | |
| 619 | + if (count($notices) > self::MAX_NOTICES) { | |
| 620 | + $errors = array_values(array_filter($notices, function ($notice) { | |
| 621 | + return isset($notice['type']) && 'error' === $notice['type']; | |
| 622 | + })); | |
| 623 | + $others = array_values(array_filter($notices, function ($notice) { | |
| 624 | + return !isset($notice['type']) || 'error' !== $notice['type']; | |
| 625 | + })); | |
| 626 | + | |
| 627 | + $keepOthers = max(0, self::MAX_NOTICES - count($errors)); | |
| 628 | + $notices = array_merge( | |
| 629 | + array_slice($errors, -self::MAX_NOTICES), | |
| 630 | + array_slice($others, -$keepOthers) | |
| 631 | + ); | |
| 632 | + } | |
| 499 | 633 | |
| 500 | 634 | set_transient(self::NOTICE_TRANSIENT, $notices, DAY_IN_SECONDS); |
| 501 | 635 | } |
| 502 | 636 | |