PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.5.0
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.5.0
5.5.0 5.4.0 5.3.2 5.3.1 5.1.6 5.1.5 trunk 2.1.5 2.11 2.12 2.13 2.15 3.0.0 3.0.1 3.0.2 3.0.3 3.0.5 3.0.51 3.0.60 3.0.61 3.0.62 3.0.70 3.0.71 3.0.72 3.1.0 All 34 releases
double-opt-in / src / Health / HealthRepairController.php

HealthRepairController.php in Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification 5.5.0, at src/Health/HealthRepairController.php

205 lines 6.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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