PluginProbe
404 Solution / 4.1.13
404 Solution v4.1.13
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.1.13, at includes/EngineProfileResolver.php

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