| 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 |
|