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

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

351 lines 16.3 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 * Published-content lookup queries used by the suggestion engine to find
9 * matching destinations for 404 URLs (posts, pages, images, tags, categories).
10 *
11 * Split from DataAccessTrait_Redirects in 4.1.x to keep both files under the
12 * 1500-line modularity cap. These methods are semantically distinct from
13 * redirect lifecycle code: they read WordPress core tables (wp_posts,
14 * wp_terms) to enumerate candidate destinations, not the plugin's own
15 * redirects table.
16 */
17 trait ABJ_404_Solution_DataAccess_PublishedContentTrait {
18
19 /** Returns rows with the IDs of the published items.
20 * @global type $wpdb
21 * @global type $abj404logic
22 * @global type $abj404dao
23 * @global type $abj404logging
24 * @param string $slug only get results for this slug. (empty means all posts)
25 * @param string $searchTerm use this string in a LIKE on the sql.
26 * @param string $limitResults
27 * @param string $orderResults
28 * @param string $extraWhereClause use this string in a where on the sql.
29 * @return array<int, object>
30 */
31 function getPublishedPagesAndPostsIDs($slug = '', $searchTerm = '',
32 $limitResults = '', $orderResults = '', $extraWhereClause = '') {
33 global $wpdb;
34 $abj404logic = abj_service('plugin_logic');
35
36 // Fix for missing table error (reported by 2 users - 4% of errors)
37 // Check if wp_posts table exists before querying
38 if (!$this->tableExists($wpdb->posts)) {
39 $this->logger->errorMessage("WordPress posts table not found: " . $wpdb->posts .
40 ". This may indicate an incorrect table prefix or database configuration issue.");
41 return array(); // Return empty array instead of crashing
42 }
43
44 // get the valid post types
45 $options = $abj404logic->getOptions();
46 $recognizedPostTypes = $this->buildPostTypeSqlList($options);
47 if ($recognizedPostTypes === '') {
48 return array();
49 }
50 // ----------------
51
52 if ($slug != "") {
53 // Sanitize invalid UTF-8 before SQL to prevent database errors
54 // (fixes bug: URLs like %9F%9F%9F%9F-%9F%9F%9F-1.png cause "invalid data" errors)
55 $slug = $this->f->sanitizeInvalidUTF8($slug);
56
57 // Check if the post_name column supports utf8mb4 collation
58 // (fixes bug: Arabic sites on latin1 databases get "invalid data" errors)
59 // Note: Check actual column collation, not database default - on mixed setups
60 // the database may be latin1 but wp_posts.post_name is utf8mb4
61 $collationResult = $this->queryAndGetResults(
62 "SELECT COLLATION_NAME FROM INFORMATION_SCHEMA.COLUMNS
63 WHERE TABLE_SCHEMA = DATABASE()
64 AND TABLE_NAME = %s
65 AND COLUMN_NAME = 'post_name'",
66 array('query_params' => array($wpdb->posts), 'log_errors' => false)
67 );
68 $collationRows = isset($collationResult['rows']) && is_array($collationResult['rows']) ? $collationResult['rows'] : array();
69 $columnCollation = null;
70 if (!empty($collationRows) && is_array($collationRows[0])) {
71 $first = reset($collationRows[0]);
72 $columnCollation = is_scalar($first) ? (string)$first : null;
73 }
74 if ($columnCollation !== null && strpos(strtolower($columnCollation), 'utf8mb4') !== false) {
75 // Column supports utf8mb4 - use CAST for proper Unicode comparison
76 $resolvedCollation = $this->sanitizeCollationIdentifier($columnCollation);
77 if ($resolvedCollation === '') {
78 $resolvedCollation = $this->getPreferredUtf8mb4Collation();
79 }
80 $specifiedSlug = " */\n and CAST(wp_posts.post_name AS CHAR CHARACTER SET utf8mb4) COLLATE utf8mb4_unicode_ci = "
81 . "'" . esc_sql($slug) . "' \n ";
82 $specifiedSlug = str_replace('utf8mb4_unicode_ci', $resolvedCollation, $specifiedSlug);
83 } else {
84 // Legacy column (latin1, utf8, etc.) - use simple comparison.
85 // 4-byte UTF-8 characters (emoji, rare CJK, etc.) cannot exist in a
86 // utf8mb3/latin1 column, so skip the slug comparison entirely to avoid
87 // "Illegal mix of collations" errors.
88 if ($this->f->containsUtf8mb4Characters($slug)) {
89 $specifiedSlug = '';
90 } else {
91 $specifiedSlug = " */\n and wp_posts.post_name = "
92 . "'" . esc_sql($slug) . "' \n ";
93 }
94 }
95 } else {
96 $specifiedSlug = '';
97 }
98
99 if ($searchTerm != "") {
100 $searchTerm = " */\n and lower(wp_posts.post_title) like "
101 . "'%" . esc_sql($this->f->strtolower($searchTerm)) . "%' \n ";
102 } else {
103 $searchTerm = '';
104 }
105
106 if ($extraWhereClause != "") {
107 $extraWhereClause = " */\n " . $extraWhereClause;
108 }
109
110 if (!empty($limitResults)) {
111 $limitResults = " */\n limit " . $limitResults;
112 }
113 if (!empty($orderResults)) {
114 $orderResults = " */\n order by " . $orderResults;
115 }
116
117 // load the query and do the replacements.
118 $query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/getPublishedPagesAndPostsIDs.sql");
119 $query = $this->doTableNameReplacements($query);
120 $query = $this->f->str_replace('{recognizedPostTypes}', $recognizedPostTypes, $query);
121 $query = $this->f->str_replace('{specifiedSlug}', $specifiedSlug, $query);
122 $query = $this->f->str_replace('{searchTerm}', $searchTerm, $query);
123 $query = $this->f->str_replace('{extraWhereClause}', $extraWhereClause, $query);
124 $query = $this->f->str_replace('{limit-results}', $limitResults, $query);
125 $query = $this->f->str_replace('{order-results}', $orderResults, $query);
126
127 $result = $this->queryAndGetResults($query, array('result_type' => OBJECT));
128 $queryError = is_string($result['last_error'] ?? '') ? ($result['last_error'] ?? '') : '';
129 $rows = is_array($result['rows']) ? $result['rows'] : array();
130
131 // Collation-error fallback: if CONVERT(... USING utf8mb4) COLLATE still fails
132 // (e.g. MySQL version quirk), retry without any COLLATE forcing. This is the
133 // pre-4.1.4 behavior that relies on implicit collation resolution.
134 if (!empty($queryError) && $this->isCollationError($queryError)) {
135 $fpreg = ABJ_404_Solution_FunctionsPreg::getInstance();
136 $fallbackQuery = $fpreg->regexReplace(
137 'CONVERT\(wpt\.name USING utf8mb4\) COLLATE [A-Za-z0-9_]+',
138 'wpt.name', $query);
139 $fallbackQuery = $fpreg->regexReplace(
140 'CONVERT\(usefulterms\.grouped_terms USING utf8mb4\) COLLATE [A-Za-z0-9_]+',
141 'usefulterms.grouped_terms', is_string($fallbackQuery) ? $fallbackQuery : $query);
142 $fallbackResult = $this->queryAndGetResults(
143 is_string($fallbackQuery) ? $fallbackQuery : $query,
144 array('result_type' => OBJECT, 'log_errors' => false));
145 $queryError = is_string($fallbackResult['last_error'] ?? '') ? ($fallbackResult['last_error'] ?? '') : '';
146 if (empty($queryError)) {
147 $rows = is_array($fallbackResult['rows']) ? $fallbackResult['rows'] : array();
148 }
149 }
150
151 if (!empty($queryError) && $this->isInvalidDataError($queryError) &&
152 $slug != "" && strpos($query, 'CAST(wp_posts.post_name AS CHAR CHARACTER SET utf8mb4)') !== false) {
153 // Compatibility fallback: retry once without CAST/COLLATE for environments
154 // where mixed encodings still reject utf8mb4 coercion.
155 $fallbackSpecifiedSlug = " */\n and wp_posts.post_name = '" . esc_sql($slug) . "' \n ";
156 $fallbackQuery = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/getPublishedPagesAndPostsIDs.sql");
157 $fallbackQuery = $this->doTableNameReplacements($fallbackQuery);
158 $fallbackQuery = $this->f->str_replace('{recognizedPostTypes}', $recognizedPostTypes, $fallbackQuery);
159 $fallbackQuery = $this->f->str_replace('{specifiedSlug}', $fallbackSpecifiedSlug, $fallbackQuery);
160 $fallbackQuery = $this->f->str_replace('{searchTerm}', $searchTerm, $fallbackQuery);
161 $fallbackQuery = $this->f->str_replace('{extraWhereClause}', $extraWhereClause, $fallbackQuery);
162 $fallbackQuery = $this->f->str_replace('{limit-results}', $limitResults, $fallbackQuery);
163 $fallbackQuery = $this->f->str_replace('{order-results}', $orderResults, $fallbackQuery);
164 $fallbackResult = $this->queryAndGetResults($fallbackQuery, array('result_type' => OBJECT, 'log_errors' => false));
165 $fallbackError = is_string($fallbackResult['last_error'] ?? '') ? ($fallbackResult['last_error'] ?? '') : '';
166 if (empty($fallbackError)) {
167 $queryError = '';
168 $rows = is_array($fallbackResult['rows']) ? $fallbackResult['rows'] : array();
169 }
170 }
171
172 // check for errors (use $queryError which tracks the latest attempt)
173 if ($queryError) {
174 // "Unknown column 'plc.content_keywords'" occurs during the DB migration window
175 // when the column hasn't been added yet (e.g. sync lock was stuck for ~24h).
176 // Degrade to warning so it doesn't generate email reports for every 404 hit.
177 if (stripos($queryError, 'unknown column') !== false &&
178 stripos($queryError, 'content_keywords') !== false) {
179 $this->logger->warn("content_keywords column not yet available (DB migration pending): " . $queryError);
180 } else if (!$this->classifyAndHandleInfrastructureError($queryError)) {
181 $this->logger->errorMessage("Error executing query. Err: " . $queryError . ", Query: " . $query);
182 }
183 }
184
185 return $rows;
186 }
187
188 /** Returns rows with the IDs of the published images.
189 * @return array<int, object>
190 */
191 function getPublishedImagesIDs() {
192 global $wpdb;
193 $abj404logic = abj_service('plugin_logic');
194
195 // get the valid post types
196 $options = $abj404logic->getOptions();
197 $recognizedPostTypes = $this->buildPostTypeSqlList($options);
198 if ($recognizedPostTypes === '') {
199 return array();
200 }
201 // ----------------
202
203 // load the query and do the replacements.
204 $query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/getPublishedImageIDs.sql");
205 $query = $this->doTableNameReplacements($query);
206 $query = $this->f->str_replace('{recognizedPostTypes}', $recognizedPostTypes, $query);
207
208 $result = $this->queryAndGetResults($query, array('result_type' => OBJECT));
209 $queryError = is_string($result['last_error'] ?? '') ? ($result['last_error'] ?? '') : '';
210 if ($queryError && !$this->classifyAndHandleInfrastructureError($queryError)) {
211 $this->logger->errorMessage("Error executing query. Err: " . $queryError . ", Query: " . $query);
212 }
213
214 return is_array($result['rows']) ? $result['rows'] : array();
215 }
216
217 /** Returns rows with the defined terms (tags).
218 * @param string|null $slug
219 * @param int|null $limit
220 * @return array<int, object>
221 */
222 function getPublishedTags($slug = null, $limit = null) {
223 global $wpdb;
224 $abj404logic = abj_service('plugin_logic');
225
226 // get the valid post types
227 $options = $abj404logic->getOptions();
228
229 $recognizedCategories = $this->buildCategorySqlList($options);
230
231 if ($slug != null) {
232 // Sanitize invalid UTF-8 before SQL to prevent database errors
233 $slug = $this->f->sanitizeInvalidUTF8($slug);
234 $slug = "*/ and wp_terms.slug = '" . esc_sql($slug) . "'\n";
235 }
236
237 $limitClause = '';
238 if ($limit !== null && is_numeric($limit) && $limit > 0) {
239 $limitClause = "LIMIT " . intval($limit);
240 }
241
242 // load the query and do the replacements.
243 $query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/getPublishedTags.sql");
244 $query = $this->f->str_replace('{slug}', $slug, $query);
245 $query = $this->f->str_replace('{limit}', $limitClause, $query);
246 $query = $this->doTableNameReplacements($query);
247 $query = $this->f->str_replace('{recognizedCategories}', $recognizedCategories, $query);
248
249 $result = $this->queryAndGetResults($query, array('result_type' => OBJECT));
250 $queryError = is_string($result['last_error'] ?? '') ? ($result['last_error'] ?? '') : '';
251 if ($queryError && !$this->classifyAndHandleInfrastructureError($queryError)) {
252 $this->logger->errorMessage("Error executing query. Err: " . $queryError . ", Query: " . $query);
253 }
254 $rows = is_array($result['rows']) ? $result['rows'] : array();
255
256 $rows = $this->addURLToTermsRows($rows);
257
258 return $rows;
259 }
260
261 /**
262 * @param array<int, object> $rows
263 * @return array<int, object>
264 */
265 function addURLToTermsRows($rows) {
266 // add url data
267 global $wp_rewrite;
268 $extraPermaStructureCache = array();
269 foreach ($rows as $row) {
270 $taxonomy = isset($row->taxonomy) ? (string)$row->taxonomy : '';
271 if (!array_key_exists($taxonomy, $extraPermaStructureCache)) {
272 $extraPermaStructureCache[$taxonomy] = $wp_rewrite->get_extra_permastruct($taxonomy);
273 }
274 $struct = $extraPermaStructureCache[$taxonomy];
275
276 $slug = isset($row->slug) ? (string)$row->slug : '';
277 $url = str_replace('%' . $taxonomy . '%', $slug, $struct);
278
279 // TODO verify one of the urls?
280 /*
281 if (!$verifiedOne) {
282 $id = $row->term_id;
283 $link = get_tag_link($id);
284 $link = get_category_link($id);
285 // $link should equal $url
286 $verifiedOne = true;
287 }
288 */
289
290 /** @var \stdClass $row */
291 $row->url = $url;
292 }
293
294 return $rows;
295 }
296
297 /** Returns rows with the defined categories.
298 * @param int|null $term_id
299 * @param string|null $slug
300 * @param int|null $limit
301 * @return array<int, object>
302 */
303 function getPublishedCategories($term_id = null, $slug = null, $limit = null) {
304 global $wpdb;
305 $abj404logic = abj_service('plugin_logic');
306
307 // get the valid post types
308 $options = $abj404logic->getOptions();
309
310 $recognizedCategories = $this->buildCategorySqlList($options);
311 if ($recognizedCategories === '') {
312 $recognizedCategories = "''";
313 }
314
315 if ($term_id != null) {
316 // Cast to integer for safety even though term_id is currently always null from callers
317 $term_id = "*/ and {wp_terms}.term_id = " . intval($term_id) . "\n";
318 }
319
320 if ($slug != null) {
321 // Sanitize invalid UTF-8 before SQL to prevent database errors
322 $slug = $this->f->sanitizeInvalidUTF8($slug);
323 $slug = "*/ and {wp_terms}.slug = '" . esc_sql($slug) . "'\n";
324 }
325
326 $limitClause = '';
327 if ($limit !== null && is_numeric($limit) && $limit > 0) {
328 $limitClause = "LIMIT " . intval($limit);
329 }
330
331 // load the query and do the replacements.
332 $query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/getPublishedCategories.sql");
333 $query = $this->f->str_replace('{recognizedCategories}', $recognizedCategories, $query);
334 $query = $this->f->str_replace('{term_id}', $term_id !== null ? (string)$term_id : '', $query);
335 $query = $this->f->str_replace('{slug}', $slug, $query);
336 $query = $this->f->str_replace('{limit}', $limitClause, $query);
337 $query = $this->doTableNameReplacements($query);
338
339 $result = $this->queryAndGetResults($query, array('result_type' => OBJECT));
340 $queryError = is_string($result['last_error'] ?? '') ? ($result['last_error'] ?? '') : '';
341 if ($queryError && !$this->classifyAndHandleInfrastructureError($queryError)) {
342 $this->logger->errorMessage("Error executing query. Err: " . $queryError . ", Query: " . $query);
343 }
344 $rows = is_array($result['rows']) ? $result['rows'] : array();
345
346 $rows = $this->addURLToTermsRows($rows);
347
348 return $rows;
349 }
350 }
351