PluginProbe
404 Solution / 4.2.0
404 Solution v4.2.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 / LogsRepository.php

LogsRepository.php in 404 Solution 4.2.0, at includes/LogsRepository.php

1,340 lines 75.0 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__ . '/LogsRepositoryInterface.php';
8
9 /**
10 * Log insertion, querying, hits rebuild, GDPR, and lookup operations.
11 *
12 * Extracted from the DataAccess monolith (Phase 3 of the DataAccess refactor).
13 * Methods originate from three sources:
14 * - DataAccessTrait_Logs (entirely absorbed)
15 * - DataAccessTrait_LogsHitsRebuild (entirely absorbed)
16 * - DataAccessTrait_Maintenance::correctDuplicateLookupValues() (relocated)
17 * - DataAccessTrait_ViewQueriesHitsLifecycle (hits lifecycle methods relocated)
18 *
19 * Receives a DatabaseCore instance for all query execution.
20 */
21 class ABJ_404_Solution_LogsRepository implements ABJ_404_Solution_LogsRepositoryInterface {
22
23 const UPDATE_LOGS_HITS_TABLE_HOOK = 'abj404_updateLogsHitsTableAction';
24
25 /** @var int Maximum age in seconds before hits table is considered stale */
26 const HITS_TABLE_MAX_AGE_SECONDS = 300;
27 /** @var int Minimum interval between hits-table rebuild schedules (server-side dedupe). */
28 const HITS_TABLE_SCHEDULE_COOLDOWN_SECONDS = 30;
29 /** @var int Cross-request lock timeout for logs-hits rebuild jobs. */
30 const HITS_TABLE_REBUILD_LOCK_TTL_SECONDS = 180;
31 /** @var int Number of logsv2 IDs to process per chunk during pre-aggregation. */
32 const HITS_TABLE_PREAGG_CHUNK_SIZE = 100000;
33 /** @var int Direct-path threshold for hits-table rebuild. */
34 const HITS_TABLE_DIRECT_PATH_THRESHOLD = 5000;
35 /** @var int Max age for cached daily-activity trend data. */
36 const TREND_DATA_CACHE_TTL_SECONDS = 900;
37
38 /** @var string Runtime flag: last time we checked whether logs-hits needs rebuild. */
39 const HITS_TABLE_LAST_CHECKED_FLAG = 'abj404_logs_hits_last_checked_at';
40 /** @var string Runtime flag: last time we scheduled a rebuild. */
41 const HITS_TABLE_LAST_SCHEDULED_FLAG = 'abj404_logs_hits_last_scheduled_at';
42 /** @var string Runtime flag: last schedule decision. */
43 const HITS_TABLE_LAST_DECISION_FLAG = 'abj404_logs_hits_last_decision';
44 /** @var string Runtime flag: last successful hits-table rebuild completion. */
45 const HITS_TABLE_LAST_REFRESHED_FLAG = 'abj404_logs_hits_last_refreshed_at';
46 /** @var string Runtime flag: first stale detection timestamp. */
47 const HITS_TABLE_FIRST_STALE_DETECTED_FLAG = 'abj404_logs_hits_first_stale_detected_at';
48 /** @var string Deduplicated admin-notice transient for stale logs_hits rollup. */
49 const HITS_TABLE_STALE_NOTICE_TRANSIENT = 'abj404_logs_hits_rollup_stale';
50 /** @var int Minimum age (seconds) of stale gap before surfacing admin notice. */
51 const HITS_TABLE_STALE_NOTICE_THRESHOLD_SECONDS = 3600;
52
53 /** @var ABJ_404_Solution_DatabaseCore */
54 private $dbCore;
55
56 /** @var ABJ_404_Solution_Functions */
57 private $f;
58
59 /** @var ABJ_404_Solution_Logging */
60 private $logger;
61
62 /** @var ABJ_404_Solution_RebuildHealthState|null */
63 private $rebuildHealth;
64
65 /** @var array<int, array<string, mixed>> Queue of log entries to be flushed at shutdown */
66 private static $logQueue = [];
67
68 /** @var bool Whether shutdown hook has been registered */
69 private static $shutdownHookRegistered = false;
70
71 /** @var bool Prevent re-entrancy during flush */
72 private static $isFlushingLogQueue = false;
73
74 /** @var bool Whether the hits table rebuild has been scheduled for this request */
75 private static $hitsTableRebuildScheduled = false;
76
77 /**
78 * @param ABJ_404_Solution_DatabaseCore $dbCore
79 * @param ABJ_404_Solution_Functions|null $functions
80 * @param ABJ_404_Solution_Logging|null $logging
81 * @param ABJ_404_Solution_RebuildHealthState|null $rebuildHealth
82 */
83 public function __construct(
84 ABJ_404_Solution_DatabaseCore $dbCore,
85 $functions = null,
86 $logging = null,
87 $rebuildHealth = null
88 ) {
89 $this->dbCore = $dbCore;
90 $this->f = $functions !== null ? $functions : abj_service('functions');
91 $this->logger = $logging !== null ? $logging : abj_service('logging');
92 $this->rebuildHealth = $rebuildHealth instanceof ABJ_404_Solution_RebuildHealthState
93 ? $rebuildHealth
94 : $this->resolveRebuildHealthState();
95 }
96
97 /** @return ABJ_404_Solution_RebuildHealthState|null */
98 private function resolveRebuildHealthState(): ?ABJ_404_Solution_RebuildHealthState {
99 if (function_exists('abj_service')
100 && class_exists('ABJ_404_Solution_ServiceContainer')
101 && ABJ_404_Solution_ServiceContainer::safeHas('rebuild_health')) {
102 try {
103 $service = abj_service('rebuild_health');
104 if ($service instanceof ABJ_404_Solution_RebuildHealthState) {
105 return $service;
106 }
107 } catch (Throwable $t) {
108 $this->logger->debugMessage(__FUNCTION__ . ' rebuild_health service unavailable: ' . $t->getMessage());
109 }
110 }
111 return null;
112 }
113
114 // =========================================================================
115 // Log data population and querying (from DataAccessTrait_Logs)
116 // =========================================================================
117
118 /** @inheritDoc */
119 function populateLogsData($rows) {
120 if (empty($rows)) {
121 return $rows;
122 }
123
124 $urls = array();
125 foreach ($rows as $row) {
126 if ($row['url'] != null && !empty($row['url'])) {
127 $variants = $this->buildHitsLookupUrlVariants($row['url']);
128 foreach ($variants as $variant) {
129 $urls[] = $variant;
130 }
131 }
132 }
133
134 if (empty($urls)) {
135 return $rows;
136 }
137
138 $urls = array_values(array_unique($urls));
139
140 if (!$this->logsHitsTableExists()) {
141 $this->scheduleHitsTableRebuild();
142 return $rows;
143 }
144
145 $logsHitsTable = $this->dbCore->doTableNameReplacements('{wp_abj404_logs_hits}');
146 $batchSize = 200;
147 $logsResults = array();
148 $urlChunks = array_chunk($urls, $batchSize);
149
150 foreach ($urlChunks as $urlChunk) {
151 $placeholders = implode(',', array_fill(0, count($urlChunk), '%s'));
152 $sql = "SELECT requested_url, logsid, last_used, logshits "
153 . "FROM {$logsHitsTable} "
154 . "WHERE BINARY requested_url IN ($placeholders)";
155
156 $chunkResult = $this->dbCore->queryAndGetResults($sql, array(
157 'query_params' => $urlChunk,
158 'log_too_slow' => false,
159 ));
160
161 if (!empty($chunkResult['timed_out']) ||
162 (isset($chunkResult['last_error']) && $chunkResult['last_error'] != '')) {
163 $errRaw = $chunkResult['last_error'] ?? '';
164 $err = is_string($errRaw) ? $errRaw : '';
165 if ($err !== '' && strpos($err, 'logs_hits') !== false) {
166 $this->scheduleHitsTableRebuild();
167 }
168 return $rows;
169 }
170
171 $chunkResults = is_array($chunkResult['rows'] ?? null) ? $chunkResult['rows'] : array();
172 if (!empty($chunkResults)) {
173 $logsResults = array_merge($logsResults, $chunkResults);
174 }
175 }
176
177 $logsDataByUrl = array();
178 foreach ($logsResults as $logRow) {
179 $canonicalUrl = $this->canonicalizeUrlForHitsMatch($logRow['requested_url'] ?? '');
180 if ($canonicalUrl === '') {
181 continue;
182 }
183 if (!isset($logsDataByUrl[$canonicalUrl])) {
184 $logsDataByUrl[$canonicalUrl] = array(
185 'logsid' => (int)($logRow['logsid'] ?? 0),
186 'logshits' => (int)($logRow['logshits'] ?? 0),
187 'last_used' => (int)($logRow['last_used'] ?? 0),
188 );
189 continue;
190 }
191 $existing = $logsDataByUrl[$canonicalUrl];
192 $currentLogsid = (int)($logRow['logsid'] ?? 0);
193 $existingLogsid = (int)$existing['logsid'];
194 $logsDataByUrl[$canonicalUrl]['logsid'] = ($existingLogsid > 0 && $currentLogsid > 0)
195 ? min($existingLogsid, $currentLogsid)
196 : max($existingLogsid, $currentLogsid);
197 $logsDataByUrl[$canonicalUrl]['logshits'] = (int)$existing['logshits'] + (int)($logRow['logshits'] ?? 0);
198 $logsDataByUrl[$canonicalUrl]['last_used'] = max((int)$existing['last_used'], (int)($logRow['last_used'] ?? 0));
199 }
200
201 foreach ($rows as &$row) {
202 if ($row['url'] != null && !empty($row['url'])) {
203 $canonicalUrl = $this->canonicalizeUrlForHitsMatch($row['url']);
204 if (isset($logsDataByUrl[$canonicalUrl])) {
205 $logData = $logsDataByUrl[$canonicalUrl];
206 $row['logsid'] = $logData['logsid'];
207 $row['logshits'] = $logData['logshits'];
208 $row['last_used'] = $logData['last_used'];
209 }
210 }
211 }
212
213 return $rows;
214 }
215
216 /** @param mixed $url @return string */
217 private function canonicalizeUrlForHitsMatch($url): string {
218 if (!is_string($url)) {
219 return '';
220 }
221 $url = trim($url);
222 if ($url === '') {
223 return '';
224 }
225 $fragment = '';
226 $fragmentPos = strpos($url, '#');
227 if ($fragmentPos !== false) {
228 $fragment = substr($url, $fragmentPos);
229 $url = substr($url, 0, $fragmentPos);
230 }
231 $query = '';
232 $queryPos = strpos($url, '?');
233 if ($queryPos !== false) {
234 $query = substr($url, $queryPos);
235 $url = substr($url, 0, $queryPos);
236 }
237 $path = trim($url, '/');
238 $normalizedPath = ($path === '') ? '/' : '/' . $path;
239 return $normalizedPath . $query . $fragment;
240 }
241
242 /** @param mixed $url @return array<int, string> */
243 private function buildHitsLookupUrlVariants($url) {
244 $variants = array();
245 if (!is_string($url)) {
246 return $variants;
247 }
248 $raw = trim($url);
249 if ($raw !== '') {
250 $variants[] = $raw;
251 }
252 $canonical = $this->canonicalizeUrlForHitsMatch($url);
253 if ($canonical !== '') {
254 $variants[] = $canonical;
255 $parts = $this->splitCanonicalHitsUrl($canonical);
256 $pathPart = $parts['path'];
257 $suffixPart = $parts['suffix'];
258 $pathVariants = array($pathPart);
259 $noLeadingPath = ltrim($pathPart, '/');
260 if ($noLeadingPath !== '') {
261 $pathVariants[] = $noLeadingPath;
262 }
263 if ($pathPart !== '/') {
264 if (substr($pathPart, -1) === '/') {
265 $toggleTrailingPath = rtrim($pathPart, '/');
266 } else {
267 $toggleTrailingPath = $pathPart . '/';
268 }
269 $pathVariants[] = $toggleTrailingPath;
270 $toggleNoLeadingPath = ltrim($toggleTrailingPath, '/');
271 if ($toggleNoLeadingPath !== '') {
272 $pathVariants[] = $toggleNoLeadingPath;
273 }
274 }
275 foreach (array_unique($pathVariants) as $pathVariant) {
276 $variants[] = $pathVariant . $suffixPart;
277 }
278 }
279 return array_values(array_unique($variants));
280 }
281
282 /** @return array{path: string, suffix: string} */
283 private function splitCanonicalHitsUrl(string $canonicalUrl): array {
284 $firstQueryPos = strpos($canonicalUrl, '?');
285 $firstFragmentPos = strpos($canonicalUrl, '#');
286 if ($firstQueryPos === false && $firstFragmentPos === false) {
287 return array('path' => $canonicalUrl, 'suffix' => '');
288 }
289 if ($firstQueryPos === false) {
290 $splitPos = $firstFragmentPos;
291 } elseif ($firstFragmentPos === false) {
292 $splitPos = $firstQueryPos;
293 } else {
294 $splitPos = min($firstQueryPos, $firstFragmentPos);
295 }
296 return array(
297 'path' => substr($canonicalUrl, 0, $splitPos),
298 'suffix' => substr($canonicalUrl, $splitPos),
299 );
300 }
301
302 /** @inheritDoc */
303 function getDistinctLoggedUrls(): array {
304 $query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/getDistinctLoggedUrls.sql");
305 $results = $this->dbCore->queryAndGetResults($query);
306 $rows = is_array($results['rows']) ? $results['rows'] : array();
307 $urls = array();
308 foreach ($rows as $row) {
309 $url = isset($row['requested_url']) && is_string($row['requested_url']) ? $row['requested_url'] : '';
310 if ($url !== '') {
311 $urls[] = $url;
312 }
313 }
314 return $urls;
315 }
316
317 /** @inheritDoc */
318 function getLogsIDandURL($specificURL = '') {
319 global $wpdb;
320 $whereClause = '';
321 if ($specificURL != '') {
322 $specificURL = $this->f->sanitizeInvalidUTF8($specificURL);
323 $escapedURL = esc_sql($specificURL);
324 $whereClause = "where requested_url = '" . $escapedURL . "'";
325 }
326 $query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/getLogsIDandURL.sql");
327 $query = $this->f->str_replace('{where_clause_here}', $whereClause, $query);
328 $results = $this->dbCore->queryAndGetResults($query);
329 return is_array($results['rows']) ? $results['rows'] : array();
330 }
331
332 /** @inheritDoc */
333 function getLogsIDandURLLike($specificURL, $limitResults) {
334 global $wpdb;
335 $whereClause = '';
336 if ($specificURL != '') {
337 $likePattern = '%' . $wpdb->esc_like($specificURL) . '%';
338 $escapedURL = esc_sql($likePattern);
339 $whereClause = "where lower(requested_url) like lower('" . $escapedURL . "')\n";
340 $whereClause .= "and min_log_id = true";
341 }
342 $query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/getLogsIDandURLForAjax.sql");
343 $query = $this->f->str_replace('{where_clause_here}', $whereClause, $query);
344 $query = $this->f->str_replace('{limit-results}', 'limit ' . absint($limitResults), $query);
345 $results = $this->dbCore->queryAndGetResults($query);
346 return is_array($results['rows']) ? $results['rows'] : array();
347 }
348
349 /** @inheritDoc */
350 function getLogRecords($tableOptions) {
351 $abj404logic = abj_service('plugin_logic');
352 $logsid_included = '';
353 $logsid = '';
354 $rawLogsId = $tableOptions['logsid'];
355 if ($rawLogsId != 0) {
356 $logsid_included = 'specific logs id included. */';
357 $logsid = esc_sql($abj404logic->sanitizeForSQL(is_scalar($rawLogsId) ? (string)$rawLogsId : ''));
358 }
359 $orderbyExpressionByName = array(
360 'timestamp' => '{wp_abj404_logsv2}.timestamp',
361 'requested_url' => '{wp_abj404_logsv2}.requested_url',
362 'url' => 'url',
363 'id' => '{wp_abj404_logsv2}.id',
364 'min_log_id' => '{wp_abj404_logsv2}.min_log_id',
365 );
366 $rawOrderByVal = $tableOptions['orderby'];
367 $orderby = sanitize_text_field($abj404logic->sanitizeForSQL(is_string($rawOrderByVal) ? $rawOrderByVal : ''));
368 $orderby = array_key_exists($orderby, $orderbyExpressionByName) ? $orderby : 'timestamp';
369 $orderbyExpression = $orderbyExpressionByName[$orderby];
370 $rawOrderVal2 = $tableOptions['order'];
371 $order = strtoupper(sanitize_text_field($abj404logic->sanitizeForSQL(is_string($rawOrderVal2) ? $rawOrderVal2 : '')));
372 if (!in_array($order, array('ASC', 'DESC'), true)) {
373 $order = 'DESC';
374 }
375 $paged = absint(is_scalar($tableOptions['paged'] ?? 1) ? ($tableOptions['paged'] ?? 1) : 1);
376 if ($paged < 1) { $paged = 1; }
377 $perpage = absint(is_scalar($tableOptions['perpage'] ?? ABJ404_OPTION_DEFAULT_PERPAGE) ? ($tableOptions['perpage'] ?? ABJ404_OPTION_DEFAULT_PERPAGE) : ABJ404_OPTION_DEFAULT_PERPAGE);
378 if ($perpage < 1) { $perpage = ABJ404_OPTION_DEFAULT_PERPAGE; }
379 $start = ($paged - 1) * $perpage;
380 $query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/getLogRecords.sql");
381 $query = $this->f->str_replace('{logsid_included}', $logsid_included, $query);
382 $query = $this->f->str_replace('{logsid}', $logsid, $query);
383 $query = $this->f->str_replace('{orderby}', $orderbyExpression, $query);
384 $query = $this->f->str_replace('{order}', $order, $query);
385 $query = $this->f->str_replace('{start}', (string)$start, $query);
386 $query = $this->f->str_replace('{perpage}', (string)$perpage, $query);
387 $results = $this->dbCore->queryAndGetResults($query);
388 $rawRows = $results['rows'];
389 return is_array($rawRows) ? $rawRows : array();
390 }
391
392 /** @inheritDoc */
393 public function getLogsv2IdsForLookupValue($lkupValue, $page = 1, $perPage = 100) {
394 $lkupValue = trim($lkupValue);
395 if ($lkupValue === '') { return array(); }
396 $page = max(1, absint($page));
397 $perPage = max(1, min(500, absint($perPage)));
398 $offset = ($page - 1) * $perPage;
399 $logsTable = $this->dbCore->doTableNameReplacements("{wp_abj404_logsv2}");
400 $lookupTable = $this->dbCore->doTableNameReplacements("{wp_abj404_lookup}");
401 $sql = "SELECT l.id FROM `{$logsTable}` l INNER JOIN `{$lookupTable}` u ON l.username = u.id WHERE u.lkup_value = %s ORDER BY l.id DESC LIMIT %d OFFSET %d";
402 $result = $this->dbCore->queryAndGetResults($sql, array('query_params' => array($lkupValue, $perPage, $offset)));
403 if (!empty($result['timed_out']) || (isset($result['last_error']) && $result['last_error'] != '')) { return array(); }
404 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
405 $ids = array();
406 foreach ($rows as $row) {
407 if (is_array($row) && isset($row['id'])) { $ids[] = absint($row['id']); }
408 }
409 return array_values(array_filter($ids));
410 }
411
412 /** @inheritDoc */
413 public function getLogsv2RowsForLookupValue($lkupValue, $page = 1, $perPage = 50) {
414 $ids = $this->getLogsv2IdsForLookupValue($lkupValue, $page, $perPage);
415 if (empty($ids)) { return array(); }
416 $logsTable = $this->dbCore->doTableNameReplacements("{wp_abj404_logsv2}");
417 $placeholders = implode(',', array_fill(0, count($ids), '%d'));
418 $sql = "SELECT id, timestamp, user_ip, referrer, requested_url, requested_url_detail, dest_url FROM `{$logsTable}` WHERE id IN ({$placeholders}) ORDER BY id DESC";
419 $result = $this->dbCore->queryAndGetResults($sql, array('query_params' => $ids));
420 if (!empty($result['timed_out']) || (isset($result['last_error']) && $result['last_error'] != '')) { return array(); }
421 return is_array($result['rows'] ?? null) ? $result['rows'] : array();
422 }
423
424 /** @inheritDoc */
425 public function anonymizeLogsv2RowsByIds($ids) {
426 if (!is_array($ids) || empty($ids)) { return true; }
427 $ids = array_values(array_filter(array_map('absint', $ids)));
428 if (empty($ids)) { return true; }
429 $logsTable = $this->dbCore->doTableNameReplacements("{wp_abj404_logsv2}");
430 $placeholders = implode(',', array_fill(0, count($ids), '%d'));
431 $sql = "UPDATE `{$logsTable}` SET user_ip = %s, referrer = NULL, requested_url_detail = NULL, username = NULL WHERE id IN ({$placeholders})";
432 $params = array_merge(array('(Anonymized)'), $ids);
433 $result = $this->dbCore->queryAndGetResults($sql, array('query_params' => $params));
434 if (!empty($result['timed_out']) || (isset($result['last_error']) && $result['last_error'] != '')) { return false; }
435 return true;
436 }
437
438 // =========================================================================
439 // Log insertion and queue (from DataAccessTrait_Logs)
440 // =========================================================================
441
442 /** @inheritDoc */
443 function logRedirectHit(string $requested_url, string $action, string $matchReason, ?string $requestedURLDetail = null, ?array $pipelineTrace = null): void {
444 global $wpdb;
445 $abj404logic = abj_service('plugin_logic');
446 $logTableName = $this->dbCore->doTableNameReplacements("{wp_abj404_logsv2}");
447 $now = time();
448 $requested_url = preg_replace('/[\x00-\x1F\x7F]/u', '', $requested_url) ?? $requested_url;
449 $requested_url = $abj404logic->normalizeToRelativePath($requested_url);
450
451 try {
452 static $requestedUrlColumnMeta = null;
453 if ($requestedUrlColumnMeta === null && function_exists('get_transient')) {
454 $requestedUrlColumnMeta = get_transient('abj404_logs_requested_url_column_meta');
455 if ($requestedUrlColumnMeta === false) { $requestedUrlColumnMeta = null; }
456 }
457 if ($requestedUrlColumnMeta === null && function_exists('get_transient')) {
458 $legacyCharset = get_transient('abj404_logs_requested_url_charset');
459 if (is_string($legacyCharset) && $legacyCharset !== '') {
460 $requestedUrlColumnMeta = array('charset_name' => $legacyCharset, 'collation_name' => null);
461 }
462 }
463 $dbName = defined('DB_NAME') ? (string)DB_NAME : '';
464 if ($requestedUrlColumnMeta === null && $dbName !== '' && is_object($wpdb)) {
465 $getCharsetQuery = $wpdb->prepare("SELECT character_set_name as charset_name, collation_name as collation_name \n FROM information_schema.columns \n WHERE lower(table_schema) = lower(%s) \n AND lower(table_name) = lower(%s) \n AND lower(column_name) = lower(%s) ", $dbName, $logTableName, 'requested_url');
466 $resultArray = $wpdb->get_results($getCharsetQuery, ARRAY_A);
467 if (!empty($resultArray)) {
468 $requestedUrlColumnMeta = array(
469 'charset_name' => $resultArray[0]['charset_name'] ?? $resultArray[0]['CHARSET_NAME'] ?? null,
470 'collation_name' => $resultArray[0]['collation_name'] ?? $resultArray[0]['COLLATION_NAME'] ?? null,
471 );
472 if (function_exists('set_transient')) {
473 // @cache-write-audit: opt-out - schema metadata probe cache, not user-query result data.
474 $ttl = defined('WEEK_IN_SECONDS') ? WEEK_IN_SECONDS : 604800;
475 set_transient('abj404_logs_requested_url_column_meta', $requestedUrlColumnMeta, $ttl);
476 if (!empty($requestedUrlColumnMeta['charset_name'])) {
477 set_transient('abj404_logs_requested_url_charset', $requestedUrlColumnMeta['charset_name'], $ttl);
478 }
479 }
480 }
481 }
482 $requestedUrlCharset = is_array($requestedUrlColumnMeta) ? ($requestedUrlColumnMeta['charset_name'] ?? null) : null;
483 $requestedUrlCollation = is_array($requestedUrlColumnMeta) ? ($requestedUrlColumnMeta['collation_name'] ?? null) : null;
484 if (!empty($requestedUrlCharset) && strpos(strtolower($requestedUrlCharset), 'utf8') === false) {
485 $requested_url = $this->f->encodeUrlForLegacyMatch($requested_url);
486 if (function_exists('get_transient') && function_exists('set_transient')) {
487 $warnKey = 'abj404_warned_logs_charset_mismatch';
488 $warnVal = $logTableName . '|' . strtolower($requestedUrlCharset);
489 $already = get_transient($warnKey);
490 if ($already !== $warnVal) {
491 $ttl = defined('WEEK_IN_SECONDS') ? WEEK_IN_SECONDS : 604800;
492 // @cache-write-audit: opt-out - charset warning dedup marker, not query result data.
493 set_transient($warnKey, $warnVal, $ttl);
494 $this->logger->warn("Logs table column charset is '{$requestedUrlCharset}' for {$logTableName}. URL-encoding stored requested URLs to avoid charset issues.");
495 }
496 }
497 }
498 } catch (Exception $e) {
499 $this->logger->debugMessage(__FUNCTION__ . " error. Issue getting character set for table: " . $logTableName . ", column: requested_url. Error message: " . $e->getMessage());
500 }
501
502 $options = $abj404logic->getOptions(true);
503 $referer = function_exists('wp_get_referer') ? wp_get_referer() : '';
504 if ($referer !== null && $referer !== false) {
505 $referer = function_exists('esc_url_raw') ? esc_url_raw($referer) : (string)$referer;
506 $referer = substr($referer, 0, 512);
507 } else {
508 $referer = '';
509 }
510 $current_user = function_exists('wp_get_current_user')
511 ? ABJ_404_Solution_UserRef::fromWpUser(wp_get_current_user())
512 : null;
513 $current_user_name = $current_user !== null ? $current_user->getLogin() : '';
514 $remoteAddrRaw = $_SERVER['REMOTE_ADDR'] ?? '';
515 $ipAddressToSave = is_string($remoteAddrRaw) ? $remoteAddrRaw : '';
516 $ipAddressToSave = filter_var($ipAddressToSave, FILTER_VALIDATE_IP)
517 ? (function_exists('esc_sql') ? esc_sql($ipAddressToSave) : $ipAddressToSave)
518 : '';
519 if (!array_key_exists('log_raw_ips', $options) || $options['log_raw_ips'] != '1') {
520 $ipAddressToSave = $this->f->md5lastOctet($ipAddressToSave);
521 }
522 if (!empty($ipAddressToSave)) {
523 $ipAddressToSave = substr($ipAddressToSave, 0, 512);
524 } else {
525 $ipAddressToSave = '(Unknown)';
526 }
527
528 $minLogID = false;
529 $comparisonCollation = $this->dbCore->sanitizeCollationIdentifier(isset($requestedUrlCollation) ? (string)$requestedUrlCollation : '');
530 if ($comparisonCollation === '' || stripos($comparisonCollation, 'utf8mb4') === false) {
531 $comparisonCollation = $this->dbCore->getPreferredUtf8mb4Collation();
532 }
533 $requestedUrlCharsetLower = isset($requestedUrlCharset) ? strtolower((string)$requestedUrlCharset) : '';
534 $canUseUtf8Cast = ($requestedUrlCharsetLower === '' || strpos($requestedUrlCharsetLower, 'utf8') !== false);
535 if ($canUseUtf8Cast) {
536 $checkMinIDSql = "SELECT id FROM `" . $logTableName . "` \n WHERE CAST(requested_url AS CHAR CHARACTER SET utf8mb4) COLLATE " . $comparisonCollation . " = %s \n LIMIT 1";
537 } else {
538 $checkMinIDSql = "SELECT id FROM `" . $logTableName . "` \n WHERE requested_url = %s \n LIMIT 1";
539 }
540 $primaryResult = $this->dbCore->queryAndGetResults($checkMinIDSql, array('query_params' => array($requested_url), 'log_errors' => false));
541 $checkMinIDQueryResults = is_array($primaryResult['rows'] ?? null) ? $primaryResult['rows'] : array();
542 $lastErrorRaw = $primaryResult['last_error'] ?? '';
543 $lastError = is_string($lastErrorRaw) ? $lastErrorRaw : '';
544 if ($lastError !== '' && $this->dbCore->isInvalidDataError($lastError) && $canUseUtf8Cast) {
545 $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));
546 $checkMinIDQueryResults = is_array($fallbackResult['rows'] ?? null) ? $fallbackResult['rows'] : array();
547 }
548 if (empty($checkMinIDQueryResults)) { $minLogID = true; }
549
550 if (trim($action) != "404") {
551 $action = function_exists('esc_url_raw') ? esc_url_raw($action) : $action;
552 }
553
554 $helperFunctions = abj_service('functions');
555 $reasonMessage = trim(implode(", ", array_filter(array(abj_service('request_context')->ignore_doprocess ?: '', abj_service('request_context')->ignore_donotprocess ?: ''))));
556 $permalinksKept = '(not set)';
557 $ctx = abj_service('request_context');
558 if ($this->logger->isDebug() && !empty($ctx->permalinks_found)) {
559 $permalinksKept = $ctx->permalinks_kept;
560 }
561 $requestUri = is_string($_SERVER['REQUEST_URI'] ?? '') ? (string)($_SERVER['REQUEST_URI'] ?? '') : '';
562 $escapeHtml = function($value) {
563 return function_exists('esc_html') ? esc_html($value) : htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
564 };
565 $this->logger->debugMessage("Logging redirect. Referer: " . $escapeHtml($referer) . " | Current user: " . $current_user_name . " | From: " . $helperFunctions->normalizeUrlString($requestUri) . $escapeHtml(" to: ") . $escapeHtml($action) . ', Reason: ' . $matchReason . ", Ignore msg(s): " . $reasonMessage . ', Execution time: ' . round((float)$helperFunctions->getExecutionTime(), 2) . ' seconds, permalinks found: ' . $permalinksKept);
566
567 $usernameLookupID = $this->insertLookupValueAndGetID($current_user_name);
568
569 $reqUrlForLog = function_exists('esc_url_raw') ? esc_url_raw($requested_url) : $requested_url;
570 $reqUrlForLogStr = is_string($reqUrlForLog) ? $reqUrlForLog : '';
571 $this->queueLogEntry([
572 'timestamp' => $now, 'user_ip' => $ipAddressToSave, 'referrer' => $referer,
573 'dest_url' => $action, 'requested_url' => $reqUrlForLogStr,
574 'requested_url_detail' => $requestedURLDetail, 'username' => $usernameLookupID,
575 'min_log_id' => $minLogID, 'engine' => substr($matchReason, 0, 64),
576 'pipeline_trace' => $this->serializePipelineTrace($pipelineTrace),
577 'canonical_url' => '/' . trim($reqUrlForLogStr, '/'),
578 ]);
579 }
580
581 /** @inheritDoc */
582 function queueLogEntry(array $entry): void {
583 self::$logQueue[] = $entry;
584 if (!self::$shutdownHookRegistered) {
585 self::$shutdownHookRegistered = true;
586 add_action('shutdown', [$this, 'flushLogQueue'], 9);
587 }
588 }
589
590 /** @inheritDoc */
591 function flushLogQueue(): void {
592 if (self::$isFlushingLogQueue) { return; }
593 self::$isFlushingLogQueue = true;
594 if (empty(self::$logQueue)) {
595 self::$shutdownHookRegistered = false;
596 self::$isFlushingLogQueue = false;
597 return;
598 }
599
600 global $wpdb;
601 $tableName = $this->dbCore->doTableNameReplacements('{wp_abj404_logsv2}');
602
603 $columns = array_keys(self::$logQueue[0]);
604 $validatedColumns = [];
605 foreach ($columns as $col) {
606 if (preg_match('/^[a-z_][a-z0-9_]*$/i', $col)) { $validatedColumns[] = $col; }
607 }
608 $schemaColumns = $this->dbCore->getTableColumnNames($tableName);
609 if (!empty($schemaColumns)) { $validatedColumns = array_intersect($validatedColumns, $schemaColumns); }
610 if (empty($validatedColumns)) {
611 self::$logQueue = []; self::$shutdownHookRegistered = false; self::$isFlushingLogQueue = false;
612 return;
613 }
614
615 $columnList = '`' . implode('`, `', $validatedColumns) . '`';
616 $sanitizedEntries = [];
617 foreach (self::$logQueue as $entry) {
618 foreach ($entry as $val) {
619 if (is_object($val) || is_array($val)) { break; }
620 }
621 $entryColumns = array_keys($entry);
622 $missingCols = array_diff($validatedColumns, $entryColumns);
623 if (!empty($missingCols)) { continue; }
624 $sanitized = $this->sanitizeLogEntry($entry);
625 if ($sanitized === null) { continue; }
626 $sanitizedEntries[] = $sanitized;
627 }
628 if (empty($sanitizedEntries)) {
629 self::$logQueue = []; self::$shutdownHookRegistered = false; self::$isFlushingLogQueue = false;
630 return;
631 }
632
633 $formats = [];
634 $flattenedValues = [];
635 foreach ($sanitizedEntries as $entry) {
636 $rowFormats = [];
637 foreach ($validatedColumns as $col) {
638 $value = $entry[$col];
639 if ($value === null) { $rowFormats[] = 'NULL'; continue; }
640 if (is_int($value)) { $rowFormats[] = '%d'; } else { $rowFormats[] = '%s'; }
641 $flattenedValues[] = $value;
642 }
643 $formats[] = '(' . implode(', ', $rowFormats) . ')';
644 }
645
646 $sql = "INSERT IGNORE INTO `{$tableName}` ({$columnList}) VALUES " . implode(', ', $formats);
647 $prepared = $wpdb->prepare($sql, $flattenedValues);
648 $wpdb->flush();
649 $result = $wpdb->query($prepared);
650
651 if ($result === false && !empty($wpdb->last_error)) {
652 $batchError = $wpdb->last_error;
653
654 if ($this->isTableFullError($batchError)) {
655 $trimmed = $this->autoTrimLogsv2IfNeeded($tableName, $batchError);
656 if ($trimmed) {
657 $wpdb->flush();
658 $retryResult = $wpdb->query($prepared);
659 if ($retryResult !== false) {
660 self::$logQueue = []; self::$shutdownHookRegistered = false; self::$isFlushingLogQueue = false;
661 return;
662 }
663 $batchError = $wpdb->last_error;
664 }
665 $this->setLogsv2FullNotice($batchError);
666 }
667
668 if ($this->isCommandsOutOfSyncError($batchError)) {
669 $isolated = $this->getIsolatedWpdb();
670 if ($isolated !== null) {
671 $isolated->flush();
672 $isolatedPrepared = $isolated->prepare($sql, $flattenedValues);
673 $isolatedResult = $isolated->query($isolatedPrepared !== null ? $isolatedPrepared : $sql);
674 if ($isolatedResult !== false) {
675 self::$logQueue = []; self::$shutdownHookRegistered = false; self::$isFlushingLogQueue = false;
676 $context = $this->getWpdbRecentQueryContextForLogs();
677 $suffix = ($context !== '') ? " | savequeries_context={$context}" : '';
678 $this->logger->warn("flushLogQueue batch INSERT succeeded using isolated DB connection (commands out of sync on shared connection).{$suffix}");
679 return;
680 }
681 $batchError .= " | isolated_error=" . $isolated->last_error;
682 } else {
683 $batchError .= " | isolated_error=no_isolated_connection";
684 }
685 }
686
687 $successCount = 0; $failCount = 0; $failureDetails = [];
688 foreach ($sanitizedEntries as $index => $entry) {
689 $rowFormats = []; $rowValues = [];
690 foreach ($validatedColumns as $col) {
691 $value = $entry[$col];
692 if ($value === null) { $rowFormats[] = 'NULL'; } else { $rowFormats[] = is_int($value) ? '%d' : '%s'; $rowValues[] = $value; }
693 }
694 $rowPlaceholder = '(' . implode(', ', $rowFormats) . ')';
695 /** @var literal-string $singleSqlTemplate */
696 $singleSqlTemplate = "INSERT IGNORE INTO `{$tableName}` ({$columnList}) VALUES {$rowPlaceholder}";
697 $singleSql = $wpdb->prepare($singleSqlTemplate, $rowValues);
698 $wpdb->flush();
699 $singleResult = $wpdb->query((string)$singleSql);
700
701 if ($singleResult === false && !empty($wpdb->last_error)) {
702 $lastError = $wpdb->last_error;
703 if ($this->isCommandsOutOfSyncError($wpdb->last_error)) {
704 $isolated = $this->getIsolatedWpdb();
705 if ($isolated !== null) {
706 $isolated->flush();
707 $isolatedSingleSql = $isolated->prepare($singleSqlTemplate, $rowValues);
708 $isolatedSingleResult = $isolated->query((string)$isolatedSingleSql);
709 if ($isolatedSingleResult !== false) { $successCount++; continue; }
710 $lastError = $lastError . " | isolated_error=" . $isolated->last_error;
711 } else {
712 $lastError = $lastError . " | isolated_error=no_isolated_connection";
713 }
714 }
715 $failCount++;
716 $payload = function_exists('wp_json_encode') ? wp_json_encode($entry) : json_encode($entry);
717 if (is_string($payload) && strlen($payload) > 1024) { $payload = substr($payload, 0, 1024) . '...'; }
718 $failureDetails[] = ['index' => $index, 'error' => $lastError, 'payload' => $payload];
719 } else {
720 $successCount++;
721 }
722 }
723
724 if ($failCount > 0) {
725 $detailsParts = [];
726 foreach (array_slice($failureDetails, 0, 3) as $detail) {
727 $detailsParts[] = "entry {$detail['index']}: {$detail['error']} | payload={$detail['payload']}";
728 }
729 $detailsSuffix = count($failureDetails) > 3 ? ' | (additional failures omitted)' : '';
730 $context = $this->getWpdbRecentQueryContextForLogs();
731 $contextSuffix = ($context !== '') ? (" | savequeries_context=" . $context) : '';
732 if ($this->dbCore->classifyAndHandleInfrastructureError($batchError)) {
733 $this->logger->warn("flushLogQueue recovery incomplete: {$successCount} inserted, {$failCount} failed. | batch_error=" . $batchError . " | failures=" . implode(' || ', $detailsParts) . $detailsSuffix . $contextSuffix);
734 } else {
735 $this->logger->errorMessage("flushLogQueue recovery incomplete: {$successCount} inserted, {$failCount} failed. | batch_error=" . $batchError . " | failures=" . implode(' || ', $detailsParts) . $detailsSuffix . $contextSuffix);
736 }
737 } else {
738 $this->logger->warn("flushLogQueue batch INSERT failed but recovered: all {$successCount} entries inserted individually. | batch_error=" . $batchError);
739 }
740 }
741
742 self::$logQueue = []; self::$shutdownHookRegistered = false; self::$isFlushingLogQueue = false;
743 }
744
745 private function isCommandsOutOfSyncError(string $error): bool {
746 return stripos($error, 'commands out of sync') !== false;
747 }
748
749 /** @param string $error @return bool */
750 public function isTableFullError(string $error): bool {
751 $lower = strtolower($error);
752 return stripos($lower, 'is full') !== false || stripos($lower, 'table full') !== false;
753 }
754
755 /** @param string $tableName @param string $errorMessage @return bool */
756 public function autoTrimLogsv2IfNeeded(string $tableName, string $errorMessage): bool {
757 if (!preg_match('/^[a-zA-Z0-9_]+$/', $tableName) || strpos($tableName, 'abj404_logsv2') === false) {
758 $this->logger->warn("autoTrimLogsv2IfNeeded: rejected unexpected table name: " . substr($tableName, 0, 100));
759 return false;
760 }
761 $cooldownKey = 'abj404_logsv2_trim_cooldown_until';
762 $alreadyTrimmed = function_exists('get_transient') ? get_transient($cooldownKey) : false;
763 if ($alreadyTrimmed) { return false; }
764 global $wpdb;
765 $trimSql = "DELETE FROM `{$tableName}` ORDER BY timestamp ASC LIMIT 1000";
766 $wpdb->query($trimSql);
767 $ttl = defined('HOUR_IN_SECONDS') ? (int) HOUR_IN_SECONDS : 3600;
768 // @cache-write-audit: opt-out - log-trim cooldown marker, not query result data.
769 if (function_exists('set_transient')) { set_transient($cooldownKey, 1, $ttl); }
770 if (!empty($wpdb->last_error)) {
771 $this->logger->warn("Log table full: auto-trim failed: " . $wpdb->last_error);
772 } else {
773 $this->logger->warn("Log table full: auto-trimmed 1000 oldest entries to free space.");
774 }
775 return true;
776 }
777
778 /** @param string $errorMessage @return void */
779 private function setLogsv2FullNotice(string $errorMessage): void {
780 $message = $this->dbCore->localizeOrDefault('The 404 Solution log table is full and cannot accept new entries. This is usually caused by a full disk. Please contact your host or manually prune the logs table.');
781 $this->dbCore->setPluginDbNotice('log_table_full', $message, $errorMessage);
782 }
783
784 /** @return wpdb|null */
785 public function getIsolatedWpdb(): ?wpdb {
786 static $isolated = null;
787 if ($isolated !== null) { return $isolated; }
788 if (!class_exists('wpdb')) { return null; }
789 if (!defined('DB_USER') || !defined('DB_PASSWORD') || !defined('DB_NAME') || !defined('DB_HOST')) {
790 static $warnedNoDbConsts = false;
791 if (!$warnedNoDbConsts) { $warnedNoDbConsts = true; $this->logger->warn(__METHOD__ . ': DB_USER/DB_PASSWORD/DB_NAME/DB_HOST undefined; isolated wpdb unavailable'); }
792 return null;
793 }
794 // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__wpdb
795 $isolated = new wpdb(DB_USER, DB_PASSWORD, DB_NAME, DB_HOST);
796 $isolated->show_errors(false);
797 $isolated->suppress_errors(true);
798 return $isolated;
799 }
800
801 /** @return string */
802 private function getWpdbRecentQueryContextForLogs(): string {
803 global $wpdb;
804 if (!isset($wpdb) || !is_object($wpdb)) { return ''; }
805 if (!defined('SAVEQUERIES') || SAVEQUERIES !== true) { return ''; }
806 if (empty($wpdb->queries) || !is_array($wpdb->queries)) { return ''; }
807 $recent = array_slice($wpdb->queries, -5);
808 $parts = [];
809 foreach ($recent as $q) {
810 $sql = $q[0] ?? ''; $time = $q[1] ?? null; $caller = $q[2] ?? '';
811 $hash = is_string($sql) ? substr(sha1($sql), 0, 10) : 'n/a';
812 $who = $this->extractWpComponentFromString(is_string($caller) ? $caller : '');
813 $t = is_numeric($time) ? round((float)$time, 3) : 'n/a';
814 $parts[] = "{$who}:{$hash}@{$t}";
815 }
816 return implode(', ', $parts);
817 }
818
819 private function extractWpComponentFromString(string $text): string {
820 $normalized = str_replace('\\', '/', $text);
821 foreach (array('/wp-content/mu-plugins/' => 'mu-plugin', '/wp-content/plugins/' => 'plugin', '/wp-content/themes/' => 'theme') as $needle => $label) {
822 $pos = strpos($normalized, $needle);
823 if ($pos !== false) {
824 $rest = substr($normalized, $pos + strlen($needle));
825 $name = explode('/', ltrim($rest, '/'))[0] ?? '';
826 return $name !== '' ? "{$label}:{$name}" : "{$label}:unknown";
827 }
828 }
829 return 'unknown';
830 }
831
832 /** @param array<string, mixed> $entry @return array<string, mixed>|null */
833 public function sanitizeLogEntry(array $entry): ?array {
834 $required = array('timestamp', 'user_ip', 'referrer', 'dest_url', 'requested_url', 'requested_url_detail', 'username', 'min_log_id', 'engine');
835 foreach ($required as $key) { if (!array_key_exists($key, $entry)) { return null; } }
836 $normalizeString = function($value, $maxLen) {
837 if (is_object($value) || is_array($value)) { return null; }
838 $str = (string)$value;
839 if (function_exists('mb_convert_encoding')) { $str = mb_convert_encoding($str, 'UTF-8', 'UTF-8'); }
840 return substr($str, 0, $maxLen);
841 };
842 $sanitized = array();
843 $tsVal = $entry['timestamp'] ?? time();
844 $sanitized['timestamp'] = absint(is_scalar($tsVal) ? $tsVal : time());
845 $sanitized['user_ip'] = $normalizeString($entry['user_ip'], 512);
846 $sanitized['referrer'] = $normalizeString($entry['referrer'], 512);
847 $sanitized['dest_url'] = $normalizeString($entry['dest_url'], 512);
848 $sanitized['requested_url'] = $normalizeString($entry['requested_url'], 2048);
849 $sanitized['requested_url_detail'] = $normalizeString($entry['requested_url_detail'], 2048);
850 $reqUrlSafe = is_string($sanitized['requested_url']) ? $sanitized['requested_url'] : '';
851 if (array_key_exists('canonical_url', $entry) && is_string($entry['canonical_url'])) { $canonical = $entry['canonical_url']; } else { $canonical = '/' . trim($reqUrlSafe, '/'); }
852 $sanitized['canonical_url'] = substr($canonical, 0, 2048);
853 $usernameVal = $entry['username'] ?? null;
854 $sanitized['username'] = ($usernameVal === null || !is_scalar($usernameVal)) ? null : absint($usernameVal);
855 $minLogIdVal = $entry['min_log_id'] ?? null;
856 $sanitized['min_log_id'] = ($minLogIdVal === null || !is_scalar($minLogIdVal)) ? null : absint($minLogIdVal);
857 $sanitized['engine'] = $normalizeString($entry['engine'], 64);
858 if (array_key_exists('pipeline_trace', $entry)) {
859 $traceVal = $entry['pipeline_trace'];
860 $sanitized['pipeline_trace'] = ($traceVal === null || is_string($traceVal)) ? $traceVal : null;
861 } else {
862 $sanitized['pipeline_trace'] = null;
863 }
864 if ($sanitized['requested_url'] === '' || $sanitized['dest_url'] === '') { return null; }
865 return $sanitized;
866 }
867
868 /** @param array<int, array{step: string, outcome: string, detail: string}>|null $trace @return string|null */
869 private function serializePipelineTrace(?array $trace): ?string {
870 if ($trace === null || empty($trace)) { return null; }
871 $json = json_encode($trace);
872 if ($json === false) { return null; }
873 $compressed = gzcompress($json, 6);
874 if ($compressed === false) { return null; }
875 return base64_encode($compressed);
876 }
877
878 /** @inheritDoc */
879 public static function decompressPipelineTrace(?string $raw): ?array {
880 if ($raw === null || $raw === '') { return null; }
881 $decoded = base64_decode($raw, true);
882 if ($decoded === false) { return null; }
883 $json = @gzuncompress($decoded);
884 if ($json === false) { return null; }
885 $result = json_decode($json, true);
886 return is_array($result) ? $result : null;
887 }
888
889 // =========================================================================
890 // Lookup table (from DataAccessTrait_Logs + Maintenance)
891 // =========================================================================
892
893 /** @inheritDoc */
894 function insertLookupValueAndGetID($valueToInsert) {
895 global $wpdb;
896 $query = "INSERT INTO {wp_abj404_lookup} (lkup_value) VALUES (%s) ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)";
897 $this->dbCore->queryAndGetResults($query, array('query_params' => array($valueToInsert)));
898 return intval($wpdb->insert_id);
899 }
900
901 /** @inheritDoc */
902 function getLookupIDForUser($userName) {
903 $query = "select id from {wp_abj404_lookup} where lkup_value = %s";
904 $results = $this->dbCore->queryAndGetResults($query, array('query_params' => array($userName)));
905 $lookupRows = is_array($results['rows']) ? $results['rows'] : array();
906 if (count($lookupRows) > 0) {
907 $row1 = is_array($lookupRows[0]) ? $lookupRows[0] : array();
908 $id = isset($row1['id']) ? $row1['id'] : 0;
909 return is_scalar($id) ? intval($id) : 0;
910 }
911 return -1;
912 }
913
914 /** @inheritDoc */
915 public function correctDuplicateLookupValues(): void {
916 $query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/correctLookupTableIssue.sql");
917 $this->dbCore->queryAndGetResults($query, array('log_errors' => false, 'skip_repair' => true));
918 }
919
920 /** @inheritDoc */
921 public function getDailyActivityTrend(int $days = 30): array {
922 $days = max(1, min(90, $days));
923 $blogId = 1;
924 if (function_exists('get_current_blog_id')) {
925 $blogId = function_exists('absint') ? absint(get_current_blog_id()) : abs(intval(get_current_blog_id()));
926 if ($blogId <= 0) { $blogId = 1; }
927 }
928 $maxLogId = 0;
929 try {
930 $maxLogId = intval($this->getMaxLogId());
931 if ($maxLogId < 0) { $maxLogId = 0; }
932 } catch (Throwable $e) {
933 $this->logger->debugMessage(__FUNCTION__ . ' getMaxLogId() failed: ' . $e->getMessage() . '. Falling back to maxLogId=0 (cache key uses 0).');
934 $maxLogId = 0;
935 }
936 $cacheKey = 'abj404_trend_v1_' . $blogId . '_' . $days . '_' . $maxLogId;
937 if (function_exists('get_transient')) { $cached = get_transient($cacheKey); if (is_array($cached)) { return $cached; } }
938 $logsTable = $this->dbCore->doTableNameReplacements('{wp_abj404_logsv2}');
939 $cutoff = time() - ($days * 86400);
940 $notFoundDest = '404';
941 $query = "SELECT DATE(FROM_UNIXTIME(`timestamp`)) AS `date`, SUM(CASE WHEN `dest_url` = %s THEN 1 ELSE 0 END) AS `hits_404`, SUM(CASE WHEN `dest_url` <> %s THEN 1 ELSE 0 END) AS `hits_redirect` FROM " . $logsTable . " WHERE `timestamp` >= " . intval($cutoff) . " GROUP BY DATE(FROM_UNIXTIME(`timestamp`)) ORDER BY `date` ASC";
942 $result = $this->dbCore->queryAndGetResults($query, array('query_params' => array($notFoundDest, $notFoundDest)));
943 $hadError = !empty($result['timed_out']) || (isset($result['last_error']) && $result['last_error'] !== '');
944 $rows = (isset($result['rows']) && is_array($result['rows'])) ? $result['rows'] : array();
945 $byDate = array();
946 foreach ($rows as $row) {
947 if (!is_array($row)) { continue; }
948 $d = isset($row['date']) ? (string)$row['date'] : '';
949 if ($d === '') { continue; }
950 $byDate[$d] = array('date' => $d, 'hits_404' => intval($row['hits_404'] ?? 0), 'hits_redirect' => intval($row['hits_redirect'] ?? 0), 'new_captures' => intval($row['hits_404'] ?? 0));
951 }
952 $output = array();
953 for ($i = $days - 1; $i >= 0; $i--) {
954 $d = date('Y-m-d', time() - ($i * 86400));
955 $output[] = isset($byDate[$d]) ? $byDate[$d] : array('date' => $d, 'hits_404' => 0, 'hits_redirect' => 0, 'new_captures' => 0);
956 }
957 if (!$hadError && function_exists('set_transient')) { set_transient($cacheKey, $output, self::TREND_DATA_CACHE_TTL_SECONDS); }
958 return $output;
959 }
960
961 // =========================================================================
962 // Collation resolution for hits rebuild (Phase 1, c632)
963 // =========================================================================
964
965 /**
966 * Resolve the collation from the abj404_redirects.canonical_url column,
967 * the actual join partner for the hits rebuild phase2 JOIN.
968 *
969 * Falls back to utf8mb4_unicode_ci if the column query fails.
970 *
971 * @return string Sanitized collation identifier.
972 */
973 public function resolveHitsJoinCollation(): string {
974 $redirectsTable = $this->dbCore->doTableNameReplacements('{wp_abj404_redirects}');
975 return $this->dbCore->getColumnCollationString($redirectsTable, 'canonical_url');
976 }
977
978 // =========================================================================
979 // Hits table rebuild (from DataAccessTrait_LogsHitsRebuild)
980 // =========================================================================
981
982 /** @inheritDoc */
983 function recordLogsHitsRollupStalenessSignal(): void {
984 $currentMaxLogId = $this->getMaxLogId();
985 $storedMaxLogId = $this->getStoredMaxLogId();
986 if ($currentMaxLogId <= $storedMaxLogId) { $this->clearLogsHitsRollupStaleSignal(); return; }
987 $rawFirstStale = $this->dbCore->getRuntimeFlag(self::HITS_TABLE_FIRST_STALE_DETECTED_FLAG);
988 $firstStale = is_scalar($rawFirstStale) ? (int)$rawFirstStale : 0;
989 if ($firstStale <= 0) { $this->dbCore->setRuntimeFlag(self::HITS_TABLE_FIRST_STALE_DETECTED_FLAG, time(), 86400); return; }
990 $age = time() - $firstStale;
991 if ($age >= self::HITS_TABLE_STALE_NOTICE_THRESHOLD_SECONDS) { $this->setLogsHitsRollupStaleNotice($age); }
992 }
993
994 private function clearLogsHitsRollupStaleSignal(): void {
995 if (function_exists('delete_transient')) { delete_transient(self::HITS_TABLE_FIRST_STALE_DETECTED_FLAG); delete_transient(self::HITS_TABLE_STALE_NOTICE_TRANSIENT); return; }
996 if (function_exists('delete_option')) { delete_option(self::HITS_TABLE_FIRST_STALE_DETECTED_FLAG); delete_option(self::HITS_TABLE_STALE_NOTICE_TRANSIENT); }
997 }
998
999 /** @param int $ageSeconds @return void */
1000 private function setLogsHitsRollupStaleNotice(int $ageSeconds): void {
1001 if (!function_exists('set_transient')) { return; }
1002 $key = self::HITS_TABLE_STALE_NOTICE_TRANSIENT;
1003 if (function_exists('get_transient') && get_transient($key) !== false) { return; }
1004 $hours = max(1, intval(floor($ageSeconds / 3600)));
1005 $template = $this->dbCore->localizeOrDefault('The 404 Solution redirects-hits rollup has been behind MAX(logsv2.id) for at least %d hour(s). The cron-driven rebuild event (abj404_updateLogsHitsTableAction) does not appear to be firing, so the redirects list will show stale "hits" and "last hit" columns until cron resumes. To resolve: if DISABLE_WP_CRON is set in wp-config.php either remove it, or configure a system cron job that requests wp-cron.php periodically. To force a rebuild right now in your browser, open the 404 Solution Redirects page with ?abj404_force_view_rebuild=1 appended to the URL.');
1006 $payload = array('type' => 'logs_hits_rollup_stale', 'message' => sprintf($template, $hours), 'timestamp' => time(), 'error_string' => '', 'age_hours' => $hours);
1007 // allow-cache-empty: intentional notice payload; error_string is empty by definition for stale-rollup state.
1008 set_transient($key, $payload, 86400);
1009 }
1010
1011 /** @inheritDoc */
1012 function hitsTableNeedsRebuild() {
1013 $storedMaxId = $this->getStoredMaxLogId();
1014 $currentMaxId = $this->getMaxLogId();
1015 if ($currentMaxId != $storedMaxId) { $this->logger->debugMessage(__FUNCTION__ . " rebuild=yes (max_id changed: stored=$storedMaxId, current=$currentMaxId)"); return true; }
1016 $lastUpdated = $this->getLogsHitsTableLastUpdated();
1017 if ($lastUpdated !== null) { $age = time() - $lastUpdated; if ($age > self::HITS_TABLE_MAX_AGE_SECONDS) { $this->logger->debugMessage(__FUNCTION__ . " rebuild=yes (stale: age={$age}s > " . self::HITS_TABLE_MAX_AGE_SECONDS . "s)"); return true; } }
1018 $this->logger->debugMessage(__FUNCTION__ . " rebuild=no (max_id=$currentMaxId unchanged, not stale)");
1019 return false;
1020 }
1021
1022 /** @inheritDoc */
1023 function getLogsHitsTableLastUpdated() {
1024 $rawRefreshedFlag = $this->dbCore->getRuntimeFlag(self::HITS_TABLE_LAST_REFRESHED_FLAG);
1025 $runtimeRefreshedAt = is_scalar($rawRefreshedFlag) ? (int)$rawRefreshedFlag : 0;
1026 $runtimeRefreshedAt = $runtimeRefreshedAt > 0 ? $runtimeRefreshedAt : null;
1027 $query = "SELECT create_time FROM information_schema.tables WHERE table_name = '{wp_abj404_logs_hits}' AND table_schema = DATABASE()";
1028 $query = $this->dbCore->doTableNameReplacements($query);
1029 $results = $this->dbCore->queryAndGetResults($query);
1030 if ($results['rows'] == null || empty($results['rows'])) {
1031 if (!empty($results['last_error'])) {
1032 $statusRow = $this->getLogsHitsTableStatusRow();
1033 $dateValue = is_array($statusRow) ? ($statusRow['update_time'] ?? ($statusRow['create_time'] ?? '')) : '';
1034 if ($dateValue !== '') { $fallbackTimestamp = strtotime(is_string($dateValue) ? $dateValue : ''); if ($fallbackTimestamp !== false) { if ($runtimeRefreshedAt !== null && $runtimeRefreshedAt > $fallbackTimestamp) { return $runtimeRefreshedAt; } return $fallbackTimestamp; } }
1035 }
1036 return $runtimeRefreshedAt;
1037 }
1038 $hitsRows = is_array($results['rows']) ? $results['rows'] : array();
1039 $row = is_array($hitsRows[0] ?? null) ? $hitsRows[0] : array();
1040 $row = array_change_key_case($row);
1041 $createTime = $row['create_time'] ?? null;
1042 if ($createTime === null) { return $runtimeRefreshedAt; }
1043 $schemaTimestamp = strtotime(is_string($createTime) ? $createTime : '');
1044 if ($schemaTimestamp === false) { return $runtimeRefreshedAt; }
1045 if ($runtimeRefreshedAt !== null && $runtimeRefreshedAt > $schemaTimestamp) { return $runtimeRefreshedAt; }
1046 return $schemaTimestamp;
1047 }
1048
1049 /** @return array<string, mixed> */
1050 private function getLogsHitsTableStatusRow() {
1051 global $wpdb;
1052 if (!isset($wpdb) || !method_exists($wpdb, 'prepare')) { return array(); }
1053 $tableName = $this->dbCore->doTableNameReplacements('{wp_abj404_logs_hits}');
1054 $query = $wpdb->prepare("SHOW TABLE STATUS LIKE %s", $tableName);
1055 if ($query === null) { return array(); }
1056 $results = $this->dbCore->queryAndGetResults($query, array('log_errors' => false));
1057 if (!is_array($results['rows']) || empty($results['rows']) || !is_array($results['rows'][0])) { return array(); }
1058 return array_change_key_case($results['rows'][0], CASE_LOWER);
1059 }
1060
1061 /** @inheritDoc */
1062 function getLogsHitsTableLastUpdatedHuman() {
1063 $timestamp = $this->getLogsHitsTableLastUpdated();
1064 if ($timestamp === null) { return ''; }
1065 $diff = time() - $timestamp;
1066 if ($diff < 60) { return __('Just now', '404-solution'); }
1067 elseif ($diff < 3600) { $minutes = (int)floor($diff / 60); return sprintf(_n('%d minute ago', '%d minutes ago', $minutes, '404-solution'), $minutes); }
1068 elseif ($diff < 86400) { $hours = (int)floor($diff / 3600); return sprintf(_n('%d hour ago', '%d hours ago', $hours, '404-solution'), $hours); }
1069 else { $days = (int)floor($diff / 86400); return sprintf(_n('%d day ago', '%d days ago', $days, '404-solution'), $days); }
1070 }
1071
1072 /** @inheritDoc */
1073 function createRedirectsForViewHitsTable(): bool {
1074 $wasRefreshed = false;
1075 if ($this->rebuildHealth !== null && !$this->rebuildHealth->beginExpensiveRebuildAttempt()) {
1076 $this->logger->debugMessage(__FUNCTION__ . " skipped because rebuild health gate is closed.");
1077 $this->dbCore->setRuntimeFlag(self::HITS_TABLE_LAST_DECISION_FLAG, 'paused', 86400);
1078 return false;
1079 }
1080 if ($this->dbCore->shouldSkipNonEssentialDbWrites()) { $this->logger->debugMessage(__FUNCTION__ . " skipped due to temporary DB write cooldown."); $this->dbCore->setRuntimeFlag(self::HITS_TABLE_LAST_DECISION_FLAG, 'paused', 86400); return false; }
1081 if (!$this->acquireHitsTableRebuildLock()) { $this->logger->debugMessage(__FUNCTION__ . " skipped because rebuild lock is already held."); $this->dbCore->setRuntimeFlag(self::HITS_TABLE_LAST_DECISION_FLAG, 'running', 86400); return false; }
1082 $preAggTable = $this->dbCore->doTableNameReplacements("{wp_abj404_logs_hits}_preagg");
1083 try {
1084 $finalDestTable = $this->dbCore->doTableNameReplacements("{wp_abj404_logs_hits}");
1085 $tempDestTable = $this->dbCore->doTableNameReplacements("{wp_abj404_logs_hits}_temp");
1086 $this->dbCore->queryAndGetResults("drop table if exists " . $tempDestTable);
1087 $resolvedCollation = $this->resolveHitsJoinCollation();
1088 $createTempTableQuery = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/createLogsHitsTempTable.sql");
1089 $createTempTableQuery = $this->dbCore->doTableNameReplacements($createTempTableQuery);
1090 $createTempTableQuery = str_replace('{COLLATION}', $resolvedCollation, $createTempTableQuery);
1091 $this->dbCore->queryAndGetResults($createTempTableQuery);
1092 // @cache-write-audit: opt-out - truncates an unpublished temp table before rebuilding it.
1093 $this->dbCore->queryAndGetResults("truncate table " . $tempDestTable);
1094 $maxLogIdSnapshot = $this->getMaxLogId();
1095 $minLogId = $this->getMinLogId();
1096 $idRange = $maxLogIdSnapshot - $minLogId;
1097 $chunkSize = $this->getHitsRebuildChunkSize($idRange);
1098 if ($idRange <= self::HITS_TABLE_DIRECT_PATH_THRESHOLD) { $results = $this->hitsTableInsertDirect($tempDestTable); } else { $results = $this->hitsTableInsertChunked($tempDestTable, $preAggTable, $minLogId, $maxLogIdSnapshot, $chunkSize); }
1099 if ($results === false || !empty($results['timed_out']) || !empty($results['last_error'])) {
1100 $errorMessage = $results === false ? 'Hits rebuild phase 1 chunk failed.' : (string)($results['last_error'] ?? 'Hits rebuild timed out.');
1101 $this->recordHitsRebuildFailure($errorMessage);
1102 if ($idRange > self::HITS_TABLE_DIRECT_PATH_THRESHOLD && $results !== false && (!empty($results['timed_out']) || !empty($results['last_error']))) {
1103 $this->recordHitsChunkFailure();
1104 }
1105 $this->dbCore->queryAndGetResults("drop table if exists " . $tempDestTable); $this->logger->debugMessage(__FUNCTION__ . " INSERT timed out or errored; aborting rebuild."); $this->dbCore->setRuntimeFlag(self::HITS_TABLE_LAST_DECISION_FLAG, 'paused', 86400); return false;
1106 }
1107 $elapsedTime = $results['elapsed_time'];
1108 $comment = $elapsedTime . '|' . $maxLogIdSnapshot;
1109 // @utf8-audit: opt-out — rebuild table comment is synthesized from numeric timing and ID values.
1110 $comment = substr(esc_sql($comment), 0, 2048);
1111 $this->dbCore->queryAndGetResults(sprintf("ALTER TABLE %s COMMENT '%s'", $tempDestTable, $comment));
1112 $statements = array("drop table if exists " . $finalDestTable, "rename table " . $tempDestTable . ' to ' . $finalDestTable);
1113 $this->dbCore->executeAsTransaction($statements);
1114 $this->dbCore->setRuntimeFlag(self::HITS_TABLE_LAST_REFRESHED_FLAG, time(), 86400);
1115 $this->recordHitsRebuildSuccess($chunkSize);
1116 $this->clearLogsHitsRollupStaleSignal();
1117 $wasRefreshed = true;
1118 $this->logger->debugMessage(__FUNCTION__ . " refreshed " . $finalDestTable . " in " . $elapsedTime . " seconds.");
1119 } catch (Throwable $e) {
1120 $this->recordHitsRebuildFailure($e->getMessage());
1121 $this->logger->errorMessage(__FUNCTION__ . " failed: " . $e->getMessage(), $e instanceof \Exception ? $e : null);
1122 $this->dbCore->setRuntimeFlag(self::HITS_TABLE_LAST_DECISION_FLAG, 'paused', 86400);
1123 } finally {
1124 $this->dbCore->queryAndGetResults("drop table if exists " . $preAggTable);
1125 $this->releaseHitsTableRebuildLock();
1126 }
1127 return $wasRefreshed;
1128 }
1129
1130 /** @param string $tempDestTable @return array<string, mixed> */
1131 private function hitsTableInsertDirect(string $tempDestTable): array {
1132 $ttSelectQuery = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/getRedirectsForViewTempTable.sql");
1133 if ($this->isLogsv2CanonicalUrlBackfillComplete()) { $ttSelectQuery = $this->dropLogsv2CanonicalCoalesceWrap($ttSelectQuery); }
1134 $ttSelectQuery = $this->dbCore->doTableNameReplacements($ttSelectQuery);
1135 $ttInsertQuery = "/* abj404:src=LogsRepository::hitsTableInsertDirect */ insert into " . $tempDestTable . " (requested_url, logsid, last_used, logshits, failed_hits) \n " . $ttSelectQuery;
1136 return $this->dbCore->queryAndGetResults($ttInsertQuery, array('log_too_slow' => false, 'timeout' => 60));
1137 }
1138
1139 /** @return bool */
1140 private function isLogsv2CanonicalUrlBackfillComplete(): bool {
1141 if (!function_exists('get_option')) { return false; }
1142 return (bool)get_option('abj404_logsv2_canonical_url_backfill_complete');
1143 }
1144
1145 /** @param string $sql @return string */
1146 private function dropLogsv2CanonicalCoalesceWrap(string $sql): string {
1147 $pattern = '/COALESCE\(\{wp_abj404_logsv2\}\.canonical_url,\s*CONCAT\(\'\/\',\s*TRIM\(BOTH\s+\'\/\'\s+FROM\s+\{wp_abj404_logsv2\}\.requested_url\)\)\)/';
1148 $result = preg_replace($pattern, '{wp_abj404_logsv2}.canonical_url', $sql);
1149 return is_string($result) ? $result : $sql;
1150 }
1151
1152 /** @return array<string, mixed>|false */
1153 private function hitsTableInsertChunked(string $tempDestTable, string $preAggTable, int $minId, int $maxId, int $chunkSize) {
1154 $logsv2Table = $this->dbCore->doTableNameReplacements("{wp_abj404_logsv2}");
1155 $redirectsTable = $this->dbCore->doTableNameReplacements("{wp_abj404_redirects}");
1156 $resolvedCollation = $this->resolveHitsJoinCollation();
1157 $startTime = microtime(true);
1158 $this->dbCore->queryAndGetResults("drop table if exists " . $preAggTable);
1159 $createPreAggQuery = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/createLogsHitsPreAggTempTable.sql");
1160 $createPreAggQuery = $this->dbCore->doTableNameReplacements($createPreAggQuery);
1161 $createPreAggQuery = str_replace('{COLLATION}', $resolvedCollation, $createPreAggQuery);
1162 $this->dbCore->queryAndGetResults($createPreAggQuery);
1163 $logsv2CanonicalExpr = $this->isLogsv2CanonicalUrlBackfillComplete() ? "canonical_url" : "COALESCE(canonical_url, CONCAT('/', TRIM(BOTH '/' FROM requested_url)))";
1164 for ($start = $minId; $start <= $maxId; $start += $chunkSize) {
1165 $end = $start + $chunkSize;
1166 $chunkQuery = "/* abj404:src=LogsRepository::hitsTableInsertChunked#phase1Chunk */ INSERT INTO " . $preAggTable . " (requested_url, logsid, last_used, logshits, failed_hits) SELECT " . $logsv2CanonicalExpr . ", MIN(id), MAX(timestamp), COUNT(*), SUM(CASE WHEN dest_url = '' OR dest_url IS NULL THEN 1 ELSE 0 END) FROM " . $logsv2Table . " WHERE id >= %d AND id < %d GROUP BY " . $logsv2CanonicalExpr;
1167 $chunkResult = $this->dbCore->queryAndGetResults($chunkQuery, array('log_too_slow' => false, 'timeout' => 10, 'query_params' => array($start, $end)));
1168 if (!empty($chunkResult['timed_out']) || !empty($chunkResult['last_error'])) { $this->recordHitsChunkFailure(); $this->logger->debugMessage(__FUNCTION__ . " Phase 1 chunk failed at id range [{$start}, {$end}); aborting."); return false; }
1169 }
1170 $phase2Query = "/* abj404:src=LogsRepository::hitsTableInsertChunked#phase2Aggregate */ INSERT INTO " . $tempDestTable . " (requested_url, logsid, last_used, logshits, failed_hits) SELECT a.requested_url, MIN(a.logsid), MAX(a.last_used), SUM(a.logshits), SUM(a.failed_hits) FROM " . $preAggTable . " a INNER JOIN " . $redirectsTable . " r ON a.requested_url = (COALESCE(r.canonical_url, CONCAT('/', TRIM(BOTH '/' FROM r.url))) COLLATE " . $resolvedCollation . ") GROUP BY a.requested_url";
1171 $results = $this->dbCore->queryAndGetResults($phase2Query, array('log_too_slow' => false, 'timeout' => 60));
1172 $results['elapsed_time'] = round(microtime(true) - $startTime, 3);
1173 $this->dbCore->queryAndGetResults("drop table if exists " . $preAggTable);
1174 return $results;
1175 }
1176
1177 /** @param int $idRange @return int */
1178 private function getHitsRebuildChunkSize(int $idRange): int {
1179 if ($this->rebuildHealth === null) {
1180 return self::HITS_TABLE_PREAGG_CHUNK_SIZE;
1181 }
1182 return $this->rebuildHealth->getHitsChunkSize($idRange);
1183 }
1184
1185 /** @return void */
1186 private function recordHitsChunkFailure(): void {
1187 if ($this->rebuildHealth !== null) {
1188 $this->rebuildHealth->recordHitsChunkFailure();
1189 }
1190 }
1191
1192 /** @param int $chunkSize @return void */
1193 private function recordHitsRebuildSuccess(int $chunkSize): void {
1194 if ($this->rebuildHealth === null) {
1195 return;
1196 }
1197 $this->rebuildHealth->recordFullRebuildSuccess($chunkSize);
1198 $this->rebuildHealth->recordSuccess();
1199 }
1200
1201 /** @param string $message @return void */
1202 private function recordHitsRebuildFailure(string $message): void {
1203 if ($this->rebuildHealth === null) {
1204 return;
1205 }
1206 $this->rebuildHealth->recordFailure($message, $this->rebuildHealth->classifyError($message));
1207 }
1208
1209 // =========================================================================
1210 // Hits table lifecycle (from DataAccessTrait_ViewQueriesHitsLifecycle)
1211 // =========================================================================
1212
1213 /** @inheritDoc */
1214 function logsHitsTableExists() {
1215 $query = "SELECT 1 FROM information_schema.tables WHERE table_name = '{wp_abj404_logs_hits}' AND table_schema = DATABASE() LIMIT 1";
1216 $query = $this->dbCore->doTableNameReplacements($query);
1217 $results = $this->dbCore->queryAndGetResults($query);
1218 if ($results['rows'] != null && !empty($results['rows'])) { return true; }
1219 if (!empty($results['last_error'])) { return $this->logsHitsTableExistsViaShowTables(); }
1220 return false;
1221 }
1222
1223 /** @return bool */
1224 private function logsHitsTableExistsViaShowTables(): bool {
1225 global $wpdb;
1226 if (!isset($wpdb) || !method_exists($wpdb, 'prepare')) { return false; }
1227 $tableName = $this->dbCore->doTableNameReplacements('{wp_abj404_logs_hits}');
1228 $showTablesQuery = $wpdb->prepare("SHOW TABLES LIKE %s", $tableName);
1229 if ($showTablesQuery === null) { return false; }
1230 $fallback = $this->dbCore->queryAndGetResults($showTablesQuery, array('log_errors' => false));
1231 if (empty($fallback['rows'])) { return false; }
1232 $fbRows = is_array($fallback['rows']) ? $fallback['rows'] : array();
1233 $firstRow = isset($fbRows[0]) ? $fbRows[0] : null;
1234 if (!is_array($firstRow)) { return false; }
1235 $value = reset($firstRow);
1236 return ((string)$value === (string)$tableName);
1237 }
1238
1239 /** @inheritDoc */
1240 function scheduleHitsTableRebuild(): void {
1241 if ($this->rebuildHealth !== null && !$this->rebuildHealth->mayStartExpensiveRebuild()) { $this->logger->debugMessage(__FUNCTION__ . " skipped because rebuild health gate is closed."); $this->dbCore->setRuntimeFlag(self::HITS_TABLE_LAST_DECISION_FLAG, 'paused', 86400); return; }
1242 if ($this->dbCore->shouldSkipNonEssentialDbWrites()) { $this->logger->debugMessage(__FUNCTION__ . " skipped due to temporary DB write cooldown."); $this->dbCore->setRuntimeFlag(self::HITS_TABLE_LAST_DECISION_FLAG, 'paused', 86400); return; }
1243 if (!self::$hitsTableRebuildScheduled) {
1244 if ($this->isHitsTableRebuildLocked()) { $this->logger->debugMessage(__FUNCTION__ . " skipping scheduling because another rebuild is already running."); $this->dbCore->setRuntimeFlag(self::HITS_TABLE_LAST_DECISION_FLAG, 'running', 86400); return; }
1245 $rawScheduledFlag = $this->dbCore->getRuntimeFlag(self::HITS_TABLE_LAST_SCHEDULED_FLAG);
1246 $lastScheduled = is_scalar($rawScheduledFlag) ? (int)$rawScheduledFlag : 0;
1247 if ($lastScheduled > 0 && (time() - $lastScheduled) < self::HITS_TABLE_SCHEDULE_COOLDOWN_SECONDS) { $this->logger->debugMessage(__FUNCTION__ . " skipping scheduling due to cooldown."); $this->dbCore->setRuntimeFlag(self::HITS_TABLE_LAST_DECISION_FLAG, 'cooldown', 86400); return; }
1248 self::$hitsTableRebuildScheduled = true;
1249 $this->dbCore->setRuntimeFlag(self::HITS_TABLE_LAST_SCHEDULED_FLAG, time(), 86400);
1250 $this->dbCore->setRuntimeFlag(self::HITS_TABLE_LAST_DECISION_FLAG, 'scheduled', 86400);
1251 if ($this->shouldScheduleHitsTableRebuildViaCron()) { $this->logger->debugMessage(__FUNCTION__ . " scheduling hits table rebuild via WP-Cron."); if (function_exists('wp_schedule_single_event')) { wp_schedule_single_event(time() + 5, 'abj404_updateLogsHitsTableAction'); } return; }
1252 $this->logger->debugMessage(__FUNCTION__ . " scheduling hits table rebuild for shutdown hook.");
1253 add_action('shutdown', function(): void { $this->createRedirectsForViewHitsTable(); });
1254 }
1255 }
1256
1257 /** @return bool */
1258 private function shouldScheduleHitsTableRebuildViaCron(): bool {
1259 if (function_exists('wp_doing_ajax') && wp_doing_ajax()) { return true; }
1260 $scriptName = isset($_SERVER['SCRIPT_NAME']) && is_string($_SERVER['SCRIPT_NAME']) ? $_SERVER['SCRIPT_NAME'] : '';
1261 if ($scriptName !== '' && basename($scriptName) === 'admin-ajax.php') { return true; }
1262 $pagenow = isset($GLOBALS['pagenow']) && is_string($GLOBALS['pagenow']) ? $GLOBALS['pagenow'] : '';
1263 return $pagenow === 'admin-ajax.php';
1264 }
1265
1266 private function getHitsTableRebuildLockOptionName(): string { return $this->dbCore->getLowercasePrefix() . 'abj404_logs_hits_rebuild_lock'; }
1267
1268 /** @return bool */
1269 private function isHitsTableRebuildLocked(): bool {
1270 if (!function_exists('get_option')) { return false; }
1271 $lockValue = get_option($this->getHitsTableRebuildLockOptionName(), false);
1272 if ($lockValue === false || $lockValue === null || $lockValue === '') { return false; }
1273 if (!is_numeric($lockValue)) { if (function_exists('delete_option')) { delete_option($this->getHitsTableRebuildLockOptionName()); } return false; }
1274 $lockTimestamp = (int)$lockValue;
1275 if ($lockTimestamp > 0 && (time() - $lockTimestamp) > self::HITS_TABLE_REBUILD_LOCK_TTL_SECONDS) { if (function_exists('delete_option')) { delete_option($this->getHitsTableRebuildLockOptionName()); } return false; }
1276 return true;
1277 }
1278
1279 /** @return bool */
1280 private function acquireHitsTableRebuildLock(): bool {
1281 if (!function_exists('add_option')) { return true; }
1282 if ($this->isHitsTableRebuildLocked()) { return false; }
1283 return (bool)add_option($this->getHitsTableRebuildLockOptionName(), (string)time(), '', false);
1284 }
1285
1286 /** @return void */
1287 private function releaseHitsTableRebuildLock(): void { if (function_exists('delete_option')) { delete_option($this->getHitsTableRebuildLockOptionName()); } }
1288
1289 /** @inheritDoc */
1290 function getMaxLogId() {
1291 $query = "SELECT MAX(id) FROM {wp_abj404_logsv2}";
1292 $query = $this->dbCore->doTableNameReplacements($query);
1293 $results = $this->dbCore->queryAndGetResults($query);
1294 $resultRows = is_array($results['rows']) ? $results['rows'] : array();
1295 if (empty($resultRows)) { return 0; }
1296 $row = $resultRows[0];
1297 $maxId = is_array($row) ? array_values($row)[0] : (array_values((array)$row)[0] ?? 0);
1298 return (int)($maxId ?? 0);
1299 }
1300
1301 /** @inheritDoc */
1302 function getMinLogId() {
1303 $query = "SELECT MIN(id) FROM {wp_abj404_logsv2}";
1304 $query = $this->dbCore->doTableNameReplacements($query);
1305 $results = $this->dbCore->queryAndGetResults($query);
1306 $resultRows = is_array($results['rows']) ? $results['rows'] : array();
1307 if (empty($resultRows)) { return 0; }
1308 $row = $resultRows[0];
1309 $minId = is_array($row) ? array_values($row)[0] : (array_values((array)$row)[0] ?? 0);
1310 return is_numeric($minId) ? (int)$minId : 0;
1311 }
1312
1313 /** @inheritDoc */
1314 function getStoredMaxLogId() {
1315 $query = "SELECT table_comment FROM information_schema.tables WHERE table_name = '{wp_abj404_logs_hits}' AND table_schema = DATABASE()";
1316 $query = $this->dbCore->doTableNameReplacements($query);
1317 $results = $this->dbCore->queryAndGetResults($query);
1318 $storedRows = is_array($results['rows']) ? $results['rows'] : array();
1319 if (empty($storedRows)) {
1320 if (!empty($results['last_error'])) { $statusRow = $this->getLogsHitsTableStatusRow(); $commentFromStatus = $statusRow['comment'] ?? ''; if ($commentFromStatus !== '') { $parts = explode('|', is_string($commentFromStatus) ? $commentFromStatus : ''); if (count($parts) >= 2) { return (int)$parts[1]; } } }
1321 return 0;
1322 }
1323 $row = is_array($storedRows[0] ?? null) ? $storedRows[0] : array();
1324 $row = array_change_key_case($row);
1325 $comment = $row['table_comment'] ?? '';
1326 $parts = explode('|', is_string($comment) ? $comment : '');
1327 if (count($parts) >= 2) { return (int)$parts[1]; }
1328 return 0;
1329 }
1330
1331 /** @inheritDoc */
1332 function getLogsHitsTableLastCheckedAt() { $rawTsFlag = $this->dbCore->getRuntimeFlag(self::HITS_TABLE_LAST_CHECKED_FLAG); $ts = is_scalar($rawTsFlag) ? (int)$rawTsFlag : 0; return $ts > 0 ? $ts : null; }
1333
1334 /** @inheritDoc */
1335 function getLogsHitsTableLastScheduledAt() { $rawTsFlag2 = $this->dbCore->getRuntimeFlag(self::HITS_TABLE_LAST_SCHEDULED_FLAG); $ts = is_scalar($rawTsFlag2) ? (int)$rawTsFlag2 : 0; return $ts > 0 ? $ts : null; }
1336
1337 /** @inheritDoc */
1338 function getLogsHitsTableLastDecision(): string { $v = $this->dbCore->getRuntimeFlag(self::HITS_TABLE_LAST_DECISION_FLAG); return is_string($v) ? $v : ''; }
1339 }
1340