PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / core / PluginLogic.php

PluginLogic.php in 404 Solution trunk, at includes/core/PluginLogic.php

485 lines 18.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3
4 if (!defined('ABSPATH')) {
5 exit;
6 }
7
8 /* the glue that holds it together / everything else. */
9
10 require_once __DIR__ . '/PluginLogicUrlNormalization.php';
11 require_once __DIR__ . '/PluginLogicAdminActions.php';
12 require_once __DIR__ . '/PluginLogicImportExport.php';
13 require_once __DIR__ . '/PluginLogicSettingsUpdate.php';
14 require_once __DIR__ . '/PluginLogicPageOrdering.php';
15 require_once __DIR__ . '/PluginLogicLifecycle.php';
16 require_once __DIR__ . '/PluginLogicDefaults.php';
17 require_once __DIR__ . '/PluginLogicInterface.php';
18 require_once __DIR__ . '/../settings/StorageOptionContracts.php';
19 require_once __DIR__ . '/PluginLogicOptionsResolver.php';
20 require_once __DIR__ . '/PluginLogicVersionUpgrader.php';
21 require_once __DIR__ . '/../services/NotFoundResponseService.php';
22 require_once __DIR__ . '/../services/RequestIgnoreNormalizer.php';
23 require_once __DIR__ . '/../services/PreviousRequestCookieTracker.php';
24
25 /**
26 * @phpstan-type PageObject object{id: int, post_parent: int, depth: int, post_type: string, post_title: string}
27 */
28 class ABJ_404_Solution_PluginLogic implements ABJ_404_Solution_PluginLogicInterface {
29
30 /** @var ABJ_404_Solution_Functions */
31 private $f = null;
32
33 /** @var ABJ_404_Solution_DataAccess */
34 private $dao = null;
35
36 /** @var ABJ_404_Solution_Logging */
37 private $logger = null;
38
39 /** @var ABJ_404_Solution_RedirectsRepositoryInterface */
40 private $redirectsRepo;
41
42 /** @var ABJ_404_Solution_ViewReadServiceInterface */
43 private $viewRead;
44
45 /** @var ABJ_404_Solution_ContentRepositoryInterface */
46 private $contentRepo;
47
48 /** @var ABJ_404_Solution_StatsRepositoryInterface */
49 private $statsRepo;
50
51 /** @var ABJ_404_Solution_DatabaseCoreInterface&ABJ_404_Solution_DatabaseQueryInterface */
52 private $dbCore;
53
54 /**
55 * @var array<string, mixed>|null Legacy test seam: reflection-based tests
56 * seed runtime options by setting this property and resetting the
57 * PluginLogicOptionsResolver singleton. Read by PluginLogicOptionsResolver::legacyPluginLogicOptionsOverride().
58 */
59 private $options = null;
60
61 /** @var ABJ_404_Solution_ExportService|null */
62 private $exportService = null;
63
64 /** @var ABJ_404_Solution_ImportService|null */
65 private $importService = null;
66
67 /** @var string|null */
68 private $urlHomeDirectory = null;
69
70 /** @var int|null */
71 private $urlHomeDirectoryLength = null;
72
73 /** @var self|null */
74 private static $instance = null;
75
76 /** @var ABJ_404_Solution_PluginLogicUrlNormalization */
77 private $urlNormalization;
78
79 /** @var ABJ_404_Solution_PluginLogicAdminActions */
80 private $adminActions;
81
82 /** @var ABJ_404_Solution_PluginLogicImportExport */
83 private $importExport;
84
85 /** @var ABJ_404_Solution_PluginLogicSettingsUpdate */
86 private $settingsUpdate;
87
88 /** @var ABJ_404_Solution_PluginLogicPageOrdering */
89 private $pageOrdering;
90
91 // (no instance-level options resolver cache; resolved via the service
92 // container on each call — see optionsResolver())
93
94 /**
95 * Non-resolving accessor for the cached singleton. Returns whatever is
96 * currently stored in self::$instance without falling back to the
97 * service container or building a new instance. Used by abj_service()
98 * to honor a test-installed singleton override (or any other code that
99 * has populated $instance directly) without forcing the container to
100 * cache a stale binding.
101 *
102 * @return self|null
103 */
104 public static function peekInstance() {
105 return self::$instance;
106 }
107
108 /**
109 * Install a singleton instance directly, bypassing the service container
110 * and the deferred construction in `getInstance()`. Symmetric with
111 * `peekInstance()`; the canonical seam for tests that need to swap in
112 * a test double, and for callers that have already constructed a fully
113 * configured instance. Pass `null` to clear the cached singleton so the
114 * next `getInstance()` falls through the container / constructor path.
115 *
116 * @param self|null $instance
117 * @return void
118 */
119 public static function setInstance($instance) {
120 self::$instance = $instance;
121 }
122
123 /** @return ABJ_404_Solution_PluginLogic The singleton instance of the class. */
124 public static function getInstance() {
125 if (self::$instance !== null) {
126 return self::$instance;
127 }
128
129 // If the DI container is initialized, prefer it.
130 if (class_exists('ABJ_404_Solution_ServiceContainer')) {
131 $resolved = ABJ_404_Solution_ServiceContainer::safeGet('plugin_logic');
132 if ($resolved instanceof self) {
133 self::$instance = $resolved;
134 return self::$instance;
135 }
136 }
137
138 self::$instance = new ABJ_404_Solution_PluginLogic();
139
140 // these filters allow non-admins to have admin access to the plugin.
141 add_filter( 'user_has_cap',
142 'ABJ_404_Solution_PluginAdminAccessPolicy::wpUserHasCapFilter', 10, 4 );
143
144 return self::$instance;
145 }
146
147 /**
148 * Constructor with dependency injection.
149 *
150 * @param ABJ_404_Solution_Functions|null $functions String manipulation utilities
151 * @param ABJ_404_Solution_DataAccess|null $dataAccess Data access layer
152 * @param ABJ_404_Solution_Logging|null $logging Logging service
153 * @param ABJ_404_Solution_StatsRepositoryInterface|null $statsRepository Stats repository
154 */
155 function __construct($functions = null, $dataAccess = null, $logging = null, $statsRepository = null) {
156 $this->f = $functions !== null ? $functions : abj_service('functions');
157 $this->dao = $dataAccess !== null ? $dataAccess : abj_service('data_access');
158 $this->logger = $logging !== null ? $logging : abj_service('logging');
159
160 if ($this->dao instanceof ABJ_404_Solution_DataAccess && get_class($this->dao) === ABJ_404_Solution_DataAccess::class) {
161 $this->redirectsRepo = $this->dao->getRedirectsRepo();
162 $this->viewRead = $this->dao->getViewReadService();
163 $this->contentRepo = $this->dao->getContentRepo();
164 $this->statsRepo = $this->resolveStatsRepository($statsRepository);
165 $this->dbCore = $this->dao->getDbCore();
166 } else {
167 $this->statsRepo = $this->resolveStatsRepository($statsRepository);
168 $this->resolveDaoAccessorsForTestMock();
169 }
170
171 $urlPath = parse_url(get_home_url(), PHP_URL_PATH);
172 // Fix MEDIUM #1 (5th review): Distinguish between parse failure (false) and no path (null)
173 if ($urlPath === false) {
174 $this->logger->debugMessage("Malformed home URL detected while initializing PluginLogic: " . get_home_url());
175 $this->logger->warn("Malformed home URL detected: " . get_home_url());
176 $urlPath = '';
177 } else if ($urlPath === null) {
178 $urlPath = '';
179 }
180
181 // Fix HIGH #2 (4th review): Decode subdirectory for consistency with runtime processing
182 $decodedPath = abj_service('sanitizer')->normalizeUrlString(rtrim($urlPath, '/'));
183 if (!is_string($decodedPath)) {
184 $decodedPath = '';
185 }
186 // Fix HIGH #3 (4th review): Remove null bytes and control characters for security
187 $cleaned = preg_replace('/[\x00-\x1F\x7F]/', '', $decodedPath);
188 $this->urlHomeDirectory = is_string($cleaned) ? $cleaned : $decodedPath;
189 $this->urlHomeDirectoryLength = $this->f->strlen($this->urlHomeDirectory);
190
191 // Initialize standalone composition classes
192 $this->urlNormalization = new ABJ_404_Solution_PluginLogicUrlNormalization(
193 $this->f, $this->urlHomeDirectory, $this->urlHomeDirectoryLength
194 );
195
196 $self = $this;
197 $this->importExport = new ABJ_404_Solution_PluginLogicImportExport(
198 function() use ($self) { return $self->getExportService(); },
199 function() use ($self) { return $self->getImportService(); }
200 );
201
202 $this->settingsUpdate = new ABJ_404_Solution_PluginLogicSettingsUpdate(
203 $this->f, $this->logger, $this->contentRepo, $this,
204 null,
205 new ABJ_404_Solution_SettingsFieldValidator()
206 );
207
208 $this->pageOrdering = new ABJ_404_Solution_PluginLogicPageOrdering(
209 $this->f,
210 $this->logger,
211 $this->contentRepo,
212 $this->statsRepo,
213 $this->urlNormalization,
214 abj_service('not_found_response')
215 );
216
217 $this->adminActions = new ABJ_404_Solution_PluginLogicAdminActions(
218 new ABJ_404_Solution_AdminActionsDependencies(
219 $this->f, $this->logger, $this->redirectsRepo, $this->viewRead,
220 $this->contentRepo, $this->dbCore, $this->dao, $this->urlNormalization, $this
221 )
222 );
223 }
224
225 /**
226 * Polymorphic test-mock fallback: $this->dao may be a mock implementing the repo
227 * getters, OR a mock that stands in for individual repos directly. Either shape is
228 * accepted at runtime; PHPStan cannot follow this without per-assignment markers.
229 * @return void
230 */
231 private function resolveDaoAccessorsForTestMock(): void {
232 $accessors = [
233 'redirectsRepo' => 'getRedirectsRepo',
234 'viewRead' => 'getViewReadService',
235 'contentRepo' => 'getContentRepo',
236 'dbCore' => 'getDbCore',
237 ];
238 foreach ($accessors as $property => $method) {
239 if ($property === 'redirectsRepo'
240 && $this->daoOverridesAny([
241 'setupRedirect',
242 'moveRedirectsToTrash',
243 'deleteRedirect',
244 'updateRedirectTypeStatus',
245 ])) {
246 $this->{$property} = $this->dao;
247 continue;
248 }
249 $value = (is_object($this->dao) && method_exists($this->dao, $method))
250 ? $this->dao->{$method}()
251 : $this->dao;
252 // @phpstan-ignore-next-line assign.propertyType
253 $this->{$property} = $value;
254 }
255 }
256
257 /** @param ABJ_404_Solution_StatsRepositoryInterface|null $provided */
258 private function resolveStatsRepository($provided): ABJ_404_Solution_StatsRepositoryInterface {
259 if ($provided instanceof ABJ_404_Solution_StatsRepositoryInterface) {
260 return $provided;
261 }
262
263 $service = class_exists('ABJ_404_Solution_ServiceContainer')
264 ? ABJ_404_Solution_ServiceContainer::safeGet('stats_repository')
265 : null;
266 if ($service instanceof ABJ_404_Solution_StatsRepositoryInterface) {
267 return $service;
268 }
269
270 return ABJ_404_Solution_StatsRepositoryResolver::resolve(__CLASS__);
271 }
272
273 /** @param array<int, string> $methods */
274 private function daoOverridesAny(array $methods): bool {
275 if (!is_object($this->dao)) {
276 return false;
277 }
278 foreach ($methods as $method) {
279 if (!method_exists($this->dao, $method)) {
280 continue;
281 }
282 $reflection = new ReflectionMethod($this->dao, $method);
283 if ($reflection->getDeclaringClass()->getName() !== ABJ_404_Solution_DataAccess::class) {
284 return true;
285 }
286 }
287 return false;
288 }
289
290 /** @return ABJ_404_Solution_PluginLogicUrlNormalization */
291 public function urlNormalization() {
292 return $this->urlNormalization;
293 }
294
295 /** @return ABJ_404_Solution_PluginLogicAdminActions */
296 public function adminActions() {
297 return $this->adminActions;
298 }
299
300 /** @return ABJ_404_Solution_PluginLogicImportExport */
301 public function importExport() {
302 return $this->importExport;
303 }
304
305 /** @return ABJ_404_Solution_PluginLogicSettingsUpdate */
306 public function settingsUpdate() {
307 return $this->settingsUpdate;
308 }
309
310 /** @return ABJ_404_Solution_PluginLogicPageOrdering */
311 public function pageOrdering() {
312 return $this->pageOrdering;
313 }
314
315 /**
316 * Access the composed options resolver. Returns whichever object is
317 * registered as abj_service('options_repository') (the same instance is
318 * exposed there for auth-time callers that must avoid the plugin_logic
319 * resolution cycle). Duck-typed at runtime: any object responding to
320 * getOptions()/updateOptions() is accepted so tests can install lightweight
321 * stubs without subclassing the concrete resolver. The declared return
322 * type is the concrete class so PHPStan can resolve caller chains.
323 *
324 * @return ABJ_404_Solution_PluginLogicOptionsResolver
325 */
326 public function optionsResolver() {
327 // No instance-level cache: tests can swap the registered service
328 // mid-run, and the service container singleton already deduplicates.
329 $candidate = abj_service_optional('options_repository');
330 if (is_object($candidate) && method_exists($candidate, 'getOptions')) {
331 return $candidate;
332 }
333 // Fallback when the container is uninitialised (very-early boot,
334 // self-healing recovery from a broken install): construct the
335 // concrete resolver inline so callers always receive a usable
336 // collaborator instead of null. Routed through new rather than
337 // ::getInstance() so lint-getinstance-callers does not flag this
338 // bootstrap fallback.
339 return new ABJ_404_Solution_PluginLogicOptionsResolver();
340 }
341
342 /**
343 * Access the composed not-found response service. The frontend
344 * dispatcher methods (sendTo404Page, forceRedirect,
345 * thereIsAUserSpecified404Page, getCommentPartAndQueryPartOfRequest)
346 * extracted from PluginLogic live here.
347 *
348 * Returns whichever object is registered as
349 * abj_service('not_found_response'). Duck-typed at runtime so tests
350 * can install lightweight doubles without subclassing the concrete
351 * service.
352 *
353 * @return ABJ_404_Solution_NotFoundResponseService
354 */
355 public function notFoundResponse() {
356 $candidate = abj_service_optional('not_found_response');
357 if (is_object($candidate) && method_exists($candidate, 'sendTo404Page')) {
358 return $candidate;
359 }
360 return new ABJ_404_Solution_NotFoundResponseService();
361 }
362
363 /**
364 * Access the composed request-ignore normalizer. The
365 * initializeIgnoreValues() and tryNormalPostQuery() methods
366 * extracted from PluginLogic live here.
367 *
368 * @return ABJ_404_Solution_RequestIgnoreNormalizer
369 */
370 public function requestIgnoreNormalizer() {
371 $candidate = abj_service_optional('request_ignore_normalizer');
372 if (is_object($candidate) && method_exists($candidate, 'tryNormalPostQuery')) {
373 return $candidate;
374 }
375 return new ABJ_404_Solution_RequestIgnoreNormalizer();
376 }
377
378 /**
379 * Access the composed previous-request cookie tracker. The
380 * readCookieWithPreviousRqeuestShort() and
381 * setCookieWithPreviousRequest() methods extracted from PluginLogic
382 * live here.
383 *
384 * @return ABJ_404_Solution_PreviousRequestCookieTracker
385 */
386 public function previousRequestCookieTracker() {
387 $candidate = abj_service_optional('previous_request_cookie_tracker');
388 if (is_object($candidate) && method_exists($candidate, 'setCookieWithPreviousRequest')) {
389 return $candidate;
390 }
391 return new ABJ_404_Solution_PreviousRequestCookieTracker();
392 }
393
394 /**
395 * Alias accessor for the primary 404 dispatcher service. The
396 * original parent task asked for a single PluginLogicRequestDispatcher
397 * accessor; the extraction split the responsibilities across three
398 * single-responsibility services instead. NotFoundResponseService is
399 * the one that actually dispatches the 404 response, so this alias
400 * returns it. Prefer notFoundResponse() / requestIgnoreNormalizer() /
401 * previousRequestCookieTracker() at new call sites.
402 *
403 * Why: per [[feedback_no_trait_extractions]], the project rejects
404 * grab-bag extractions and prefers SRP-aligned classes; combining
405 * the three responsibilities behind one wrapper would be a
406 * pass-through facade flagged by [[modularity-extraction]].
407 *
408 * @return ABJ_404_Solution_NotFoundResponseService
409 */
410 public function requestDispatcher() {
411 return $this->notFoundResponse();
412 }
413
414 /**
415 * Access the composed version upgrader. Returns whichever object is
416 * registered as abj_service('version_upgrade'). Duck-typed at runtime:
417 * any object responding to upgradeIfNeeded()/runUpgradeAction()/
418 * stampDbVersion() is accepted so tests can install lightweight stubs
419 * without subclassing the concrete upgrader. The declared return type
420 * is the concrete class so PHPStan can resolve caller chains.
421 *
422 * @return ABJ_404_Solution_PluginLogicVersionUpgrader
423 */
424 public function versionUpgrader() {
425 $candidate = abj_service('version_upgrade');
426 if (is_object($candidate) && method_exists($candidate, 'upgradeIfNeeded')) {
427 return $candidate;
428 }
429 // Fallback when the `version_upgrade` container key is unavailable
430 // (very-early boot, self-healing recovery from a broken install).
431 // Construct via `new` with individual services resolved so we route
432 // through the container's dependency wiring without going through
433 // the singleton entry (lint-getinstance-callers).
434 $dao = abj_service('data_access');
435 $dbCore = is_object($dao) && method_exists($dao, 'getDbCore') ? $dao->getDbCore() : $dao;
436 return new ABJ_404_Solution_PluginLogicVersionUpgrader(
437 abj_service('functions'),
438 abj_service('logging'),
439 is_object($dbCore) ? $dbCore : (object)[]
440 );
441 }
442
443 /** @return ABJ_404_Solution_ExportService */
444 private function getExportService() {
445 if ($this->exportService !== null) {
446 return $this->exportService;
447 }
448
449 if (!class_exists('ABJ_404_Solution_ExportService')) {
450 require_once __DIR__ . '/../import/ExportService.php';
451 }
452
453 $this->exportService = new ABJ_404_Solution_ExportService(
454 abj_service('view_read_service'),
455 $this->logger,
456 abj_service('redirects_repository')
457 );
458 return $this->exportService;
459 }
460
461 /** @return ABJ_404_Solution_ImportService */
462 private function getImportService() {
463 if ($this->importService !== null) {
464 return $this->importService;
465 }
466
467 if (!class_exists('ABJ_404_Solution_ImportService')) {
468 require_once __DIR__ . '/../import/ImportService.php';
469 }
470
471 $this->importService = new ABJ_404_Solution_ImportService(
472 abj_service('redirects_repository'),
473 abj_service('content_repository'),
474 $this->logger
475 );
476 return $this->importService;
477 }
478
479 /** Instance counterpart used by the Data layer through PluginLogicInterface. */
480 public function registerCrons(): void {
481 ABJ_404_Solution_PluginLogicLifecycle::doRegisterCrons();
482 }
483
484 }
485