| @@ -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,20 +201,36 @@ | ||
| 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 | - // Log the action | |
| 199 | - error_log('MetaSync: Debug mode enabled' . ($indefinite ? ' (indefinite)' : ' (24 hours)')); | |
| 200 | 233 | } |
| 201 | 234 | |
| 202 | 235 | return $result; |
| 203 | 236 | } |
| @@ -209,8 +242,10 @@ | ||
| 209 | 242 | * @return bool Success status |
| 210 | 243 | */ |
| 211 | 244 | public function disable_debug_mode($reason = 'manual') |
| 212 | 245 | { |
| 246 | + $previousSettings = get_option(self::OPTION_KEY); | |
| 247 | + | |
| 213 | 248 | $settings = $this->get_settings(); |
| 214 | 249 | $settings['enabled'] = false; |
| 215 | 250 | $settings['disabled_at'] = current_time('timestamp'); |
| 216 | 251 | $settings['disabled_reason'] = $reason; |
| @@ -218,10 +253,20 @@ | ||
| 218 | 253 | $result = update_option(self::OPTION_KEY, $settings); |
| 219 | 254 | |
| 220 | 255 | if ($result) { |
| 221 | 256 | // Disable WP_DEBUG constants via ConfigController |
| 222 | - $this->update_wp_debug_constants(false); | |
| 257 | + if (self::WRITE_OK !== $this->update_wp_debug_constants(false)) { | |
| 258 | + // Debug is still on in wp-config.php, wholly or partly. Restore the | |
| 259 | + // enabled state so it matches the file: that keeps the dashboard widget | |
| 260 | + // visible and the auto-disable cron running, which is what will eventually | |
| 261 | + // clear it. Reporting "disabled" here would hide both. | |
| 262 | + if (false !== $previousSettings) { | |
| 263 | + update_option(self::OPTION_KEY, $previousSettings); | |
| 264 | + } | |
| 223 | 265 | |
| 266 | + return false; | |
| 267 | + } | |
| 268 | + | |
| 224 | 269 | // Add admin notice |
| 225 | 270 | $this->add_notice( |
| 226 | 271 | 'Debug mode has been disabled (' . $reason . ').', |
| 227 | 272 | 'info' |
| @@ -226,10 +271,8 @@ | ||
| 226 | 271 | 'Debug mode has been disabled (' . $reason . ').', |
| 227 | 272 | 'info' |
| 228 | 273 | ); |
| 229 | 274 | |
| 230 | - // Log the action | |
| 231 | - error_log('MetaSync: Debug mode disabled - ' . $reason); | |
| 232 | 275 | } |
| 233 | 276 | |
| 234 | 277 | return $result; |
| 235 | 278 | } |
| @@ -306,13 +349,17 @@ | ||
| 306 | 349 | $elapsed_time = $current_time - $enabled_at; |
| 307 | 350 | |
| 308 | 351 | // Check if 24 hours have passed |
| 309 | 352 | 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 | - ); | |
| 353 | + // Only announce the auto-disable if it actually succeeded. This runs hourly, | |
| 354 | + // so claiming it unconditionally would repeat the message every hour on a site | |
| 355 | + // where wp-config.php cannot be written. | |
| 356 | + if ($this->disable_debug_mode('auto_expired')) { | |
| 357 | + $this->add_notice( | |
| 358 | + 'Debug mode auto-disabled after 24 hours.', | |
| 359 | + 'warning' | |
| 360 | + ); | |
| 361 | + } | |
| 315 | 362 | } |
| 316 | 363 | } |
| 317 | 364 | |
| 318 | 365 | /** |
| @@ -351,18 +398,18 @@ | ||
| 351 | 398 | $backup_path = $this->log_file_path . '.old'; |
| 352 | 399 | |
| 353 | 400 | // Remove existing .old file if it exists |
| 354 | 401 | if (file_exists($backup_path)) { |
| 355 | - @unlink($backup_path); | |
| 402 | + @wp_delete_file($backup_path); | |
| 356 | 403 | } |
| 357 | 404 | |
| 358 | 405 | // Rename current log to .old |
| 359 | - $result = @rename($this->log_file_path, $backup_path); | |
| 406 | + $result = @rename($this->log_file_path, $backup_path); // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- atomic same-filesystem replace; WP_Filesystem::move() needs credential bootstrapping on non-direct hosts | |
| 360 | 407 | |
| 361 | 408 | if ($result) { |
| 362 | 409 | // Create new empty log file |
| 363 | 410 | @file_put_contents($this->log_file_path, ''); |
| 364 | - error_log('MetaSync: Debug log rotated - exceeded 10MB limit'); | |
| 411 | + error_log('MetaSync: Debug log rotated - exceeded 10MB limit'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- plugin logger sink / last-resort fallback | |
| 365 | 412 | } |
| 366 | 413 | |
| 367 | 414 | // Cleanup old rotations (keep only MAX_ROTATED_LOGS) |
| 368 | 415 | $this->cleanup_old_rotations(); |
| @@ -391,14 +438,31 @@ | ||
| 391 | 438 | |
| 392 | 439 | // Delete oldest files, keep only MAX_ROTATED_LOGS |
| 393 | 440 | $to_delete = array_slice($old_logs, 0, count($old_logs) - self::MAX_ROTATED_LOGS); |
| 394 | 441 | foreach ($to_delete as $old_log) { |
| 395 | - @unlink($old_log); | |
| 442 | + @wp_delete_file($old_log); | |
| 396 | 443 | } |
| 397 | 444 | } |
| 398 | 445 | } |
| 399 | 446 | |
| 400 | 447 | /** |
| 448 | + * Check whether debug mode is currently enabled | |
| 449 | + * | |
| 450 | + * Static and side-effect free: reads the option directly rather than going | |
| 451 | + * through get_instance(), which registers hooks as part of construction. | |
| 452 | + * Safe to call from front-end request paths, including ones that run before | |
| 453 | + * the singleton is initialized on 'init'. | |
| 454 | + * | |
| 455 | + * @return bool True when debug mode is on | |
| 456 | + */ | |
| 457 | + public static function is_enabled() | |
| 458 | + { | |
| 459 | + $settings = get_option(self::OPTION_KEY, array()); | |
| 460 | + | |
| 461 | + return is_array($settings) && !empty($settings['enabled']); | |
| 462 | + } | |
| 463 | + | |
| 464 | + /** | |
| 401 | 465 | * Get current debug mode settings |
| 402 | 466 | * |
| 403 | 467 | * @return array Debug mode settings |
| 404 | 468 | */ |
| @@ -461,20 +525,57 @@ | ||
| 461 | 525 | /** |
| 462 | 526 | * Update WP_DEBUG constants via ConfigController |
| 463 | 527 | * |
| 464 | 528 | * @param bool $enable Whether to enable or disable |
| 465 | - * @return void | |
| 529 | + * @return string One of self::WRITE_OK, self::WRITE_PARTIAL or self::WRITE_FAILED. | |
| 466 | 530 | */ |
| 467 | 531 | private function update_wp_debug_constants($enable) |
| 468 | 532 | { |
| 533 | + $previousEnabled = get_option('wp_debug_enabled', 'false'); | |
| 534 | + $previousLog = get_option('wp_debug_log_enabled', 'false'); | |
| 535 | + | |
| 469 | 536 | try { |
| 470 | 537 | update_option('wp_debug_enabled', $enable ? 'true' : 'false'); |
| 471 | 538 | update_option('wp_debug_log_enabled', $enable ? 'true' : 'false'); |
| 472 | 539 | |
| 473 | 540 | $config_controller = new ConfigControllerMetaSync(); |
| 474 | - $config_controller->store(); | |
| 475 | - } catch (Exception $e) { | |
| 476 | - error_log('MetaSync: Error updating wp-config.php - ' . $e->getMessage()); | |
| 541 | + | |
| 542 | + $reason = ''; | |
| 543 | + $partial = false; | |
| 544 | + if (!$config_controller->isReady()) { | |
| 545 | + $reason = $config_controller->getConfigError() !== '' | |
| 546 | + ? $config_controller->getConfigError() | |
| 547 | + : 'the file is not writable.'; | |
| 548 | + } elseif (!$config_controller->store()) { | |
| 549 | + $reason = $config_controller->getConfigError() !== '' | |
| 550 | + ? $config_controller->getConfigError() | |
| 551 | + : 'the write did not complete.'; | |
| 552 | + $partial = $config_controller->hadPartialWrite(); | |
| 553 | + } | |
| 554 | + | |
| 555 | + if ('' !== $reason) { | |
| 556 | + if (!$partial) { | |
| 557 | + // Nothing reached the file, so the flags can safely go back. | |
| 558 | + update_option('wp_debug_enabled', $previousEnabled); | |
| 559 | + update_option('wp_debug_log_enabled', $previousLog); | |
| 560 | + } | |
| 561 | + | |
| 562 | + // admin_notices never fires during a REST request, so the controller's own | |
| 563 | + // notice would be discarded - use the transient notices the admin reads. | |
| 564 | + $this->add_notice('wp-config.php could not be updated: ' . $reason, 'error'); | |
| 565 | + error_log('MetaSync: wp-config.php not updated - ' . $reason); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets | |
| 566 | + | |
| 567 | + return $partial ? self::WRITE_PARTIAL : self::WRITE_FAILED; | |
| 568 | + } | |
| 569 | + | |
| 570 | + return self::WRITE_OK; | |
| 571 | + } catch (\Throwable $e) { | |
| 572 | + // Throwable rather than Exception: an Error here would be a fatal on what is | |
| 573 | + // an ordinary REST request. | |
| 574 | + update_option('wp_debug_enabled', $previousEnabled); | |
| 575 | + update_option('wp_debug_log_enabled', $previousLog); | |
| 576 | + error_log('MetaSync: Error updating wp-config.php - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets | |
| 577 | + return self::WRITE_FAILED; | |
| 477 | 578 | } |
| 478 | 579 | } |
| 479 | 580 | |
| 480 | 581 | /** |
| @@ -490,13 +591,42 @@ | ||
| 490 | 591 | if (!is_array($notices)) { |
| 491 | 592 | $notices = array(); |
| 492 | 593 | } |
| 493 | 594 | |
| 595 | + // Skip an identical message that is already queued. The limit checks run hourly, | |
| 596 | + // so a persistent failure would otherwise append the same notice every hour - | |
| 597 | + // and set_transient() refreshes the TTL each time, so the queue never expires. | |
| 598 | + foreach ($notices as $existing) { | |
| 599 | + if (isset($existing['message'], $existing['type']) | |
| 600 | + && $existing['message'] === $message | |
| 601 | + && $existing['type'] === $type) { | |
| 602 | + return; | |
| 603 | + } | |
| 604 | + } | |
| 605 | + | |
| 494 | 606 | $notices[] = array( |
| 495 | 607 | 'message' => $message, |
| 496 | 608 | 'type' => $type, |
| 497 | 609 | 'timestamp' => current_time('timestamp') |
| 498 | 610 | ); |
| 611 | + | |
| 612 | + // Keep the queue bounded so the admin screen cannot be flooded. Drop informational | |
| 613 | + // notices first: evicting oldest-first would discard an error about wp-config.php | |
| 614 | + // not being writable in favour of routine status messages. | |
| 615 | + if (count($notices) > self::MAX_NOTICES) { | |
| 616 | + $errors = array_values(array_filter($notices, function ($notice) { | |
| 617 | + return isset($notice['type']) && 'error' === $notice['type']; | |
| 618 | + })); | |
| 619 | + $others = array_values(array_filter($notices, function ($notice) { | |
| 620 | + return !isset($notice['type']) || 'error' !== $notice['type']; | |
| 621 | + })); | |
| 622 | + | |
| 623 | + $keepOthers = max(0, self::MAX_NOTICES - count($errors)); | |
| 624 | + $notices = array_merge( | |
| 625 | + array_slice($errors, -self::MAX_NOTICES), | |
| 626 | + array_slice($others, -$keepOthers) | |
| 627 | + ); | |
| 628 | + } | |
| 499 | 629 | |
| 500 | 630 | set_transient(self::NOTICE_TRANSIENT, $notices, DAY_IN_SECONDS); |
| 501 | 631 | } |
| 502 | 632 | |