PluginProbe
404 Solution / 4.3.0
404 Solution v4.3.0
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 4.3.0, at includes/logs/LogsWriter.php

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