PluginProbe
404 Solution / 4.3.0
404 Solution v4.3.0
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 / matching / EngineProfileResolver.php

EngineProfileResolver.php in 404 Solution 4.3.0, at includes/matching/EngineProfileResolver.php

417 lines 15.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Engine Profile Resolver
9 *
10 * Allows admins to create URL-pattern-based "engine profiles" that control
11 * which matching engines run for a given 404 URL. First matching profile wins.
12 * If no profile matches, all engines are used (default behavior preserved).
13 *
14 * Profiles are stored in wp_abj404_engine_profiles and cached per-request
15 * via a static property to avoid repeated DB queries on the same page.
16 */
17 class ABJ_404_Solution_EngineProfileResolver {
18
19 /** @var self|null */
20 private static $instance = null;
21 /**
22 * Test seam: install or clear the cached singleton instance without
23 * private-field reflection. Pass null to reset between tests; pass a
24 * configured instance (or double) to install it. Mirrors the setInstance()
25 * contract on DataAccess / PluginLogic (M105 singleton-reset seam).
26 *
27 * @param self|null $instance
28 * @return void
29 */
30 public static function setInstance($instance) {
31 self::$instance = $instance;
32 }
33
34
35 /** @var array<int, object>|null Cached profile rows for current request */
36 private $cachedProfiles = null;
37
38 /** @var ABJ_404_Solution_DataAccess|null Lazy DAO accessor for centralized query handling. */
39 /** @var ABJ_404_Solution_DatabaseCore|null */
40 private $dbCore = null;
41
42 /** @return self */
43 public static function getInstance() {
44 if (self::$instance === null) {
45 self::$instance = new self();
46 }
47 return self::$instance;
48 }
49
50 /**
51 * Lazy dbCore accessor, defers resolution until first use so unit tests
52 * that exercise pure-logic methods (URL matching, JSON decoding) don't
53 * boot the database layer.
54 *
55 * @return ABJ_404_Solution_DatabaseCore
56 */
57 private function dbCore() {
58 if ($this->dbCore === null) {
59 $this->dbCore = abj_service('db_core');
60 }
61 return $this->dbCore;
62 }
63
64 /**
65 * Resolve which engines to run for a given 404 URL.
66 *
67 * Queries active profiles ordered by priority ASC. First profile whose
68 * url_pattern matches the requested URL wins. The matched profile's
69 * enabled_engines list (JSON array of full class names) is used to filter
70 * the $allEngines array.
71 *
72 * If no profile matches, $allEngines is returned unchanged.
73 *
74 * The result is passed through `apply_filters('abj404_resolved_engines', ...)`.
75 *
76 * @param string $requestedURL The 404 URL being processed (path only or full URL).
77 * @param array<int, mixed> $allEngines The full list of matching engine instances.
78 * @return array<int, mixed> Filtered (or unchanged) engine list.
79 */
80 public function resolve(string $requestedURL, array $allEngines): array {
81 if (empty($allEngines)) {
82 return $allEngines;
83 }
84
85 $profiles = $this->getActiveProfiles();
86
87 if (empty($profiles)) {
88 return $allEngines;
89 }
90
91 foreach ($profiles as $profile) {
92 if ($this->urlMatchesProfile($requestedURL, $profile)) {
93 $filtered = $this->filterEnginesByProfile($allEngines, $profile);
94 /** @var array<int, mixed> $filtered */
95 $filtered = apply_filters('abj404_resolved_engines', $filtered, $requestedURL, $profile);
96 return $filtered;
97 }
98 }
99
100 // No match — return all engines unchanged.
101 return $allEngines;
102 }
103
104 /**
105 * Determine whether a specific engine class is enabled for a requested URL.
106 *
107 * This mirrors profile matching + fail-open behavior used by resolve():
108 * - no matching profile -> enabled
109 * - empty/malformed enabled_engines -> enabled
110 * - matching profile with explicit enabled_engines -> enabled only if listed
111 *
112 * @param string $requestedURL
113 * @param string $engineClassName Fully-qualified class name or short suffix.
114 * @return bool
115 */
116 public function isEngineEnabledForUrl(string $requestedURL, string $engineClassName): bool {
117 $profiles = $this->getActiveProfiles();
118
119 if (empty($profiles)) {
120 return true;
121 }
122
123 foreach ($profiles as $profile) {
124 if (!$this->urlMatchesProfile($requestedURL, $profile)) {
125 continue;
126 }
127
128 $enabledLower = $this->decodeEnabledEnginesLower($profile);
129 if ($enabledLower === null || empty($enabledLower)) {
130 // Fail-open to preserve historical behavior for broken/empty config.
131 return true;
132 }
133
134 return $this->classMatchesEnabledList($engineClassName, $enabledLower);
135 }
136
137 // No profile matched this URL.
138 return true;
139 }
140
141 /**
142 * Load all active profiles ordered by priority ASC (lowest priority number = first).
143 *
144 * Uses a per-request cache to avoid repeated DB queries.
145 *
146 * @return array<int, object>
147 */
148 private function getActiveProfiles(): array {
149 if ($this->cachedProfiles !== null) {
150 return $this->cachedProfiles;
151 }
152
153 $table = $this->getTableName();
154
155 if (!$this->tableExists($table)) {
156 $this->cachedProfiles = [];
157 return $this->cachedProfiles;
158 }
159
160 $queryResult = $this->dbCore()->queryAndGetResults(
161 "SELECT `id`, `name`, `url_pattern`, `is_regex`, `enabled_engines`, `priority`
162 FROM `{$table}`
163 WHERE `status` = 1
164 ORDER BY `priority` ASC, `id` ASC
165 LIMIT %d",
166 ['query_params' => [200], 'result_type' => OBJECT]
167 );
168 $rows = $queryResult['rows'] ?? [];
169
170 $this->cachedProfiles = is_array($rows) ? $rows : [];
171 return $this->cachedProfiles;
172 }
173
174 /**
175 * Check whether a URL matches a profile's pattern.
176 *
177 * @param string $url
178 * @param object $profile
179 * @return bool
180 */
181 private function urlMatchesProfile(string $url, object $profile): bool {
182 $pattern = isset($profile->url_pattern) ? (string)$profile->url_pattern : '';
183
184 if ($pattern === '') {
185 return false;
186 }
187
188 $isRegex = isset($profile->is_regex) && (int)$profile->is_regex === 1;
189
190 if ($isRegex) {
191 // Patterns are stored without PHP delimiters (users write ^/shop/, not #^/shop/#).
192 // Auto-wrap with # delimiters unless the pattern already starts with one.
193 $commonDelimiters = ['/', '#', '~', '!', '@', '|', '%'];
194 if (!in_array(substr($pattern, 0, 1), $commonDelimiters, true)) {
195 $pattern = '#' . $pattern . '#';
196 }
197 // Suppress errors to prevent site breakage from malformed patterns.
198 set_error_handler(function (int $errno, string $errstr, string $errfile = '', int $errline = 0): bool { return false; }, E_WARNING);
199 $matched = @preg_match($pattern, $url);
200 restore_error_handler();
201 return $matched === 1;
202 }
203
204 // Non-regex: fnmatch-style pattern with * as wildcard, case-insensitive.
205 // fnmatch is not available on all Windows PHP builds, so fall back to
206 // a manual conversion via the approach used by the rest of the codebase.
207 if (function_exists('fnmatch')) {
208 return fnmatch($pattern, $url, FNM_CASEFOLD | FNM_PATHNAME) ||
209 fnmatch($pattern, $url, FNM_CASEFOLD);
210 }
211
212 // Fallback: convert * wildcard to a regex.
213 $regex = '/^' . str_replace(
214 ['\\*', '\\?'],
215 ['.*', '.'],
216 preg_quote($pattern, '/')
217 ) . '$/i';
218 return (bool)preg_match($regex, $url);
219 }
220
221 /**
222 * Filter $allEngines to only those listed in the profile's enabled_engines JSON.
223 *
224 * If enabled_engines is empty or malformed, all engines are returned (fail-open).
225 *
226 * @param array<int, mixed> $allEngines
227 * @param object $profile
228 * @return array<int, mixed>
229 */
230 private function filterEnginesByProfile(array $allEngines, object $profile): array {
231 $enabledLower = $this->decodeEnabledEnginesLower($profile);
232 if ($enabledLower === null || empty($enabledLower)) {
233 // Empty/malformed list — no restriction (fail-open).
234 return $allEngines;
235 }
236
237 $filtered = array_values(array_filter($allEngines, function ($engine) use ($enabledLower) {
238 if (!is_object($engine)) {
239 return false;
240 }
241 return $this->classMatchesEnabledList(get_class($engine), $enabledLower);
242 }));
243
244 return $filtered;
245 }
246
247 /**
248 * Parse enabled_engines JSON and normalize class names to lowercase.
249 *
250 * @param object $profile
251 * @return array<int, string>|null Null when empty/missing/malformed.
252 */
253 private function decodeEnabledEnginesLower(object $profile): ?array {
254 $json = isset($profile->enabled_engines) ? (string)$profile->enabled_engines : '';
255 if (trim($json) === '') {
256 return null;
257 }
258
259 $enabledClassNames = json_decode($json, true);
260 if (!is_array($enabledClassNames) || empty($enabledClassNames)) {
261 return null;
262 }
263
264 $enabledLower = array_values(array_filter(array_map(function ($name) {
265 return is_scalar($name) ? strtolower((string)$name) : '';
266 }, $enabledClassNames), function ($name) {
267 return $name !== '';
268 }));
269
270 return empty($enabledLower) ? null : $enabledLower;
271 }
272
273 /**
274 * Case-insensitive class-name check with suffix matching compatibility.
275 *
276 * @param string $className
277 * @param array<int, string> $enabledLower
278 * @return bool
279 */
280 private function classMatchesEnabledList(string $className, array $enabledLower): bool {
281 $classLower = strtolower($className);
282 foreach ($enabledLower as $allowed) {
283 if ($classLower === $allowed || substr($classLower, -strlen($allowed)) === $allowed) {
284 return true;
285 }
286 }
287 return false;
288 }
289
290 /**
291 * @return string The fully-prefixed table name.
292 */
293 private function getTableName(): string {
294 global $wpdb;
295 return strtolower($wpdb->prefix) . 'abj404_engine_profiles';
296 }
297
298 /**
299 * Check if the engine profiles table exists in the current database.
300 *
301 * @param string $table
302 * @return bool
303 */
304 private function tableExists(string $table): bool {
305 $queryResult = $this->dbCore()->queryAndGetResults(
306 'SHOW TABLES LIKE %s',
307 ['query_params' => [$table]]
308 );
309 $rows = isset($queryResult['rows']) && is_array($queryResult['rows']) ? $queryResult['rows'] : [];
310 $first = $rows[0] ?? null;
311 if (!is_array($first)) {
312 return false;
313 }
314 $firstValue = reset($first);
315 return $firstValue === $table;
316 }
317
318 /**
319 * Insert or update a profile row.
320 *
321 * @param array<string, mixed> $data
322 * @return int|false Inserted/updated row ID, or false on failure.
323 */
324 public function saveProfile(array $data) {
325 $table = $this->getTableName();
326
327 $id = isset($data['id']) && is_numeric($data['id']) ? (int)$data['id'] : 0;
328
329 $name = isset($data['name']) ? sanitize_text_field(is_string($data['name']) ? $data['name'] : '') : '';
330 $urlPattern = isset($data['url_pattern']) ? wp_unslash(is_string($data['url_pattern']) ? $data['url_pattern'] : '') : '';
331 $isRegex = isset($data['is_regex']) ? (int)(bool)$data['is_regex'] : 0;
332 $enabledEngines = isset($data['enabled_engines']) ? (is_string($data['enabled_engines']) ? $data['enabled_engines'] : '[]') : '[]';
333 $priority = isset($data['priority']) ? (is_numeric($data['priority']) ? (int)$data['priority'] : 0) : 0;
334 $status = isset($data['status']) ? (int)(bool)$data['status'] : 1;
335
336 // Validate enabled_engines is valid JSON array.
337 $decoded = json_decode($enabledEngines, true);
338 if (!is_array($decoded)) {
339 $enabledEngines = '[]';
340 }
341
342 if ($id > 0) {
343 $queryResult = $this->dbCore()->queryAndGetResults(
344 "UPDATE `{$table}`
345 SET `name` = %s, `url_pattern` = %s, `is_regex` = %d,
346 `enabled_engines` = %s, `priority` = %d, `status` = %d
347 WHERE `id` = %d",
348 ['query_params' => [$name, $urlPattern, $isRegex, $enabledEngines, $priority, $status, $id]]
349 );
350 $this->cachedProfiles = null;
351 $updateError = isset($queryResult['last_error']) && is_string($queryResult['last_error']) ? $queryResult['last_error'] : '';
352 return $updateError === '' ? $id : false;
353 }
354
355 $queryResult = $this->dbCore()->queryAndGetResults(
356 "INSERT INTO `{$table}` (`name`, `url_pattern`, `is_regex`, `enabled_engines`, `priority`, `status`)
357 VALUES (%s, %s, %d, %s, %d, %d)",
358 ['query_params' => [$name, $urlPattern, $isRegex, $enabledEngines, $priority, $status]]
359 );
360 $this->cachedProfiles = null;
361 $lastError = isset($queryResult['last_error']) && is_string($queryResult['last_error']) ? $queryResult['last_error'] : '';
362 if ($lastError !== '') {
363 return false;
364 }
365 $insertId = isset($queryResult['insert_id']) && is_scalar($queryResult['insert_id']) ? (int)$queryResult['insert_id'] : 0;
366 return $insertId > 0 ? $insertId : false;
367 }
368
369 /**
370 * Delete a profile by ID.
371 *
372 * @param int $id
373 * @return bool
374 */
375 public function deleteProfile(int $id): bool {
376 $table = $this->getTableName();
377 $queryResult = $this->dbCore()->queryAndGetResults(
378 "DELETE FROM `{$table}` WHERE `id` = %d",
379 ['query_params' => [$id]]
380 );
381 $this->cachedProfiles = null;
382 $deleteError = isset($queryResult['last_error']) && is_string($queryResult['last_error']) ? $queryResult['last_error'] : '';
383 return $deleteError === '';
384 }
385
386 /**
387 * Return all profiles (including inactive) for admin display.
388 *
389 * @return array<int, array<string, mixed>>
390 */
391 public function getAllProfilesForAdmin(): array {
392 $table = $this->getTableName();
393
394 if (!$this->tableExists($table)) {
395 return [];
396 }
397
398 $queryResult = $this->dbCore()->queryAndGetResults(
399 "SELECT `id`, `name`, `url_pattern`, `is_regex`, `enabled_engines`, `priority`, `status`
400 FROM `{$table}`
401 ORDER BY `priority` ASC, `id` ASC"
402 );
403 $rows = $queryResult['rows'] ?? [];
404
405 return is_array($rows) ? $rows : [];
406 }
407
408 /**
409 * Reset the request-level cache (used in tests).
410 *
411 * @return void
412 */
413 public function clearCache(): void {
414 $this->cachedProfiles = null;
415 }
416 }
417