| 1 |
<?php |
| 2 |
/** |
| 3 |
* One-click repairs for the states the health checks report. |
| 4 |
* |
| 5 |
* @package Forge12\DoubleOptIn\Health |
| 6 |
* @since 5.5.0 |
| 7 |
*/ |
| 8 |
|
| 9 |
declare( strict_types=1 ); |
| 10 |
|
| 11 |
namespace Forge12\DoubleOptIn\Health; |
| 12 |
|
| 13 |
if ( ! defined( 'ABSPATH' ) ) { |
| 14 |
exit; |
| 15 |
} |
| 16 |
|
| 17 |
/** |
| 18 |
* Handles the two repairs an operator can trigger from a health check: |
| 19 |
* deactivating the legacy Pro monolith, and clearing bundle-pro's burned |
| 20 |
* one-shot markers. |
| 21 |
* |
| 22 |
* ## admin_post, not REST |
| 23 |
* |
| 24 |
* Both repairs exist for sites that are already in trouble. The React admin |
| 25 |
* may not be reachable there — and the WordPress Site Health screen, which |
| 26 |
* is where a check's action link is rendered, is a plain admin page. A |
| 27 |
* nonce-protected `admin_post` handler works in both places and needs no |
| 28 |
* JavaScript. |
| 29 |
* |
| 30 |
* ## Deactivate, never delete |
| 31 |
* |
| 32 |
* Deactivating is enough to clear the fatal: the redeclare only happens |
| 33 |
* while both plugins load. The folder may stay on disk. |
| 34 |
* |
| 35 |
* Deleting it from here would be actively harmful. `delete_plugins()` runs |
| 36 |
* the target's `uninstall.php`, and the monolith's routine drops |
| 37 |
* `{prefix}f12_cf7_doubleoptin_optout` — the very table addon-opt-out uses. |
| 38 |
* A customer lost their opt-out records to exactly that on 2026-09-09, via |
| 39 |
* the WordPress plugin delete button. The core must not offer a second way |
| 40 |
* to do it. Removing the folder stays a manual step, and the check says so. |
| 41 |
* |
| 42 |
* `deactivate_plugins( ..., true )` passes `$silent = true` so the old |
| 43 |
* plugin's deactivation hook does not run — that code is the half of the |
| 44 |
* plugin we are trying not to execute. |
| 45 |
*/ |
| 46 |
final class HealthRepairController { |
| 47 |
|
| 48 |
public const ACTION_DEACTIVATE_LEGACY = 'f12_doi_repair_legacy_pro'; |
| 49 |
public const ACTION_RESET_MARKERS = 'f12_doi_repair_pro_markers'; |
| 50 |
|
| 51 |
/** |
| 52 |
* Query arg carrying the outcome back to the screen we came from. |
| 53 |
*/ |
| 54 |
public const RESULT_ARG = 'f12_doi_repair'; |
| 55 |
|
| 56 |
public function register(): void { |
| 57 |
add_action( 'admin_post_' . self::ACTION_DEACTIVATE_LEGACY, array( $this, 'handleDeactivateLegacy' ) ); |
| 58 |
add_action( 'admin_post_' . self::ACTION_RESET_MARKERS, array( $this, 'handleResetMarkers' ) ); |
| 59 |
add_action( 'admin_notices', array( $this, 'renderResultNotice' ) ); |
| 60 |
} |
| 61 |
|
| 62 |
/** |
| 63 |
* Nonce-protected URL for one of the repair actions. |
| 64 |
*/ |
| 65 |
public static function repairUrl( string $action ): string { |
| 66 |
$url = add_query_arg( 'action', $action, admin_url( 'admin-post.php' ) ); |
| 67 |
|
| 68 |
return wp_nonce_url( $url, $action ); |
| 69 |
} |
| 70 |
|
| 71 |
/** |
| 72 |
* Request handler: deactivate, then report back. |
| 73 |
*/ |
| 74 |
public function handleDeactivateLegacy(): void { |
| 75 |
$this->assertAllowed( self::ACTION_DEACTIVATE_LEGACY ); |
| 76 |
|
| 77 |
$deactivated = $this->deactivateLegacy(); |
| 78 |
|
| 79 |
$this->finish( empty( $deactivated ) ? 'nothing' : 'deactivated' ); |
| 80 |
} |
| 81 |
|
| 82 |
/** |
| 83 |
* Request handler: clear the markers, then report back. |
| 84 |
*/ |
| 85 |
public function handleResetMarkers(): void { |
| 86 |
$this->assertAllowed( self::ACTION_RESET_MARKERS ); |
| 87 |
|
| 88 |
$this->resetMarkers(); |
| 89 |
|
| 90 |
$this->finish( 'markers-reset' ); |
| 91 |
} |
| 92 |
|
| 93 |
/** |
| 94 |
* Deactivate every legacy monolith that is currently active. |
| 95 |
* |
| 96 |
* Separate from the request handler so the repair itself can be |
| 97 |
* exercised without a nonce, a capability and a redirect that would |
| 98 |
* end the process. |
| 99 |
* |
| 100 |
* @return array<int,string> The plugin files that were deactivated. |
| 101 |
*/ |
| 102 |
public function deactivateLegacy(): array { |
| 103 |
$active = LegacyProEnvironment::activeLegacyInstallations(); |
| 104 |
|
| 105 |
if ( empty( $active ) ) { |
| 106 |
return array(); |
| 107 |
} |
| 108 |
|
| 109 |
if ( ! function_exists( 'deactivate_plugins' ) ) { |
| 110 |
require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 111 |
} |
| 112 |
|
| 113 |
$files = array(); |
| 114 |
foreach ( $active as $entry ) { |
| 115 |
$files[] = $entry['file']; |
| 116 |
} |
| 117 |
|
| 118 |
// $silent = true: do not run the old plugin's deactivation hook. |
| 119 |
deactivate_plugins( $files, true ); |
| 120 |
|
| 121 |
LegacyMonolithDetector::flushCache(); |
| 122 |
|
| 123 |
return $files; |
| 124 |
} |
| 125 |
|
| 126 |
/** |
| 127 |
* Clear the one-shot markers so migration and addon install can run |
| 128 |
* again. Leaves the licence key and every form setting untouched. |
| 129 |
*/ |
| 130 |
public function resetMarkers(): void { |
| 131 |
foreach ( LegacyProEnvironment::ONE_SHOT_MARKERS as $marker ) { |
| 132 |
delete_option( $marker ); |
| 133 |
|
| 134 |
if ( function_exists( 'delete_site_option' ) ) { |
| 135 |
delete_site_option( $marker ); |
| 136 |
} |
| 137 |
} |
| 138 |
|
| 139 |
foreach ( LegacyProEnvironment::STALE_TRANSIENTS as $transient ) { |
| 140 |
delete_transient( $transient ); |
| 141 |
|
| 142 |
if ( function_exists( 'delete_site_transient' ) ) { |
| 143 |
delete_site_transient( $transient ); |
| 144 |
} |
| 145 |
} |
| 146 |
} |
| 147 |
|
| 148 |
/** |
| 149 |
* Capability + nonce, in that order, before anything is touched. |
| 150 |
*/ |
| 151 |
private function assertAllowed( string $action ): void { |
| 152 |
if ( ! current_user_can( 'manage_options' ) ) { |
| 153 |
wp_die( esc_html__( 'You are not allowed to run this repair.', 'double-opt-in' ) ); |
| 154 |
} |
| 155 |
|
| 156 |
check_admin_referer( $action ); |
| 157 |
} |
| 158 |
|
| 159 |
/** |
| 160 |
* Back to where the operator came from, carrying the outcome. |
| 161 |
*/ |
| 162 |
private function finish( string $result ): void { |
| 163 |
$target = wp_get_referer(); |
| 164 |
|
| 165 |
if ( ! is_string( $target ) || $target === '' ) { |
| 166 |
$target = admin_url( 'site-health.php' ); |
| 167 |
} |
| 168 |
|
| 169 |
wp_safe_redirect( add_query_arg( self::RESULT_ARG, $result, $target ) ); |
| 170 |
exit; |
| 171 |
} |
| 172 |
|
| 173 |
/** |
| 174 |
* Tell the operator what happened. Deliberately dismissible — unlike |
| 175 |
* the problem notices, this one is a receipt, not a warning. |
| 176 |
*/ |
| 177 |
public function renderResultNotice(): void { |
| 178 |
if ( ! current_user_can( 'manage_options' ) ) { |
| 179 |
return; |
| 180 |
} |
| 181 |
|
| 182 |
// phpcs:ignore WordPress.Security.NonceVerification.Recommended |
| 183 |
$result = isset( $_GET[ self::RESULT_ARG ] ) ? sanitize_key( wp_unslash( $_GET[ self::RESULT_ARG ] ) ) : ''; |
| 184 |
|
| 185 |
if ( $result === '' ) { |
| 186 |
return; |
| 187 |
} |
| 188 |
|
| 189 |
$messages = array( |
| 190 |
'deactivated' => __( 'The old Double Opt-In Pro plugin has been deactivated. You can now activate Double Opt-In Pro 4 and install the modules you need. To remove the old plugin for good, delete its folder over FTP — do not use the WordPress delete button, which would drop your opt-out table.', 'double-opt-in' ), |
| 191 |
'markers-reset' => __( 'The Pro setup markers have been cleared. Licence migration and module installation can run again. Your licence key was not touched.', 'double-opt-in' ), |
| 192 |
'nothing' => __( 'Nothing to repair — no active copy of the old Double Opt-In Pro plugin was found.', 'double-opt-in' ), |
| 193 |
); |
| 194 |
|
| 195 |
if ( ! isset( $messages[ $result ] ) ) { |
| 196 |
return; |
| 197 |
} |
| 198 |
|
| 199 |
printf( |
| 200 |
'<div class="notice notice-success is-dismissible"><p>%s</p></div>', |
| 201 |
esc_html( $messages[ $result ] ) |
| 202 |
); |
| 203 |
} |
| 204 |
} |
| 205 |
|