PluginProbe
404 Solution / 4.2.0
404 Solution v4.2.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 / EngineProfileResolver.php

EngineProfileResolver.php in 404 Solution 4.2.0, at includes/EngineProfileResolver.php

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