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_ViewQueries.php

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

1,286 lines 61.8 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_ViewQueriesTrait {
8
9 /**
10 * Get counts for each redirect status type for display in tabs.
11 * Uses transient caching for performance.
12 * @param bool $bypassCache If true, skip cache and query database directly
13 * @return array<string, int> An array with keys: all, manual, auto, regex, trash
14 */
15 function getRedirectStatusCounts($bypassCache = false) {
16 // Try to get cached value first
17 if (!$bypassCache) {
18 $cached = get_transient(self::CACHE_KEY_REDIRECT_STATUS);
19 if ($cached !== false && is_array($cached)) {
20 /** @var array<string, int> $cached */
21 return $cached;
22 }
23 }
24
25 // IMPORTANT: The redirects table also stores captured/ignored/later rows.
26 // The Redirects page "All/Manual/Auto/Trash" tabs should only count actual redirects
27 // (manual/auto/regex), not captured URLs.
28 $query = "SELECT
29 SUM(CASE WHEN disabled = 0 THEN 1 ELSE 0 END) as active_count,
30 SUM(CASE WHEN disabled = 0 AND status = " . ABJ404_STATUS_MANUAL . " THEN 1 ELSE 0 END) as manual_count,
31 SUM(CASE WHEN disabled = 0 AND status = " . ABJ404_STATUS_AUTO . " THEN 1 ELSE 0 END) as auto_count,
32 SUM(CASE WHEN disabled = 0 AND status = " . ABJ404_STATUS_REGEX . " THEN 1 ELSE 0 END) as regex_count,
33 SUM(CASE WHEN disabled = 1 THEN 1 ELSE 0 END) as trash_count
34 FROM {wp_abj404_redirects}
35 WHERE status IN (" . ABJ404_STATUS_MANUAL . ", " . ABJ404_STATUS_AUTO . ", " . ABJ404_STATUS_REGEX . ")";
36 $query = $this->doTableNameReplacements($query);
37
38 $result = $this->queryAndGetResults($query);
39 $hadError = !empty($result['last_error']) || !empty($result['timed_out']);
40 $rows = is_array($result['rows']) ? $result['rows'] : array();
41
42 $counts = array('all' => 0, 'manual' => 0, 'auto' => 0, 'regex' => 0, 'trash' => 0);
43 if (!empty($rows)) {
44 $row = is_array($rows[0] ?? null) ? $rows[0] : array();
45 $counts = array(
46 'all' => intval(is_scalar($row['active_count'] ?? 0) ? $row['active_count'] : 0),
47 'manual' => intval(is_scalar($row['manual_count'] ?? 0) ? $row['manual_count'] : 0),
48 'auto' => intval(is_scalar($row['auto_count'] ?? 0) ? $row['auto_count'] : 0),
49 'regex' => intval(is_scalar($row['regex_count'] ?? 0) ? $row['regex_count'] : 0),
50 'trash' => intval(is_scalar($row['trash_count'] ?? 0) ? $row['trash_count'] : 0)
51 );
52 }
53
54 // Skip the cache write when the SUM(...) query returned an error or
55 // timed out: $rows is empty in that case so $counts is the all-zero
56 // default, and pinning that for STATUS_CACHE_TTL (24h) would make the
57 // Redirects admin page show "0 of every status" until the transient
58 // expires. Same policy as 6454a7dd / b857be36.
59 if (!$hadError) {
60 set_transient(self::CACHE_KEY_REDIRECT_STATUS, $counts, self::STATUS_CACHE_TTL);
61 }
62
63 return $counts;
64 }
65
66 /**
67 * Get counts for each captured URL status type.
68 * Uses transient caching for performance.
69 * @param bool $bypassCache If true, skip cache and query database directly
70 * @return array<string, int> Array with keys: all, captured, ignored, later, trash
71 */
72 function getCapturedStatusCounts($bypassCache = false) {
73 // Try to get cached value first
74 if (!$bypassCache) {
75 $cached = get_transient(self::CACHE_KEY_CAPTURED_STATUS);
76 if ($cached !== false && is_array($cached)) {
77 /** @var array<string, int> $cached */
78 return $cached;
79 }
80 }
81
82 $query = "SELECT
83 COUNT(*) as total,
84 SUM(CASE WHEN disabled = 0 THEN 1 ELSE 0 END) as active,
85 SUM(CASE WHEN disabled = 0 AND status = " . ABJ404_STATUS_CAPTURED . " THEN 1 ELSE 0 END) as captured,
86 SUM(CASE WHEN disabled = 0 AND status = " . ABJ404_STATUS_IGNORED . " THEN 1 ELSE 0 END) as ignored,
87 SUM(CASE WHEN disabled = 0 AND status = " . ABJ404_STATUS_LATER . " THEN 1 ELSE 0 END) as later,
88 SUM(CASE WHEN disabled = 1 THEN 1 ELSE 0 END) as trash
89 FROM {wp_abj404_redirects}
90 WHERE status IN (" . ABJ404_STATUS_CAPTURED . ", " . ABJ404_STATUS_IGNORED . ", " . ABJ404_STATUS_LATER . ")";
91 $query = $this->doTableNameReplacements($query);
92
93 $result = $this->queryAndGetResults($query);
94 $hadError = !empty($result['last_error']) || !empty($result['timed_out']);
95 $rows = is_array($result['rows']) ? $result['rows'] : array();
96
97 $counts = array('all' => 0, 'captured' => 0, 'ignored' => 0, 'later' => 0, 'trash' => 0);
98 if (!empty($rows)) {
99 $row = is_array($rows[0] ?? null) ? $rows[0] : array();
100 $counts = array(
101 'all' => intval(is_scalar($row['active'] ?? 0) ? $row['active'] : 0),
102 'captured' => intval(is_scalar($row['captured'] ?? 0) ? $row['captured'] : 0),
103 'ignored' => intval(is_scalar($row['ignored'] ?? 0) ? $row['ignored'] : 0),
104 'later' => intval(is_scalar($row['later'] ?? 0) ? $row['later'] : 0),
105 'trash' => intval(is_scalar($row['trash'] ?? 0) ? $row['trash'] : 0)
106 );
107 }
108
109 // Skip the cache write when the SUM(...) query returned an error or
110 // timed out: $rows is empty in that case so $counts is the all-zero
111 // default, and pinning that for STATUS_CACHE_TTL (24h) would make the
112 // Captured-URLs admin page show "0 captured / 0 ignored / 0 later"
113 // until the transient expires. Same policy as 6454a7dd / b857be36.
114 if (!$hadError) {
115 set_transient(self::CACHE_KEY_CAPTURED_STATUS, $counts, self::STATUS_CACHE_TTL);
116 }
117
118 return $counts;
119 }
120
121 /**
122 * Count captured URLs that have been hit 3 or more times (signal of real user impact).
123 * Uses transient caching for performance.
124 *
125 * Implementation: INNER JOIN against the pre-aggregated logs_hits rollup,
126 * which already stores logshits per requested_url and is rebuilt by cron via
127 * createRedirectsForViewHitsTable(). Same JOIN shape as
128 * getRedirectsForViewQuery() — BINARY column equality.
129 *
130 * The previous implementation aggregated logsv2 with GROUP BY + HAVING per
131 * call; on busy sites with millions of log rows that took 30–60s and hit
132 * the AJAX timeout. The pre-aggregated table makes the count O(distinct
133 * URLs) instead of O(total log rows).
134 *
135 * Fallback: if logs_hits is missing or empty, return 0 and schedule a
136 * shutdown-time rebuild so a subsequent request can serve real data. We
137 * deliberately do NOT fall back to the old logsv2 GROUP BY query: the whole
138 * point of this function is to never run that scan again.
139 *
140 * Cache policy: only the result of a successful query against a populated
141 * rollup is cached for STATUS_CACHE_TTL (24h). Errors, timeouts, missing
142 * rollups, and empty-during-rebuild outcomes all return 0 *without
143 * caching* so the next request retries — otherwise a single transient
144 * failure would hide repeat-visitor URLs for a full day.
145 *
146 * @return int Number of captured URLs with 3+ log hits
147 */
148 function getHighImpactCapturedCount(): int {
149 $cached = get_transient(self::CACHE_KEY_HIGH_IMPACT_CAPTURED);
150 if ($cached !== false) {
151 return intval(is_scalar($cached) ? $cached : 0);
152 }
153
154 // If the rollup is not available, defer rather than scan logsv2.
155 // Do not cache: the rebuild is in flight and the next request should retry.
156 if (!$this->logsHitsTableExists()) {
157 $this->scheduleHitsTableRebuild();
158 return 0;
159 }
160
161 $query = $this->buildHighImpactCapturedCountQuery();
162
163 $result = $this->queryWithTimeout($query, 60);
164 $timedOut = !empty($result['timed_out']);
165 $hadError = !empty($result['last_error']) || $timedOut;
166 $rows = is_array($result['rows']) ? $result['rows'] : array();
167 $count = (!empty($rows) && isset($rows[0]['cnt'])) ? intval($rows[0]['cnt']) : 0;
168
169 // Timeout self-heal (Bruno regression). Without this branch every
170 // admin pageview re-pays the 60s timeout cost. We schedule a hits
171 // table rebuild so the next post-cache request can return real
172 // data, and cache 0 for the short STATUS_CACHE_TIMEOUT_SELFHEAL_TTL
173 // window (5 min) so subsequent pageviews are instant. The short
174 // TTL is far less than STATUS_CACHE_TTL (24h), so a transient
175 // timeout cannot hide repeat-visitor URLs for a full day.
176 if ($timedOut) {
177 $this->scheduleHitsTableRebuild();
178 // allow-cache-empty: timeout self-heal sentinel, 5-minute window. Real value returns once the rebuild completes and the short cache expires.
179 set_transient(self::CACHE_KEY_HIGH_IMPACT_CAPTURED, 0, self::STATUS_CACHE_TIMEOUT_SELFHEAL_TTL);
180 return 0;
181 }
182
183 // Non-timeout errors (network blip, replication lag, etc.) return
184 // 0 without caching so the next request retries promptly.
185 if ($hadError) {
186 return 0;
187 }
188
189 // If the rollup exists but has no rows yet (first run, or rebuild in
190 // progress), schedule a rebuild AND skip caching so the next request
191 // can serve real data once the rebuild completes (typically seconds).
192 if ($count === 0) {
193 if ($this->isHitsTableEmpty()) {
194 $this->scheduleHitsTableRebuild();
195 return 0;
196 }
197 }
198
199 set_transient(self::CACHE_KEY_HIGH_IMPACT_CAPTURED, $count, self::STATUS_CACHE_TTL);
200
201 return $count;
202 }
203
204 /**
205 * Build the SQL for getHighImpactCapturedCount(). Exposed so structural
206 * regression tests can assert no logsv2 access and verify the EXPLAIN plan.
207 *
208 * @return string Fully-replaced SQL (table-name placeholders resolved).
209 */
210 function buildHighImpactCapturedCountQuery(): string {
211 // logs_hits.requested_url is stored in canonical form (leading '/',
212 // no trailing '/') by createRedirectsForViewHitsTable(). Match against
213 // the persisted r.canonical_url column (added 4.1.10) so the JOIN is
214 // an indexed equality lookup instead of CONCAT/TRIM per row. The
215 // COALESCE fallback covers rows from upgraded sites where the chunked
216 // backfill hasn't reached yet.
217 $query = "SELECT COUNT(*) AS cnt
218 FROM {wp_abj404_redirects} r
219 INNER JOIN {wp_abj404_logs_hits} h
220 ON BINARY h.requested_url = BINARY
221 COALESCE(r.canonical_url, CONCAT('/', TRIM(BOTH '/' FROM r.url)))
222 WHERE r.status = " . ABJ404_STATUS_CAPTURED . " AND r.disabled = 0
223 AND h.logshits >= 3";
224 return $this->doTableNameReplacements($query);
225 }
226
227 /**
228 * Cheap probe: does the logs_hits rollup contain at least one row?
229 * SELECT 1 ... LIMIT 1 against a small table.
230 *
231 * @return bool true when the rollup has zero rows (rebuild in progress,
232 * cold start, or post-truncate). false when at least one
233 * row exists OR when the probe itself errors (treat
234 * ambiguous probes as "not empty" so we don't spam reschedules).
235 */
236 private function isHitsTableEmpty(): bool {
237 $check = "SELECT 1 FROM {wp_abj404_logs_hits} LIMIT 1";
238 $check = $this->doTableNameReplacements($check);
239 $result = $this->queryAndGetResults($check);
240 if (!empty($result['last_error']) || !empty($result['timed_out'])) {
241 return false;
242 }
243 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
244 return empty($rows);
245 }
246
247 /**
248 * Execute a query with a timeout to prevent it from blocking the page indefinitely.
249 * On timeout, logs an error with the query shape and returns an empty result.
250 *
251 * Delegates to queryAndGetResults() with the 'timeout' option, which handles
252 * MySQL 5.7+ (MAX_EXECUTION_TIME hint) and MariaDB 10.1+ (max_statement_time).
253 *
254 * @param string $query The SQL query to execute
255 * @param int $timeoutSeconds Maximum execution time in seconds
256 * @return array<string, mixed> Same format as queryAndGetResults()
257 */
258 private function queryWithTimeout(string $query, int $timeoutSeconds = 60): array {
259 return $this->queryAndGetResults($query, array(
260 'timeout' => $timeoutSeconds,
261 ));
262 }
263
264 /**
265 * Per-request "bulk mutation in progress" flag. When set, per-row
266 * invalidateStatusCountsCache() calls short-circuit to a no-op so a
267 * 10K-row CSV import does not fire 60K invalidation queries (each row
268 * cascades to bumpMutationWatermark + delete_option +
269 * delete_transient × N + DELETE FROM view_cache; for a 10K import
270 * this took ~63s before this guard). The bulk caller is responsible
271 * for issuing ONE final invalidation (typically via
272 * markViewDoneInvalidatedByAdminMutation()) after the bulk write
273 * completes, so admin reads see the imported rows immediately.
274 *
275 * Implemented as a static on the DAO instance ($this only because
276 * trait scoping requires it) so the flag survives across multiple
277 * setupRedirect() calls within one request without needing every
278 * caller to thread a parameter through.
279 *
280 * @var bool
281 */
282 public static $bulkMutationInProgress = false;
283
284 /**
285 * Open/close the bulk-mutation window. Bulk importers (CSV import,
286 * sitemap regeneration, future bulk admin actions) wrap their per-row
287 * loop with this. The callable is invoked while the flag is set;
288 * exceptions are rethrown but the flag is always restored.
289 *
290 * On window close, issues exactly one bumpMutationWatermark() to
291 * represent the entire batch as a single mutation tick. Without this
292 * the per-row chain bumps are all suppressed and a later
293 * markViewDoneInvalidatedByAdminMutation() call would observe the
294 * pre-batch counter, leaving the admin-visibility gate un-raised
295 * and the next read returning the stale snapshot (the failure mode
296 * WpCliMutationEndToEndCharacterizationTest::testCliBulkAddFromCsv
297 * pins). The bump fires even when the callable returned early or
298 * threw, because the side effect of "we entered a mutation window"
299 * is what the watermark documents -- whether downstream rows landed
300 * is the caller's concern.
301 *
302 * @template T
303 * @param callable():T $work
304 * @return T
305 */
306 public function runWithDeferredInvalidation(callable $work) {
307 $prior = self::$bulkMutationInProgress;
308 self::$bulkMutationInProgress = true;
309 try {
310 return $work();
311 } finally {
312 self::$bulkMutationInProgress = $prior;
313 $this->bumpMutationWatermark();
314 }
315 }
316
317 /**
318 * Invalidate cached status counts.
319 * Call this when redirects are created, updated, or deleted.
320 *
321 * No-op when {@see self::$bulkMutationInProgress} is set; the bulk
322 * caller must issue one final invalidation after the loop completes.
323 */
324 /** @return void */
325 function invalidateStatusCountsCache(): void {
326 if (self::$bulkMutationInProgress) {
327 return;
328 }
329 delete_transient(self::CACHE_KEY_REDIRECT_STATUS);
330 delete_transient(self::CACHE_KEY_CAPTURED_STATUS);
331 delete_transient(self::CACHE_KEY_HIGH_IMPACT_CAPTURED);
332 $this->invalidateViewSnapshotCache();
333 }
334
335 /**
336 * Clear the view snapshot cache so the admin redirect/captured tables
337 * reflect newly created, updated, trashed, or deleted redirects immediately.
338 *
339 * This clears both the custom wp_abj404_view_cache table and the
340 * WordPress transients used as a secondary cache layer.
341 *
342 * @return void
343 */
344 function invalidateViewSnapshotCache(): void {
345 // Source-mutation signal (Phase 4 of the staged view-build watermark
346 // refactor; see docs/refactor-staged-view-build-watermark.md). Every
347 // DAO mutator (deleteRedirect, setupRedirect, updateRedirect,
348 // updateRedirectTypeStatus, moveRedirectsToTrash, removeDuplicatesCron,
349 // purgeRedirectsByStatus) routes through invalidateStatusCountsCache()
350 // -> here. Bump the per-blog mutation watermark so the staged-build
351 // runner observes it at the next stage boundary and either aborts
352 // the in-flight build cleanly (so the next build covers the new row)
353 // or, if the build is already running for an earlier watermark, the
354 // active_build_started_watermark gate keeps the runner from
355 // publishing a snapshot that misses the mutation.
356 $this->bumpMutationWatermark();
357
358 // Clear view_done freshness so the read path's TTL check trips and
359 // a rebuild gets scheduled on the next request. The runner remains
360 // the sole owner of progress markers, the S1 prefix capture, and
361 // the transient buffer tables: external code (this seam included)
362 // must not touch them. scheduleViewDoneRebuild() is idempotent
363 // (wp_next_scheduled short-circuit) so concurrent mutators do not
364 // pile up cron events.
365 if (function_exists('delete_option')) {
366 delete_option($this->viewDoneFreshnessOptionName());
367 }
368 $this->invalidateViewDoneServeableCache();
369 $this->scheduleViewDoneRebuild();
370
371 // Clear all rows from the view cache table. log_errors=false marks
372 // this as a best-effort operation (the cache expires naturally via
373 // TTL if the DELETE fails). skip_repair=true blocks the missing-
374 // table auto-create + retry path: a missing view_cache means
375 // "nothing to invalidate"; spinning up the full createDatabaseTables
376 // flow to make the DELETE succeed is wasteful in production and in
377 // tests it cascades correctCollations -> bumpMutationWatermark, which
378 // breaks the "exactly one bump per source-data mutation" contract
379 // pinned by MixedSourceConcurrentMutationIntegrationTest.
380 $query = "DELETE FROM {wp_abj404_view_cache} WHERE 1=1";
381 $this->queryAndGetResults($query, array('log_errors' => false, 'skip_repair' => true));
382
383 // Clear WordPress transients for view row and count snapshots.
384 // The transient keys are hashed (e.g. abj404_view_rows_<md5>), so
385 // we delete by prefix from wp_options directly.
386 global $wpdb;
387 if (isset($wpdb->options) && method_exists($wpdb, 'query')) {
388 // @utf8-audit: opt-out — $wpdb->options is the WordPress core
389 // options table name (system value); never user input.
390 /** @var string $optionsTable */
391 $optionsTable = esc_sql($wpdb->options);
392 // DAO-bypass-approved: View-cache clear targets wp_options — outside the plugin's owned tables; runs during cache invalidation hot path; failure is best-effort
393 $wpdb->query(
394 "DELETE FROM `{$optionsTable}` WHERE option_name LIKE '_transient_abj404_view_%'"
395 . " OR option_name LIKE '_transient_timeout_abj404_view_%'"
396 );
397 }
398 }
399
400 /**
401 * Clear the per-request regex redirects cache.
402 * Primarily used for testing. In production, the cache resets automatically
403 * on each new request since it uses static variables.
404 */
405 /** @return void */
406 function clearRegexRedirectsCache(): void {
407 self::$regexRedirectsCache = null;
408 self::$regexCacheDisabled = false;
409 }
410
411 /**
412 * @global type $wpdb
413 * @param int $logID only return results that correspond to the URL of this $logID. Use 0 to get all records.
414 * @return int the number of records found.
415 */
416 function getLogsCount($logID) {
417 // Sanitize logID to prevent SQL injection
418 $logID = absint($logID);
419
420 // Audit F4: cache the unfiltered total. InnoDB has no maintained row
421 // counter so `SELECT COUNT(id) FROM logsv2` is a full index scan that
422 // dominates the Logs admin tab on multi-million-row logsv2. The cache
423 // is keyed on (blog_id, max_log_id) so new inserts move the key
424 // (fresh value picked up immediately); deletions are bounded by the
425 // LOGS_COUNT_CACHE_TTL_SECONDS staleness window. The filtered path
426 // (logID != 0) is per-URL and has unbounded key cardinality, so it
427 // stays uncached.
428 $cacheKey = null;
429 if ($logID === 0 && function_exists('get_transient')) {
430 $blogId = 1;
431 if (function_exists('get_current_blog_id')) {
432 $rawBlogId = function_exists('absint')
433 ? absint(get_current_blog_id())
434 : abs(intval(get_current_blog_id()));
435 if ($rawBlogId > 0) {
436 $blogId = $rawBlogId;
437 }
438 }
439 $maxLogId = 0;
440 try {
441 $maxLogId = intval($this->getMaxLogId());
442 if ($maxLogId < 0) {
443 $maxLogId = 0;
444 }
445 } catch (Throwable $e) {
446 // getMaxLogId() failed (table missing, query timeout). Fall back
447 // to maxLogId=0 so the cache key still varies; the count will
448 // recompute on every request until the underlying query recovers.
449 $this->logger->debugMessage(__FUNCTION__ . ' getMaxLogId() failed: '
450 . $e->getMessage() . '. Falling back to maxLogId=0.');
451 $maxLogId = 0;
452 }
453 $cacheKey = 'abj404_logs_count_v1_' . $blogId . '_' . $maxLogId;
454 $cached = get_transient($cacheKey);
455 if (is_numeric($cached)) {
456 return (int)$cached;
457 }
458 }
459
460 $query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/getLogsCount.sql");
461
462 if ($logID != 0) {
463 $query = $this->f->str_replace('/* {SPECIFIC_ID}', '', $query);
464 $query = $this->f->str_replace('{logID}', (string)$logID, $query);
465 }
466
467 // Route through queryAndGetResults() so the count query (potentially
468 // a JOIN against logsv2 when SPECIFIC_ID is set) inherits the
469 // centralized 60s timeout. Bypassing via $wpdb->get_row() leaves the
470 // admin page with no upper bound on slow logsv2 lookups.
471 $result = $this->queryAndGetResults($query);
472 $hadError = !empty($result['timed_out'])
473 || (isset($result['last_error']) && $result['last_error'] != '');
474
475 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
476 $count = 0;
477 if (!empty($rows)) {
478 $first = $rows[0];
479 $value = is_array($first) ? reset($first) : $first;
480 $count = intval($value);
481 }
482
483 // Only cache on success. A DB error / timeout would otherwise pin a
484 // zero for LOGS_COUNT_CACHE_TTL_SECONDS, so the Logs admin tab would
485 // show "0 entries" until the cache expires. Mirrors the
486 // getDailyActivityTrend() write-on-success policy.
487 if (!$hadError && $cacheKey !== null && function_exists('set_transient')) {
488 set_transient($cacheKey, $count, self::LOGS_COUNT_CACHE_TTL_SECONDS);
489 }
490
491 return $count;
492 }
493
494 /**
495 * @return array<int, array<string, mixed>>
496 */
497 function getRedirectsAll() {
498 $query = "select id, url from {wp_abj404_redirects} order by url";
499
500 // Route through queryAndGetResults() so this list query inherits the
501 // centralized 60s timeout. The redirects table can be very large on
502 // busy sites and an unbounded ORDER BY without timeout protection
503 // could exceed reverse-proxy limits.
504 $result = $this->queryAndGetResults($query);
505 if (!empty($result['timed_out']) || (isset($result['last_error']) && $result['last_error'] != '')) {
506 return array();
507 }
508 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
509 return $rows;
510 }
511
512 /** @param string $tempFile @return void */
513 function doRedirectsExport(string $tempFile): void {
514 global $wpdb;
515
516 if (file_exists($tempFile)) {
517 ABJ_404_Solution_Functions::safeUnlink($tempFile);
518 }
519
520 $query = ABJ_404_Solution_Functions::readFileContents(__DIR__ .
521 "/sql/getRedirectsExport.sql");
522 $query = $this->doTableNameReplacements($query);
523
524 // we use mysqli here instead of the normal wordpress get_results in order
525 // to get one row at a time, so we don't run out of memory by trying to store
526 // everything in memory all at once.
527 $result = mysqli_query($wpdb->dbh, $query);
528 if ($result instanceof \mysqli_result) {
529 $fh = fopen($tempFile, 'w');
530 if ($fh === false) {
531 return;
532 }
533 fputcsv($fh, array('from_url', 'status', 'type', 'to_url', 'wp_type', 'engine', 'code'), ',', '"', '\\');
534
535 while (($row = mysqli_fetch_array($result, MYSQLI_ASSOC))) {
536 fputcsv($fh, array(
537 $row['from_url'],
538 $row['status'],
539 $row['type'],
540 $row['to_url'],
541 $row['type_wp'],
542 isset($row['engine']) ? $row['engine'] : '',
543 isset($row['code']) ? $row['code'] : '301'
544 ), ',', '"', '\\');
545 }
546 fclose($fh);
547 mysqli_free_result($result);
548 }
549 }
550
551 /** Only return redirects that have a log entry.
552 * @global type $wpdb
553 * @global type $abj404dao
554 * @return array<int, array<string, mixed>>
555 */
556 function getRedirectsWithLogs() {
557 $query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/getRedirectsWithLogs.sql");
558
559 // Route through queryAndGetResults() so this redirects+logs JOIN
560 // inherits the centralized 60s timeout. logsv2 can be huge, and the
561 // join shape is identical to the one already protected in the hits
562 // table rebuild path (commit 70f3b5fe).
563 $result = $this->queryAndGetResults($query);
564 if (!empty($result['timed_out']) || (isset($result['last_error']) && $result['last_error'] != '')) {
565 return array();
566 }
567 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
568 return $rows;
569 }
570
571 /**
572 * Get all regex redirects for pattern matching.
573 * Uses per-request caching when redirect count is <= 50 to avoid repeated queries.
574 * Cache is automatically skipped if there are too many regex redirects (memory guard).
575 *
576 * @return array<int, array<string, mixed>>
577 */
578 function getRedirectsWithRegEx() {
579 // Return cached results if available (and caching wasn't disabled due to count)
580 if (self::$regexRedirectsCache !== null && !self::$regexCacheDisabled) {
581 return self::$regexRedirectsCache;
582 }
583
584 // If caching was disabled due to too many redirects, just query without caching
585 if (self::$regexCacheDisabled) {
586 return $this->queryRegexRedirects();
587 }
588
589 // First query - check count and decide whether to cache
590 $results = $this->queryRegexRedirects();
591
592 // Only cache if count is within safe memory limits
593 if (count($results) <= self::REGEX_CACHE_MAX_COUNT) {
594 self::$regexRedirectsCache = $results;
595 } else {
596 // Too many regex redirects - disable caching for this request
597 self::$regexCacheDisabled = true;
598 }
599
600 return $results;
601 }
602
603 /**
604 * Execute the regex redirects query.
605 * Separated from getRedirectsWithRegEx() for cache logic clarity.
606 *
607 * @return array<int, array<string, mixed>>
608 */
609 private function queryRegexRedirects() {
610 $query = "select \n {wp_abj404_redirects}.id,\n {wp_abj404_redirects}.url,\n {wp_abj404_redirects}.status,\n"
611 . " {wp_abj404_redirects}.type,\n {wp_abj404_redirects}.final_dest,\n {wp_abj404_redirects}.code,\n"
612 . " {wp_abj404_redirects}.timestamp,\n {wp_posts}.id as wp_post_id\n ";
613 $query .= "from {wp_abj404_redirects}\n " .
614 " LEFT OUTER JOIN {wp_posts} \n " .
615 " on {wp_abj404_redirects}.final_dest = {wp_posts}.id \n ";
616
617 $query .= "where status in (" . ABJ404_STATUS_REGEX . ") \n " .
618 " and disabled = 0";
619 $results = $this->queryAndGetResults($query);
620
621 /** @var array<int, array<string, mixed>> $rows */
622 $rows = is_array($results['rows']) ? $results['rows'] : array();
623 return $rows;
624 }
625
626 /**
627 * Find MANUAL redirects whose `url` column contains an unambiguous
628 * regex metacharacter (`* [ ] | ^ \ { }`). These are rows the admin
629 * created via a pre-auto-promote path (older plugin version, direct
630 * DB write, CSV import before the 4.1.x sniff was widened) that
631 * really should be treated as regex. The runtime fallback in
632 * SpellCheckerTrait_URLMatching tries them as regex without
633 * mutating the stored status; the auto-promote on next save sweeps
634 * them into the regular regex query.
635 *
636 * The LIKE filter is deliberately broad to keep the query simple;
637 * the runtime caller re-checks with the precise PHP-side helper
638 * (looksLikeUnambiguousRegex) before treating any row as regex.
639 *
640 * @return array<int, array<string, mixed>>
641 */
642 function getManualRedirectsWithRegexMetachars() {
643 $query = "select \n {wp_abj404_redirects}.id,\n {wp_abj404_redirects}.url,\n {wp_abj404_redirects}.status,\n"
644 . " {wp_abj404_redirects}.type,\n {wp_abj404_redirects}.final_dest,\n {wp_abj404_redirects}.code,\n"
645 . " {wp_abj404_redirects}.timestamp,\n {wp_posts}.id as wp_post_id\n ";
646 $query .= "from {wp_abj404_redirects}\n " .
647 " LEFT OUTER JOIN {wp_posts} \n " .
648 " on {wp_abj404_redirects}.final_dest = {wp_posts}.id \n ";
649
650 // SQL-side prefilter using INSTR per metachar. INSTR avoids LIKE's
651 // wildcard/escape semantics so we do not have to special-case
652 // the backslash byte. The PHP-side caller re-checks each row with
653 // looksLikeUnambiguousRegex(), so a few false positives here
654 // are harmless; the goal is to never miss a row that should be
655 // considered. Set matches the helper class.
656 $query .= "where status = " . ABJ404_STATUS_MANUAL . " \n " .
657 " and disabled = 0 \n " .
658 " and (INSTR(`url`, '*') > 0 " .
659 " OR INSTR(`url`, '[') > 0 " .
660 " OR INSTR(`url`, ']') > 0 " .
661 " OR INSTR(`url`, '|') > 0 " .
662 " OR INSTR(`url`, '^') > 0 " .
663 " OR INSTR(`url`, '\\\\') > 0 " .
664 " OR INSTR(`url`, '{') > 0 " .
665 " OR INSTR(`url`, '}') > 0)";
666 $results = $this->queryAndGetResults($query);
667
668 /** @var array<int, array<string, mixed>> $rows */
669 $rows = is_array($results['rows']) ? $results['rows'] : array();
670 return $rows;
671 }
672
673 /** Returns the redirects that are in place.
674 * @global type $wpdb
675 * @param string $sub either "redirects" or "captured".
676 * @param array<string, mixed> $tableOptions filter, order by, paged, perpage etc.
677 * @return array<int|string, mixed> rows from the redirects table.
678 */
679 function getRedirectsForView($sub, $tableOptions) {
680 $canUseSnapshotCache = $this->canUseViewTableSnapshotCache($tableOptions);
681 $queryTimeout = isset($tableOptions['_abj404_query_timeout']) && is_numeric($tableOptions['_abj404_query_timeout'])
682 ? max(1, intval($tableOptions['_abj404_query_timeout'])) : 0;
683 $throwOnQueryError = !empty($tableOptions['_abj404_throw_on_view_query_error']);
684 $snapshotCacheKey = '';
685 if ($canUseSnapshotCache && $queryTimeout <= 0) {
686 $snapshotCacheKey = $this->getViewSnapshotCacheKey('abj404_view_rows', $sub, $tableOptions);
687 $cachedRowsFromTable = $this->getViewRowsSnapshotFromTable($snapshotCacheKey, false, false);
688 if (is_array($cachedRowsFromTable)) {
689 return $cachedRowsFromTable;
690 }
691 if (function_exists('get_transient')) {
692 $cachedRows = get_transient($snapshotCacheKey);
693 if (is_array($cachedRows)) {
694 return $cachedRows;
695 }
696 }
697 }
698
699 try {
700 $rows = $this->runRedirectsForViewStaged((string)$sub, is_array($tableOptions) ? $tableOptions : array());
701 } catch (ABJ_404_Solution_ViewBuildPendingException $pending) {
702 // Cold-start state, not an error. The fetch AJAX gate normally
703 // intercepts this before the read; non-AJAX callers (REST, warmup)
704 // see an empty page and retry once cron / the JS poller advances
705 // the build. Re-throw when the warmup pipeline asks for it so its
706 // attempt counter advances and a stage gets blamed.
707 if ($throwOnQueryError) {
708 throw $pending;
709 }
710 $this->logger->debugMessage('[staged] getRedirectsForView pending: ' . $pending->getMessage());
711 return array();
712 } catch (Throwable $e) {
713 if ($throwOnQueryError) {
714 $stagedFailureMarker = '/* staged: ' . $e->getMessage() . ' */';
715 $diagnostics = $this->captureViewQueryFailureDiagnostics(
716 (string)$sub,
717 $stagedFailureMarker,
718 is_array($tableOptions) ? $tableOptions : array(),
719 array('last_error' => $e->getMessage(), 'timed_out' => false)
720 );
721 $diagnostics['failed_query_label'] = 'getRedirectsForView';
722 $diagnostics['staged_error'] = $e->getMessage();
723 $message = 'getRedirectsForView failed; last_error=' . $e->getMessage()
724 . '; timed_out=false; sql_source=' . $stagedFailureMarker;
725 throw new ABJ_404_Solution_ViewQueryFailureException($message, $diagnostics);
726 }
727 $this->logger->errorMessage('[staged] getRedirectsForView failed: ' . $e->getMessage(),
728 $e instanceof \Exception ? $e : null);
729 return array();
730 }
731
732 $this->logger->debugMessage(sprintf(
733 '[staged] getRedirectsForView returned %d rows for page %s',
734 count($rows),
735 (string)$sub
736 ));
737
738 if ($canUseSnapshotCache && $snapshotCacheKey === '') {
739 $snapshotCacheKey = $this->getViewSnapshotCacheKey('abj404_view_rows', $sub, $tableOptions);
740 }
741 if ($canUseSnapshotCache && $snapshotCacheKey !== '') {
742 $this->setViewRowsSnapshotToTable($snapshotCacheKey, $sub, $rows, self::VIEW_SNAPSHOT_CACHE_TTL_SECONDS);
743 if (function_exists('set_transient')) {
744 // allow-cache-empty: empty $rows is a legitimate result on a fresh install (no redirects yet); error paths early-return above without reaching this line
745 set_transient($snapshotCacheKey, $rows, self::VIEW_SNAPSHOT_CACHE_TTL_SECONDS);
746 }
747 }
748
749 return $rows;
750 }
751
752 /**
753 * Return whether the admin rows view already has a usable snapshot.
754 *
755 * Used by the AJAX first-paint path to avoid running an expensive cold
756 * table query inline. Fresh snapshots are preferred, but a recently
757 * refreshed stale snapshot is still usable because it lets the admin see
758 * real rows while background refresh detects newer data non-destructively.
759 *
760 * @param string $sub
761 * @param array<string, mixed> $tableOptions
762 * @return bool
763 */
764 function viewRowsSnapshotAvailable($sub, array $tableOptions): bool {
765 $canUseSnapshotCache = $this->canUseViewTableSnapshotCache($tableOptions);
766 if (!$canUseSnapshotCache) {
767 return false;
768 }
769
770 $snapshotCacheKey = $this->getViewSnapshotCacheKey('abj404_view_rows', $sub, $tableOptions);
771 $freshRows = $this->getViewRowsSnapshotFromTable($snapshotCacheKey, false, false);
772 if (is_array($freshRows)) {
773 return true;
774 }
775 $recentRows = $this->getViewRowsSnapshotFromTable($snapshotCacheKey, true, true);
776 if (is_array($recentRows)) {
777 return true;
778 }
779 if (function_exists('get_transient')) {
780 $transientRows = get_transient($snapshotCacheKey);
781 if (is_array($transientRows)) {
782 return true;
783 }
784 }
785
786 return false;
787 }
788
789 /**
790 * Return whether the full AJAX table response can be rendered from cache.
791 *
792 * Rows alone are not enough for first paint: pagination rendering also
793 * needs getRedirectsForViewCount(). If the count snapshot is cold, the
794 * "cached" path can still block on a heavy COUNT query. The initial AJAX
795 * cache gate uses this method so cold counts are also pushed to the
796 * background hydrate request.
797 *
798 * @param string $sub
799 * @param array<string, mixed> $tableOptions
800 * @return bool
801 */
802 function viewTableSnapshotAvailable($sub, array $tableOptions): bool {
803 if (!$this->viewRowsSnapshotAvailable($sub, $tableOptions)) {
804 return false;
805 }
806
807 $canUseSnapshotCache = function_exists('get_transient')
808 && $this->canUseViewTableSnapshotCache($tableOptions);
809 if (!$canUseSnapshotCache) {
810 return false;
811 }
812
813 $countCacheKey = $this->getViewSnapshotCacheKey('abj404_view_count', $sub, $tableOptions);
814 return get_transient($countCacheKey) !== false;
815 }
816
817 /**
818 * @param string $sub
819 * @param array<string, mixed> $tableOptions
820 * @return int
821 */
822 function getRedirectsForViewCount(string $sub, array $tableOptions): int {
823 $queryTimeout = isset($tableOptions['_abj404_query_timeout']) && is_numeric($tableOptions['_abj404_query_timeout'])
824 ? max(1, intval($tableOptions['_abj404_query_timeout'])) : 0;
825 $throwOnQueryError = !empty($tableOptions['_abj404_throw_on_view_query_error']);
826 $canUseSnapshotCache = function_exists('get_transient')
827 && $this->canUseViewTableSnapshotCache($tableOptions);
828 $requestCountCacheKey = (string)$sub . '|' . md5(serialize($tableOptions));
829 $countCacheKey = '';
830 if ($canUseSnapshotCache && $queryTimeout <= 0) {
831 $countCacheKey = $this->getViewSnapshotCacheKey('abj404_view_count', $sub, $tableOptions);
832 $cachedCount = get_transient($countCacheKey);
833 if ($cachedCount !== false) {
834 return intval(is_scalar($cachedCount) ? $cachedCount : 0);
835 }
836 }
837 if (array_key_exists($requestCountCacheKey, $this->redirectsForViewCountRequestCache)) {
838 return intval($this->redirectsForViewCountRequestCache[$requestCountCacheKey]);
839 }
840
841 $rawFilterText = is_string($tableOptions['filterText'] ?? null) ? $tableOptions['filterText'] : '';
842 if ($rawFilterText === '') {
843 // No search filter: simple COUNT against the live redirects table
844 // is fast enough that there is no value building view_done just
845 // for this. Keeps cold-start counts cheap.
846 $query = $this->getOptimizedRedirectsForViewCountQuery($sub, $tableOptions);
847 $this->setSqlBigSelects();
848 $queryOptions = $queryTimeout > 0 ? array('timeout' => $queryTimeout) : array();
849 $results = $this->queryAndGetResults($query, $queryOptions);
850 $lastErrorRaw = $results['last_error'] ?? '';
851 $lastError = is_string($lastErrorRaw) ? $lastErrorRaw : '';
852 } else {
853 // Search-filtered count needs to apply the LIKE composite against
854 // the precomputed dest_for_view/status_for_view/type_for_view
855 // columns, so route through the staged path.
856 try {
857 $countValue = $this->runRedirectsForViewCountStaged((string)$sub, $tableOptions);
858 $this->redirectsForViewCountRequestCache[$requestCountCacheKey] = $countValue;
859 if ($canUseSnapshotCache && $countCacheKey === '') {
860 $countCacheKey = $this->getViewSnapshotCacheKey('abj404_view_count', $sub, $tableOptions);
861 }
862 if ($canUseSnapshotCache && $countCacheKey !== '') {
863 // allow-cache-empty: $countValue=0 is a legitimate result when no rows match the search filter; the staged pending/error paths throw above without reaching this line
864 set_transient($countCacheKey, $countValue, self::VIEW_SNAPSHOT_CACHE_TTL_SECONDS);
865 }
866 return $countValue;
867 } catch (ABJ_404_Solution_ViewBuildPendingException $pending) {
868 // view_done not yet built. Same treatment as getRedirectsForView:
869 // signal pending up the warmup pipeline if requested, otherwise
870 // record a sentinel count and let the caller retry next request.
871 if ($throwOnQueryError) {
872 throw $pending;
873 }
874 $this->logger->debugMessage('[staged] getRedirectsForViewCount pending: ' . $pending->getMessage());
875 $this->redirectsForViewCountRequestCache[$requestCountCacheKey] = -1;
876 return -1;
877 } catch (Throwable $e) {
878 if ($throwOnQueryError) {
879 $stagedFailureMarker = '/* staged-count: ' . $e->getMessage() . ' */';
880 $diagnostics = $this->captureViewQueryFailureDiagnostics(
881 (string)$sub,
882 $stagedFailureMarker,
883 $tableOptions,
884 array('last_error' => $e->getMessage(), 'timed_out' => false)
885 );
886 $diagnostics['failed_query_label'] = 'getRedirectsForViewCount';
887 $diagnostics['staged_error'] = $e->getMessage();
888 throw new ABJ_404_Solution_ViewQueryFailureException($e->getMessage(), $diagnostics);
889 }
890 $this->logger->errorMessage('[staged] getRedirectsForViewCount failed: ' . $e->getMessage(),
891 $e instanceof \Exception ? $e : null);
892 $this->redirectsForViewCountRequestCache[$requestCountCacheKey] = -1;
893 return -1;
894 }
895 }
896
897 if ($throwOnQueryError && (!empty($results['timed_out']) || $lastError !== '')) {
898 $message = $this->formatViewQueryFailureMessage('getRedirectsForViewCount', $query, $results);
899 $diagnostics = $this->captureViewQueryFailureDiagnostics($sub, $query, $tableOptions, $results);
900 $diagnostics['failed_query_label'] = 'getRedirectsForViewCount';
901 throw new ABJ_404_Solution_ViewQueryFailureException($message, $diagnostics);
902 }
903
904 if ($lastError != '' && trim($lastError) != '') {
905 $diagnostics = $this->captureViewQueryFailureDiagnostics($sub, $query, $tableOptions, $results);
906 $diagnostics['failed_query_label'] = 'getRedirectsForViewCount';
907 throw new ABJ_404_Solution_ViewQueryFailureException(
908 "Error getting redirect count: " . esc_html($lastError),
909 $diagnostics
910 );
911 }
912 $rows = is_array($results['rows']) ? $results['rows'] : array();
913 if (empty($rows)) {
914 $this->redirectsForViewCountRequestCache[$requestCountCacheKey] = -1;
915 return -1;
916 }
917 $row = is_array($rows[0] ?? null) ? $rows[0] : array();
918 $rawCount = $row['count'] ?? $row['COUNT(*)'] ?? reset($row);
919 $countValue = intval(is_scalar($rawCount) ? $rawCount : 0);
920 $this->redirectsForViewCountRequestCache[$requestCountCacheKey] = $countValue;
921 if ($canUseSnapshotCache && $countCacheKey === '') {
922 $countCacheKey = $this->getViewSnapshotCacheKey('abj404_view_count', $sub, $tableOptions);
923 }
924 if ($canUseSnapshotCache && $countCacheKey !== '') {
925 set_transient($countCacheKey, $countValue, self::VIEW_SNAPSHOT_CACHE_TTL_SECONDS);
926 }
927 return $countValue;
928 }
929
930 /**
931 * @param string $sub
932 * @param array<string, mixed> $tableOptions
933 * @return string
934 */
935 private function getOptimizedRedirectsForViewCountQuery(string $sub, array $tableOptions): string {
936 global $abj404_redirect_types, $abj404_captured_types;
937
938 $statusTypes = '';
939 if ($tableOptions['filter'] == 0 || $tableOptions['filter'] == ABJ404_TRASH_FILTER) {
940 if ($sub == 'abj404_redirects') {
941 $statusTypes = implode(", ", $abj404_redirect_types);
942 } else if ($sub == 'abj404_captured') {
943 $statusTypes = implode(", ", $abj404_captured_types);
944 }
945 } else if ($tableOptions['filter'] == ABJ404_STATUS_MANUAL) {
946 $statusTypes = implode(", ", array(ABJ404_STATUS_MANUAL, ABJ404_STATUS_REGEX));
947 } else if ($tableOptions['filter'] == ABJ404_HANDLED_FILTER) {
948 $statusTypes = implode(", ", array(ABJ404_STATUS_IGNORED, ABJ404_STATUS_LATER));
949 } else {
950 $statusTypes = $tableOptions['filter'];
951 }
952 $statusTypes = preg_replace('/[^\d, ]/', '', trim(is_string($statusTypes) ? $statusTypes : ''));
953
954 $trashValue = ($tableOptions['filter'] == ABJ404_TRASH_FILTER) ? 1 : 0;
955
956 $scoreRangeClause = '';
957 $rawScoreRange = is_string($tableOptions['score_range'] ?? '') ? ($tableOptions['score_range'] ?? 'all') : 'all';
958 // Each `wp_abj404_redirects.*` reference below is the SQL alias bound by the
959 // `FROM {wp_abj404_redirects} wp_abj404_redirects` clause in the assembled
960 // query, not a hardcoded table-name literal. Per-line markers keep the
961 // lint window (+/- 1 line) honest.
962 switch ($rawScoreRange) {
963 case 'high': $scoreRangeClause = 'AND wp_abj404_redirects.score >= 80'; break; // allow-prefix-literal: SQL alias, see comment above
964 case 'medium': $scoreRangeClause = 'AND wp_abj404_redirects.score >= 50 AND wp_abj404_redirects.score < 80'; break; // allow-prefix-literal: SQL alias
965 case 'low': $scoreRangeClause = 'AND wp_abj404_redirects.score IS NOT NULL AND wp_abj404_redirects.score < 50'; break; // allow-prefix-literal: SQL alias
966 case 'manual': $scoreRangeClause = 'AND wp_abj404_redirects.score IS NULL'; break; // allow-prefix-literal: SQL alias
967 }
968
969 $query = "SELECT COUNT(*) AS count\n" .
970 "FROM {wp_abj404_redirects} wp_abj404_redirects\n" . // allow-prefix-literal: second token is the SQL alias name, not a table reference
971 "WHERE 1 and status IN (" . $statusTypes . ") AND disabled = " . intval($trashValue) . "\n" .
972 $scoreRangeClause;
973
974 return $this->doTableNameReplacements($query);
975 }
976
977 /**
978 * @param string $sub
979 * @param array<string, mixed> $tableOptions
980 * @param bool $queryAllRowsAtOnce
981 * @param int $limitStart
982 * @param int $limitEnd
983 * @param bool $selectCountOnly
984 * @return string
985 */
986 function getRedirectsForViewQuery($sub, $tableOptions, $queryAllRowsAtOnce,
987 $limitStart, $limitEnd, $selectCountOnly) {
988 global $abj404_redirect_types;
989 global $abj404_captured_types;
990 global $wpdb;
991
992 $logsTableColumns = '';
993 $logsTableColumns = "null as logshits, \n null as logsid, \n null as last_used, \n";
994 $logsTableJoin = '';
995 $statusTypes = '';
996 $trashValue = '';
997 $selectCountReplacement = '/* selecting data as usual */';
998
999 /* if we only want the count(*) then comment out everything else. */
1000 if ($selectCountOnly) {
1001 $selectCountReplacement = "\n /*+ SET_VAR(max_join_size=18446744073709551615) */\n" .
1002 "count(*) as count\n /* only selecting for count";
1003 }
1004
1005 if ($queryAllRowsAtOnce && !$selectCountOnly) {
1006 // create a temp table and use that instead of a subselect to avoid the sql error
1007 // "The SELECT would examine more than MAX_JOIN_SIZE rows"
1008 $this->maybeUpdateRedirectsForViewHitsTable();
1009
1010 // Verify table was actually created before using it (handles silent creation failures)
1011 if ($this->logsHitsTableExists()) {
1012 // if we're showing all rows include all of the log data in the query already. this makes the query very slow.
1013 // this should be replaced by the dynamic loading of log data using ajax queries as the page is viewed.
1014 $logsTableColumns = "logstable.logshits as logshits, \n" .
1015 "logstable.logsid, \n" .
1016 "logstable.last_used, \n";
1017
1018 // canonical_url is the persisted CONCAT('/', TRIM(BOTH '/' FROM url))
1019 // form (added 4.1.10) so this JOIN is a single indexed equality
1020 // lookup against logs_hits.requested_url instead of evaluating
1021 // the function on every redirects row. The COALESCE fallback
1022 // covers rows from upgraded sites where the chunked backfill
1023 // hasn't reached yet — those rows merge in via the original
1024 // expression so behavior matches pre-upgrade exactly.
1025 // wp_abj404_redirects below is the SQL alias from the assembled FROM clause, not a hardcoded table name.
1026 $logsTableJoin = " LEFT OUTER JOIN {wp_abj404_logs_hits} logstable \n " .
1027 " on binary logstable.requested_url = " .
1028 "binary COALESCE(wp_abj404_redirects.canonical_url, " . // allow-prefix-literal: SQL alias
1029 "concat('/', trim(both '/' from wp_abj404_redirects.url))) \n "; // allow-prefix-literal: SQL alias
1030 } else {
1031 // Fall back to null columns if table creation failed
1032 $this->logger->debugMessage("logs_hits table not available, falling back to null columns");
1033 }
1034 }
1035
1036 if ($tableOptions['filter'] == 0 || $tableOptions['filter'] == ABJ404_TRASH_FILTER) {
1037 if ($sub == 'abj404_redirects') {
1038 $statusTypes = implode(", ", $abj404_redirect_types);
1039
1040 } else if ($sub == 'abj404_captured') {
1041 $statusTypes = implode(", ", $abj404_captured_types);
1042
1043 } else {
1044 $this->logger->errorMessage("Unrecognized sub type: " . esc_html($sub));
1045 }
1046
1047 } else if ($tableOptions['filter'] == ABJ404_STATUS_MANUAL) {
1048 $statusTypes = implode(", ", array(ABJ404_STATUS_MANUAL, ABJ404_STATUS_REGEX));
1049
1050 } else if ($tableOptions['filter'] == ABJ404_HANDLED_FILTER) {
1051 // Composite filter: Ignored + Later (Simple mode "Handled" tab)
1052 $statusTypes = implode(", ", array(ABJ404_STATUS_IGNORED, ABJ404_STATUS_LATER));
1053
1054 } else {
1055 $statusTypes = $tableOptions['filter'];
1056 }
1057 $statusTypes = preg_replace('/[^\d, ]/', '', trim(is_string($statusTypes) ? $statusTypes : ''));
1058
1059 if ($tableOptions['filter'] == ABJ404_TRASH_FILTER) {
1060 $trashValue = 1;
1061 } else if ($tableOptions['filter'] == ABJ404_HANDLED_FILTER) {
1062 // Show both active (disabled=0) and trashed (disabled=1) in Handled view
1063 $trashValue = 0;
1064 } else {
1065 $trashValue = 0;
1066 }
1067
1068 /* only try to order by if we're actually selecting data and not only
1069 * counting the number of rows. */
1070 $orderByString = '';
1071 if (!$selectCountOnly) {
1072 $rawOrderBy = $tableOptions['orderby'] ?? '';
1073 $orderBy = $this->f->strtolower(is_string($rawOrderBy) ? $rawOrderBy : '');
1074 if ($orderBy == "final_dest") {
1075 // TODO change the final dest type to an integer and store external URLs somewhere else.
1076 $orderBy = "case when post_title is null then 1 else 0 end asc, post_title";
1077 } else {
1078 // only allow letters and the underscore in the orderby string.
1079 $orderBy = preg_replace('/[^a-zA-Z_]/', '', trim($orderBy));
1080 }
1081 $rawOrderVal = $tableOptions['order'] ?? '';
1082 $rawOrderValX = is_string($rawOrderVal) ? $rawOrderVal : '';
1083 $order = strtoupper((string)preg_replace('/[^a-zA-Z_]/', '', trim($rawOrderValX)));
1084 if ($order !== 'DESC') {
1085 $order = 'ASC';
1086 }
1087 $orderByString = "order by published_status asc, " . $orderBy . " " . $order .
1088 ", wp_abj404_redirects.url ASC, wp_abj404_redirects.id " . $order; // allow-prefix-literal: SQL alias bound by `FROM {wp_abj404_redirects} wp_abj404_redirects`
1089 }
1090
1091 // Score range filter clause. wp_abj404_redirects below is the SQL alias from the assembled FROM clause, not a hardcoded table name.
1092 $rawScoreRange = is_string($tableOptions['score_range'] ?? '') ? ($tableOptions['score_range'] ?? 'all') : 'all';
1093 switch ($rawScoreRange) {
1094 case 'high':
1095 $scoreRangeClause = 'AND wp_abj404_redirects.score >= 80'; // allow-prefix-literal: SQL alias
1096 break;
1097 case 'medium':
1098 $scoreRangeClause = 'AND wp_abj404_redirects.score >= 50 AND wp_abj404_redirects.score < 80'; // allow-prefix-literal: SQL alias
1099 break;
1100 case 'low':
1101 $scoreRangeClause = 'AND wp_abj404_redirects.score IS NOT NULL AND wp_abj404_redirects.score < 50'; // allow-prefix-literal: SQL alias
1102 break;
1103 case 'manual':
1104 $scoreRangeClause = 'AND wp_abj404_redirects.score IS NULL'; // allow-prefix-literal: SQL alias
1105 break;
1106 default:
1107 $scoreRangeClause = '';
1108 break;
1109 }
1110
1111 $searchFilterForRedirectsExists = "no redirects fiter text found";
1112 $searchFilterForCapturedExists = "no captured 404s filter text found";
1113 $filterText = '';
1114 $rawFilterText = is_string($tableOptions['filterText'] ?? null) ? $tableOptions['filterText'] : '';
1115 if ($rawFilterText != '') {
1116 if ($sub == 'abj404_redirects') {
1117 // Close the comment without including user input to avoid comment breakout.
1118 $searchFilterForRedirectsExists = ' filter text enabled */';
1119
1120 } else if ($sub == 'abj404_captured') {
1121 // Close the comment without including user input to avoid comment breakout.
1122 $searchFilterForCapturedExists = ' filter text enabled */';
1123
1124 } else {
1125 throw new Exception("Unrecognized page for filter text request.");
1126 }
1127 }
1128
1129 // Sanitize filter text for use inside LIKE; strip comment markers and escape for SQL LIKE.
1130 $filterTextRaw = str_replace(array('*', '/', '$'), '', $rawFilterText);
1131 if (isset($wpdb) && is_object($wpdb) && method_exists($wpdb, 'esc_like')) {
1132 /** @var wpdb $wpdb */
1133 $filterTextRaw = $wpdb->esc_like($filterTextRaw);
1134 } else {
1135 $filterTextRaw = addcslashes($filterTextRaw, '_%\\');
1136 }
1137 $filterText = esc_sql($filterTextRaw);
1138
1139 $query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/getRedirectsForView.sql");
1140 // Ensure consistent collation for string operations (e.g., REPLACE/LOWER) to avoid
1141 // "Illegal mix of collations" errors when plugin tables use *_bin collations.
1142 $wpdbCollate = 'utf8mb4_unicode_ci';
1143 $hasForcedCollate = false;
1144 if (array_key_exists('forceCollate', $tableOptions) && !empty($tableOptions['forceCollate'])) {
1145 $rawForceCollateVal = $tableOptions['forceCollate'];
1146 $rawForceCollate = is_string($rawForceCollateVal) ? $rawForceCollateVal : '';
1147 $forced = preg_replace('/[^A-Za-z0-9_]/', '', $rawForceCollate);
1148 if ($forced !== '') {
1149 $wpdbCollate = $forced;
1150 $hasForcedCollate = true;
1151 }
1152 }
1153 if (!$hasForcedCollate && isset($wpdb) && isset($wpdb->collate) && !empty($wpdb->collate)) {
1154 $wpdbCollate = preg_replace('/[^A-Za-z0-9_]/', '', $wpdb->collate);
1155 }
1156 if ($wpdbCollate === '') {
1157 $wpdbCollate = 'utf8mb4_unicode_ci';
1158 }
1159 $query = $this->f->str_replace('{selecting-for-count-true-false}', $selectCountReplacement, $query);
1160 $query = $this->f->str_replace('{statusTypes}', $statusTypes, $query);
1161 $query = $this->f->str_replace('{orderByString}', $orderByString, $query);
1162 $query = $this->f->str_replace('{limitStart}', (string)$limitStart, $query);
1163 $query = $this->f->str_replace('{limitEnd}', (string)$limitEnd, $query);
1164 $query = $this->f->str_replace('{searchFilterForRedirectsExists}', $searchFilterForRedirectsExists, $query);
1165 $query = $this->f->str_replace('{searchFilterForCapturedExists}', $searchFilterForCapturedExists, $query);
1166 $query = $this->f->str_replace('{filterText}', $filterText, $query);
1167 $query = $this->f->str_replace('{wpdb_collate}', $wpdbCollate, $query);
1168 $query = $this->f->str_replace('{logsTableColumns}', $logsTableColumns, $query);
1169 $query = $this->f->str_replace('{logsTableJoin}', $logsTableJoin, $query);
1170 $query = $this->f->str_replace('{trashValue}', (string)$trashValue, $query);
1171 $query = $this->f->str_replace('{scoreRangeClause}', $scoreRangeClause, $query);
1172 $query = $this->doTableNameReplacements($query);
1173
1174 if (array_key_exists('translations', $tableOptions) && is_array($tableOptions['translations'])) {
1175 $keys = array_keys($tableOptions['translations']);
1176 $values = array_values($tableOptions['translations']);
1177 /** @var array<int, string> $keys */
1178 $query = $this->f->str_replace($keys, array_map('strval', $values), $query);
1179 }
1180
1181 $query = $this->f->doNormalReplacements($query);
1182
1183 return $query;
1184 }
1185
1186 /**
1187 * Build an actionable query failure message for table warmup errors.
1188 *
1189 * @param string $queryLabel
1190 * @param string $query
1191 * @param array<string, mixed> $result
1192 * @return string
1193 */
1194 private function formatViewQueryFailureMessage(string $queryLabel, string $query, array $result): string {
1195 $lastErrorRaw = $result['last_error'] ?? '';
1196 $lastError = is_string($lastErrorRaw) ? trim($lastErrorRaw) : '';
1197 $timedOut = !empty($result['timed_out']);
1198 $sqlSource = $this->extractSqlFilename($query);
1199
1200 if ($lastError === '' && $timedOut) {
1201 $lastError = $queryLabel . ' timed out';
1202 } else if ($lastError === '') {
1203 $lastError = $queryLabel . ' failed without a database error message';
1204 }
1205
1206 return $queryLabel . ' failed'
1207 . '; last_error=' . $lastError
1208 . '; timed_out=' . ($timedOut ? 'true' : 'false')
1209 . '; sql_source=' . $sqlSource;
1210 }
1211
1212 /**
1213 * @param array<int, string> $postIDs
1214 * @return array<int, mixed>
1215 */
1216 function getExtraDataToPermalinkSuggestions(array $postIDs): array {
1217 // Sanitize all post IDs to prevent SQL injection
1218 $postIDs = array_map('absint', $postIDs);
1219 $postIDJoined = implode(", ", $postIDs);
1220
1221 $query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/getAdditionalPostData.sql");
1222 $query = $this->f->str_replace('{IDS_TO_INCLUDE}', $postIDJoined, $query);
1223 $query = $this->doTableNameReplacements($query);
1224 $query = $this->f->doNormalReplacements($query);
1225
1226 $results = $this->queryAndGetResults($query);
1227
1228 /** @var array<int, mixed> $rows */
1229 $rows = is_array($results['rows']) ? $results['rows'] : array();
1230 return $rows;
1231 }
1232
1233 /**
1234 * Prepare a WordPress SQL query with placeholders and an associative data array.
1235 *
1236 * @param string $query The SQL query string with {placeholder} style placeholders.
1237 * @param array<string, mixed> $data An associative array with keys matching the placeholders in the query.
1238 * @return string The fully prepared SQL query.
1239 */
1240 function prepare_query_wp($query, $data) {
1241 global $wpdb;
1242 list($prepared_query, $ordered_values) = $this->prepare_query($query, $data);
1243 // DAO-bypass-approved: $wpdb->prepare is read-only string formatting; callers execute the result through queryAndGetResults
1244 return $wpdb->prepare($prepared_query, $ordered_values);
1245 }
1246
1247 /**
1248 * Prepare a SQL query with placeholders and an associative data array.
1249 *
1250 * @param string $query The SQL query string with {placeholder} style placeholders.
1251 * @param array<string, mixed> $data An associative array with keys matching the placeholders in the query.
1252 * @return array{0: string, 1: array<int, mixed>} Returns an array containing two elements: the prepared query string with %s or %d placeholders, and an ordered array of values for those placeholders.
1253 */
1254 function prepare_query($query, $data) {
1255 $ordered_values = [];
1256 $prepared_query = preg_replace_callback('/\{(\w+)\}/', function($matches) use ($data, &$ordered_values) {
1257 $key = $matches[1];
1258 if (!isset($data[$key])) {
1259 // Placeholder key not found in data array, ignore and continue
1260 return $matches[0];
1261 }
1262 $value = $data[$key];
1263
1264 // Append the value to the ordered values array
1265 $ordered_values[] = $value;
1266
1267 // Determine the placeholder type
1268 $placeholder_type = is_int($value) ? '%d' : '%s';
1269
1270 return $placeholder_type;
1271 }, $query);
1272
1273 return [$prepared_query !== null ? $prepared_query : $query, $ordered_values];
1274 }
1275
1276 /**
1277 * Check if the hits table needs to be rebuilt.
1278 *
1279 * Rebuild is needed if:
1280 * 1. MAX(id) from logs differs from stored value (new entries or deletions)
1281 * 2. Table is older than HITS_TABLE_MAX_AGE_SECONDS (staleness check)
1282 *
1283 * @return bool True if rebuild needed
1284 */
1285 }
1286