PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.6.2
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.6.2
5.6.2 5.6.3 5.6.1 5.6.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 All 38 releases
double-opt-in / src / Health / LegacyMonolithDetector.php

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

221 lines 7.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Finds installations of the pre-4.x Pro monolith.
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 * Locates the legacy "Double Opt-In ... Pro" monolith (<= 3.7) on disk.
19 *
20 * The monolith declares the same class names in the same namespace as the
21 * compat classes of addon-elementor and addon-opt-out. Loading both is a
22 * fatal `Cannot redeclare class`, which takes wp-admin with it — the state
23 * behind every "critical error after the Pro update" ticket.
24 *
25 * ## Why the folder name is not the criterion
26 *
27 * It cannot be. Every candidate name has been used by two different
28 * products over the years:
29 *
30 * | Folder | used by |
31 * |-----------------------|---------------------------------------------|
32 * | `double-opt-in-pro` | Pro 3.0.0 - 3.2.0 **and** bundle-pro 4.x |
33 * | `f12-cf7-doubleoptin` | free <= 2.x **and** Pro 2.x, 3.6, 3.7 |
34 * | `double-opt-in` | free >= 3.0.0, including 5.x |
35 *
36 * The directory layout is no better: 3.1 keeps licensing in
37 * `core/License.class.php`, 3.7 moved it to `vendor/`. And the main file
38 * name is shared outright — the free plugin still ships
39 * `CF7DoubleOptIn.class.php` today, so anything keying on that alone would
40 * happily flag the free plugin the operator is currently running.
41 *
42 * Two signals do separate them, and both are needed:
43 *
44 * 1. `Text Domain: double-opt-in-pro` in a `CF7DoubleOptIn.class.php`.
45 * Reliable from 3.0.0 on. bundle-pro 4.x carries the same text domain
46 * but lives in `double-opt-in-pro.php`, and the version gate keeps it
47 * out regardless.
48 * 2. A sibling `ui/UILicense.class.php`. Needed because the header alone
49 * misses the whole 2.x line: Pro 2.3.3 through 2.4.0 shipped
50 * `Text Domain: double-opt-in` — the free plugin's domain — while
51 * carrying the Pro licensing code. Verified against 2.3.3, 2.3.7,
52 * 2.4.0, 3.0.0, 3.2.0 and 3.7.1; the free plugin has never shipped
53 * that file in any version.
54 */
55 final class LegacyMonolithDetector {
56
57 /**
58 * The monolith's main file, in every version that ever shipped.
59 */
60 private const MAIN_FILE = 'CF7DoubleOptIn.class.php';
61
62 /**
63 * The header value that tells the monolith from the free plugin —
64 * from 3.0.0 on. See LICENSE_UI_FILE for why that is not enough.
65 */
66 private const PRO_TEXT_DOMAIN = 'double-opt-in-pro';
67
68 /**
69 * Licensing screen, relative to the plugin folder. Shipped by every
70 * Pro monolith from 2.3.3 to 3.7.1, by no free version ever.
71 */
72 private const LICENSE_UI_FILE = 'ui/UILicense.class.php';
73
74 /**
75 * First major version that is NOT the monolith. bundle-pro starts at 4.
76 */
77 private const SUCCESSOR_MAJOR = 4;
78
79 /**
80 * Per-request memo, keyed by scanned directory.
81 *
82 * Health checks run on every admin page load, so the directory scan
83 * must not repeat within a request. It is cheap either way: one
84 * `is_file()` per plugin folder, and a header read only for the one
85 * or two folders that actually carry the file.
86 *
87 * @var array<string,array<int,array<string,string>>>
88 */
89 private static $memo = array();
90
91 /**
92 * All legacy monolith installations found under $pluginDir.
93 *
94 * @param string $pluginDir Absolute path to the plugins directory.
95 * Injectable so this is testable without
96 * WordPress.
97 *
98 * @return array<int,array<string,string>> One entry per find, with
99 * `folder`, `file` (the
100 * plugin_basename-style
101 * `folder/main.php`), `path`,
102 * `name` and `version`.
103 */
104 public static function scan( string $pluginDir = '' ): array {
105 if ( $pluginDir === '' ) {
106 $pluginDir = defined( 'WP_PLUGIN_DIR' ) ? WP_PLUGIN_DIR : '';
107 }
108 if ( $pluginDir === '' || ! is_dir( $pluginDir ) ) {
109 return array();
110 }
111
112 $pluginDir = rtrim( $pluginDir, '/\\' );
113
114 if ( isset( self::$memo[ $pluginDir ] ) ) {
115 return self::$memo[ $pluginDir ];
116 }
117
118 $found = array();
119
120 foreach ( (array) glob( $pluginDir . '/*', GLOB_ONLYDIR ) as $folder ) {
121 if ( ! is_string( $folder ) ) {
122 continue;
123 }
124
125 $main = $folder . '/' . self::MAIN_FILE;
126 if ( ! is_file( $main ) ) {
127 continue;
128 }
129
130 $header = self::readHeader( $main );
131 $hasLicenseUi = is_file( $folder . '/' . self::LICENSE_UI_FILE );
132
133 if ( ! self::isLegacyMonolith( $header, $hasLicenseUi ) ) {
134 continue;
135 }
136
137 $found[] = array(
138 'folder' => basename( $folder ),
139 'file' => basename( $folder ) . '/' . self::MAIN_FILE,
140 'path' => $folder,
141 'name' => $header['Plugin Name'] ?? '',
142 'version' => $header['Version'] ?? '',
143 );
144 }
145
146 self::$memo[ $pluginDir ] = $found;
147
148 return $found;
149 }
150
151 /**
152 * Drop the memo. Needed after a repair inside the same request —
153 * otherwise the check keeps reporting the pre-repair state.
154 */
155 public static function flushCache(): void {
156 self::$memo = array();
157 }
158
159 /**
160 * Does this folder hold the pre-4.x Pro monolith?
161 *
162 * @param array<string,string> $header Parsed plugin header.
163 * @param bool $hasLicenseUi Whether the folder ships
164 * the Pro licensing screen.
165 */
166 private static function isLegacyMonolith( array $header, bool $hasLicenseUi ): bool {
167 $isProDomain = ( $header['Text Domain'] ?? '' ) === self::PRO_TEXT_DOMAIN;
168
169 if ( ! $isProDomain && ! $hasLicenseUi ) {
170 return false;
171 }
172
173 $version = $header['Version'] ?? '';
174 if ( $version === '' ) {
175 // Either signal without a readable version still means the
176 // monolith — no other product ever shipped that combination.
177 // Treated as a find rather than ignored, because missing the
178 // collision is worse than naming one folder for nothing.
179 return true;
180 }
181
182 return (int) $version < self::SUCCESSOR_MAJOR;
183 }
184
185 /**
186 * Read plugin headers without executing the file.
187 *
188 * Deliberately not `get_file_data()`: this runs while another plugin
189 * may already have fataled the request, it is called from unit tests
190 * with no WordPress loaded, and `get_file_data()` passes its result
191 * through `extra_plugin_headers`-style filters that a broken third
192 * party could interfere with. The regex is the same one WordPress
193 * uses, and the read is capped at the header block.
194 *
195 * @return array<string,string>
196 */
197 private static function readHeader( string $file ): array {
198 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
199 $handle = @fopen( $file, 'r' );
200 if ( ! $handle ) {
201 return array();
202 }
203
204 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread
205 $head = (string) fread( $handle, 8192 );
206 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
207 fclose( $handle );
208
209 $out = array();
210
211 foreach ( array( 'Plugin Name', 'Version', 'Text Domain' ) as $field ) {
212 $matches = array();
213 if ( preg_match( '/^[ \t\/*#@]*' . preg_quote( $field, '/' ) . ':(.*)$/mi', $head, $matches ) ) {
214 $out[ $field ] = trim( $matches[1] );
215 }
216 }
217
218 return $out;
219 }
220 }
221