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

ViewQueryPolicy.php in 404 Solution 4.3.0, at includes/view-build/ViewQueryPolicy.php

378 lines 15.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Query policy for admin redirect and captured-url table reads.
9 *
10 * Owns safe status-filter resolution, score-range fragments, text-search
11 * fragments, order-by allowlists, collation selection, and view-build labels.
12 */
13 class ABJ_404_Solution_ViewQueryPolicy {
14
15 /**
16 * @param array<string, mixed> $tableOptions
17 * @param string $columnPrefix
18 * @return string
19 */
20 public function buildScoreRangeClause(array $tableOptions, string $columnPrefix): string {
21 $rawScoreRange = $tableOptions['score_range'] ?? 'all';
22 $scoreRange = is_string($rawScoreRange) ? $rawScoreRange : 'all';
23 $col = $columnPrefix . 'score';
24 $high = (int) ABJ_404_Solution_ScoreThresholds::HIGH;
25 $medium = (int) ABJ_404_Solution_ScoreThresholds::MEDIUM;
26 switch ($scoreRange) {
27 case ABJ_404_Solution_ScoreThresholds::RANGE_HIGH:
28 return 'AND ' . $col . ' >= ' . $high;
29 case ABJ_404_Solution_ScoreThresholds::RANGE_MEDIUM:
30 return 'AND ' . $col . ' >= ' . $medium . ' AND ' . $col . ' < ' . $high;
31 case ABJ_404_Solution_ScoreThresholds::RANGE_LOW:
32 return 'AND ' . $col . ' IS NOT NULL AND ' . $col . ' < ' . $medium;
33 case ABJ_404_Solution_ScoreThresholds::MANUAL:
34 return 'AND ' . $col . ' IS NULL';
35 default:
36 return '';
37 }
38 }
39
40 /**
41 * @param string $sub
42 * @param array<string, mixed> $tableOptions
43 * @return string
44 */
45 public function resolveStatusTypeList(string $sub, array $tableOptions): string {
46 global $abj404_redirect_types, $abj404_captured_types;
47 $filter = $tableOptions['filter'] ?? 0;
48
49 if ($filter == 0 || $filter == ABJ404_TRASH_FILTER) {
50 if ($sub === 'abj404_redirects') {
51 return $this->statusArrayToList(is_array($abj404_redirect_types) ? $abj404_redirect_types : array());
52 }
53 if ($sub === 'abj404_captured') {
54 return $this->statusArrayToList(is_array($abj404_captured_types) ? $abj404_captured_types : array());
55 }
56 return '0';
57 }
58 if ($filter == ABJ404_STATUS_MANUAL) {
59 return implode(', ', array(ABJ404_STATUS_MANUAL, ABJ404_STATUS_REGEX));
60 }
61 if ($filter == ABJ404_HANDLED_FILTER) {
62 return implode(', ', array(ABJ404_STATUS_IGNORED, ABJ404_STATUS_LATER));
63 }
64
65 return $this->singleStatusFilter($filter);
66 }
67
68 /**
69 * @param mixed $filter
70 * @return string
71 */
72 private function singleStatusFilter($filter): string {
73 if (!is_scalar($filter)) {
74 return '0';
75 }
76 $raw = trim((string)$filter);
77 if (!preg_match('/^\d+$/', $raw)) {
78 return '0';
79 }
80 return (string)intval($raw);
81 }
82
83 /**
84 * @param array<int, mixed> $types
85 * @return string
86 */
87 private function statusArrayToList(array $types): string {
88 $clean = array();
89 foreach ($types as $type) {
90 if (is_scalar($type)) {
91 $clean[] = intval($type);
92 }
93 }
94 return count($clean) > 0 ? implode(', ', $clean) : '0';
95 }
96
97 /** @param array<string, mixed> $tableOptions @return int */
98 public function resolveTrashValue(array $tableOptions): int {
99 return ($tableOptions['filter'] ?? 0) == ABJ404_TRASH_FILTER ? 1 : 0;
100 }
101
102 /**
103 * @param array<string, mixed> $tableOptions
104 * @return string
105 */
106 public function resolveOrderByColumn(array $tableOptions): string {
107 $rawOrderBy = $tableOptions['orderby'] ?? '';
108 $orderBy = strtolower(is_string($rawOrderBy) ? $rawOrderBy : '');
109 $allowed = array('url', 'status', 'type', 'code', 'score', 'timestamp',
110 'logshits', 'last_used', 'final_dest', 'dest', 'id');
111 if ($orderBy === 'dest' || $orderBy === 'final_dest') {
112 return "CASE WHEN dest_for_view IS NULL OR dest_for_view = '' THEN 1 ELSE 0 END ASC, dest_for_view";
113 }
114 if (!in_array($orderBy, $allowed, true)) {
115 return 'url';
116 }
117 return $orderBy;
118 }
119
120 /**
121 * @param array<string, mixed> $tableOptions
122 * @return string
123 */
124 public function resolveOrderDirection(array $tableOptions): string {
125 $rawOrderVal = $tableOptions['order'] ?? '';
126 $rawOrderValStr = is_string($rawOrderVal) ? $rawOrderVal : '';
127 $order = strtoupper((string)preg_replace('/[^a-zA-Z]/', '', trim($rawOrderValStr)));
128 return $order === 'DESC' ? 'DESC' : 'ASC';
129 }
130
131 /**
132 * @param string $sub
133 * @param array<string, mixed> $tableOptions
134 * @param bool $singleTable When true, the post-type label predicate resolves
135 * the matching destination posts via a {wp_posts} subquery on final_dest
136 * rather than a wp_post_type column reference, because the single-table
137 * redirects read (Denorm Step 3b) has no denormalized wp_post_type column.
138 * @param bool $destColumnAvailable When false, the destination-title match
139 * drops the dest_for_view column from the search expression (schema-drift
140 * tolerance: an old redirects table may lack it). The search then matches
141 * url/code/labels only.
142 * @return string
143 */
144 public function buildFilterTextClause(string $sub, array $tableOptions, bool $singleTable = false, bool $destColumnAvailable = true): string {
145 $rawFilterText = $tableOptions['filterText'] ?? '';
146 $rawFilterText = is_string($rawFilterText) ? $rawFilterText : '';
147 if ($rawFilterText === '') {
148 return '';
149 }
150
151 $filterText = $this->sanitizeFilterText($rawFilterText);
152 $collation = $this->resolveCollation($tableOptions);
153 $needle = $this->normalizedSearchExpression("'%" . $filterText . "%'", $collation);
154 if ($sub === 'abj404_redirects') {
155 $predicates = $this->labelPredicatesForFilterText($filterText, $singleTable);
156 $searchConcat = $destColumnAvailable
157 ? "CONCAT(url, '////', dest_for_view, '////', code)"
158 : "CONCAT(url, '////', code)";
159 $predicates[] = $this->normalizedSearchExpression($searchConcat, $collation) . " LIKE " . $needle;
160 return 'AND (' . implode(' OR ', $predicates) . ')';
161 }
162 if ($sub === 'abj404_captured') {
163 return "AND " . $this->normalizedSearchExpression('url', $collation) . " LIKE " . $needle;
164 }
165 return 'AND 0 = 1';
166 }
167
168 /**
169 * @param string $sqlExpression
170 * @param string $collation
171 * @return string
172 */
173 private function normalizedSearchExpression(string $sqlExpression, string $collation): string {
174 return "REPLACE(LOWER(CONVERT(" . $sqlExpression . " USING utf8mb4) COLLATE "
175 . $collation . "), ' ', '')";
176 }
177
178 /**
179 * @param string $rawFilterText
180 * @return string
181 */
182 public function sanitizeFilterText(string $rawFilterText): string {
183 global $wpdb;
184 $sanitized = str_replace(array('*', '/', '$'), '', $rawFilterText);
185 if (isset($wpdb) && is_object($wpdb) && method_exists($wpdb, 'esc_like')) {
186 /** @var wpdb $wpdb */
187 $sanitized = $wpdb->esc_like($sanitized);
188 } else {
189 $sanitized = addcslashes($sanitized, '_%\\');
190 }
191 return esc_sql($sanitized);
192 }
193
194 /**
195 * @param array<string, mixed> $tableOptions
196 * @return string
197 */
198 public function resolveCollation(array $tableOptions): string {
199 global $wpdb;
200 $wpdbCollate = 'utf8mb4_unicode_ci';
201 $hasForcedCollate = false;
202 if (array_key_exists('forceCollate', $tableOptions) && !empty($tableOptions['forceCollate'])) {
203 $rawForceCollateVal = $tableOptions['forceCollate'];
204 $rawForceCollate = is_string($rawForceCollateVal) ? $rawForceCollateVal : '';
205 $forced = preg_replace('/[^A-Za-z0-9_]/', '', $rawForceCollate);
206 if ($forced !== '') {
207 $wpdbCollate = $forced;
208 $hasForcedCollate = true;
209 }
210 }
211 if (!$hasForcedCollate && isset($wpdb) && isset($wpdb->collate) && !empty($wpdb->collate)) {
212 $wpdbCollate = preg_replace('/[^A-Za-z0-9_]/', '', $wpdb->collate);
213 }
214 return $wpdbCollate === '' ? 'utf8mb4_unicode_ci' : $wpdbCollate;
215 }
216
217 /**
218 * @param string $filterText
219 * @param bool $singleTable
220 * @return array<int, string>
221 */
222 private function labelPredicatesForFilterText(string $filterText, bool $singleTable = false): array {
223 $normalized = $this->normalizeSearchLabel($filterText);
224 if ($normalized === '') {
225 return array();
226 }
227
228 $statusMatches = $this->matchingLabelCodes($normalized, $this->statusSearchLabels());
229 $typeMatches = $this->matchingLabelCodes($normalized, $this->typeSearchLabels());
230 $predicates = array();
231 if (count($statusMatches) > 0) {
232 $predicates[] = 'status IN (' . implode(', ', $statusMatches) . ')';
233 }
234 if (count($typeMatches) > 0) {
235 $predicates[] = 'type IN (' . implode(', ', $typeMatches) . ')';
236 }
237 $predicates = array_merge($predicates, $this->postTypeLabelPredicates($normalized, $singleTable));
238 return $predicates;
239 }
240
241 /**
242 * @param array<int, array<int, string>> $labelsByCode
243 * @return array<int, int>
244 */
245 private function matchingLabelCodes(string $normalized, array $labelsByCode): array {
246 $matches = array();
247 foreach ($labelsByCode as $code => $labels) {
248 foreach ($labels as $label) {
249 if ($this->normalizeSearchLabel($label) === $normalized) {
250 $matches[] = (int)$code;
251 break;
252 }
253 }
254 }
255 return $matches;
256 }
257
258 /** @return array<int, array<int, string>> */
259 private function statusSearchLabels(): array {
260 return array(
261 ABJ404_STATUS_MANUAL => array(__('Manual', '404-solution')),
262 ABJ404_STATUS_AUTO => array(__('Auto', '404-solution'), __('Automatic', '404-solution')),
263 ABJ404_STATUS_REGEX => array(__('Regex', '404-solution')),
264 );
265 }
266
267 /** @return array<int, array<int, string>> */
268 private function typeSearchLabels(): array {
269 return array(
270 ABJ404_TYPE_EXTERNAL => array(__('External', '404-solution')),
271 ABJ404_TYPE_CAT => array(__('Category', '404-solution')),
272 ABJ404_TYPE_TAG => array(__('Tag', '404-solution')),
273 ABJ404_TYPE_HOME => array(__('Home', '404-solution')),
274 ABJ404_TYPE_404_DISPLAYED => array(__('(404 page)', '404-solution')),
275 );
276 }
277
278 /**
279 * @param string $normalized
280 * @param bool $singleTable When true, match the destination post type via a
281 * {wp_posts} subquery on final_dest instead of the denormalized
282 * wp_post_type column (which the single-table redirects read lacks).
283 * @return array<int, string>
284 */
285 private function postTypeLabelPredicates(string $normalized, bool $singleTable = false): array {
286 $matchingSlugs = $this->matchingPostTypeSlugs($normalized);
287 if (count($matchingSlugs) === 0) {
288 return array();
289 }
290
291 $quotedSlugs = array();
292 foreach ($matchingSlugs as $slug) {
293 // Post-type slugs are validated by register_post_type() to be
294 // lowercase ASCII word/dash characters. Strip anything outside
295 // that whitelist defensively before esc_sql() so a misregistered
296 // (or scanner-injected) slug with invalid UTF-8 cannot reach SQL.
297 $safeSlug = preg_replace('/[^a-zA-Z0-9_-]/', '', $slug);
298 if ($safeSlug === null || $safeSlug === '') {
299 continue;
300 }
301 $quotedSlugs[] = "'" . esc_sql($safeSlug) . "'";
302 }
303 if (count($quotedSlugs) === 0) {
304 return array();
305 }
306 if ($singleTable) {
307 // No wp_post_type column on wp_abj404_redirects: resolve the matching
308 // destination posts by id. final_dest holds the numeric post id as a
309 // string for POST-typed rows. The subquery only appears for a (rare)
310 // post-type-label search, so it never affects the no-filter
311 // single-table EXPLAIN plan asserted by the scale test.
312 return array('(type = ' . (int)ABJ404_TYPE_POST
313 . ' AND final_dest IN (SELECT ID FROM {wp_posts} WHERE post_type IN ('
314 . implode(', ', $quotedSlugs) . ')))');
315 }
316 return array('(type = ' . (int)ABJ404_TYPE_POST . ' AND wp_post_type IN (' . implode(', ', $quotedSlugs) . '))');
317 }
318
319 /** @return array<int, string> */
320 private function matchingPostTypeSlugs(string $normalized): array {
321 $postTypes = $this->currentPostTypeSearchLabels();
322 $matchingSlugs = array();
323 foreach ($postTypes as $slug => $labels) {
324 foreach ($labels as $label) {
325 if ($this->normalizeSearchLabel($label) === $normalized) {
326 $matchingSlugs[] = (string)$slug;
327 break;
328 }
329 }
330 }
331 return array_values(array_unique($matchingSlugs));
332 }
333
334 /** @return array<string, array<int, string>> */
335 private function currentPostTypeSearchLabels(): array {
336 $labelsBySlug = array(
337 'post' => array(__('Post', '404-solution'), 'Post'),
338 'page' => array(__('Page', '404-solution'), 'Page'),
339 );
340 if (!function_exists('get_post_types')) {
341 return $labelsBySlug;
342 }
343
344 $postTypes = get_post_types(array(), 'objects');
345 if (!is_array($postTypes)) {
346 return $labelsBySlug;
347 }
348 foreach ($postTypes as $slug => $postType) {
349 $slugString = (string)$slug;
350 if ($slugString === '' && is_object($postType) && property_exists($postType, 'name')
351 && is_scalar($postType->name)) {
352 $slugString = (string)$postType->name;
353 }
354 if ($slugString === '') {
355 continue;
356 }
357 $labels = array(ucfirst(strtolower($slugString)));
358 if (is_object($postType) && property_exists($postType, 'labels') && is_object($postType->labels)
359 && property_exists($postType->labels, 'singular_name') && is_scalar($postType->labels->singular_name)) {
360 $singular = trim((string)$postType->labels->singular_name);
361 if ($singular !== '') {
362 $labels[] = $singular;
363 }
364 }
365 $labelsBySlug[$slugString] = array_values(array_unique(array_merge(
366 $labelsBySlug[$slugString] ?? array(),
367 $labels
368 )));
369 }
370 return $labelsBySlug;
371 }
372
373 private function normalizeSearchLabel(string $label): string {
374 $clean = str_replace(array('*', '/', '$'), '', $label);
375 return strtolower(str_replace(' ', '', trim($clean)));
376 }
377 }
378