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 / matching / EngineProfileResolver.php

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

343 lines 12.2 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 /**
36 * Per-blog memo of the resolved profile rows. Scoped to the blog id active
37 * at cache time (not just the request) because wp_abj404_engine_profiles is
38 * a genuinely per-blog table (the repository derives it from $wpdb->prefix,
39 * which switch_to_blog() changes): a multisite background batch that
40 * switch_to_blog()s mid-request (DatabaseUpgradeMultiSite::processMultisiteBatch(),
41 * NGramCacheRebuildScheduler, network-wide deactivation/uninstall) would
42 * otherwise permanently pin the memoized profile list to whichever blog
43 * happened to trigger the first resolution, silently applying blog A's
44 * engine-filtering profile to blog B's 404 matching once the blog context
45 * changes. Cleared by saveProfile()/deleteProfile()/clearCache().
46 *
47 * @var array<int, object>|null Cached profile rows for current request
48 */
49 private $cachedProfiles = null;
50
51 /** @var int|null Blog id $cachedProfiles was resolved for. */
52 private $cachedProfilesBlogId = null;
53
54 /** @var ABJ_404_Solution_DataAccess|null Lazy DAO accessor for centralized query handling. */
55 /** @var ABJ_404_Solution_EngineProfileRepository|null */
56 private $repository = null;
57
58 /** @return self */
59 public static function getInstance() {
60 if (self::$instance === null) {
61 self::$instance = new self();
62 }
63 return self::$instance;
64 }
65
66 /**
67 * Lazy repository accessor, deferring resolution until first use so unit
68 * tests that exercise pure-logic methods (URL matching, JSON decoding)
69 * never boot the database layer.
70 */
71 private function repository(): ABJ_404_Solution_EngineProfileRepository {
72 if ($this->repository === null) {
73 $this->repository = new ABJ_404_Solution_EngineProfileRepository();
74 }
75 return $this->repository;
76 }
77
78 /**
79 * Resolve which engines to run for a given 404 URL.
80 *
81 * Queries active profiles ordered by priority ASC. First profile whose
82 * url_pattern matches the requested URL wins. The matched profile's
83 * enabled_engines list (JSON array of full class names) is used to filter
84 * the $allEngines array.
85 *
86 * If no profile matches, $allEngines is returned unchanged.
87 *
88 * The result is passed through `apply_filters('abj404_resolved_engines', ...)`.
89 *
90 * @param string $requestedURL The 404 URL being processed (path only or full URL).
91 * @param array<int, mixed> $allEngines The full list of matching engine instances.
92 * @return array<int, mixed> Filtered (or unchanged) engine list.
93 */
94 public function resolve(string $requestedURL, array $allEngines): array {
95 if (empty($allEngines)) {
96 return $allEngines;
97 }
98
99 $profiles = $this->getActiveProfiles();
100
101 if (empty($profiles)) {
102 return $allEngines;
103 }
104
105 foreach ($profiles as $profile) {
106 if ($this->urlMatchesProfile($requestedURL, $profile)) {
107 $filtered = $this->filterEnginesByProfile($allEngines, $profile);
108 /** @var array<int, mixed> $filtered */
109 $filtered = apply_filters('abj404_resolved_engines', $filtered, $requestedURL, $profile);
110 return $filtered;
111 }
112 }
113
114 // No match — return all engines unchanged.
115 return $allEngines;
116 }
117
118 /**
119 * Determine whether a specific engine class is enabled for a requested URL.
120 *
121 * This mirrors profile matching + fail-open behavior used by resolve():
122 * - no matching profile -> enabled
123 * - empty/malformed enabled_engines -> enabled
124 * - matching profile with explicit enabled_engines -> enabled only if listed
125 *
126 * @param string $requestedURL
127 * @param string $engineClassName Fully-qualified class name or short suffix.
128 * @return bool
129 */
130 public function isEngineEnabledForUrl(string $requestedURL, string $engineClassName): bool {
131 $profiles = $this->getActiveProfiles();
132
133 if (empty($profiles)) {
134 return true;
135 }
136
137 foreach ($profiles as $profile) {
138 if (!$this->urlMatchesProfile($requestedURL, $profile)) {
139 continue;
140 }
141
142 $enabledLower = $this->decodeEnabledEnginesLower($profile);
143 if ($enabledLower === null || empty($enabledLower)) {
144 // Fail-open to preserve historical behavior for broken/empty config.
145 return true;
146 }
147
148 return $this->classMatchesEnabledList($engineClassName, $enabledLower);
149 }
150
151 // No profile matched this URL.
152 return true;
153 }
154
155 /**
156 * Load all active profiles ordered by priority ASC (lowest priority number = first).
157 *
158 * Uses a per-request cache to avoid repeated DB queries.
159 *
160 * @return array<int, object>
161 */
162 private function getActiveProfiles(): array {
163 $currentBlogId = function_exists('get_current_blog_id') ? (int)get_current_blog_id() : 0;
164 if ($this->cachedProfiles !== null && $this->cachedProfilesBlogId === $currentBlogId) {
165 return $this->cachedProfiles;
166 }
167
168 $this->cachedProfiles = $this->repository()->readActiveProfiles();
169 $this->cachedProfilesBlogId = $currentBlogId;
170 return $this->cachedProfiles;
171 }
172
173 /**
174 * Check whether a URL matches a profile's pattern.
175 *
176 * @param string $url
177 * @param object $profile
178 * @return bool
179 */
180 private function urlMatchesProfile(string $url, object $profile): bool {
181 $pattern = isset($profile->url_pattern) ? (string)$profile->url_pattern : '';
182
183 if ($pattern === '') {
184 return false;
185 }
186
187 $isRegex = isset($profile->is_regex) && (int)$profile->is_regex === 1;
188
189 if ($isRegex) {
190 // Patterns are stored without PHP delimiters (users write ^/shop/, not #^/shop/#).
191 // Auto-wrap with # delimiters unless the pattern already starts with one.
192 $commonDelimiters = ['/', '#', '~', '!', '@', '|', '%'];
193 if (!in_array(substr($pattern, 0, 1), $commonDelimiters, true)) {
194 $pattern = '#' . $pattern . '#';
195 }
196 // Suppress errors to prevent site breakage from malformed patterns.
197 set_error_handler(function (int $errno, string $errstr, string $errfile = '', int $errline = 0): bool { return false; }, E_WARNING);
198 $matched = @preg_match($pattern, $url);
199 restore_error_handler();
200 return $matched === 1;
201 }
202
203 // Non-regex: fnmatch-style pattern with * as wildcard, case-insensitive.
204 // fnmatch is not available on all Windows PHP builds, so fall back to
205 // a manual conversion via the approach used by the rest of the codebase.
206 if (function_exists('fnmatch')) {
207 return fnmatch($pattern, $url, FNM_CASEFOLD | FNM_PATHNAME) ||
208 fnmatch($pattern, $url, FNM_CASEFOLD);
209 }
210
211 // Fallback: convert * wildcard to a regex.
212 $regex = '/^' . str_replace(
213 ['\\*', '\\?'],
214 ['.*', '.'],
215 preg_quote($pattern, '/')
216 ) . '$/i';
217 return (bool)preg_match($regex, $url);
218 }
219
220 /**
221 * Filter $allEngines to only those listed in the profile's enabled_engines JSON.
222 *
223 * If enabled_engines is empty or malformed, all engines are returned (fail-open).
224 *
225 * @param array<int, mixed> $allEngines
226 * @param object $profile
227 * @return array<int, mixed>
228 */
229 private function filterEnginesByProfile(array $allEngines, object $profile): array {
230 $enabledLower = $this->decodeEnabledEnginesLower($profile);
231 if ($enabledLower === null || empty($enabledLower)) {
232 // Empty/malformed list — no restriction (fail-open).
233 return $allEngines;
234 }
235
236 $filtered = array_values(array_filter($allEngines, function ($engine) use ($enabledLower) {
237 if (!is_object($engine)) {
238 return false;
239 }
240 return $this->classMatchesEnabledList(get_class($engine), $enabledLower);
241 }));
242
243 return $filtered;
244 }
245
246 /**
247 * Parse enabled_engines JSON and normalize class names to lowercase.
248 *
249 * @param object $profile
250 * @return array<int, string>|null Null when empty/missing/malformed.
251 */
252 private function decodeEnabledEnginesLower(object $profile): ?array {
253 $json = isset($profile->enabled_engines) ? (string)$profile->enabled_engines : '';
254 if (trim($json) === '') {
255 return null;
256 }
257
258 $enabledClassNames = json_decode($json, true);
259 if (!is_array($enabledClassNames) || empty($enabledClassNames)) {
260 return null;
261 }
262
263 $enabledLower = array_values(array_filter(array_map(function ($name) {
264 return is_scalar($name) ? strtolower((string)$name) : '';
265 }, $enabledClassNames), function ($name) {
266 return $name !== '';
267 }));
268
269 return empty($enabledLower) ? null : $enabledLower;
270 }
271
272 /**
273 * Case-insensitive class-name check with suffix matching compatibility.
274 *
275 * @param string $className
276 * @param array<int, string> $enabledLower
277 * @return bool
278 */
279 private function classMatchesEnabledList(string $className, array $enabledLower): bool {
280 $classLower = strtolower($className);
281 foreach ($enabledLower as $allowed) {
282 if ($classLower === $allowed || substr($classLower, -strlen($allowed)) === $allowed) {
283 return true;
284 }
285 }
286 return false;
287 }
288
289 /**
290 * Insert or update a profile row.
291 *
292 * @param array<string, mixed> $data
293 * @return int|false Inserted/updated row ID, or false on failure.
294 */
295 public function saveProfile(array $data) {
296 $result = $this->repository()->insertOrUpdate($data);
297 $this->clearCache();
298 return $result;
299 }
300
301 /**
302 * Delete a profile by ID.
303 *
304 * @param int $id
305 * @return bool
306 */
307 public function deleteProfile(int $id): bool {
308 $deleted = $this->repository()->delete($id);
309 $this->clearCache();
310 return $deleted;
311 }
312
313 /**
314 * Every profile, active or not, for the admin edit screen.
315 *
316 * The rows and the ceiling belong to
317 * ABJ_404_Solution_EngineProfileRepository; this stays as the published
318 * entry point because the admin AJAX handlers reach the profile subsystem
319 * through this singleton, which is also the seam their tests inject at.
320 * Kept deliberately thin: no SQL, no policy, just the subsystem's front door.
321 *
322 * @return array<int, array<string, mixed>>
323 */
324 public function getAllProfilesForAdmin(): array {
325 return $this->repository()->readAllForAdmin();
326 }
327
328 /** Whether the last getAllProfilesForAdmin() call filled the repository's ceiling. */
329 public function adminProfileListWasTruncated(): bool {
330 return $this->repository()->lastAdminReadWasTruncated();
331 }
332
333 /**
334 * Reset the request-level cache (used in tests).
335 *
336 * @return void
337 */
338 public function clearCache(): void {
339 $this->cachedProfiles = null;
340 $this->cachedProfilesBlogId = null;
341 }
342 }
343