PluginProbe
WP-Optimize – Cache, Compress images, Minify & Clean database to boost page speed & performance / 4.5.2
WP-Optimize – Cache, Compress images, Minify & Clean database to boost page speed & performance v4.5.2
4.7.0 4.6.1 4.6.0 4.5.5 4.5.4 4.5.3 4.5.2 3.2.20 3.2.21 3.2.22 3.2.3 3.2.5 3.2.6 3.2.7 3.2.9 3.3.0 3.3.1 3.3.2 3.4.0 3.4.1 3.4.2 3.5.0 3.6.0 3.7.0 3.7.1 All 111 releases
wp-optimize / includes / class-wp-optimize-404-detector.php

class-wp-optimize-404-detector.php in WP-Optimize – Cache, Compress images, Minify & Clean database to boost page speed & performance 4.5.2, at includes/class-wp-optimize-404-detector.php

496 lines 15.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 if (!defined('ABSPATH')) die('No direct access allowed');
3
4 if (!class_exists('WP_Optimize_404_Detector')) :
5
6 class WP_Optimize_404_Detector {
7
8 /**
9 * Suspicious requests threshold
10 *
11 * @var int
12 */
13 private $suspicious_request_count_threshold = 50;
14
15 /**
16 * Remove trivial requests older than hours
17 *
18 * @var int
19 */
20 private $suspicious_trivial_request_ttl_in_hours = 24;
21
22 /**
23 * Remove suspicious requests older than hours
24 *
25 * @var int
26 */
27 private $suspicious_request_ttl_in_hours = 672;
28
29 /**
30 * How many suspicious requests count in total to show in the dashboard?
31 *
32 * @var int
33 */
34 private $dashboard_alert_request_count_threshold = 100;
35
36 /**
37 * Store the total count for each url, to be able to sort it later
38 *
39 * @var array
40 */
41 private $total_count_per_url = array();
42
43 /**
44 * Class constructor
45 */
46 private function __construct() {
47 add_action('wpo_prune_404_log', array($this, 'prune_404_log'));
48 }
49
50 /**
51 * Initialize the class as a singleton
52 *
53 * @return WP_Optimize_404_Detector
54 */
55 public static function get_instance() {
56 static $_instance = null;
57 if (null === $_instance) {
58 $_instance = new self();
59 }
60 return $_instance;
61 }
62
63 /**
64 * Handle 404 requests
65 *
66 * @return void
67 */
68 public function handle_request() {
69 $now = time();
70 $request_timestamp = $now - ($now % 3600);
71
72 $url_data = isset($_SERVER['REQUEST_URI']) ? $this->parse_url(esc_url_raw(wp_unslash($_SERVER['REQUEST_URI']))) : false;
73
74 if (!$url_data || !isset($url_data['path'])) {
75 return;
76 }
77
78 $is_plain_structure = $this->is_plain_permalink_structure();
79
80 if (($is_plain_structure && empty($url_data['query'])) || (!$is_plain_structure && '/' === $url_data['path'])) {
81 return;
82 }
83
84 $url = $is_plain_structure ? home_url('?' . $url_data['query']) : home_url($url_data['path']);
85
86 $this->save_request_hour_row($request_timestamp, $url);
87 }
88
89 /**
90 * Log an entry for 404 errors
91 *
92 * @param string $request_timestamp The moment the request is happening
93 * @param string $url Relative url to the URL being analyzed
94 * @return void
95 */
96 private function save_request_hour_row($request_timestamp, $url) {
97 global $wpdb;
98
99 $log_table_name = esc_sql($this->get_table_name());
100
101 $referrer = isset($_SERVER['HTTP_REFERER']) ? esc_url_raw(wp_unslash($_SERVER['HTTP_REFERER'])) : "";
102
103 $safe_referrer = '';
104 if ('' !== $referrer) {
105 $referrer_parsed = $this->parse_url($referrer);
106 $safe_referrer = (isset($referrer_parsed['scheme']) ? $referrer_parsed['scheme'] . '://' : '') .
107 ($referrer_parsed['host'] ?? '') .
108 (isset($referrer_parsed['port']) ? ':' . $referrer_parsed['port'] : '') .
109 ($referrer_parsed['path'] ?? '') .
110 (isset($referrer_parsed['query']) ? '?' . $referrer_parsed['query'] : '');
111 }
112
113 $wpdb->query($wpdb->prepare("INSERT INTO `{$log_table_name}` SET `url` = %s, request_timestamp = %d, referrer = %s, request_count = 1 ON DUPLICATE KEY UPDATE request_count = request_count + 1", $url, $request_timestamp, $safe_referrer)); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $log_table_name uses esc_sql, %i not supported till WP 6.2
114 }
115
116 /**
117 * Remove trivial requests older than 24 hours, and suspicious requests after 4 weeks
118 *
119 * @return void
120 */
121 public function prune_404_log() {
122 global $wpdb;
123
124 $log_table_name = esc_sql($this->get_table_name());
125
126 // Remove old trivial requests
127 $hs_to_remove_older = $this->suspicious_trivial_request_ttl_in_hours * 3600;
128 $remove_date = time() - $hs_to_remove_older;
129
130
131 $wpdb->query($wpdb->prepare("DELETE FROM `$log_table_name` WHERE request_timestamp < %d AND request_count < %d", $remove_date, $this->suspicious_request_count_threshold)); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $log_table_name uses esc_sql, %i not supported till WP 6.2
132
133 // Remove any type of old request
134 $hs_to_remove_older = $this->suspicious_request_ttl_in_hours * 3600;
135 $remove_date = time() - $hs_to_remove_older;
136
137 $wpdb->query($wpdb->prepare("DELETE FROM `$log_table_name` WHERE request_timestamp < %d", $remove_date)); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $log_table_name uses esc_sql, %i not supported till WP 6.2
138 }
139
140 /**
141 * Get suspicious requests from DB grouped by url and referrer
142 *
143 * @return array
144 */
145 public function get_suspicious_requests() {
146 $all_suspicious_referrers = array();
147 $by_referrer = $this->get_single_suspicious_requests_by_referer($all_suspicious_referrers);
148
149 $by_url = $this->get_grouped_requests_by_url($all_suspicious_referrers);
150
151 $result = array_merge($by_url, $by_referrer);
152
153 usort($result, array($this, 'sort_suspicious_requests'));
154
155 $per_url = array();
156
157 foreach ($result as $item) {
158 $per_url[$item->url][] = $item;
159 }
160
161 return $per_url;
162 }
163
164 /**
165 * Find requests that by themselves have a request count over the threshold
166 *
167 * @param array|null $all_suspicious_referrers Optional. By reference, will be populated with hashed referrers
168 * @return array
169 */
170 private function get_single_suspicious_requests_by_referer(&$all_suspicious_referrers = null) {
171 global $wpdb;
172 $log_table_name = esc_sql($this->get_table_name());
173
174 $threshold = $this->suspicious_request_count_threshold;
175
176 $by_referrer = $wpdb->get_results(
177 $wpdb->prepare("SELECT `url`,
178 SUM(IF(request_count < %d, 0, request_count)) AS total_count,
179 referrer,
180 MIN(request_timestamp) AS first_access,
181 MAX(request_timestamp) AS last_access,
182 COUNT(1) AS occurrences,
183 1 AS total_referrers,
184 'singles' AS row_type
185 FROM `$log_table_name` GROUP BY `url`, referrer HAVING total_count >= %d ORDER BY request_timestamp DESC", $threshold, $threshold) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $log_table_name uses esc_sql, %i not supported till WP 6.2
186 );
187
188 foreach ($by_referrer as &$item) {
189 $item->referrer = esc_html($item->referrer);
190 $item->suspicious_referrers = 1;
191 $item->non_suspicious_referrers = 0;
192
193 $this->total_count_per_url[$item->url] = $item->total_count;
194
195 if (is_array($all_suspicious_referrers)) {
196 $all_suspicious_referrers[] = substr(md5($item->referrer), 0, 6);
197 }
198 }
199
200 return $by_referrer;
201 }
202
203 /**
204 * Find urls that if we sum the requests for all referrers, the result is greater than the threshold
205 *
206 * @param array $known_suspicious_referrers Hashed referrers to filter out from non-suspicious count
207 * @return array
208 */
209 private function get_grouped_requests_by_url($known_suspicious_referrers) {
210 global $wpdb;
211 $log_table_name = esc_sql($this->get_table_name());
212
213 $threshold = $this->suspicious_request_count_threshold;
214
215 $known_suspicious_referrers = implode(',', array_unique($known_suspicious_referrers));
216
217 $by_url = $wpdb->get_results(
218 $wpdb->prepare("SELECT `url`,
219 SUM(request_count) AS total_count,
220 '' AS referrer,
221 MIN(request_timestamp) AS first_access,
222 MAX(request_timestamp) AS last_access,
223 COUNT(1) AS occurrences,
224 (COUNT(DISTINCT(IF(%d < request_count, '--nonsuspcious--', referrer)))) AS suspicious_referrers,
225 (SUM(IF(%d < request_count AND LOCATE(MD5(SUBSTRING(referrer,1,6)), %s) = 0, 1, 0))) AS non_suspicious_referrers,
226 COUNT(DISTINCT(referrer)) AS total_referrers,
227 'grouped' AS row_type
228 FROM `$log_table_name` GROUP BY `url` HAVING 1 < occurrences AND %d <= total_count ORDER BY request_timestamp DESC", $threshold, $threshold, $known_suspicious_referrers, $threshold) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $log_table_name uses esc_sql, %i not supported till WP 6.2
229 );
230
231 foreach ($by_url as &$item) {
232 $item->referrer = esc_html__('(any)', 'wp-optimize');
233
234 if (0 < $item->non_suspicious_referrers) {
235 // Some non-suspicious referrers exist in the group, all those are grouped under `--nonsuspicious--`, so remove it from the suspicious count
236 $item->suspicious_referrers = $item->suspicious_referrers - 1;
237 }
238
239 $this->total_count_per_url[$item->url] = $item->total_count;
240 }
241
242 return $by_url;
243 }
244
245 /**
246 * Get suspicious requests from DB for a single request url, grouped by under/over threshold
247 *
248 * @param string $url The actual url we are fetching for
249 * @return array
250 */
251 public function get_url_requests_by_referrer($url) {
252 global $wpdb;
253
254 $log_table_name = esc_sql($this->get_table_name());
255
256 $return = array('over' => array(), 'under' => array());
257
258 $threshold = $this->suspicious_request_count_threshold;
259
260 $requests = $wpdb->get_results(
261 $wpdb->prepare("SELECT SUM(request_count) AS total_count,
262 referrer,
263 MIN(request_timestamp) AS first_access,
264 MAX(request_timestamp) AS last_access
265 FROM `$log_table_name` " . // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $log_table_name uses esc_sql, %i not supported till WP 6.2
266 "WHERE `url` = %s GROUP BY referrer, (%d < request_count)
267 ORDER BY request_count DESC",
268 $url,
269 $threshold
270 )
271 );
272
273 foreach ($requests as $request) {
274 $group = $request->total_count >= $threshold ? 'over' : 'under';
275 $return[$group][] = $request;
276 }
277
278 return $return;
279 }
280
281 /**
282 * Check if there are any suspicious requests logged, then return the count of unique URLs and total 404 requests
283 *
284 * @return array
285 */
286 public function get_suspicious_requests_count() {
287 $requests = $this->get_single_suspicious_requests_by_referer();
288
289 $result = array(
290 'unique_urls' => 0,
291 'total_requests' => 0,
292 );
293
294 if (empty($requests)) {
295 return $result;
296 }
297
298 if (!is_multisite() || (is_multisite() && is_network_admin())) {
299 $result = $this->handle_single_site_or_network_admin_requests($requests, $result);
300 } elseif (is_multisite() && defined('SUBDOMAIN_INSTALL') && SUBDOMAIN_INSTALL) {
301 $result = $this->handle_subdomain_multisite_requests($requests, $result);
302 } elseif (is_multisite()) {
303 $result = $this->handle_subdirectory_multisite_requests($requests, $result);
304 }
305 return $result;
306 }
307
308 /**
309 * This method run to count total 404 requests and unique URLs for single site and multi site network admin.
310 *
311 * @param array $requests requests save in db
312 * @param array $result result array containing unique urls and total request count default values (zero)
313 *
314 * @return array
315 */
316 private function handle_single_site_or_network_admin_requests($requests, $result) {
317
318 foreach ($requests as $req) {
319 $result = $this->increment_result($req->total_count, $result);
320 }
321
322 return $result;
323 }
324
325 /**
326 * This method run to count total 404 requests and unique URLs for multi site sub-domain setup only (not for network admin).
327 *
328 * @param array $requests requests save in db
329 * @param array $result result array containing unique urls and total request count default values (zero)
330 *
331 * @return array
332 */
333 private function handle_subdomain_multisite_requests($requests, $result) {
334
335 $current_blog_id = get_current_blog_id();
336 $current_site_url = get_site_url($current_blog_id);
337 $current_host = $this->parse_url($current_site_url, PHP_URL_HOST);
338
339 foreach ($requests as $req) {
340 $req_host = $this->parse_url($req->url, PHP_URL_HOST);
341
342 if ($req_host === $current_host) {
343 $result = $this->increment_result($req->total_count, $result);
344 }
345 }
346
347 return $result;
348 }
349
350 /**
351 * This method run to count total 404 requests and unique URLs for multi site sub-directory setup only.
352 *
353 * @param array $requests requests save in db
354 * @param array $result result array containing unique urls and total request count default values (zero)
355 *
356 * @return array
357 */
358 private function handle_subdirectory_multisite_requests($requests, $result) {
359
360 $current_blog_id = get_current_blog_id();
361 $current_site_url = get_site_url($current_blog_id);
362 $current_host = $this->parse_url($current_site_url, PHP_URL_HOST);
363 $current_path = trim((string) $this->parse_url($current_site_url, PHP_URL_PATH), '/');
364 $other_paths = array();
365 $all_sites = get_sites(array('fields' => 'ids'));
366
367 foreach ($all_sites as $site_id) {
368 if ($site_id === $current_blog_id) {
369 continue;
370 }
371 $site_path = trim((string) $this->parse_url(get_site_url($site_id), PHP_URL_PATH), '/');
372 if (!empty($site_path)) {
373 $other_paths[] = '/' . $site_path;
374 }
375 }
376
377 foreach ($requests as $req) {
378
379 $req_host = $this->parse_url($req->url, PHP_URL_HOST);
380 $req_path = trim((string) $this->parse_url($req->url, PHP_URL_PATH), '/');
381
382 if ($req_host !== $current_host) {
383 continue;
384 }
385
386 if (empty($current_path)) {
387 $is_sub_site_request = false;
388 foreach ($other_paths as $sub_path) {
389 if (0 === strpos('/' . $req_path, $sub_path)) {
390 $is_sub_site_request = true;
391 break;
392 }
393 }
394 if ($is_sub_site_request) {
395 continue;
396 }
397 } else {
398 if (0 !== strpos('/' . $req_path, '/' . $current_path)) {
399 continue;
400 }
401 }
402 $result = $this->increment_result($req->total_count, $result);
403 }
404
405 return $result;
406 }
407
408 /**
409 * Returns the incremented result for unique urls and total requests
410 *
411 * @param int $total_count total requests count (didn't type cast as $wpdb->get_results() doesn't guarantee it)
412 * @param array $result result array containing unique urls and total request count
413 *
414 * @return array
415 */
416 private function increment_result($total_count, $result) {
417 $result['unique_urls']++;
418 $result['total_requests'] += (int) $total_count;
419 return $result;
420 }
421
422 /**
423 * Returns the dashboard alert request count
424 *
425 * @return int
426 */
427 public function get_dashboard_alert_request_count_threshold() {
428 return $this->dashboard_alert_request_count_threshold;
429 }
430
431 /**
432 * Returns the suspicious request count threshold
433 *
434 * @return int
435 */
436 public function get_suspicious_request_count_threshold() {
437 return $this->suspicious_request_count_threshold;
438 }
439
440 /**
441 * Returns the table name
442 *
443 * @return string
444 */
445 private function get_table_name() {
446 return WP_Optimize_Table_404_Detector::get_instance()->get_table_name();
447 }
448
449 /**
450 * Sorts the array by `url total count` DESC, `url` ASC, and `request total_count` DESC
451 *
452 * @param object $result_a
453 * @param object $result_b
454 *
455 * @return int
456 */
457 private function sort_suspicious_requests($result_a, $result_b) {
458 $a_url_total = $this->total_count_per_url[$result_a->url] ?? 0;
459 $b_url_total = $this->total_count_per_url[$result_b->url] ?? 0;
460
461 $a = (PHP_INT_MAX - $a_url_total) . ' ' . $result_a->url . ' ' . (PHP_INT_MAX - $result_a->total_count);
462 $b = (PHP_INT_MAX - $b_url_total) . ' ' . $result_b->url . ' ' . (PHP_INT_MAX - $result_b->total_count);
463
464 return $a < $b ? -1 : 1;
465 }
466
467 /**
468 * Wrapper over `wp_parse_url` to handle a `false` response, in such case if component is -1 then we return empty array else empty string
469 * This wrapper always requests `wp_parse_url` the default return an array with all components found.
470 *
471 * @param string $url The URL to be parsed
472 * @param int $component The specific component to retrieve. Use one of the PHP predefined constants to specify which one. Defaults to -1 (= return all parts as an array).
473 *
474 * @return string|array
475 */
476 private function parse_url($url, $component = -1) {
477 $parsed = wp_parse_url($url, $component);
478
479 if (false !== $parsed && null !== $parsed) {
480 return $parsed;
481 }
482 return (-1 === $component) ? array() : '';
483 }
484
485 /**
486 * Check if the plain permalink structure is currently set
487 *
488 * @return bool
489 */
490 private function is_plain_permalink_structure(): bool {
491 return "" === get_option('permalink_structure');
492 }
493 }
494
495 endif;
496