|null Cached profile rows for current request */ private $cachedProfiles = null; /** @var ABJ_404_Solution_DataAccess|null Lazy DAO accessor for centralized query handling. */ /** @var ABJ_404_Solution_DatabaseCore|null */ private $dbCore = null; /** @return self */ public static function getInstance() { if (self::$instance === null) { self::$instance = new self(); } return self::$instance; } /** * Lazy dbCore accessor, defers resolution until first use so unit tests * that exercise pure-logic methods (URL matching, JSON decoding) don't * boot the database layer. * * @return ABJ_404_Solution_DatabaseCore */ private function dbCore() { if ($this->dbCore === null) { $this->dbCore = abj_service('db_core'); } return $this->dbCore; } /** * Resolve which engines to run for a given 404 URL. * * Queries active profiles ordered by priority ASC. First profile whose * url_pattern matches the requested URL wins. The matched profile's * enabled_engines list (JSON array of full class names) is used to filter * the $allEngines array. * * If no profile matches, $allEngines is returned unchanged. * * The result is passed through `apply_filters('abj404_resolved_engines', ...)`. * * @param string $requestedURL The 404 URL being processed (path only or full URL). * @param array $allEngines The full list of matching engine instances. * @return array Filtered (or unchanged) engine list. */ public function resolve(string $requestedURL, array $allEngines): array { if (empty($allEngines)) { return $allEngines; } $profiles = $this->getActiveProfiles(); if (empty($profiles)) { return $allEngines; } foreach ($profiles as $profile) { if ($this->urlMatchesProfile($requestedURL, $profile)) { $filtered = $this->filterEnginesByProfile($allEngines, $profile); /** @var array $filtered */ $filtered = apply_filters('abj404_resolved_engines', $filtered, $requestedURL, $profile); return $filtered; } } // No match — return all engines unchanged. return $allEngines; } /** * Determine whether a specific engine class is enabled for a requested URL. * * This mirrors profile matching + fail-open behavior used by resolve(): * - no matching profile -> enabled * - empty/malformed enabled_engines -> enabled * - matching profile with explicit enabled_engines -> enabled only if listed * * @param string $requestedURL * @param string $engineClassName Fully-qualified class name or short suffix. * @return bool */ public function isEngineEnabledForUrl(string $requestedURL, string $engineClassName): bool { $profiles = $this->getActiveProfiles(); if (empty($profiles)) { return true; } foreach ($profiles as $profile) { if (!$this->urlMatchesProfile($requestedURL, $profile)) { continue; } $enabledLower = $this->decodeEnabledEnginesLower($profile); if ($enabledLower === null || empty($enabledLower)) { // Fail-open to preserve historical behavior for broken/empty config. return true; } return $this->classMatchesEnabledList($engineClassName, $enabledLower); } // No profile matched this URL. return true; } /** * Load all active profiles ordered by priority ASC (lowest priority number = first). * * Uses a per-request cache to avoid repeated DB queries. * * @return array */ private function getActiveProfiles(): array { if ($this->cachedProfiles !== null) { return $this->cachedProfiles; } $table = $this->getTableName(); if (!$this->tableExists($table)) { $this->cachedProfiles = []; return $this->cachedProfiles; } $queryResult = $this->dbCore()->queryAndGetResults( "SELECT `id`, `name`, `url_pattern`, `is_regex`, `enabled_engines`, `priority` FROM `{$table}` WHERE `status` = 1 ORDER BY `priority` ASC, `id` ASC LIMIT %d", ['query_params' => [200], 'result_type' => OBJECT] ); $rows = $queryResult['rows'] ?? []; $this->cachedProfiles = is_array($rows) ? $rows : []; return $this->cachedProfiles; } /** * Check whether a URL matches a profile's pattern. * * @param string $url * @param object $profile * @return bool */ private function urlMatchesProfile(string $url, object $profile): bool { $pattern = isset($profile->url_pattern) ? (string)$profile->url_pattern : ''; if ($pattern === '') { return false; } $isRegex = isset($profile->is_regex) && (int)$profile->is_regex === 1; if ($isRegex) { // Patterns are stored without PHP delimiters (users write ^/shop/, not #^/shop/#). // Auto-wrap with # delimiters unless the pattern already starts with one. $commonDelimiters = ['/', '#', '~', '!', '@', '|', '%']; if (!in_array(substr($pattern, 0, 1), $commonDelimiters, true)) { $pattern = '#' . $pattern . '#'; } // Suppress errors to prevent site breakage from malformed patterns. set_error_handler(function (int $errno, string $errstr, string $errfile = '', int $errline = 0): bool { return false; }, E_WARNING); $matched = @preg_match($pattern, $url); restore_error_handler(); return $matched === 1; } // Non-regex: fnmatch-style pattern with * as wildcard, case-insensitive. // fnmatch is not available on all Windows PHP builds, so fall back to // a manual conversion via the approach used by the rest of the codebase. if (function_exists('fnmatch')) { return fnmatch($pattern, $url, FNM_CASEFOLD | FNM_PATHNAME) || fnmatch($pattern, $url, FNM_CASEFOLD); } // Fallback: convert * wildcard to a regex. $regex = '/^' . str_replace( ['\\*', '\\?'], ['.*', '.'], preg_quote($pattern, '/') ) . '$/i'; return (bool)preg_match($regex, $url); } /** * Filter $allEngines to only those listed in the profile's enabled_engines JSON. * * If enabled_engines is empty or malformed, all engines are returned (fail-open). * * @param array $allEngines * @param object $profile * @return array */ private function filterEnginesByProfile(array $allEngines, object $profile): array { $enabledLower = $this->decodeEnabledEnginesLower($profile); if ($enabledLower === null || empty($enabledLower)) { // Empty/malformed list — no restriction (fail-open). return $allEngines; } $filtered = array_values(array_filter($allEngines, function ($engine) use ($enabledLower) { if (!is_object($engine)) { return false; } return $this->classMatchesEnabledList(get_class($engine), $enabledLower); })); return $filtered; } /** * Parse enabled_engines JSON and normalize class names to lowercase. * * @param object $profile * @return array|null Null when empty/missing/malformed. */ private function decodeEnabledEnginesLower(object $profile): ?array { $json = isset($profile->enabled_engines) ? (string)$profile->enabled_engines : ''; if (trim($json) === '') { return null; } $enabledClassNames = json_decode($json, true); if (!is_array($enabledClassNames) || empty($enabledClassNames)) { return null; } $enabledLower = array_values(array_filter(array_map(function ($name) { return is_scalar($name) ? strtolower((string)$name) : ''; }, $enabledClassNames), function ($name) { return $name !== ''; })); return empty($enabledLower) ? null : $enabledLower; } /** * Case-insensitive class-name check with suffix matching compatibility. * * @param string $className * @param array $enabledLower * @return bool */ private function classMatchesEnabledList(string $className, array $enabledLower): bool { $classLower = strtolower($className); foreach ($enabledLower as $allowed) { if ($classLower === $allowed || substr($classLower, -strlen($allowed)) === $allowed) { return true; } } return false; } /** * @return string The fully-prefixed table name. */ private function getTableName(): string { global $wpdb; return strtolower($wpdb->prefix) . 'abj404_engine_profiles'; } /** * Check if the engine profiles table exists in the current database. * * @param string $table * @return bool */ private function tableExists(string $table): bool { $queryResult = $this->dbCore()->queryAndGetResults( 'SHOW TABLES LIKE %s', ['query_params' => [$table]] ); $rows = isset($queryResult['rows']) && is_array($queryResult['rows']) ? $queryResult['rows'] : []; $first = $rows[0] ?? null; if (!is_array($first)) { return false; } $firstValue = reset($first); return $firstValue === $table; } /** * Insert or update a profile row. * * @param array $data * @return int|false Inserted/updated row ID, or false on failure. */ public function saveProfile(array $data) { $table = $this->getTableName(); $id = isset($data['id']) && is_numeric($data['id']) ? (int)$data['id'] : 0; $name = isset($data['name']) ? sanitize_text_field(is_string($data['name']) ? $data['name'] : '') : ''; $urlPattern = isset($data['url_pattern']) ? wp_unslash(is_string($data['url_pattern']) ? $data['url_pattern'] : '') : ''; $isRegex = isset($data['is_regex']) ? (int)(bool)$data['is_regex'] : 0; $enabledEngines = isset($data['enabled_engines']) ? (is_string($data['enabled_engines']) ? $data['enabled_engines'] : '[]') : '[]'; $priority = isset($data['priority']) ? (is_numeric($data['priority']) ? (int)$data['priority'] : 0) : 0; $status = isset($data['status']) ? (int)(bool)$data['status'] : 1; // Validate enabled_engines is valid JSON array. $decoded = json_decode($enabledEngines, true); if (!is_array($decoded)) { $enabledEngines = '[]'; } if ($id > 0) { $queryResult = $this->dbCore()->queryAndGetResults( "UPDATE `{$table}` SET `name` = %s, `url_pattern` = %s, `is_regex` = %d, `enabled_engines` = %s, `priority` = %d, `status` = %d WHERE `id` = %d", ['query_params' => [$name, $urlPattern, $isRegex, $enabledEngines, $priority, $status, $id]] ); $this->cachedProfiles = null; $updateError = isset($queryResult['last_error']) && is_string($queryResult['last_error']) ? $queryResult['last_error'] : ''; return $updateError === '' ? $id : false; } $queryResult = $this->dbCore()->queryAndGetResults( "INSERT INTO `{$table}` (`name`, `url_pattern`, `is_regex`, `enabled_engines`, `priority`, `status`) VALUES (%s, %s, %d, %s, %d, %d)", ['query_params' => [$name, $urlPattern, $isRegex, $enabledEngines, $priority, $status]] ); $this->cachedProfiles = null; $lastError = isset($queryResult['last_error']) && is_string($queryResult['last_error']) ? $queryResult['last_error'] : ''; if ($lastError !== '') { return false; } $insertId = isset($queryResult['insert_id']) && is_scalar($queryResult['insert_id']) ? (int)$queryResult['insert_id'] : 0; return $insertId > 0 ? $insertId : false; } /** * Delete a profile by ID. * * @param int $id * @return bool */ public function deleteProfile(int $id): bool { $table = $this->getTableName(); $queryResult = $this->dbCore()->queryAndGetResults( "DELETE FROM `{$table}` WHERE `id` = %d", ['query_params' => [$id]] ); $this->cachedProfiles = null; $deleteError = isset($queryResult['last_error']) && is_string($queryResult['last_error']) ? $queryResult['last_error'] : ''; return $deleteError === ''; } /** * Return all profiles (including inactive) for admin display. * * @return array> */ public function getAllProfilesForAdmin(): array { $table = $this->getTableName(); if (!$this->tableExists($table)) { return []; } $queryResult = $this->dbCore()->queryAndGetResults( "SELECT `id`, `name`, `url_pattern`, `is_regex`, `enabled_engines`, `priority`, `status` FROM `{$table}` ORDER BY `priority` ASC, `id` ASC" ); $rows = $queryResult['rows'] ?? []; return is_array($rows) ? $rows : []; } /** * Reset the request-level cache (used in tests). * * @return void */ public function clearCache(): void { $this->cachedProfiles = null; } }