PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / repositories / InternalSourceEvidenceRepository.php

InternalSourceEvidenceRepository.php in 404 Solution trunk, at includes/repositories/InternalSourceEvidenceRepository.php

402 lines 16.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 /**
8 * Reads observed same-site referrer evidence for the visible captured 404 rows.
9 *
10 * The repository is intentionally read-side only: it does not scan content, crawl
11 * pages, or add write-path cost to 404 logging. Callers pass the captured URLs
12 * already visible on the current table page, and this class bounds the logsv2
13 * aggregate to those URLs.
14 */
15 class ABJ_404_Solution_InternalSourceEvidenceRepository {
16
17 /**
18 * Hard upper bound on aggregate rows returned per queryAggregateRows()
19 * call. Referrer cardinality for a captured URL is visitor-supplied and
20 * unbounded (bots and open redirects can drive arbitrarily many distinct
21 * Referer values at one captured URL); this caps the SQL-level result
22 * set so the query itself cannot pull unbounded rows into memory before
23 * the PHP-side $maxSources slice in getEvidenceForCapturedUrls() runs.
24 * Matches ABJ_404_Solution_ContentKeywordsRepository::MAX_LIMIT.
25 */
26 const MAX_AGGREGATE_ROWS = 5000;
27
28 /**
29 * Hard upper bound on how many caller-supplied captured URLs this
30 * repository will accept per call. $capturedUrls is meant to be "URLs
31 * visible on the current admin table page" (well under 100 in practice,
32 * matching RestApiRequestParser's per_page cap of 100), but the method
33 * is public and does not otherwise validate cardinality; every accepted
34 * URL drives a SQL IN() placeholder and bound parameter in
35 * queryAggregateRows(), so an unbounded caller-supplied array would
36 * still cost memory and query-planning work before MAX_AGGREGATE_ROWS'
37 * LIMIT ever applies. Extra accepted URLs beyond this bound are dropped,
38 * not errored, consistent with this repository degrading gracefully
39 * rather than failing the admin table render.
40 */
41 const MAX_CAPTURED_URLS = 200;
42
43 /** @var ABJ_404_Solution_DatabaseQueryInterface */
44 private $db;
45
46 /** @var ABJ_404_Solution_Functions */
47 private $functions;
48
49 /**
50 * Same-site referrer host, in lowercase. Resolved once at construction
51 * time rather than lazily memoized on first use: home_url() does not
52 * change mid-request, so there is no benefit to deferring the read, and
53 * resolving it eagerly keeps this a plain immutable value instead of a
54 * query-shaped accessor with a mutation side effect (CQS violation --
55 * see homeHost() removal, c308).
56 *
57 * @var string
58 */
59 private $homeHost;
60
61 /**
62 * @param ABJ_404_Solution_DatabaseQueryInterface $db
63 * @param ABJ_404_Solution_Functions|null $functions UTF-8 sanitizer source.
64 * Defaults to the `functions` service so every call site (including
65 * tests that omit this argument) still gets real UTF-8 sanitization
66 * before values reach $wpdb->prepare() -- see queryAggregateRows()
67 * and resolvePostIdFromPermalinkCache(), which both take
68 * visitor-supplied captured 404 URLs. prepare() escapes quote and
69 * percent characters but does not validate or repair encoding, so
70 * sanitization must still happen before values become bound params.
71 */
72 public function __construct(ABJ_404_Solution_DatabaseQueryInterface $db, $functions = null) {
73 $this->db = $db;
74 $this->functions = $functions !== null ? $functions : abj_service('functions');
75 $this->homeHost = $this->resolveHomeHost();
76 }
77
78 /**
79 * Return source evidence keyed by captured requested URL.
80 *
81 * source_count is the number of distinct same-site source paths OBSERVED
82 * for that captured URL. When the underlying aggregate saturates its
83 * MAX_AGGREGATE_ROWS cap it is a floor rather than a total; that condition
84 * is warned about in queryAggregateRows() so it is never silent.
85 *
86 * @param array<int, string> $capturedUrls Visible captured URLs only.
87 * @param int $maxSources Maximum source rows to display per captured URL.
88 * @return array<string, array{source_count:int,displayed_source_count:int,sources:array<int,array<string,mixed>>}>
89 */
90 public function getEvidenceForCapturedUrls(array $capturedUrls, int $maxSources = 5): array {
91 $visibleUrls = $this->visibleUrlSet($capturedUrls);
92 if (empty($visibleUrls)) {
93 return array();
94 }
95
96 $aggregateRows = $this->queryAggregateRows(array_keys($visibleUrls));
97 $grouped = $this->groupRowsByCapturedUrl($aggregateRows, $visibleUrls);
98
99 $evidence = array();
100 foreach ($grouped as $capturedUrl => $sourcesByPath) {
101 uasort($sourcesByPath, function (array $a, array $b): int {
102 $hitsCompare = $this->intField($b, 'hit_count') <=> $this->intField($a, 'hit_count');
103 if ($hitsCompare !== 0) {
104 return $hitsCompare;
105 }
106 return $this->intField($b, 'last_seen') <=> $this->intField($a, 'last_seen');
107 });
108
109 $sourceCount = count($sourcesByPath);
110 if ($sourceCount === 0) {
111 continue;
112 }
113
114 $displayed = array_slice(array_values($sourcesByPath), 0, max(1, $maxSources));
115 $evidence[$capturedUrl] = array(
116 'source_count' => $sourceCount,
117 'displayed_source_count' => count($displayed),
118 'sources' => $displayed,
119 );
120 }
121
122 return $evidence;
123 }
124
125 /**
126 * @param array<int, string> $capturedUrls
127 * @return array<string, bool>
128 */
129 private function visibleUrlSet(array $capturedUrls): array {
130 $set = array();
131 foreach ($capturedUrls as $url) {
132 if (!is_string($url) || $url === '') {
133 continue;
134 }
135 // Same per-URL length bound the capture path itself enforces
136 // (UserRequest::getPath(), SettingsRedirectPolicy, etc.). This
137 // repository is a separate trust boundary from whatever called
138 // it with $capturedUrls, so it must not assume the caller
139 // already enforced the invariant: MAX_CAPTURED_URLS above only
140 // bounds the count, not the size of each accepted string, and
141 // every accepted URL is later bound into a SQL IN() clause.
142 if (defined('ABJ404_MAX_URL_LENGTH') && strlen($url) > ABJ404_MAX_URL_LENGTH) {
143 continue;
144 }
145 $set[$url] = true;
146 if (count($set) >= self::MAX_CAPTURED_URLS) {
147 break;
148 }
149 }
150 return $set;
151 }
152
153 /**
154 * @param array<int, string> $visibleUrls
155 * @return array<int, array<string, mixed>>
156 */
157 private function queryAggregateRows(array $visibleUrls): array {
158 $cleanUrls = array();
159 foreach ($visibleUrls as $url) {
160 // Captured 404 URLs are visitor-supplied (bots routinely deliver
161 // garbage bytes through the request path); sanitize invalid
162 // UTF-8 before it reaches $wpdb->prepare(), which does not
163 // validate encoding on its own.
164 $cleanUrls[] = $this->functions->sanitizeInvalidUTF8($url);
165 }
166 if (empty($cleanUrls)) {
167 return array();
168 }
169
170 $placeholders = implode(',', array_fill(0, count($cleanUrls), '%s'));
171 $query = "SELECT requested_url, referrer, COUNT(*) AS hit_count, MAX(timestamp) AS last_seen"
172 . " FROM {wp_abj404_logsv2}"
173 . " WHERE requested_url IN (" . $placeholders . ")"
174 . " AND referrer IS NOT NULL AND referrer != ''"
175 . " GROUP BY requested_url, referrer"
176 . " ORDER BY requested_url ASC, hit_count DESC, last_seen DESC"
177 . " LIMIT " . self::MAX_AGGREGATE_ROWS;
178
179 $result = $this->db->queryAndGetResults($query, array('query_params' => $cleanUrls));
180 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
181
182 // A saturated result set means the LIMIT truncated the aggregate, so
183 // every count derived from these rows is a floor rather than a total.
184 // Say so: a capped result that reads as a complete one is how a
185 // backlog stays invisible (see the n-gram reconciler's post-LIMIT
186 // "Found 50 posts missing" line, i582).
187 if (count($rows) >= self::MAX_AGGREGATE_ROWS) {
188 $this->logger()->warn(sprintf(
189 'InternalSourceEvidenceRepository: source-evidence aggregate hit its %d-row cap for %d captured URLs. '
190 . 'Reported source counts are floors, not totals, and trailing captured URLs may show no sources.',
191 self::MAX_AGGREGATE_ROWS,
192 count($cleanUrls)
193 ));
194 }
195
196 $typedRows = array();
197 foreach ($rows as $row) {
198 if (is_array($row)) {
199 $typedRows[] = $this->stringKeyedRow($row);
200 }
201 }
202 return $typedRows;
203 }
204
205 /** @return ABJ_404_Solution_Logging */
206 private function logger() {
207 return abj_service('logging');
208 }
209
210 /**
211 * @param array<int, array<string, mixed>> $rows
212 * @param array<string, bool> $visibleUrls
213 * @return array<string, array<string, array<string, mixed>>>
214 */
215 private function groupRowsByCapturedUrl(array $rows, array $visibleUrls): array {
216 $grouped = array();
217 // Call-scoped memo, not instance state: resolveSource() does a
218 // url_to_postid()/permalink-cache lookup per unique source path, and
219 // the same source path commonly repeats across rows within one
220 // top-level call. Keeping the memo local (rather than an instance
221 // property mutated by a "read" method) avoids a command-query
222 // separation violation and stale-across-calls results if this
223 // repository instance is ever reused for a second page render (see
224 // homeHost() eager-resolve fix above for the same class of issue).
225 $resolvedSources = array();
226 foreach ($rows as $row) {
227 if (!is_array($row)) {
228 continue;
229 }
230
231 $capturedUrl = $this->stringField($row, 'requested_url');
232 if ($capturedUrl === '' || !isset($visibleUrls[$capturedUrl])) {
233 continue;
234 }
235
236 $sourcePath = $this->normalizeSameSiteReferrer($this->stringField($row, 'referrer'));
237 if ($sourcePath === null) {
238 continue;
239 }
240
241 if (!isset($grouped[$capturedUrl][$sourcePath])) {
242 $grouped[$capturedUrl][$sourcePath] = $this->resolveSource($sourcePath, $resolvedSources);
243 }
244
245 $currentSource = $grouped[$capturedUrl][$sourcePath];
246 $currentSource['hit_count'] = $this->intField($currentSource, 'hit_count')
247 + max(0, $this->intField($row, 'hit_count'));
248 $currentSource['last_seen'] = max(
249 $this->intField($currentSource, 'last_seen'),
250 $this->intField($row, 'last_seen')
251 );
252 $grouped[$capturedUrl][$sourcePath] = $currentSource;
253 }
254 return $grouped;
255 }
256
257 private function normalizeSameSiteReferrer(string $referrer): ?string {
258 $referrer = trim($referrer);
259 if ($referrer === '') {
260 return null;
261 }
262
263 if (strpos($referrer, '/') === 0 && strpos($referrer, '//') !== 0) {
264 $path = parse_url($referrer, PHP_URL_PATH);
265 } else {
266 $parts = parse_url($referrer);
267 if (!is_array($parts)) {
268 return null;
269 }
270 $host = isset($parts['host']) && is_string($parts['host']) ? strtolower($parts['host']) : '';
271 if ($host === '' || $host !== $this->homeHost) {
272 return null;
273 }
274 $path = isset($parts['path']) && is_string($parts['path']) ? $parts['path'] : '';
275 }
276
277 $path = is_string($path) ? '/' . ltrim($path, '/') : '';
278 if ($path === '' || $path === '/') {
279 return null;
280 }
281 if ($this->isExcludedPath($path)) {
282 return null;
283 }
284
285 return $path;
286 }
287
288 private function resolveHomeHost(): string {
289 $home = function_exists('home_url') ? (string)home_url('/') : '';
290 $host = parse_url($home, PHP_URL_HOST);
291 return is_string($host) ? strtolower($host) : '';
292 }
293
294 private function isExcludedPath(string $path): bool {
295 $lower = strtolower($path);
296 foreach (array('/wp-admin', '/wp-login.php', '/wp-json', '/wp-content', '/wp-includes') as $prefix) {
297 if ($lower === $prefix || strpos($lower, $prefix . '/') === 0) {
298 return true;
299 }
300 }
301
302 return preg_match('/\.(css|js|map|json|xml|jpg|jpeg|png|gif|webp|svg|ico|pdf|zip|woff|woff2|ttf|eot)$/i', $lower) === 1;
303 }
304
305 /**
306 * Resolves raw post identity data for a same-site referrer path only:
307 * post_id and post_title. Authorization (current_user_can('edit_post'))
308 * and the edit_url presentation link are NOT this repository's concern
309 * -- a data-access repository must not make authorization decisions or
310 * build admin-link HTML (CLAUDE.md "Strict layer separation"). The
311 * caller that renders source-evidence rows
312 * (ABJ_404_Solution_CapturedSourceEvidenceRenderer::editLinkHtml()) owns
313 * that decision, using the post_id returned here (c308).
314 *
315 * @param array<string, array<string, mixed>> $resolvedSources Call-scoped
316 * memo, keyed and updated by reference so repeat source paths within
317 * the same top-level call skip the post-id lookup. See
318 * groupRowsByCapturedUrl() for why this is a parameter rather than
319 * instance state.
320 * @return array<string, mixed>
321 */
322 private function resolveSource(string $sourcePath, array &$resolvedSources): array {
323 if (isset($resolvedSources[$sourcePath])) {
324 return $resolvedSources[$sourcePath];
325 }
326
327 $postId = $this->resolvePostId($sourcePath);
328 $title = $postId > 0 && function_exists('get_the_title') ? (string)get_the_title($postId) : '';
329
330 $resolvedSources[$sourcePath] = array(
331 'referrer_url' => $sourcePath,
332 'post_id' => $postId,
333 'post_title' => $title,
334 'hit_count' => 0,
335 'last_seen' => 0,
336 );
337 return $resolvedSources[$sourcePath];
338 }
339
340 private function resolvePostId(string $sourcePath): int {
341 if (function_exists('url_to_postid') && function_exists('home_url')) {
342 $postId = (int)url_to_postid(home_url($sourcePath));
343 if ($postId > 0) {
344 return $postId;
345 }
346 }
347
348 return $this->resolvePostIdFromPermalinkCache($sourcePath);
349 }
350
351 private function resolvePostIdFromPermalinkCache(string $sourcePath): int {
352 $trimmed = trim($sourcePath, '/');
353 $variants = array_values(array_unique(array($sourcePath, '/' . $trimmed, $trimmed, '/' . $trimmed . '/')));
354 $cleanVariants = array();
355 foreach ($variants as $variant) {
356 if ($variant === '') {
357 continue;
358 }
359 // $sourcePath is derived from the HTTP Referer header (see
360 // normalizeSameSiteReferrer()), which is visitor-supplied and can
361 // carry invalid UTF-8 byte sequences; sanitize before
362 // $wpdb->prepare().
363 $cleanVariants[] = $this->functions->sanitizeInvalidUTF8($variant);
364 }
365 if (empty($cleanVariants)) {
366 return 0;
367 }
368
369 $placeholders = implode(',', array_fill(0, count($cleanVariants), '%s'));
370 // allow-unbounded-select: literal "LIMIT 1" lands in a separate concatenated string than SELECT/FROM (audit blind spot #1); $cleanVariants is also a fixed <=4-element set, not visitor-controlled cardinality.
371 $query = "SELECT id FROM {wp_abj404_permalink_cache} WHERE url IN (" . $placeholders . ") LIMIT 1";
372 $result = $this->db->queryAndGetResults($query, array('query_params' => $cleanVariants));
373 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
374 $row = is_array($rows[0] ?? null) ? $rows[0] : array();
375 return $this->intField($row, 'id');
376 }
377
378 /** @param array<string, mixed> $row */
379 private function stringField(array $row, string $key): string {
380 return isset($row[$key]) && is_scalar($row[$key]) ? (string)$row[$key] : '';
381 }
382
383 /** @param array<string, mixed> $row */
384 private function intField(array $row, string $key): int {
385 return isset($row[$key]) && is_numeric($row[$key]) ? (int)$row[$key] : 0;
386 }
387
388 /**
389 * @param array<array-key, mixed> $row
390 * @return array<string, mixed>
391 */
392 private function stringKeyedRow(array $row): array {
393 $stringKeyed = array();
394 foreach ($row as $key => $value) {
395 if (is_string($key)) {
396 $stringKeyed[$key] = $value;
397 }
398 }
399 return $stringKeyed;
400 }
401 }
402