requireAdminWithNonce('abj404_engine_profiles_nonce'); $resolver = ABJ_404_Solution_EngineProfileResolver::getInstance(); $profiles = $resolver->getAllProfilesForAdmin(); // `truncated` travels with the list rather than being inferred from its // length: a capped list and a genuinely short one are the same size to // a reader, and an admin whose profiles stopped appearing must be able // to tell that from having deleted them. Additive, so a client that // does not know the field keeps working. wp_send_json_success([ 'profiles' => $profiles, 'truncated' => $resolver->adminProfileListWasTruncated(), 'limit' => ABJ_404_Solution_EngineProfileRepository::MAX_ADMIN_PROFILES, ]); } /** * Insert or update a profile. * * Expected POST fields: name, url_pattern, is_regex, enabled_engines (JSON), * priority, status, and optionally id (for update). * * @return void */ public static function handleSave(): void { if (!ABJ_404_Solution_AjaxRequestContractValidator::requireValidCurrentRequest('ajax-engine-profiles')) { return; } abj_service('ajax_security_gate')->requireAdminWithNonce('abj404_engine_profiles_nonce'); // Boundary normalizer: $_POST shape probing lives in the VO, not here. // See ABJ_404_Solution_EngineProfileSaveRequest. $req = ABJ_404_Solution_EngineProfileSaveRequest::fromPost($_POST); if (trim($req->getName()) === '') { wp_send_json_error(['message' => __('Profile name is required.', '404-solution')]); return; // @phpstan-ignore deadCode.unreachable } if (trim($req->getUrlPattern()) === '') { wp_send_json_error(['message' => __('URL pattern is required.', '404-solution')]); return; // @phpstan-ignore deadCode.unreachable } // Validate regex pattern before saving. // Patterns are stored WITHOUT PHP delimiters (users write ^/shop/ not #^/shop/#). // The resolver wraps with # delimiters at match-time when the first char is not // a common delimiter; we mirror that same logic here so validation matches // what will actually be executed. if ($req->isRegex()) { $testPattern = $req->getUrlPattern(); $commonDelimiters = ['/', '#', '~', '!', '@', '|', '%']; if (!in_array(substr($testPattern, 0, 1), $commonDelimiters, true)) { $testPattern = '#' . $testPattern . '#'; } set_error_handler(function (int $errno, string $errstr, string $errfile = '', int $errline = 0): bool { return false; }, E_WARNING); $testResult = @preg_match($testPattern, ''); restore_error_handler(); if ($testResult === false) { wp_send_json_error(['message' => __('Invalid regular expression pattern.', '404-solution')]); return; // @phpstan-ignore deadCode.unreachable } } $resolver = ABJ_404_Solution_EngineProfileResolver::getInstance(); $resultId = $resolver->saveProfile($req->toResolverPayload()); if ($resultId === false) { $dbError = self::readLastWriteError($resolver); $logger = abj_service('logging'); if ($logger !== null) { $logger->warn('Ajax_EngineProfiles::handleSave: saveProfile() returned false. id=' . $req->getId() . ', name=' . $req->getName() . ', is_regex=' . $req->getIsRegexInt() . '. Returning HTTP 200 with success=false to AJAX caller.'); } // Surface the wpdb-reported failure so the admin can self-diagnose // (table missing, column-count mismatch, deadlock, full disk, etc.) // instead of seeing only a canned "Failed to save" message. $framing = __('Failed to save engine profile.', '404-solution'); $message = $dbError !== '' ? $framing . ' (DB error: ' . $dbError . ')' : $framing; wp_send_json_error(['message' => $message]); return; // @phpstan-ignore deadCode.unreachable } wp_send_json_success(['id' => $resultId]); } /** * Delete a profile by ID. * * @return void */ public static function handleDelete(): void { if (!ABJ_404_Solution_AjaxRequestContractValidator::requireValidCurrentRequest('ajax-engine-profiles')) { return; } abj_service('ajax_security_gate')->requireAdminWithNonce('abj404_engine_profiles_nonce'); $id = isset($_POST['id']) ? absint($_POST['id']) : 0; if ($id <= 0) { wp_send_json_error(['message' => __('Invalid profile ID.', '404-solution')]); return; // @phpstan-ignore deadCode.unreachable } $resolver = ABJ_404_Solution_EngineProfileResolver::getInstance(); $ok = $resolver->deleteProfile($id); if (!$ok) { $dbError = self::readLastWriteError($resolver); $logger = abj_service('logging'); if ($logger !== null) { $logger->warn('Ajax_EngineProfiles::handleDelete: deleteProfile(' . (int)$id . ') returned false (row missing or DB error). Returning HTTP 200 with success=false to AJAX caller.'); } // Surface the wpdb-reported failure so the admin can self-diagnose // instead of seeing only a canned "Failed to delete" message. When // the resolver returns false because the row simply wasn't there, // last_error is empty and the framing message stands on its own. $framing = __('Failed to delete engine profile.', '404-solution'); $message = $dbError !== '' ? $framing . ' (DB error: ' . $dbError . ')' : $framing; wp_send_json_error(['message' => $message]); return; // @phpstan-ignore deadCode.unreachable } wp_send_json_success(['id' => $id]); } /** * Read the write failure without calling an accessor a previous release * does not have. A present accessor owns the answer even when it is blank; * only the missing-capability branch may use the legacy mutable wpdb value. * * @param object $resolver * @return string */ private static function readLastWriteError($resolver): string { if (method_exists($resolver, 'getLastWriteError')) { return trim((string)$resolver->getLastWriteError()); } global $wpdb; return isset($wpdb->last_error) && is_string($wpdb->last_error) ? trim($wpdb->last_error) : ''; } }