PluginProbe
404 Solution / 4.1.19
404 Solution v4.1.19
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 / DataAccessTrait_Stats.php

DataAccessTrait_Stats.php in 404 Solution 4.1.19, at includes/DataAccessTrait_Stats.php

935 lines 37.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 trait ABJ_404_Solution_DataAccess_StatsTrait {
8
9 /**
10 * @param string $query
11 * @param array<int|string, mixed> $valueParams
12 * @return int
13 */
14 function getStatsCount($query, array $valueParams) {
15 if ($query == '') {
16 return 0;
17 }
18
19 // Route through queryAndGetResults() so the query inherits the
20 // centralized timeout, retry, and corrupted-table recovery.
21 $result = $this->queryAndGetResults($query, array('query_params' => $valueParams));
22
23 if (!empty($result['timed_out']) || (isset($result['last_error']) && $result['last_error'] != '')) {
24 return 0;
25 }
26
27 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
28 if (empty($rows)) {
29 $this->logger->debugMessage("getStatsCount returned no results for query: " . esc_html($query));
30 return 0;
31 }
32
33 $first = $rows[0];
34 if (is_array($first)) {
35 $value = reset($first);
36 } else {
37 $value = $first;
38 }
39 return intval($value);
40 }
41
42 /**
43 * Get periodic log statistics in one query for a given time threshold.
44 *
45 * This replaces multiple per-metric count queries on the stats page and
46 * significantly reduces page-load query overhead.
47 *
48 * @param int $sinceTimestamp Include rows with timestamp >= this value.
49 * @param string $notFoundDest Destination value used for "404" events.
50 * @return array{
51 * disp404:int,
52 * distinct404:int,
53 * visitors404:int,
54 * refer404:int,
55 * redirected:int,
56 * distinctredirected:int,
57 * distinctvisitors:int,
58 * distinctrefer:int
59 * }
60 */
61 function getPeriodicStatsSummary($sinceTimestamp, $notFoundDest = '404') {
62 $sinceTimestamp = absint($sinceTimestamp);
63 $notFoundDest = sanitize_text_field((string)$notFoundDest);
64 if ($notFoundDest === '') {
65 $notFoundDest = '404';
66 }
67
68 $zero = array(
69 'disp404' => 0,
70 'distinct404' => 0,
71 'visitors404' => 0,
72 'refer404' => 0,
73 'redirected' => 0,
74 'distinctredirected' => 0,
75 'distinctvisitors' => 0,
76 'distinctrefer' => 0,
77 );
78
79 $logsTable = $this->doTableNameReplacements('{wp_abj404_logsv2}');
80 $sql = "SELECT
81 COUNT(CASE WHEN dest_url = %s THEN 1 END) AS disp404,
82 COUNT(DISTINCT CASE WHEN dest_url = %s THEN requested_url END) AS distinct404,
83 COUNT(DISTINCT CASE WHEN dest_url = %s THEN user_ip END) AS visitors404,
84 COUNT(DISTINCT CASE WHEN dest_url = %s THEN referrer END) AS refer404,
85 COUNT(CASE WHEN dest_url <> %s THEN 1 END) AS redirected,
86 COUNT(DISTINCT CASE WHEN dest_url <> %s THEN requested_url END) AS distinctredirected,
87 COUNT(DISTINCT CASE WHEN dest_url <> %s THEN user_ip END) AS distinctvisitors,
88 COUNT(DISTINCT CASE WHEN dest_url <> %s THEN referrer END) AS distinctrefer
89 FROM {$logsTable}
90 WHERE timestamp >= %d";
91
92 // Route through queryAndGetResults() so this 8x DISTINCT aggregate on
93 // logsv2 inherits the centralized 60-second timeout. Without it, a
94 // large logsv2 table can stall the Stats dashboard refresh AJAX past
95 // the reverse-proxy limit (Cloudflare 524, nginx 504).
96 $result = $this->queryAndGetResults($sql, array(
97 'query_params' => array(
98 $notFoundDest, $notFoundDest, $notFoundDest, $notFoundDest,
99 $notFoundDest, $notFoundDest, $notFoundDest, $notFoundDest,
100 $sinceTimestamp,
101 ),
102 ));
103
104 if (!empty($result['timed_out']) || (isset($result['last_error']) && $result['last_error'] != '')) {
105 return $zero;
106 }
107
108 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
109 if (empty($rows) || !is_array($rows[0] ?? null)) {
110 return $zero;
111 }
112 $row = $rows[0];
113
114 foreach ($zero as $key => $unused) {
115 $zero[$key] = isset($row[$key]) ? intval($row[$key]) : 0;
116 }
117
118 return $zero;
119 }
120
121 /**
122 * Return periodic stats for today/month/year/all with short-lived cache.
123 *
124 * This avoids repeatedly running expensive DISTINCT aggregates each time the
125 * stats tab is opened while still keeping data reasonably fresh.
126 *
127 * @param string $notFoundDest Destination value used for "404" events.
128 * @return array{
129 * today:array<string,int>,
130 * month:array<string,int>,
131 * year:array<string,int>,
132 * all:array<string,int>
133 * }
134 */
135 function getPeriodicStatsSummariesCached($notFoundDest = '404') {
136 $today = mktime(0, 0, 0, abs(intval(date('m'))), abs(intval(date('d'))), abs(intval(date('Y'))));
137 $firstm = mktime(0, 0, 0, abs(intval(date('m'))), 1, abs(intval(date('Y'))));
138 $firsty = mktime(0, 0, 0, 1, 1, abs(intval(date('Y'))));
139
140 $thresholds = array(
141 'today' => intval($today),
142 'month' => intval($firstm),
143 'year' => intval($firsty),
144 'all' => 0,
145 );
146
147 $zero = array(
148 'disp404' => 0,
149 'distinct404' => 0,
150 'visitors404' => 0,
151 'refer404' => 0,
152 'redirected' => 0,
153 'distinctredirected' => 0,
154 'distinctvisitors' => 0,
155 'distinctrefer' => 0,
156 );
157 $emptyPayload = array(
158 'today' => $zero,
159 'month' => $zero,
160 'year' => $zero,
161 'all' => $zero,
162 );
163
164 $blogId = 1;
165 if (function_exists('get_current_blog_id')) {
166 $blogId = absint(get_current_blog_id());
167 if ($blogId <= 0) {
168 $blogId = 1;
169 }
170 }
171
172 $cacheKey = 'abj404_stats_periodic_v1_' . $blogId . '_' . md5(
173 $notFoundDest . '|' . $thresholds['today'] . '|' . $thresholds['month'] . '|' . $thresholds['year']
174 );
175 $cached = null;
176 if (function_exists('get_transient')) {
177 $cached = get_transient($cacheKey);
178 }
179
180 $isCachedValid = (is_array($cached) && isset($cached['periods']) && is_array($cached['periods']));
181 $currentMaxLogId = -1;
182 try {
183 $currentMaxLogId = intval($this->getMaxLogId());
184 } catch (Throwable $unused) {
185 $currentMaxLogId = -1;
186 }
187
188 if ($isCachedValid) {
189 $refreshedAt = intval($cached['refreshed_at'] ?? 0);
190 $ageSeconds = max(0, time() - $refreshedAt);
191 $cachedMaxLogId = intval($cached['max_log_id'] ?? -1);
192 if ($currentMaxLogId >= 0 && $cachedMaxLogId === $currentMaxLogId) {
193 /** @var array{today: array<string, int>, month: array<string, int>, year: array<string, int>, all: array<string, int>} */
194 $merged = array_merge($emptyPayload, $cached['periods']);
195 return $merged;
196 }
197 if ($ageSeconds < self::PERIODIC_STATS_REFRESH_COOLDOWN_SECONDS) {
198 /** @var array{today: array<string, int>, month: array<string, int>, year: array<string, int>, all: array<string, int>} */
199 $merged = array_merge($emptyPayload, $cached['periods']);
200 return $merged;
201 }
202 }
203
204 $lockKey = 'stats-periodic:' . $cacheKey;
205 $lockAcquired = $this->acquireViewSnapshotRefreshLock($lockKey);
206 if (!$lockAcquired && $isCachedValid) {
207 /** @var array{today: array<string, int>, month: array<string, int>, year: array<string, int>, all: array<string, int>} */
208 $merged = array_merge($emptyPayload, $cached['periods']);
209 return $merged;
210 }
211
212 try {
213 $periods = array();
214 foreach ($thresholds as $key => $ts) {
215 $periods[$key] = $this->getPeriodicStatsSummary($ts, $notFoundDest);
216 }
217 /** @var array{today: array<string, int>, month: array<string, int>, year: array<string, int>, all: array<string, int>} */
218 $result = array_merge($emptyPayload, $periods);
219
220 if (function_exists('set_transient')) {
221 set_transient(
222 $cacheKey,
223 array(
224 'refreshed_at' => time(),
225 'max_log_id' => $currentMaxLogId,
226 'periods' => $result,
227 ),
228 self::PERIODIC_STATS_CACHE_TTL_SECONDS
229 );
230 }
231
232 return $result;
233 } finally {
234 if ($lockAcquired) {
235 $this->releaseViewSnapshotRefreshLock($lockKey);
236 }
237 }
238 }
239
240 /**
241 * Return a cached snapshot used by the Stats dashboard.
242 *
243 * For user experience, we intentionally prefer stale data over blocking
244 * the request. Fresh recomputation is done by a background AJAX refresh.
245 *
246 * @param bool $allowStale If true, return any cached snapshot immediately.
247 * @return array{refreshed_at:int,hash:string,data:array<string, mixed>}
248 */
249 function getStatsDashboardSnapshot($allowStale = true) {
250 $cached = $this->getStatsDashboardSnapshotFromCache();
251 if (is_array($cached) && !empty($cached['data']) && $allowStale) {
252 /** @var array{refreshed_at: int, hash: string, data: array<string, mixed>} $cached */
253 return $cached;
254 }
255
256 // No cache exists — compute synchronously so the user sees real data
257 // on first load rather than depending entirely on AJAX background refresh.
258
259 return $this->refreshStatsDashboardSnapshot(false);
260 }
261
262 /**
263 * Recompute and store the stats dashboard snapshot.
264 *
265 * @param bool $force If true, bypass refresh cooldown checks.
266 * @return array{refreshed_at:int,hash:string,data:array<string, mixed>}
267 */
268 function refreshStatsDashboardSnapshot($force = false) {
269 $cached = $this->getStatsDashboardSnapshotFromCache();
270 $hasCachedData = (is_array($cached) && !empty($cached['data']));
271 $cachedAge = $hasCachedData ? max(0, time() - (is_scalar($cached['refreshed_at'] ?? 0) ? intval($cached['refreshed_at'] ?? 0) : 0)) : PHP_INT_MAX;
272
273 if (!$force && $hasCachedData && $cachedAge < self::STATS_DASHBOARD_REFRESH_COOLDOWN_SECONDS) {
274 /** @var array{refreshed_at: int, hash: string, data: array<string, mixed>} $cached */
275 return $cached;
276 }
277
278 $lockKey = 'stats-dashboard:' . $this->getStatsDashboardSnapshotCacheKey();
279 $lockAcquired = $this->acquireViewSnapshotRefreshLock($lockKey);
280 if (!$lockAcquired && $hasCachedData) {
281 /** @var array{refreshed_at: int, hash: string, data: array<string, mixed>} $cached */
282 return $cached;
283 }
284
285 try {
286 $data = $this->buildStatsDashboardSnapshotData();
287 $payload = array(
288 'refreshed_at' => time(),
289 'hash' => $this->hashStatsDashboardSnapshot($data),
290 'data' => $data,
291 );
292 if (function_exists('set_transient')) {
293 set_transient($this->getStatsDashboardSnapshotCacheKey(), $payload, self::STATS_DASHBOARD_CACHE_TTL_SECONDS);
294 }
295 return $payload;
296 } catch (Throwable $e) {
297 if ($hasCachedData) {
298 $this->logger->debugMessage(__FUNCTION__ . ' failed to recompute stats snapshot; returning cached snapshot. Error: ' . $e->getMessage());
299 /** @var array{refreshed_at: int, hash: string, data: array<string, mixed>} $cached */
300 return $cached;
301 }
302 throw $e;
303 } finally {
304 if ($lockAcquired) {
305 $this->releaseViewSnapshotRefreshLock($lockKey);
306 }
307 }
308 }
309
310 /** @return array<string, mixed>|null */
311 private function getStatsDashboardSnapshotFromCache() {
312 if (!function_exists('get_transient')) {
313 return null;
314 }
315 $cached = get_transient($this->getStatsDashboardSnapshotCacheKey());
316 if (!is_array($cached)) {
317 return null;
318 }
319 if (!array_key_exists('data', $cached) || !is_array($cached['data'])) {
320 return null;
321 }
322 $cached['refreshed_at'] = intval($cached['refreshed_at'] ?? 0);
323 $cached['hash'] = is_string($cached['hash'] ?? null) ? $cached['hash'] : '';
324 return $cached;
325 }
326
327 /** @return string */
328 private function getStatsDashboardSnapshotCacheKey(): string {
329 $blogId = 1;
330 if (function_exists('get_current_blog_id')) {
331 $blogId = absint(get_current_blog_id());
332 if ($blogId <= 0) {
333 $blogId = 1;
334 }
335 }
336 return 'abj404_stats_dashboard_snapshot_v1_' . $blogId;
337 }
338
339 /**
340 * @param array<string, mixed> $data
341 * @return string
342 */
343 private function hashStatsDashboardSnapshot($data) {
344 $encoded = function_exists('wp_json_encode') ? wp_json_encode($data) : json_encode($data);
345 if (!is_string($encoded)) {
346 $encoded = '';
347 }
348 return md5($encoded);
349 }
350
351 /** @return array<string, mixed> */
352 private function buildStatsDashboardSnapshotData() {
353 $redirectsTable = $this->doTableNameReplacements("{wp_abj404_redirects}");
354
355 $auto301 = $this->getStatsCount(
356 "select count(id) from $redirectsTable where disabled = 0 and code = 301 and status = %d",
357 array(ABJ404_STATUS_AUTO)
358 );
359 $auto302 = $this->getStatsCount(
360 "select count(id) from $redirectsTable where disabled = 0 and code = 302 and status = %d",
361 array(ABJ404_STATUS_AUTO)
362 );
363 $manual301 = $this->getStatsCount(
364 "select count(id) from $redirectsTable where disabled = 0 and code = 301 and status = %d",
365 array(ABJ404_STATUS_MANUAL)
366 );
367 $manual302 = $this->getStatsCount(
368 "select count(id) from $redirectsTable where disabled = 0 and code = 302 and status = %d",
369 array(ABJ404_STATUS_MANUAL)
370 );
371 $trashedRedirects = $this->getStatsCount(
372 "select count(id) from $redirectsTable where disabled = 1 and (status = %d or status = %d)",
373 array(ABJ404_STATUS_AUTO, ABJ404_STATUS_MANUAL)
374 );
375
376 $captured = $this->getStatsCount(
377 "select count(id) from $redirectsTable where disabled = 0 and status = %d",
378 array(ABJ404_STATUS_CAPTURED)
379 );
380 $ignored = $this->getStatsCount(
381 "select count(id) from $redirectsTable where disabled = 0 and status in (%d, %d)",
382 array(ABJ404_STATUS_IGNORED, ABJ404_STATUS_LATER)
383 );
384 $trashedCaptured = $this->getStatsCount(
385 "select count(id) from $redirectsTable where disabled = 1 and (status in (%d, %d, %d) )",
386 array(ABJ404_STATUS_CAPTURED, ABJ404_STATUS_IGNORED, ABJ404_STATUS_LATER)
387 );
388
389 $thresholds = array(
390 'today' => (int)mktime(0, 0, 0, abs(intval(date('m'))), abs(intval(date('d'))), abs(intval(date('Y')))),
391 'month' => (int)mktime(0, 0, 0, abs(intval(date('m'))), 1, abs(intval(date('Y')))),
392 'year' => (int)mktime(0, 0, 0, 1, 1, abs(intval(date('Y')))),
393 'all' => 0,
394 );
395 $periods = array();
396 foreach ($thresholds as $periodKey => $ts) {
397 $periods[$periodKey] = $this->getPeriodicStatsSummary($ts, '404');
398 }
399
400 return array(
401 'redirects' => array(
402 'auto301' => intval($auto301),
403 'auto302' => intval($auto302),
404 'manual301' => intval($manual301),
405 'manual302' => intval($manual302),
406 'trashed' => intval($trashedRedirects),
407 ),
408 'captured' => array(
409 'captured' => intval($captured),
410 'ignored' => intval($ignored),
411 'trashed' => intval($trashedCaptured),
412 ),
413 'periods' => $periods,
414 );
415 }
416
417
418 /**
419 * @global type $wpdb
420 * @return int
421 * @throws Exception
422 */
423 function getEarliestLogTimestamp() {
424 $query = 'SELECT min(timestamp) as timestamp FROM {wp_abj404_logsv2}';
425
426 // Route through queryAndGetResults() so this aggregate on logsv2 inherits
427 // the centralized timeout — covered by the timestamp index, but a corrupted
428 // index or stat refresh could still take >60s on huge tables.
429 $result = $this->queryAndGetResults($query);
430
431 if (!empty($result['timed_out']) || (isset($result['last_error']) && $result['last_error'] != '')) {
432 return -1;
433 }
434
435 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
436 if (empty($rows)) {
437 return -1;
438 }
439
440 $first = $rows[0];
441 $value = is_array($first) ? reset($first) : $first;
442 if ($value === null || $value === false || $value === '') {
443 return -1;
444 }
445 return intval($value);
446 }
447
448 /** Look at $_POST and $_GET for the specified option and return the default value if it's not set.
449 * @param string $name The key to retrieve the value for.
450 * @param string $defaultValue The value to return if the value is not set.
451 * @return string The sanitized value.
452 */
453 function getPostOrGetSanitize($name, $defaultValue = null) {
454 $returnValue = isset($_GET[$name]) ? $_GET[$name] : (isset($_POST[$name]) ? $_POST[$name] : null);
455 // Back-compat: some UI flows submit actions under 'abj404action' instead of 'action'.
456 // Treat it as an alias so handlers that look for 'action' still run.
457 if ($returnValue === null && $name === 'action') {
458 $returnValue = isset($_GET['abj404action']) ? $_GET['abj404action'] : (isset($_POST['abj404action']) ? $_POST['abj404action'] : null);
459 }
460 if ($returnValue !== null) {
461 if (is_array($returnValue)) {
462 $returnValue = array_map('sanitize_text_field', $returnValue);
463 } else {
464 $returnValue = sanitize_text_field($returnValue);
465 }
466 }
467 $finalValue = $returnValue ?? $defaultValue;
468 return is_string($finalValue) ? $finalValue : (is_string($defaultValue) ? $defaultValue : '');
469 }
470
471 /** Look at $_POST and $_GET for the specified URL option and return the default value if it's not set.
472 * URL inputs should not use sanitize_text_field because it strips percent-encoded octets.
473 * @param string $name The key to retrieve the value for.
474 * @param string|null $defaultValue The value to return if the value is not set.
475 * @return string|array<string>|null The normalized URL value.
476 */
477 function getPostOrGetSanitizeUrl($name, $defaultValue = null) {
478 $returnValue = isset($_GET[$name]) ? $_GET[$name] : (isset($_POST[$name]) ? $_POST[$name] : null);
479 if ($returnValue === null) {
480 return $defaultValue;
481 }
482
483 $f = abj_service('functions');
484 $unslash = function($value) {
485 return function_exists('wp_unslash') ? wp_unslash($value) : $value;
486 };
487
488 if (is_array($returnValue)) {
489 return array_map(function($value) use ($f, $unslash) {
490 $value = $unslash($value);
491 return $f->normalizeUrlString($value);
492 }, $returnValue);
493 }
494
495 $returnValue = $unslash($returnValue);
496 return $f->normalizeUrlString($returnValue);
497 }
498
499 /**
500 * @param array<int, int|string> $ids
501 * @return array<int, array<string, mixed>>
502 */
503 function getRedirectsByIDs($ids) {
504 if (!is_array($ids) || empty($ids)) {
505 return array();
506 }
507 $validids = array_map('absint', $ids);
508 $multipleIds = implode(',', $validids);
509
510 $query = "select id, url, type, status, final_dest, code, COALESCE(engine, '') as engine, start_ts, end_ts from {wp_abj404_redirects} " .
511 "where id in (" . $multipleIds . ")";
512 $result = $this->queryAndGetResults($query);
513 $rawRows = isset($result['rows']) && is_array($result['rows']) ? $result['rows'] : array();
514
515 $rows = array();
516 foreach ($rawRows as $row) {
517 if (is_array($row)) {
518 $rows[] = $row;
519 }
520 }
521 return $rows;
522 }
523
524 /** Change the status to "trash" or "ignored," for example.
525 * @global type $wpdb
526 * @param int $id
527 * @param string $newstatus
528 * @return string
529 */
530 function updateRedirectTypeStatus($id, $newstatus) {
531 // Use prepared statement to prevent SQL injection
532 $query = "update {wp_abj404_redirects} set status = %s where id = %d";
533 $result = $this->queryAndGetResults($query, array(
534 'query_params' => array($newstatus, absint($id))
535 ));
536
537 // Invalidate caches - status change might affect regex redirects
538 $this->invalidateStatusCountsCache();
539 $this->clearRegexRedirectsCache();
540
541 return is_string($result['last_error']) ? $result['last_error'] : '';
542 }
543
544 /** Move a redirect to the "trash" folder.
545 * @global type $wpdb
546 * @param int $id
547 * @param int $trash 1 for trash, 0 for not trash.
548 * @return string
549 */
550 function moveRedirectsToTrash($id, $trash) {
551 $message = "";
552 $hadError = false;
553 if ($this->f->regexMatch('[0-9]+', '' . $id)) {
554
555 $redirectsTable = $this->doTableNameReplacements("{wp_abj404_redirects}");
556 $updateResult = $this->queryAndGetResults(
557 "UPDATE `" . $redirectsTable . "` SET disabled = %d WHERE id = %d",
558 array('query_params' => array(absint(esc_html((string)$trash)), absint($id)))
559 );
560 $updateError = isset($updateResult['last_error']) && is_string($updateResult['last_error']) ? $updateResult['last_error'] : '';
561 $hadError = $updateError !== '';
562
563 // Invalidate caches - disabled change affects regex redirects
564 $this->invalidateStatusCountsCache();
565 $this->clearRegexRedirectsCache();
566 } else {
567 $hadError = true;
568 }
569 if ($hadError) {
570 $message = __('Error: Unknown Database Error!', '404-solution');
571 }
572 return $message;
573 }
574
575 /** @return array<string, mixed> */
576 function updatePermalinkCache() {
577 $query = ABJ_404_Solution_Functions::readFileContents(__DIR__ .
578 "/sql/updatePermalinkCache.sql");
579
580 $this->setSqlBigSelects();
581
582 $results = $this->queryAndGetResults($query);
583
584 return $results;
585 }
586
587 /** @return array<string, mixed> */
588 function updatePermalinkCacheParentPages() {
589 $query = ABJ_404_Solution_Functions::readFileContents(__DIR__ .
590 "/sql/updatePermalinkCacheParentPages.sql");
591
592 // depthSoFar makes sure we don't have an infinite loop somehow.
593 $depthSoFar = 0;
594 $results = array();
595 do {
596 $results = $this->queryAndGetResults($query);
597 $depthSoFar++;
598 } while ($results['rows_affected'] != 0 && $depthSoFar < 15);
599
600 return $results;
601 }
602
603 /** @return int */
604 function getPermalinkCacheCount(): int {
605 $table = $this->doTableNameReplacements('{wp_abj404_permalink_cache}');
606 return $this->queryScalarInt("SELECT COUNT(*) FROM `{$table}`");
607 }
608
609 /**
610 * @global type $wpdb
611 * @global type $abj404logging
612 * @param int $type ABJ404_EXTERNAL, ABJ404_POST, ABJ404_CAT, or ABJ404_TAG.
613 * @param string $dest
614 * @param string $fromURL
615 * @param int $idForUpdate
616 * @param string $redirectCode
617 * @param string $statusType ABJ404_STATUS_MANUAL or ABJ404_STATUS_REGEX
618 * @param int|null $startTs Unix timestamp when redirect becomes active (null = always)
619 * @param int|null $endTs Unix timestamp when redirect expires (null = never)
620 * @return string
621 */
622 function updateRedirect($type, $dest, $fromURL, $idForUpdate, $redirectCode, $statusType, $startTs = null, $endTs = null) {
623 if (($type < 0) || ($idForUpdate <= 0)) {
624 $this->logger->errorMessage("Bad data passed for update redirect request. Type: " .
625 esc_html((string)$type) . ", Dest: " . esc_html($dest) . ", ID(s): " . esc_html((string)$idForUpdate));
626 echo __('Error: Bad data passed for update redirect request.', '404-solution');
627 return '';
628 }
629
630 $redirectsTable = $this->doTableNameReplacements("{wp_abj404_redirects}");
631
632 $updateData = array(
633 'url' => $fromURL,
634 'status' => $statusType,
635 'type' => absint($type),
636 'final_dest' => $dest,
637 'code' => esc_attr($redirectCode),
638 );
639 $updateFormats = array('%s', '%d', '%d', '%s', '%d');
640
641 // Include non-null timestamps in the main update.
642 if ($startTs !== null) {
643 $updateData['start_ts'] = (int)$startTs;
644 $updateFormats[] = '%d';
645 }
646 if ($endTs !== null) {
647 $updateData['end_ts'] = (int)$endTs;
648 $updateFormats[] = '%d';
649 }
650
651 $setFragments = array();
652 $idx = 0;
653 foreach ($updateData as $col => $unusedValue) {
654 $format = isset($updateFormats[$idx]) ? $updateFormats[$idx] : '%s';
655 $setFragments[] = '`' . $col . '` = ' . $format;
656 $idx++;
657 }
658 $updateSql = "UPDATE `" . $redirectsTable . "` SET " . implode(', ', $setFragments) .
659 " WHERE `id` = %d";
660 $updateParams = array_values($updateData);
661 $updateParams[] = absint($idForUpdate);
662 $this->queryAndGetResults($updateSql, array('query_params' => $updateParams));
663
664 // Explicitly set timestamp columns to NULL when no schedule is set.
665 // queryAndGetResults' %d placeholder for null converts to 0 via (int)null,
666 // which breaks the SQL filter "end_ts IS NULL OR end_ts > UNIX_TIMESTAMP()"
667 // — end_ts=0 means "expired in 1970" and silently stops the redirect from matching.
668 $nullParts = [];
669 if ($startTs === null) {
670 $nullParts[] = '`start_ts` = NULL';
671 }
672 if ($endTs === null) {
673 $nullParts[] = '`end_ts` = NULL';
674 }
675 if (!empty($nullParts)) {
676 $nullSql = "UPDATE `" . $redirectsTable . "` SET " . implode(', ', $nullParts) .
677 " WHERE id = %d";
678 $this->queryAndGetResults($nullSql, array('query_params' => array(absint($idForUpdate))));
679 }
680
681 // Invalidate caches - status/url change affects regex redirects
682 $this->invalidateStatusCountsCache();
683 $this->clearRegexRedirectsCache();
684
685 // move this redirect out of the trash.
686 $this->moveRedirectsToTrash(absint($idForUpdate), 0);
687
688 return '';
689 }
690
691 /**
692 * Get the top N captured 404s by hit count for the digest email.
693 *
694 * Implementation: LEFT JOIN against the pre-aggregated logs_hits rollup,
695 * which stores logshits per *canonical* requested_url and is rebuilt by
696 * cron via createRedirectsForViewHitsTable(). The canonical form is
697 * CONCAT('/', TRIM(BOTH '/' FROM url)) — the same normalization the
698 * legacy slash-tolerant LEFT JOIN on logsv2 used, hoisted to write time.
699 *
700 * The join canonicalizes r.url on the (small) redirects side and probes
701 * the indexed h.requested_url on the (canonical) rollup side, so URL
702 * variants like '/foo', 'foo', and '/foo/' all match the same rollup row
703 * — recovering the legacy query's variant-folding behavior without
704 * defeating any index.
705 *
706 * Why LEFT JOIN with COALESCE rather than INNER JOIN: the legacy query
707 * was a slash-normalized LEFT JOIN on logsv2 with COUNT(l.id) — captured
708 * rows with no matching logs (purged logs, fresh capture before any logs)
709 * appeared in the digest with logshits=0. INNER JOIN against logs_hits
710 * silently dropped those rows; LEFT JOIN + COALESCE(h.logshits, 0)
711 * restores parity for the no-hits / purged-hits cases.
712 *
713 * Routes through queryAndGetResults() so the cron digest query inherits
714 * the centralized 60-second SELECT timeout, retry on transient errors,
715 * and corrupted-table REPAIR recovery.
716 *
717 * Fallback: if logs_hits is missing, log a warning, schedule a rebuild,
718 * and return []. Callers that need to distinguish "rollup unavailable"
719 * from "no captured 404s" should pre-check via {@see logsHitsTableExists()}
720 * (see EmailDigest::send for the canonical pattern). We deliberately do
721 * NOT fall back to scanning logsv2 — the whole point of this rewrite is
722 * to never run that query again.
723 *
724 * @param int $limit Maximum number of rows to return.
725 * @return array<int, array<string, mixed>> Each row has keys: url, logshits, created.
726 */
727 function getTopCapturedForDigest(int $limit): array {
728 $limit = max(1, $limit);
729
730 if (!$this->logsHitsTableExists()) {
731 // Log so the operator can correlate "no top URLs in digest" with
732 // a rollup rebuild in flight, instead of silently shipping an
733 // empty table that looks like "no captured 404s in this period."
734 $this->logger->warn('getTopCapturedForDigest: logs_hits rollup unavailable; '
735 . 'digest top-captured table will be empty until rebuild completes. '
736 . 'EmailDigest pre-checks via logsHitsTableExists() to render an "unavailable" message instead.');
737 $this->scheduleHitsTableRebuild();
738 return array();
739 }
740
741 $query = $this->buildTopCapturedForDigestQuery($limit);
742 $result = $this->queryAndGetResults($query, array('timeout' => 60));
743
744 if (!empty($result['timed_out']) || (isset($result['last_error']) && $result['last_error'] != '')) {
745 // Transient query failure on a present rollup. Log so the operator
746 // can correlate "empty digest table" with a real DB hiccup rather
747 // than assuming "no captured 404s in this period."
748 $errRaw = $result['last_error'] ?? '';
749 $errMsg = is_string($errRaw) ? $errRaw : '';
750 $timedOut = !empty($result['timed_out']);
751 $this->logger->warn('getTopCapturedForDigest: query failed against present rollup; '
752 . 'digest top-captured table will be empty. timed_out=' . ($timedOut ? '1' : '0')
753 . ', error=' . ($errMsg !== '' ? $errMsg : '(none)'));
754 return array();
755 }
756
757 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
758 return $rows;
759 }
760
761 /**
762 * Build the SQL for getTopCapturedForDigest(). Exposed so structural
763 * regression tests can assert no logsv2 access and verify the EXPLAIN plan.
764 *
765 * Uses LEFT JOIN + COALESCE so captured rows with no matching logs_hits
766 * row still appear with logshits=0, restoring parity with the legacy
767 * slash-normalized LEFT JOIN. ORDER BY ... NULLS-via-COALESCE puts hit-bearing
768 * rows first; rows with 0 hits only surface when fewer than $limit captured
769 * URLs have any hits at all.
770 *
771 * @param int $limit Already-normalized positive integer LIMIT.
772 * @return string Fully-replaced SQL (table-name placeholders resolved).
773 */
774 function buildTopCapturedForDigestQuery(int $limit): string {
775 $limit = max(1, $limit);
776 // logs_hits.requested_url is canonical (leading '/', no trailing '/').
777 // Match against the persisted r.canonical_url column (added 4.1.10)
778 // so the JOIN is an indexed equality lookup instead of CONCAT/TRIM
779 // per row. The COALESCE fallback covers rows from upgraded sites
780 // where the chunked backfill hasn't reached yet.
781 $query = "SELECT r.url, COALESCE(h.logshits, 0) AS logshits, r.timestamp AS created
782 FROM {wp_abj404_redirects} r
783 LEFT JOIN {wp_abj404_logs_hits} h
784 ON BINARY h.requested_url = BINARY
785 COALESCE(r.canonical_url, CONCAT('/', TRIM(BOTH '/' FROM r.url)))
786 WHERE r.status = " . ABJ404_STATUS_CAPTURED . " AND r.disabled = 0
787 ORDER BY logshits DESC, r.url ASC
788 LIMIT " . $limit;
789 return $this->doTableNameReplacements($query);
790 }
791
792 /**
793 * Get summary stats for the digest email.
794 *
795 * @return array{total_captured: int, total_manual: int, total_auto: int}
796 */
797 function getDigestSummaryStats(): array {
798 $zero = array(
799 'total_captured' => 0,
800 'total_manual' => 0,
801 'total_auto' => 0,
802 );
803
804 $redirectsTable = $this->doTableNameReplacements('{wp_abj404_redirects}');
805
806 try {
807 $total_captured = $this->getStatsCount(
808 "SELECT COUNT(id) FROM {$redirectsTable} WHERE status = %d AND disabled = 0",
809 array(ABJ404_STATUS_CAPTURED)
810 );
811 $total_manual = $this->getStatsCount(
812 "SELECT COUNT(id) FROM {$redirectsTable} WHERE status = %d AND disabled = 0",
813 array(ABJ404_STATUS_MANUAL)
814 );
815 $total_auto = $this->getStatsCount(
816 "SELECT COUNT(id) FROM {$redirectsTable} WHERE status = %d AND disabled = 0",
817 array(ABJ404_STATUS_AUTO)
818 );
819 } catch (Throwable $e) {
820 // Infrastructure-class failure: log a warn so the support-bundle
821 // reader can see the dashboard's zero counts came from a query
822 // failure (missing column, partial migration, etc.) rather than
823 // an empty redirects table.
824 $this->logger->warn(
825 'getRedirectsBreakdownStats failed; returning zero counts: '
826 . $e->getMessage()
827 );
828 return $zero;
829 }
830
831 return array(
832 'total_captured' => intval($total_captured),
833 'total_manual' => intval($total_manual),
834 'total_auto' => intval($total_auto),
835 );
836 }
837
838 /** @return int */
839 function getCapturedCountForNotification(): int {
840 $abj404dao = abj_service('data_access');
841 return $abj404dao->getRecordCount(array(ABJ404_STATUS_CAPTURED));
842 }
843
844 /**
845 * Get posts whose permalink cache rows have NULL content_keywords.
846 *
847 * @param int $limit Maximum rows to return.
848 * @return array<int, object> Each object has ->id and ->post_content.
849 */
850 function getPostsNeedingContentKeywords(int $limit = 500): array {
851 $limitResults = " */\n limit " . absint($limit);
852
853 $query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/getPostsNeedingContentKeywords.sql");
854 $query = $this->f->str_replace('{limit-results}', $limitResults, $query);
855
856 $result = $this->queryAndGetResults($query, array(
857 'result_type' => OBJECT,
858 'log_errors' => false,
859 ));
860
861 $lastError = isset($result['last_error']) && is_string($result['last_error']) ? $result['last_error'] : '';
862 if ($lastError !== '') {
863 // "Unknown column" means content_keywords hasn't been added yet (DB migration pending,
864 // e.g. sync lock was stuck for ~24h). Degrade to warning; the caller returns empty.
865 if (stripos($lastError, 'unknown column') !== false) {
866 $this->logger->warn("content_keywords column not yet available (DB migration pending): " . $lastError);
867 } else if (!$this->classifyAndHandleInfrastructureError($lastError)) {
868 $this->logger->errorMessage("Error fetching posts for content keywords: " . $lastError);
869 }
870 return array();
871 }
872
873 $rows = isset($result['rows']) && is_array($result['rows']) ? $result['rows'] : array();
874 return $rows;
875 }
876
877 /**
878 * Bulk-update content_keywords for many permalink cache rows in a single
879 * UPDATE statement, eliminating the N+1 round trips that previously hit
880 * the DB on every cron tick and on every anonymous-AJAX suggestion-compute
881 * request that touched populateContentKeywords (audit finding G3).
882 *
883 * Builds:
884 * UPDATE {table} SET content_keywords = CASE id
885 * WHEN %d THEN %s ... END
886 * WHERE id IN (%d, %d, ...)
887 *
888 * Routes through queryAndGetResults() so the bulk write inherits the
889 * centralized timeout, retry, and corrupted-table recovery.
890 *
891 * @param array<int, string> $idToKeywords Map of permalink cache id => keywords string.
892 * @return void
893 */
894 function bulkUpdateContentKeywords(array $idToKeywords): void {
895 if (empty($idToKeywords)) {
896 return;
897 }
898
899 $table = $this->doTableNameReplacements('{wp_abj404_permalink_cache}');
900
901 $whenClauses = array();
902 $params = array();
903 $ids = array();
904 foreach ($idToKeywords as $id => $keywords) {
905 $intId = (int) $id;
906 $whenClauses[] = 'WHEN %d THEN %s';
907 $params[] = $intId;
908 $params[] = $keywords;
909 $ids[] = $intId;
910 }
911
912 $idPlaceholders = implode(',', array_fill(0, count($ids), '%d'));
913
914 $sql = "UPDATE `{$table}` SET content_keywords = CASE id\n "
915 . implode("\n ", $whenClauses)
916 . "\n END\n WHERE id IN ({$idPlaceholders})";
917
918 $allParams = array_merge($params, $ids);
919
920 $result = $this->queryAndGetResults($sql, array('query_params' => $allParams));
921
922 $lastErrorRaw = $result['last_error'] ?? '';
923 $lastError = is_string($lastErrorRaw) ? $lastErrorRaw : '';
924 if ($lastError !== '') {
925 // "Unknown column" means content_keywords hasn't been added yet (DB migration pending).
926 // Other infrastructure errors are already handled by queryAndGetResults; only log
927 // unclassified errors here.
928 if (stripos($lastError, 'unknown column') !== false) {
929 $this->logger->warn("content_keywords column not yet available (DB migration pending): " . $lastError);
930 }
931 }
932 }
933
934 }
935