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 / Addon / AddonRegistry.php

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

355 lines 7.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Addon Registry
4 *
5 * Central registry for all Double Opt-In addons.
6 *
7 * @package Forge12\DoubleOptIn\Addon
8 * @since 4.3.0
9 */
10
11 namespace Forge12\DoubleOptIn\Addon;
12
13 use Forge12\DoubleOptIn\Container\ContainerInterface;
14 use Forge12\DoubleOptIn\Versioning\SemverConstraint;
15 use Forge12\Shared\LoggerInterface;
16
17 if ( ! defined( 'ABSPATH' ) ) {
18 exit;
19 }
20
21 /**
22 * Class AddonRegistry
23 *
24 * @api
25 *
26 * Singleton registry that collects addon registrations from plugins hooking
27 * into `f12_cf7_doubleoptin_register_addons` and boots them in a single pass
28 * once registration is complete.
29 *
30 * An addon is identified by its {@see AddonInterface::getId()}. Duplicate
31 * registrations are ignored with a warning — the first-registered addon
32 * wins. This protects against double-activation when an addon is bundled
33 * inside both a free build and a paid bundle.
34 */
35 final class AddonRegistry {
36
37 /**
38 * Singleton instance.
39 *
40 * @var AddonRegistry|null
41 */
42 private static ?AddonRegistry $instance = null;
43
44 /**
45 * Registered addons, keyed by addon ID.
46 *
47 * @var array<string, AddonInterface>
48 */
49 private array $addons = array();
50
51 /**
52 * Set of addon IDs that have completed booting.
53 *
54 * @var array<string, true>
55 */
56 private array $booted = array();
57
58 /**
59 * Whether bootAll() has run.
60 *
61 * @var bool
62 */
63 private bool $bootCompleted = false;
64
65 /**
66 * Logger instance.
67 *
68 * @var LoggerInterface|null
69 */
70 private ?LoggerInterface $logger = null;
71
72 /**
73 * Private constructor — use {@see getInstance()}.
74 */
75 private function __construct() {}
76
77 /**
78 * Prevent cloning.
79 */
80 private function __clone() {}
81
82 /**
83 * Prevent unserialization.
84 *
85 * @throws \Exception
86 */
87 public function __wakeup() {
88 throw new \Exception( 'Cannot unserialize singleton' );
89 }
90
91 /**
92 * Get the singleton instance.
93 *
94 * @return AddonRegistry
95 */
96 public static function getInstance(): AddonRegistry {
97 if ( self::$instance === null ) {
98 self::$instance = new self();
99 }
100 return self::$instance;
101 }
102
103 /**
104 * Reset the singleton instance. Intended for tests only.
105 *
106 * @internal
107 * @return void
108 */
109 public static function resetInstance(): void {
110 self::$instance = null;
111 }
112
113 /**
114 * Set the logger instance.
115 *
116 * @param LoggerInterface $logger The logger.
117 * @return void
118 */
119 public function setLogger( LoggerInterface $logger ): void {
120 $this->logger = $logger;
121 }
122
123 /**
124 * Register an addon.
125 *
126 * Addons register themselves in response to the
127 * `f12_cf7_doubleoptin_register_addons` action.
128 *
129 * If {@see bootAll()} has already run and the addon being registered is
130 * available, it will be booted immediately (late registration).
131 *
132 * @param AddonInterface $addon The addon to register.
133 * @return bool True if registered, false if an addon with the same ID
134 * was already registered.
135 */
136 public function register( AddonInterface $addon ): bool {
137 $id = $addon->getId();
138
139 if ( isset( $this->addons[ $id ] ) ) {
140 $this->log(
141 'warning',
142 'Addon already registered — skipping duplicate',
143 array(
144 'addon_id' => $id,
145 'existing_class' => get_class( $this->addons[ $id ] ),
146 'new_class' => get_class( $addon ),
147 )
148 );
149 return false;
150 }
151
152 $this->addons[ $id ] = $addon;
153
154 $this->log(
155 'info',
156 'Addon registered',
157 array(
158 'addon_id' => $id,
159 'version' => $addon->getVersion(),
160 'available' => $addon->isAvailable(),
161 )
162 );
163
164 return true;
165 }
166
167 /**
168 * Get an addon by ID.
169 *
170 * @param string $id The addon ID.
171 * @return AddonInterface|null The addon, or null if not registered.
172 */
173 public function get( string $id ): ?AddonInterface {
174 return $this->addons[ $id ] ?? null;
175 }
176
177 /**
178 * Check whether an addon is registered.
179 *
180 * @param string $id The addon ID.
181 * @return bool
182 */
183 public function has( string $id ): bool {
184 return isset( $this->addons[ $id ] );
185 }
186
187 /**
188 * Get all registered addons, regardless of availability.
189 *
190 * @return array<string, AddonInterface>
191 */
192 public function all(): array {
193 return $this->addons;
194 }
195
196 /**
197 * Get all addons for which {@see AddonInterface::isAvailable()} returns true.
198 *
199 * @return array<string, AddonInterface>
200 */
201 public function available(): array {
202 return array_filter(
203 $this->addons,
204 static function ( AddonInterface $addon ) {
205 return $addon->isAvailable();
206 }
207 );
208 }
209
210 /**
211 * Check whether any registered, available addon advertises the given capability.
212 *
213 * @param string $capability Capability ID, e.g. "mail.reminder".
214 * @return bool
215 */
216 public function hasCapability( string $capability ): bool {
217 foreach ( $this->available() as $addon ) {
218 if ( in_array( $capability, $addon->getCapabilities(), true ) ) {
219 return true;
220 }
221 }
222 return false;
223 }
224
225 /**
226 * Find all available addons that advertise the given capability.
227 *
228 * @param string $capability Capability ID.
229 * @return AddonInterface[] List of matching addons. May be empty.
230 */
231 public function findByCapability( string $capability ): array {
232 $matches = array();
233 foreach ( $this->available() as $addon ) {
234 if ( in_array( $capability, $addon->getCapabilities(), true ) ) {
235 $matches[] = $addon;
236 }
237 }
238 return $matches;
239 }
240
241 /**
242 * Boot all available, not-yet-booted addons.
243 *
244 * Called by the core once addon registration is complete. Idempotent —
245 * safe to call multiple times; each addon boots at most once.
246 *
247 * Failures in a single addon's boot() do not abort the loop; they are
248 * logged and the remaining addons still boot.
249 *
250 * @param ContainerInterface $container The core DI container.
251 * @return void
252 */
253 public function bootAll( ContainerInterface $container ): void {
254 $coreApiVersion = defined( 'F12_DOI_CORE_API_VERSION' )
255 ? F12_DOI_CORE_API_VERSION
256 : '0.0.0';
257
258 foreach ( $this->available() as $id => $addon ) {
259 if ( isset( $this->booted[ $id ] ) ) {
260 continue;
261 }
262
263 // Core-version compatibility check. Addons that declare a
264 // requirement incompatible with the current core are skipped
265 // rather than booted into a broken state. This mirrors
266 // Composer's behaviour of refusing to install incompatible
267 // packages rather than crashing at runtime.
268 $requirement = $addon->getCoreVersionRequirement();
269 if ( $requirement !== '' && ! SemverConstraint::matches( $coreApiVersion, $requirement ) ) {
270 $this->log(
271 'warning',
272 'Addon skipped: core version requirement not met',
273 array(
274 'addon_id' => $id,
275 'required_core' => $requirement,
276 'current_core_api' => $coreApiVersion,
277 )
278 );
279 continue;
280 }
281
282 try {
283 $addon->boot( $container );
284 $this->booted[ $id ] = true;
285
286 $this->log(
287 'info',
288 'Addon booted',
289 array(
290 'addon_id' => $id,
291 )
292 );
293 } catch ( \Throwable $e ) {
294 $this->log(
295 'error',
296 'Addon failed to boot',
297 array(
298 'addon_id' => $id,
299 'error' => $e->getMessage(),
300 'file' => $e->getFile(),
301 'line' => $e->getLine(),
302 )
303 );
304 }
305 }
306
307 $this->bootCompleted = true;
308 }
309
310 /**
311 * Whether {@see bootAll()} has been invoked at least once.
312 *
313 * @return bool
314 */
315 public function isBootCompleted(): bool {
316 return $this->bootCompleted;
317 }
318
319 /**
320 * Log a message through the configured logger, if any.
321 *
322 * @param string $level Log level.
323 * @param string $message Message.
324 * @param array $context Context.
325 * @return void
326 */
327 private function log( string $level, string $message, array $context = array() ): void {
328 if ( $this->logger === null ) {
329 return;
330 }
331
332 $context = array_merge(
333 array(
334 'plugin' => 'double-opt-in',
335 'component' => 'addon-registry',
336 ),
337 $context
338 );
339
340 switch ( $level ) {
341 case 'error':
342 $this->logger->error( $message, $context );
343 break;
344 case 'warning':
345 $this->logger->warning( $message, $context );
346 break;
347 case 'info':
348 $this->logger->info( $message, $context );
349 break;
350 default:
351 $this->logger->debug( $message, $context );
352 }
353 }
354 }
355