| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
require_once __DIR__ . '/../database/DatabaseCollationHelper.php'; |
| 8 |
require_once __DIR__ . '/LogsEntrySanitizer.php'; |
| 9 |
require_once __DIR__ . '/LogsRequestedUrlColumnMetadata.php'; |
| 10 |
require_once __DIR__ . '/LogsQueueFlusher.php'; |
| 11 |
|
| 12 |
/** |
| 13 |
* Log-write pipeline for the wp_abj404_logsv2 table. |
| 14 |
* |
| 15 |
* Responsibilities: |
| 16 |
* - logRedirectHit: build a log entry from the live request context (IP hash, |
| 17 |
* referrer, current user, charset-aware requested_url handling) and enqueue it. |
| 18 |
* |
| 19 |
* The queue is process-static so multiple LogsWriter instances within the |
| 20 |
* same request share one batch. Queue flushing, recovery, metadata lookup, |
| 21 |
* and sanitization are delegated to focused collaborators. |
| 22 |
* |
| 23 |
* Extracted from LogsRepository under M201. Consumed by the LogsRepository |
| 24 |
* facade. |
| 25 |
*/ |
| 26 |
class ABJ_404_Solution_LogsWriter { |
| 27 |
|
| 28 |
/** @var ABJ_404_Solution_DatabaseCore */ |
| 29 |
private $dbCore; |
| 30 |
|
| 31 |
/** @var ABJ_404_Solution_Functions */ |
| 32 |
private $f; |
| 33 |
|
| 34 |
/** @var ABJ_404_Solution_Logging */ |
| 35 |
private $logger; |
| 36 |
|
| 37 |
/** @var ABJ_404_Solution_DatabaseErrorClassifier */ |
| 38 |
private $errorClassifier; |
| 39 |
|
| 40 |
/** @var ABJ_404_Solution_DatabaseCollationHelper */ |
| 41 |
private $collationHelper; |
| 42 |
|
| 43 |
/** @var ABJ_404_Solution_LogsLookupRepository */ |
| 44 |
private $lookups; |
| 45 |
|
| 46 |
/** @var ABJ_404_Solution_LogsEntrySanitizer */ |
| 47 |
private $entrySanitizer; |
| 48 |
|
| 49 |
/** @var ABJ_404_Solution_LogsRequestedUrlColumnMetadata */ |
| 50 |
private $requestedUrlColumnMetadata; |
| 51 |
|
| 52 |
/** @var ABJ_404_Solution_LogsWriteRecoveryPolicy */ |
| 53 |
private $recoveryPolicy; |
| 54 |
|
| 55 |
/** @var ABJ_404_Solution_LogsQueueFlusher */ |
| 56 |
private $queueFlusher; |
| 57 |
|
| 58 |
/** @var array<int, array<string, mixed>> Queue of log entries to be flushed at shutdown */ |
| 59 |
private static $logQueue = []; |
| 60 |
|
| 61 |
/** @var bool Whether shutdown hook has been registered */ |
| 62 |
private static $shutdownHookRegistered = false; |
| 63 |
|
| 64 |
/** @var bool Prevent re-entrancy during flush */ |
| 65 |
private static $isFlushingLogQueue = false; |
| 66 |
|
| 67 |
/** |
| 68 |
* Test seam: clear all cached static state (the pending log queue, the |
| 69 |
* shutdown-hook-registered latch, and the flush re-entrancy guard) without |
| 70 |
* private-field reflection (M105 singleton-reset seam). |
| 71 |
* |
| 72 |
* @return void |
| 73 |
*/ |
| 74 |
public static function resetForTests() { |
| 75 |
self::$logQueue = []; |
| 76 |
self::$shutdownHookRegistered = false; |
| 77 |
self::$isFlushingLogQueue = false; |
| 78 |
} |
| 79 |
|
| 80 |
public function __construct( |
| 81 |
ABJ_404_Solution_DatabaseCore $dbCore, |
| 82 |
ABJ_404_Solution_Functions $f, |
| 83 |
$logger, |
| 84 |
ABJ_404_Solution_DatabaseErrorClassifier $errorClassifier, |
| 85 |
ABJ_404_Solution_DatabaseCollationHelper $collationHelper, |
| 86 |
ABJ_404_Solution_DatabaseNoticeStateHolder $noticeState, |
| 87 |
ABJ_404_Solution_LogsLookupRepository $lookups |
| 88 |
) { |
| 89 |
$this->dbCore = $dbCore; |
| 90 |
$this->f = $f; |
| 91 |
$this->logger = $logger; |
| 92 |
$this->errorClassifier = $errorClassifier; |
| 93 |
$this->collationHelper = $collationHelper; |
| 94 |
$this->lookups = $lookups; |
| 95 |
$this->entrySanitizer = new ABJ_404_Solution_LogsEntrySanitizer(); |
| 96 |
$this->requestedUrlColumnMetadata = new ABJ_404_Solution_LogsRequestedUrlColumnMetadata($logger); |
| 97 |
$this->recoveryPolicy = new ABJ_404_Solution_LogsWriteRecoveryPolicy( |
| 98 |
$logger, |
| 99 |
$noticeState |
| 100 |
); |
| 101 |
$this->queueFlusher = new ABJ_404_Solution_LogsQueueFlusher($dbCore, $logger, $this->entrySanitizer, $this->recoveryPolicy); |
| 102 |
} |
| 103 |
|
| 104 |
/** |
| 105 |
* Capture a redirect or 404 event and enqueue it for write at shutdown. |
| 106 |
*/ |
| 107 |
public function logRedirectHit(ABJ_404_Solution_RedirectHitLogEntry $entry): void { |
| 108 |
$requested_url = $entry->requestedUrl; |
| 109 |
$action = $entry->action; |
| 110 |
$matchReason = $entry->matchReason; |
| 111 |
$requestedURLDetail = $entry->requestedUrlDetail; |
| 112 |
$pipelineTrace = $entry->pipelineTrace; |
| 113 |
global $wpdb; |
| 114 |
$abj404logic = abj_service('plugin_logic'); |
| 115 |
$logTableName = $this->dbCore->doTableNameReplacements("{wp_abj404_logsv2}"); |
| 116 |
$now = abj_clock()->now(); |
| 117 |
$requested_url = preg_replace('/[\x00-\x1F\x7F]/u', '', $requested_url) ?? $requested_url; |
| 118 |
$requested_url = $abj404logic->urlNormalization()->normalizeToRelativePath($requested_url); |
| 119 |
|
| 120 |
$requestedUrlCharset = null; |
| 121 |
$requestedUrlCollation = null; |
| 122 |
try { |
| 123 |
$columnMeta = $this->requestedUrlColumnMetadata->resolveRequestedUrlColumnMeta($logTableName, $wpdb); |
| 124 |
$requestedUrlCharset = is_array($columnMeta) ? ($columnMeta['charset_name'] ?? null) : null; |
| 125 |
$requestedUrlCollation = is_array($columnMeta) ? ($columnMeta['collation_name'] ?? null) : null; |
| 126 |
if (!empty($requestedUrlCharset) && strpos(strtolower($requestedUrlCharset), 'utf8') === false) { |
| 127 |
$requested_url = abj_service('url_encoder')->encodeUrlForLegacyMatch($requested_url); |
| 128 |
$this->requestedUrlColumnMetadata->warnLogsCharsetMismatchOnce($logTableName, $requestedUrlCharset); |
| 129 |
} |
| 130 |
} catch (Exception $e) { |
| 131 |
$this->logger->debugMessage(__FUNCTION__ . " error. Issue getting character set for table: " . $logTableName . ", column: requested_url. Error message: " . $e->getMessage()); |
| 132 |
} |
| 133 |
|
| 134 |
$options = abj_service('options_repository')->getOptions(true); |
| 135 |
$referer = function_exists('wp_get_referer') ? wp_get_referer() : ''; |
| 136 |
if ($referer !== null && $referer !== false) { |
| 137 |
$referer = function_exists('esc_url_raw') ? esc_url_raw($referer) : (string)$referer; |
| 138 |
$referer = substr($referer, 0, 512); |
| 139 |
} else { |
| 140 |
$referer = ''; |
| 141 |
} |
| 142 |
$current_user = null; |
| 143 |
if (function_exists('wp_get_current_user')) { |
| 144 |
try { |
| 145 |
$current_user = ABJ_404_Solution_UserRef::fromWpUser(wp_get_current_user()); |
| 146 |
} catch (\Throwable $e) { |
| 147 |
// allow-silent-catch: optional WP user context for log enrichment; absence leaves user_login blank. |
| 148 |
$current_user = null; |
| 149 |
} |
| 150 |
} |
| 151 |
$current_user_name = $current_user !== null ? $current_user->getLogin() : ''; |
| 152 |
$remoteAddrRaw = $_SERVER['REMOTE_ADDR'] ?? ''; |
| 153 |
$ipAddressToSave = is_string($remoteAddrRaw) ? $remoteAddrRaw : ''; |
| 154 |
$ipAddressToSave = filter_var($ipAddressToSave, FILTER_VALIDATE_IP) |
| 155 |
? (function_exists('esc_sql') ? esc_sql($ipAddressToSave) : $ipAddressToSave) |
| 156 |
: ''; |
| 157 |
if (!array_key_exists('log_raw_ips', $options) || $options['log_raw_ips'] != '1') { |
| 158 |
$ipAddressToSave = $this->f->md5lastOctet($ipAddressToSave); |
| 159 |
} |
| 160 |
if (!empty($ipAddressToSave)) { |
| 161 |
$ipAddressToSave = substr($ipAddressToSave, 0, 512); |
| 162 |
} else { |
| 163 |
$ipAddressToSave = '(Unknown)'; |
| 164 |
} |
| 165 |
|
| 166 |
// Probe whether the requested_url is new to logsv2. The result becomes the |
| 167 |
// min_log_id flag on the row about to be enqueued. CAST + COLLATE makes |
| 168 |
// the lookup case-sensitive even on a ci-collated column; the raw |
| 169 |
// equality fallback handles non-utf8 columns and strict-mode CAST errors. |
| 170 |
$minLogID = false; |
| 171 |
$comparisonCollation = $this->resolveUtf8mb4ComparisonCollation( |
| 172 |
isset($requestedUrlCollation) ? $requestedUrlCollation : null |
| 173 |
); |
| 174 |
$requestedUrlCharsetLower = isset($requestedUrlCharset) ? strtolower((string)$requestedUrlCharset) : ''; |
| 175 |
$canUseUtf8Cast = ($requestedUrlCharsetLower === '' || strpos($requestedUrlCharsetLower, 'utf8') !== false); |
| 176 |
if ($canUseUtf8Cast) { |
| 177 |
// Index-assisted: the bare `requested_url = %s` predicate is sargable |
| 178 |
// on the requested_url(190) prefix index and narrows to the rows |
| 179 |
// sharing this URL; the CAST...COLLATE refinement then enforces the |
| 180 |
// utf8mb4-harmonized comparison (its original purpose -- it equalizes |
| 181 |
// utf8 vs utf8mb4 charset, not case). Without the bare predicate the |
| 182 |
// CAST wraps the column and forces a full table scan of logsv2 on |
| 183 |
// EVERY 404 log write -- the hottest path under scanner-flood traffic |
| 184 |
// on a large log table. A bound %s is collation-coercible to the |
| 185 |
// column, so the prefilter neither throws an illegal-mix error nor |
| 186 |
// changes which row is found (min_log_id only drives the admin logs |
| 187 |
// unique-URL dropdown, so the comparison need not be byte-exact). |
| 188 |
$checkMinIDSql = "SELECT id FROM `" . $logTableName . "` \n WHERE requested_url = %s AND CAST(requested_url AS CHAR CHARACTER SET utf8mb4) COLLATE " . $comparisonCollation . " = %s \n LIMIT 1"; |
| 189 |
$checkMinIDParams = array($requested_url, $requested_url); |
| 190 |
} else { |
| 191 |
$checkMinIDSql = "SELECT id FROM `" . $logTableName . "` \n WHERE requested_url = %s \n LIMIT 1"; |
| 192 |
$checkMinIDParams = array($requested_url); |
| 193 |
} |
| 194 |
$primaryResult = $this->dbCore->queryAndGetResults($checkMinIDSql, array('query_params' => $checkMinIDParams, 'log_errors' => false)); |
| 195 |
$checkMinIDQueryResults = is_array($primaryResult['rows'] ?? null) ? $primaryResult['rows'] : array(); |
| 196 |
$lastErrorRaw = $primaryResult['last_error'] ?? ''; |
| 197 |
$lastError = is_string($lastErrorRaw) ? $lastErrorRaw : ''; |
| 198 |
if ($lastError !== '' && $this->errorClassifier->taxonomy()->schema()->isInvalidDataError($lastError) && $canUseUtf8Cast) { |
| 199 |
$fallbackResult = $this->dbCore->queryAndGetResults("SELECT id FROM `" . $logTableName . "` \n WHERE requested_url = %s \n LIMIT 1", array('query_params' => array($requested_url), 'log_errors' => false)); |
| 200 |
$checkMinIDQueryResults = is_array($fallbackResult['rows'] ?? null) ? $fallbackResult['rows'] : array(); |
| 201 |
} |
| 202 |
if (empty($checkMinIDQueryResults)) { $minLogID = true; } |
| 203 |
|
| 204 |
if (trim($action) != "404") { |
| 205 |
$action = function_exists('esc_url_raw') ? esc_url_raw($action) : $action; |
| 206 |
} |
| 207 |
|
| 208 |
$helperFunctions = abj_service('functions'); |
| 209 |
$reasonMessage = trim(implode(", ", array_filter(array(abj_service('request_context')->ignore_doprocess ?: '', abj_service('request_context')->ignore_donotprocess ?: '')))); |
| 210 |
$permalinksKept = '(not set)'; |
| 211 |
$ctx = abj_service('request_context'); |
| 212 |
if ($this->logger->isDebug() && !empty($ctx->permalinks_found)) { |
| 213 |
$permalinksKept = $ctx->permalinks_kept; |
| 214 |
} |
| 215 |
$requestUri = is_string($_SERVER['REQUEST_URI'] ?? '') ? (string)($_SERVER['REQUEST_URI'] ?? '') : ''; |
| 216 |
$escapeHtml = function($value) { |
| 217 |
return function_exists('esc_html') ? esc_html($value) : htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8'); |
| 218 |
}; |
| 219 |
$this->logger->debugMessage("Logging redirect. Referer: " . $escapeHtml($referer) . " | Current user: " . $current_user_name . " | From: " . abj_service('sanitizer')->normalizeUrlString($requestUri) . $escapeHtml(" to: ") . $escapeHtml($action) . ', Reason: ' . $matchReason . ", Ignore msg(s): " . $reasonMessage . ', Execution time: ' . round((float)$helperFunctions->getExecutionTime(), 2) . ' seconds, permalinks found: ' . $permalinksKept); |
| 220 |
|
| 221 |
$usernameLookupID = $this->lookups->insertLookupValueAndGetID($current_user_name); |
| 222 |
|
| 223 |
$reqUrlForLog = function_exists('esc_url_raw') ? esc_url_raw($requested_url) : $requested_url; |
| 224 |
$reqUrlForLogStr = is_string($reqUrlForLog) ? $reqUrlForLog : ''; |
| 225 |
$this->queueLogEntry([ |
| 226 |
'timestamp' => $now, 'user_ip' => $ipAddressToSave, 'referrer' => $referer, |
| 227 |
'dest_url' => $action, 'requested_url' => $reqUrlForLogStr, |
| 228 |
'requested_url_detail' => $requestedURLDetail, 'username' => $usernameLookupID, |
| 229 |
'min_log_id' => $minLogID, 'engine' => substr($matchReason, 0, 64), |
| 230 |
'pipeline_trace' => $this->entrySanitizer->serializePipelineTrace($pipelineTrace), |
| 231 |
'canonical_url' => '/' . trim($reqUrlForLogStr, '/'), |
| 232 |
]); |
| 233 |
} |
| 234 |
|
| 235 |
/** |
| 236 |
* The collation for a comparison whose charset is pinned to utf8mb4. |
| 237 |
* |
| 238 |
* The lookup CASTs requested_url to a hard-coded `CHARACTER SET utf8mb4`, |
| 239 |
* so the collation beside it must belong to that family or the engine |
| 240 |
* refuses the statement outright ("COLLATION 'x' is not valid for CHARACTER |
| 241 |
* SET 'utf8mb4'", errno 1253). Preference order is the column's own |
| 242 |
* collation, so the comparison keeps the column's semantics, then the |
| 243 |
* site's, then the guaranteed-present default. |
| 244 |
* |
| 245 |
* @param mixed $requestedUrlCollation The requested_url column's collation, |
| 246 |
* or null when it could not be read. |
| 247 |
* @return string A collation valid for CHARACTER SET utf8mb4. |
| 248 |
*/ |
| 249 |
private function resolveUtf8mb4ComparisonCollation($requestedUrlCollation): string { |
| 250 |
if (ABJ_404_Solution_DatabaseCollationHelper::isUtf8mb4Collation($requestedUrlCollation)) { |
| 251 |
return ABJ_404_Solution_DatabaseCollationHelper::utf8mb4CollationOrFallback($requestedUrlCollation); |
| 252 |
} |
| 253 |
return $this->collationHelper->getPreferredUtf8mb4Collation(); |
| 254 |
} |
| 255 |
|
| 256 |
/** |
| 257 |
* Enqueue a sanitized entry to be flushed at shutdown. |
| 258 |
* |
| 259 |
* @param array<string, mixed> $entry |
| 260 |
*/ |
| 261 |
public function queueLogEntry(array $entry): void { |
| 262 |
$this->queueFlusher->queueLogEntry(self::$logQueue, self::$shutdownHookRegistered, [$this, 'flushLogQueue'], $entry); |
| 263 |
} |
| 264 |
|
| 265 |
/** |
| 266 |
* Flush the pending log queue to logsv2 as one INSERT IGNORE batch, with |
| 267 |
* per-failure recovery (table-full auto-trim, shared-connection reset, |
| 268 |
* and per-row fallback). |
| 269 |
*/ |
| 270 |
public function flushLogQueue(): void { |
| 271 |
$this->queueFlusher->flushLogQueue(self::$logQueue, self::$shutdownHookRegistered, self::$isFlushingLogQueue); |
| 272 |
} |
| 273 |
|
| 274 |
public function isTableFullError(string $error): bool { |
| 275 |
return $this->recoveryPolicy->isTableFullError($error); |
| 276 |
} |
| 277 |
|
| 278 |
/** |
| 279 |
* Free space in logsv2 by deleting the oldest 1000 entries. Rate-limited |
| 280 |
* to once per hour via a transient cooldown so a repeated table-full |
| 281 |
* error doesn't drain the table. |
| 282 |
*/ |
| 283 |
public function autoTrimLogsv2IfNeeded(string $tableName, string $errorMessage): bool { |
| 284 |
return $this->recoveryPolicy->autoTrimLogsv2IfNeeded($tableName, $errorMessage); |
| 285 |
} |
| 286 |
|
| 287 |
/** |
| 288 |
* Normalize a log entry into the strict shape expected by INSERT IGNORE. |
| 289 |
* Returns null when a required column is missing or sanitization rejects |
| 290 |
* the entry (empty requested_url, empty dest_url, or non-scalar payload). |
| 291 |
* |
| 292 |
* @param array<string, mixed> $entry |
| 293 |
* @return array<string, mixed>|null |
| 294 |
*/ |
| 295 |
public function sanitizeLogEntry(array $entry): ?array { |
| 296 |
return $this->entrySanitizer->sanitizeLogEntry($entry); |
| 297 |
} |
| 298 |
} |
| 299 |
|