PluginProbe
HTTP 410 (Gone) responses / 1.0.2
HTTP 410 (Gone) responses v1.0.2
1.2.1 trunk 0.1 0.2 0.3 0.4 0.5 0.6 0.6.1 0.7 0.7.1 0.7.2 0.8.1 0.8.2 1.0.2 1.0.3 1.1.0
wp-410 / wp-410.php

wp-410.php in HTTP 410 (Gone) responses 1.0.2, at wp-410.php

675 lines 20.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Plugin Name: HTTP 410 (Gone) responses
4 * Plugin URI: https://wordpress.org/plugins/wp-410/
5 * Description: Sends HTTP 410 (Gone) responses to requests for pages that no longer exist on your blog.
6 * Version: 1.0.2
7 * Author: Samir Shah
8 * Author URI: http://rayofsolaris.net/
9 * Maintainer: Matt Calvert
10 * Maintainer URI: https://calvert.media
11 * License: GPLv2 or later
12 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
13 *
14 * @package MCLV_410_Plugin
15 */
16
17 if ( ! defined( 'ABSPATH' ) ) {
18 exit;
19 }
20
21
22 /**
23 * Main plugin class for HTTP 410 (Gone) responses.
24 */
25 class MCLV_410_Plugin {
26
27 /**
28 * Current database schema version.
29 *
30 * @var int
31 */
32 const DB_VERSION = 5;
33
34 /**
35 * Whether pretty permalinks are enabled for the current site.
36 *
37 * @var bool
38 */
39 private $permalinks;
40
41 /**
42 * Name of the plugin's database table.
43 *
44 * @var string
45 */
46 private $table;
47
48 /**
49 * Set initial state and register admin/front-end hooks.
50 *
51 * Determines permalink support, stores the plugin table name, and hooks
52 * upgrade checks plus admin or template redirects depending on context.
53 * Always listens for new posts to reconcile obsolete link entries.
54 */
55 public function __construct() {
56 $this->permalinks = (bool) get_option( 'permalink_structure' );
57 $this->table = $GLOBALS['wpdb']->prefix . '410_links';
58
59 add_action( 'plugins_loaded', array( $this, 'upgrade_check' ) );
60
61 if ( is_admin() ) {
62 add_action( 'admin_menu', array( $this, 'settings_menu' ) );
63 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_assets' ) );
64 } else {
65 add_action( 'template_redirect', array( $this, 'check_for_410' ) );
66 }
67
68 // these could theoretically happen both with/without is_admin().
69 add_action( 'wp_insert_post', array( $this, 'note_inserted_post' ) );
70 }
71
72 /**
73 * Create the plugin's custom database table if it does not exist.
74 *
75 * Uses dbDelta to ensure the latest schema (including indexes) is present.
76 *
77 * @return void
78 */
79 private function install_table() {
80 // remember, two spaces after PRIMARY KEY otherwise WP borks.
81 $sql = "CREATE TABLE $this->table (
82 gone_id MEDIUMINT unsigned NOT NULL AUTO_INCREMENT,
83 gone_key VARCHAR(512) NOT NULL,
84 gone_regex VARCHAR(512) NOT NULL,
85 is_404 SMALLINT(1) unsigned NOT NULL DEFAULT 0,
86 PRIMARY KEY (gone_id),
87 KEY is_404 (is_404)
88 );";
89
90 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
91 dbDelta( $sql );
92 }
93
94 /**
95 * Fetch all registered 410 links.
96 *
97 * @return object[] Array of link rows keyed by gone_key.
98 */
99 private function get_links() {
100 global $wpdb;
101
102 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table, data changes frequently, caching would show stale results.
103 return $wpdb->get_results(
104 $wpdb->prepare(
105 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
106 "SELECT gone_key, gone_regex FROM {$this->table} WHERE is_404 = %d",
107 0
108 ),
109 OBJECT_K
110 );
111 }
112
113 /**
114 * Maximum number of 404 entries to retain.
115 *
116 * @return int
117 */
118 private function max_404_list_length() {
119 return get_option( 'mclv_410_max_404s', 50 );
120 }
121
122 /**
123 * Fetch recent logged 404 entries, trimmed to the configured limit.
124 *
125 * @return object[] Array of 404 link rows keyed by gone_key.
126 */
127 private function get_404s() {
128 global $wpdb;
129
130 $this->concat_404_list();
131
132 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table, data changes frequently, caching would show stale results.
133 return $wpdb->get_results(
134 $wpdb->prepare(
135 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
136 "SELECT gone_key, gone_regex FROM {$this->table} WHERE is_404 = %d ORDER BY gone_id DESC",
137 1
138 ),
139 OBJECT_K
140 );
141 }
142
143 /**
144 * Insert a new 410 or logged 404 entry and store its regex matcher.
145 *
146 * Skips when 404 logging is disabled or the key already exists.
147 *
148 * @param string $key Fully qualified URL (supports * wildcards).
149 * @param bool $is_404 Whether this entry represents a logged 404 hit.
150 * @return int|null Number of rows affected when duplicate is found, otherwise void.
151 */
152 private function add_link( $key, $is_404 = false ) {
153 // just supply the link.
154 global $wpdb;
155
156 // 404 logging enabled?
157 if ( $is_404 && 0 === $this->max_404_list_length() ) {
158 return;
159 }
160
161 // build regex.
162 $parts = preg_split( '/(\*)/', $key, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY );
163 foreach ( $parts as &$part ) {
164 if ( '*' !== $part ) {
165 $part = preg_quote( $part, '|' );
166 }
167 }
168 $parts = str_replace( '*', '.*', $parts );
169 $regex = '|^' . implode( '', $parts ) . '$|i';
170
171 // avoid duplicates - messy but MySQL doesn't allow url-length unique keys.
172 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table, checking for duplicates before insert.
173 $count = (int) $wpdb->get_var(
174 $wpdb->prepare(
175 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
176 "SELECT COUNT(*) FROM {$this->table} WHERE gone_key = %s",
177 $key
178 )
179 );
180
181 if ( $count > 0 ) {
182 return 0;
183 }
184
185 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table, insert operation.
186 $wpdb->insert(
187 $this->table,
188 array(
189 'gone_key' => $key,
190 'gone_regex' => $regex,
191 'is_404' => intval( $is_404 ),
192 )
193 );
194
195 // Don't let 404 list grow forever.
196 if ( $is_404 ) {
197 $this->concat_404_list();
198 }
199 }
200
201 /**
202 * Trim the logged 404 list to the configured maximum length.
203 *
204 * @return void
205 */
206 private function concat_404_list() {
207 global $wpdb;
208
209 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table, count needed for immediate trim operation.
210 $total_404s = (int) $wpdb->get_var(
211 $wpdb->prepare(
212 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
213 "SELECT COUNT(*) FROM {$this->table} WHERE is_404 = %d",
214 1
215 )
216 );
217
218 $n = $total_404s - $this->max_404_list_length();
219
220 if ( $n > 0 ) {
221 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table, delete operation to trim list.
222 $wpdb->query(
223 $wpdb->prepare(
224 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
225 "DELETE FROM {$this->table} WHERE is_404 = %d ORDER BY gone_id LIMIT %d",
226 1,
227 $n
228 )
229 );
230 }
231 }
232
233 /**
234 * Promote a logged 404 entry to a 410 entry.
235 *
236 * @param string $key URL key to convert.
237 * @return int|false Rows updated or false on error.
238 */
239 private function convert_404( $key ) {
240 global $wpdb;
241
242 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table, update operation.
243 return $wpdb->query(
244 $wpdb->prepare(
245 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
246 "UPDATE {$this->table} SET is_404 = %d WHERE gone_key = %s LIMIT 1",
247 0,
248 $key
249 )
250 );
251 }
252
253 /**
254 * Delete a stored link (410 or 404) by its key.
255 *
256 * @param string $key URL key to remove.
257 * @return int|false Rows deleted or false on error.
258 */
259 private function remove_link( $key ) {
260 global $wpdb;
261
262 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table, delete operation.
263 return $wpdb->query(
264 $wpdb->prepare(
265 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
266 "DELETE FROM {$this->table} WHERE gone_key = %s",
267 $key
268 )
269 );
270 }
271
272 /**
273 * Checks whether the plugin's stored database/version options need upgrading,
274 * and performs required migrations when moving between older plugin versions.
275 *
276 * This handles:
277 * - Installing the custom 410 table when upgrading from versions before DB version 5.
278 * - Migrating legacy stored links (options-based) into the database when upgrading from
279 * versions prior to DB version 3.
280 * - Removing deprecated options once migration is complete.
281 *
282 * @return void
283 */
284 public function upgrade_check() {
285 $options_version = (int) get_option( 'mclv_410_options_version', 0 );
286
287 if ( self::DB_VERSION === $options_version ) {
288 return;
289 }
290
291 // last db change was in version 5.
292 if ( $options_version < 5 ) {
293 $this->install_table();
294 }
295
296 if ( $options_version < 3 ) {
297 $old_links = get_option( 'mclv_410_links_list', array() );
298 $new_links = array(); // just a simple array of links.
299
300 if ( 0 === $options_version ) { // links were stored just as links.
301 $new_links = array_map( 'rawurldecode', $old_links );
302 } elseif ( 1 === $options_version ) { // links were stored as array( link => regex ). We only need the link.
303 $new_links = array_map( 'rawurldecode', array_keys( $old_links ) );
304 } else { // moved to using the database in DB_VERSION 3.
305 $new_links = array_keys( $old_links );
306 }
307
308 foreach ( $new_links as $link ) {
309 $this->add_link( $link );
310 }
311
312 delete_option( 'mclv_410_links_list' ); // remove old option.
313 }
314
315 update_option( 'mclv_410_options_version', self::DB_VERSION );
316 }
317
318 /**
319 * Registers the 410 plugin settings page within the WordPress admin Plugins menu.
320 *
321 * Adds a submenu item under "Plugins" that links to the management screen for
322 * obsolete URLs, recent 404s, and other plugin configuration options.
323 *
324 * @return void
325 */
326 public function settings_menu() {
327 add_submenu_page( 'plugins.php', 'HTTP 410 (Gone) responses', 'HTTP 410 (Gone) responses', 'manage_options', 'mclv_410_settings', array( $this, 'settings_page' ) );
328 }
329
330 /**
331 * Enqueue admin styles and scripts for the plugin settings page.
332 *
333 * @param string $hook_suffix The current admin page hook suffix.
334 * @return void
335 */
336 public function enqueue_admin_assets( $hook_suffix ) {
337 // Only load on our settings page.
338 if ( 'plugins_page_mclv_410_settings' !== $hook_suffix ) {
339 return;
340 }
341
342 // Enqueue admin CSS.
343 wp_enqueue_style(
344 'mclv-410-admin',
345 plugin_dir_url( __FILE__ ) . 'css/admin.css',
346 array(),
347 '1.0.0'
348 );
349
350 // Enqueue admin JavaScript.
351 wp_enqueue_script(
352 'mclv-410-admin',
353 plugin_dir_url( __FILE__ ) . 'js/admin.js',
354 array(),
355 '1.0.0',
356 true
357 );
358 }
359
360 /**
361 * Render and handle the plugin settings page.
362 *
363 * Processes add/delete/link-length form submissions, refreshes link lists,
364 * and loads the admin settings view template.
365 *
366 * @return void
367 */
368 public function settings_page() {
369 $links = $this->get_links();
370 $logged_404s = $this->get_404s();
371
372 // Handle form submissions and show success message if action was taken.
373 $action_taken = $this->handle_settings_form_submissions( $links, $logged_404s );
374 if ( $action_taken ) {
375 echo '<div id="message" class="updated fade"><p>Options updated.</p></div>';
376 }
377
378 // Separate wildcards from regular URLs.
379 $wildcard_links = array();
380 $regular_links = array();
381
382 foreach ( $links as $key => $link ) {
383 if ( $this->is_wildcard_url( $key ) ) {
384 $wildcard_links[ $key ] = $link;
385 } else {
386 $regular_links[ $key ] = $link;
387 }
388 }
389
390 ksort( $wildcard_links );
391 ksort( $regular_links );
392
393 // Prepare variables for the view.
394 $max_404_length = $this->max_404_list_length();
395 $has_410_template = (bool) locate_template( '410.php' );
396 $plugin = $this;
397
398 // Load the view template.
399 include plugin_dir_path( __FILE__ ) . 'views/admin-settings.php';
400 }
401
402 /**
403 * Handle form submissions on the settings page.
404 *
405 * @param array $links Reference to the links array (modified in place).
406 * @param array $logged_404s Reference to the 404s array (modified in place).
407 * @return bool Whether an action was taken.
408 */
409 private function handle_settings_form_submissions( &$links, &$logged_404s ) {
410 // Delete regular URLs.
411 if ( isset( $_POST['delete-regular-urls'] ) && ! empty( $_POST['regular_links_to_remove'] ) ) {
412 check_admin_referer( 'mclv-410-settings' );
413 $regular_links_to_remove = array_map( 'sanitize_text_field', wp_unslash( $_POST['regular_links_to_remove'] ) );
414 foreach ( $regular_links_to_remove as $key ) {
415 if ( isset( $links[ $key ] ) ) {
416 $this->remove_link( $key );
417 unset( $links[ $key ] );
418 }
419 }
420 return true;
421 }
422
423 // Delete wildcard URLs.
424 if ( isset( $_POST['delete-wildcard-urls'] ) && ! empty( $_POST['wildcard_links_to_remove'] ) ) {
425 check_admin_referer( 'mclv-410-settings' );
426 $wildcard_links_to_remove = array_map( 'sanitize_text_field', wp_unslash( $_POST['wildcard_links_to_remove'] ) );
427 foreach ( $wildcard_links_to_remove as $key ) {
428 if ( isset( $links[ $key ] ) ) {
429 $this->remove_link( $key );
430 unset( $links[ $key ] );
431 }
432 }
433 return true;
434 }
435
436 if ( isset( $_POST['add-to-410-list'] ) ) {
437 // Entries to add, either manually or from 404 list.
438 check_admin_referer( 'mclv-410-settings' );
439 $failed_to_add = array();
440
441 if ( ! empty( $_POST['links_to_add'] ) ) {
442 $links_to_add_raw = sanitize_textarea_field( wp_unslash( $_POST['links_to_add'] ) );
443 foreach ( preg_split( '/(\r?\n)+/', $links_to_add_raw, -1, PREG_SPLIT_NO_EMPTY ) as $link ) {
444 $link = sanitize_text_field( $link );
445 if ( $this->is_valid_url( $link ) ) {
446 $this->add_link( $link );
447 } else {
448 $failed_to_add[] = '<code>' . esc_html( $link ) . '</code>';
449 }
450 }
451 }
452
453 if ( ! empty( $_POST['add_404s'] ) ) {
454 $add_404s = array_map( 'sanitize_text_field', wp_unslash( $_POST['add_404s'] ) );
455 foreach ( $add_404s as $link ) {
456 if ( isset( $logged_404s[ $link ] ) ) {
457 $this->convert_404( $link );
458 }
459 }
460 }
461
462 // Refresh lists after adding.
463 $links = $this->get_links();
464 $logged_404s = $this->get_404s();
465
466 if ( $failed_to_add ) {
467 $message = '<div class="error"><p>The following entries could not be recognised as URLs that your WordPress site handles, and were not added to the list. ';
468 $message .= 'This can be because the domain name and path does not match that of your WordPress site, or because pretty permalinks are disabled.</p>';
469 $message .= '<p>- ' . implode( '<br> - ', $failed_to_add ) . '</p></div>';
470 echo wp_kses_post( $message );
471 }
472 return true;
473 }
474
475 if ( isset( $_POST['set-404-list-length'] ) ) {
476 check_admin_referer( 'mclv-410-settings' );
477 $max_404_length = isset( $_POST['max_404_list_length'] ) ? absint( $_POST['max_404_list_length'] ) : 50;
478 update_option( 'mclv_410_max_404s', $max_404_length );
479 $logged_404s = $this->get_404s();
480 return true;
481 }
482
483 return false;
484 }
485
486 /**
487 * Determine if a URL can be handled by the current WordPress install.
488 *
489 * Checks path prefix and, when permalinks are off, ensures the URL is not
490 * a pretty permalink format.
491 *
492 * @param string $link Fully qualified URL to validate.
493 * @return bool
494 */
495 private function is_valid_url( $link ) {
496 // Determine whether WP will handle a request for this URL.
497 $wp_path = wp_parse_url( home_url( '/' ), PHP_URL_PATH );
498 $link_path = wp_parse_url( $link, PHP_URL_PATH );
499
500 if ( 0 !== strpos( $link_path, $wp_path ) ) {
501 return false;
502 }
503
504 if ( ! $this->permalinks ) {
505 $req = preg_replace( '|' . preg_quote( $wp_path, '|' ) . '/?|', '', $link_path );
506 if ( strlen( $req ) && '?' !== $req[0] ) { // this is a pretty permalink, but pretty permalinks are disabled.
507 return false;
508 }
509 }
510
511 return true;
512 }
513
514 /**
515 * Check if a URL contains wildcard characters.
516 *
517 * @param string $url URL to check.
518 * @return bool True if URL contains wildcards.
519 */
520 private function is_wildcard_url( $url ) {
521 return false !== strpos( $url, '*' );
522 }
523
524 /**
525 * Render a table of URLs for the admin interface.
526 *
527 * @param array $urls Array of URL objects keyed by gone_key.
528 * @param string $table_id HTML ID for the table.
529 * @param string $checkbox_id Prefix for checkbox IDs.
530 * @param string $select_all_id ID for the select-all checkbox.
531 * @param string $checkbox_name Name attribute for checkboxes (default: 'old_links_to_remove[]').
532 * @return bool Whether any invalid URLs were found.
533 */
534 public function render_url_table( $urls, $table_id, $checkbox_id, $select_all_id, $checkbox_name = 'old_links_to_remove[]' ) {
535 $invalid_links_exist = false;
536
537 echo '<div class="mclv-410-table-wrap"><table id="' . esc_attr( $table_id ) . '" class="wp-list-table widefat fixed">';
538 echo '<thead><th class="check-column"><input type="checkbox" id="' . esc_attr( $select_all_id ) . '" /><label for="' . esc_attr( $select_all_id ) . '" class="screen-reader-text"> Select all</label></th><th>URL</th></thead>';
539 echo '<tbody>';
540
541 foreach ( array_keys( $urls ) as $k ) {
542 $valid = $this->is_valid_url( $k );
543
544 if ( ! $valid ) {
545 $invalid_links_exist = true;
546 }
547
548 $k_attr = esc_attr( $k );
549 $k_text = esc_html( $k );
550 $class = $valid ? '' : ' class="invalid"';
551
552 $row_html = '<tr' . $class . '>';
553 $row_html .= '<td><input type="checkbox" name="' . esc_attr( $checkbox_name ) . '" id="' . esc_attr( $checkbox_id ) . '-' . $k_attr . '" value="' . $k_attr . '" /></td>';
554 $row_html .= '<td><label for="' . esc_attr( $checkbox_id ) . '-' . $k_attr . '"><code>' . $k_text . '</code></label></td>';
555 $row_html .= '</tr>';
556
557 echo wp_kses(
558 $row_html,
559 array(
560 'tr' => array( 'class' => true ),
561 'td' => array(),
562 'input' => array(
563 'type' => true,
564 'name' => true,
565 'id' => true,
566 'value' => true,
567 ),
568 'label' => array( 'for' => true ),
569 'code' => array(),
570 )
571 );
572 }
573
574 echo '</tbody></table></div>';
575
576 return $invalid_links_exist;
577 }
578
579 /**
580 * Remove matching obsolete links when a post is created or updated.
581 *
582 * @param int $id Post ID.
583 * @return void
584 */
585 public function note_inserted_post( $id ) {
586 $post = get_post( $id );
587
588 if ( ! $post instanceof WP_Post ) {
589 return;
590 }
591
592 if ( 'revision' === $post->post_type || 'draft' === $post->post_status ) {
593 return;
594 }
595
596 // Check our list of URLs against the new/updated post's permalink, and if they match, scratch it from our list.
597 $created_links = array();
598
599 $created_links[] = rawurldecode( get_permalink( $id ) );
600 $created_links[] = get_post_comments_feed_link( $id ); // back compat.
601
602 if ( $this->permalinks ) {
603 $created_links[] = $created_links[0] . '*';
604 }
605
606 foreach ( $created_links as $link ) {
607 $this->remove_link( $link );
608 }
609 }
610
611 /**
612 * Intercept 404 requests and emit a 410 response for known obsolete URLs.
613 *
614 * Logs unknown 404s when logging is enabled.
615 *
616 * @return void
617 */
618 public function check_for_410() {
619 // Don't mess if WordPress has found something to display.
620 if ( ! is_404() ) {
621 return;
622 }
623
624 $links = $this->get_links();
625
626 // Sanitize server variables.
627 $http_host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '';
628 $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
629
630 $req = ( is_ssl() ? 'https://' : 'http://' ) . $http_host . $request_uri;
631 $req = rawurldecode( $req );
632
633 foreach ( $links as $link ) {
634 $match_result = preg_match( $link->gone_regex, $req );
635
636 if ( false === $match_result ) {
637 // Invalid regex – skip this pattern rather than breaking the request.
638 continue;
639 }
640
641 if ( 1 === $match_result ) {
642 define( 'DONOTCACHEPAGE', true );
643 status_header( 410 );
644
645 /**
646 * Fires when a 410 response is about to be sent.
647 *
648 * @since 1.0.0
649 */
650 do_action( 'mclv_410_response' );
651
652 /**
653 * Fires when a 410 response is about to be sent.
654 *
655 * @since 0.4
656 * @deprecated 1.0.0 Use 'mclv_410_response' instead.
657 */
658 do_action_deprecated( 'wp_410_response', array(), '1.0.0', 'mclv_410_response' );
659
660 if ( ! locate_template( '410.php', true ) ) {
661 echo 'Sorry, the page you requested has been permanently removed.';
662 }
663
664 exit;
665 }
666 }
667
668 // no hit, log 404.
669 $this->add_link( $req, true );
670 }
671 }
672
673 // Bootstrap the plugin.
674 new MCLV_410_Plugin();
675