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 / logs / LogsWriter.php

LogsWriter.php in 404 Solution trunk, at includes/logs/LogsWriter.php

299 lines 14.5 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 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