PluginProbe
HTTP 410 (Gone) responses / 1.2.1
HTTP 410 (Gone) responses v1.2.1
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.2.1, at wp-410.php

1,269 lines 41.6 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.2.1
7 * Requires at least: 5.0
8 * Requires PHP: 7.4
9 * Author: Samir Shah
10 * Author URI: http://rayofsolaris.net/
11 * Maintainer: Matt Calvert
12 * Maintainer URI: https://calvert.media
13 * License: GPLv2 or later
14 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
15 *
16 * @package MCLV_410_Plugin
17 */
18
19 if ( ! defined( 'ABSPATH' ) ) {
20 exit;
21 }
22
23
24 /**
25 * Main plugin class for HTTP 410 (Gone) responses.
26 */
27 class MCLV_410_Plugin {
28
29 /**
30 * Current database schema version.
31 *
32 * @var int
33 */
34 const DB_VERSION = 5;
35
36 /**
37 * Maximum number of primary keys to include in a single bulk UPDATE/DELETE query.
38 *
39 * Keeps generated SQL and prepared-statement placeholder counts within safe limits
40 * when a very large number of rows is selected at once.
41 *
42 * @var int
43 */
44 const BULK_CHUNK_SIZE = 200;
45
46 /**
47 * Maximum number of URLs accepted in a single manual "add URLs" submission.
48 *
49 * The manual-add textarea is a single POST field, so it cannot trigger the
50 * max_input_vars failure that affects checkbox lists, but each line still
51 * runs its own duplicate-check and insert query in a loop. A very large
52 * paste could still exhaust the request's execution time limit, so the
53 * submission is rejected up front rather than processed partway.
54 *
55 * @var int
56 */
57 const MAX_MANUAL_URLS = 500;
58
59 /**
60 * Nonce action/name shared by all settings-page forms.
61 *
62 * @var string
63 */
64 const NONCE_ACTION = 'mclv-410-settings';
65
66 /**
67 * Whether pretty permalinks are enabled for the current site.
68 *
69 * @var bool
70 */
71 private $permalinks;
72
73 /**
74 * Name of the plugin's database table.
75 *
76 * @var string
77 */
78 private $table;
79
80 /**
81 * Set initial state and register admin/front-end hooks.
82 *
83 * Determines permalink support, stores the plugin table name, and hooks
84 * upgrade checks plus admin or template redirects depending on context.
85 * Always listens for new posts to reconcile obsolete link entries.
86 */
87 public function __construct() {
88 $this->permalinks = (bool) get_option( 'permalink_structure' );
89 $this->table = $GLOBALS['wpdb']->prefix . '410_links';
90
91 add_action( 'plugins_loaded', array( $this, 'upgrade_check' ) );
92
93 if ( is_admin() ) {
94 add_action( 'admin_menu', array( $this, 'settings_menu' ) );
95 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_assets' ) );
96 } else {
97 add_action( 'template_redirect', array( $this, 'check_for_410' ) );
98 }
99
100 // these could theoretically happen both with/without is_admin().
101 add_action( 'wp_insert_post', array( $this, 'note_inserted_post' ) );
102 }
103
104 /**
105 * Create the plugin's custom database table if it does not exist.
106 *
107 * Uses dbDelta to ensure the latest schema (including indexes) is present.
108 *
109 * @return void
110 */
111 private function install_table() {
112 // remember, two spaces after PRIMARY KEY otherwise WP borks.
113 $sql = "CREATE TABLE $this->table (
114 gone_id MEDIUMINT unsigned NOT NULL AUTO_INCREMENT,
115 gone_key VARCHAR(512) NOT NULL,
116 gone_regex VARCHAR(512) NOT NULL,
117 is_404 SMALLINT(1) unsigned NOT NULL DEFAULT 0,
118 PRIMARY KEY (gone_id),
119 KEY is_404 (is_404)
120 );";
121
122 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
123 dbDelta( $sql );
124 }
125
126 /**
127 * Fetch all registered 410 links.
128 *
129 * Used on the front end, where every stored pattern must be checked against
130 * the current request, so it intentionally does not paginate.
131 *
132 * @return object[] Array of link rows keyed by gone_key.
133 */
134 private function get_links() {
135 global $wpdb;
136
137 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table, data changes frequently, caching would show stale results.
138 return $wpdb->get_results(
139 $wpdb->prepare(
140 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
141 "SELECT gone_id, gone_key, gone_regex FROM {$this->table} WHERE is_404 = %d",
142 0
143 ),
144 OBJECT_K
145 );
146 }
147
148 /**
149 * Maximum number of 404 entries to retain.
150 *
151 * @return int
152 */
153 private function max_404_list_length() {
154 return get_option( 'mclv_410_max_404s', 50 );
155 }
156
157 /**
158 * Run a paginated SELECT against the plugin's table.
159 *
160 * Shared by get_links_page() and get_404s_page(): both need the same
161 * count -> clamp-page -> LIMIT/OFFSET shape, differing only in their WHERE
162 * and ORDER BY clauses.
163 *
164 * @param string $where_sql SQL WHERE clause (without the WHERE keyword), using %d/%s placeholders.
165 * @param array $where_args Values for the WHERE clause placeholders, in order.
166 * @param string $order_sql SQL ORDER BY clause (without the ORDER BY keyword).
167 * @param int $page Requested 1-based page number.
168 * @param int $per_page Number of rows per page.
169 * @return object Object with `items`, `total`, `page`, `per_page`, `total_pages`.
170 */
171 private function paginate_query( $where_sql, array $where_args, $order_sql, $page, $per_page ) {
172 global $wpdb;
173
174 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table, count needed for pagination.
175 $total = (int) $wpdb->get_var(
176 $wpdb->prepare(
177 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $where_sql/$where_args are built internally by callers (not from user input); the placeholder count varies by caller and always matches $where_args.
178 "SELECT COUNT(*) FROM {$this->table} WHERE {$where_sql}",
179 $where_args
180 )
181 );
182
183 $total_pages = max( 1, (int) ceil( $total / $per_page ) );
184 $page = min( max( 1, absint( $page ) ), $total_pages );
185 $offset = ( $page - 1 ) * $per_page;
186
187 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table, paginated listing fetched directly with LIMIT/OFFSET.
188 $items = $wpdb->get_results(
189 // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Placeholder count varies by caller ($where_sql) and always matches $where_args plus the two LIMIT/OFFSET values.
190 $wpdb->prepare(
191 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $where_sql/$order_sql are built internally by callers, not from user input.
192 "SELECT gone_id, gone_key, gone_regex FROM {$this->table} WHERE {$where_sql} ORDER BY {$order_sql} LIMIT %d OFFSET %d",
193 array_merge( $where_args, array( $per_page, $offset ) )
194 )
195 );
196
197 return (object) array(
198 'items' => $items,
199 'total' => $total,
200 'page' => $page,
201 'per_page' => $per_page,
202 'total_pages' => $total_pages,
203 );
204 }
205
206 /**
207 * Fetch a single page of non-404 links (regular URLs or wildcard patterns).
208 *
209 * Uses LIMIT/OFFSET so the admin page never has to load the full list into
210 * memory merely to render or submit one page of rows.
211 *
212 * @param bool $wildcard Whether to fetch wildcard patterns (true) or plain URLs (false).
213 * @param int $page Requested 1-based page number.
214 * @param int $per_page Number of rows per page.
215 * @return object Object with `items`, `total`, `page`, `per_page`, `total_pages`.
216 */
217 private function get_links_page( $wildcard, $page, $per_page ) {
218 // $like_op is a fixed internal string ('' or 'NOT'), never derived from user input.
219 $like_op = $wildcard ? '' : 'NOT';
220
221 return $this->paginate_query( "is_404 = %d AND gone_key {$like_op} LIKE %s", array( 0, '%*%' ), 'gone_key ASC', $page, $per_page );
222 }
223
224 /**
225 * Fetch a single page of logged 404 entries, most recent first.
226 *
227 * @param int $page Requested 1-based page number.
228 * @param int $per_page Number of rows per page.
229 * @return object Object with `items`, `total`, `page`, `per_page`, `total_pages`.
230 */
231 private function get_404s_page( $page, $per_page ) {
232 $this->concat_404_list();
233
234 return $this->paginate_query( 'is_404 = %d', array( 1 ), 'gone_id DESC', $page, $per_page );
235 }
236
237 /**
238 * Number of rows to display per page on the admin settings screen.
239 *
240 * @return int
241 */
242 private function per_page() {
243 /**
244 * Filters the number of rows shown per page on the plugin's admin lists.
245 *
246 * @since 1.2.0
247 *
248 * @param int $per_page Rows per page. Default 100.
249 */
250 $per_page = apply_filters( 'mclv_410_admin_per_page', 100 );
251
252 return max( 1, absint( $per_page ) );
253 }
254
255 /**
256 * Read and sanitise a pagination query argument.
257 *
258 * @param string $key Query string key to read.
259 * @return int Sanitised, 1-or-greater page number.
260 */
261 private function get_current_page( $key ) {
262 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only pagination parameter, does not change state.
263 $page = isset( $_GET[ $key ] ) ? absint( wp_unslash( $_GET[ $key ] ) ) : 1;
264
265 return $page > 0 ? $page : 1;
266 }
267
268 /**
269 * Insert a new 410 or logged 404 entry and store its regex matcher.
270 *
271 * Skips when 404 logging is disabled or the key already exists.
272 *
273 * @param string $key Fully qualified URL (supports * wildcards).
274 * @param bool $is_404 Whether this entry represents a logged 404 hit.
275 * @return int|false Number of rows inserted (1), 0 if the key already exists or
276 * logging is disabled, or false on a database error.
277 */
278 private function add_link( $key, $is_404 = false ) {
279 // just supply the link.
280 global $wpdb;
281
282 // 404 logging enabled?
283 if ( $is_404 && 0 === $this->max_404_list_length() ) {
284 return 0;
285 }
286
287 // build regex.
288 $parts = preg_split( '/(\*)/', $key, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY );
289 foreach ( $parts as &$part ) {
290 if ( '*' !== $part ) {
291 $part = preg_quote( $part, '|' );
292 }
293 }
294 $parts = str_replace( '*', '.*', $parts );
295 $regex = '|^' . implode( '', $parts ) . '$|i';
296
297 // avoid duplicates - messy but MySQL doesn't allow url-length unique keys.
298 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table, checking for duplicates before insert.
299 $count = (int) $wpdb->get_var(
300 $wpdb->prepare(
301 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
302 "SELECT COUNT(*) FROM {$this->table} WHERE gone_key = %s",
303 $key
304 )
305 );
306
307 if ( $count > 0 ) {
308 return 0;
309 }
310
311 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table, insert operation.
312 $inserted = $wpdb->insert(
313 $this->table,
314 array(
315 'gone_key' => $key,
316 'gone_regex' => $regex,
317 'is_404' => intval( $is_404 ),
318 )
319 );
320
321 if ( false === $inserted ) {
322 $this->log_db_error( 'add_link' );
323 }
324
325 // Don't let 404 list grow forever.
326 if ( $is_404 ) {
327 $this->concat_404_list();
328 }
329
330 return $inserted;
331 }
332
333 /**
334 * Trim the logged 404 list to the configured maximum length.
335 *
336 * @return void
337 */
338 private function concat_404_list() {
339 global $wpdb;
340
341 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table, count needed for immediate trim operation.
342 $total_404s = (int) $wpdb->get_var(
343 $wpdb->prepare(
344 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
345 "SELECT COUNT(*) FROM {$this->table} WHERE is_404 = %d",
346 1
347 )
348 );
349
350 $n = $total_404s - $this->max_404_list_length();
351
352 if ( $n > 0 ) {
353 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table, delete operation to trim list.
354 $wpdb->query(
355 $wpdb->prepare(
356 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
357 "DELETE FROM {$this->table} WHERE is_404 = %d ORDER BY gone_id LIMIT %d",
358 1,
359 $n
360 )
361 );
362 }
363 }
364
365 /**
366 * Promote a logged 404 entry to a 410 entry.
367 *
368 * Retained for single-record use; bulk operations use bulk_promote_404_ids()
369 * instead of calling this in a loop.
370 *
371 * @param string $key URL key to convert.
372 * @return int|false Rows updated or false on error.
373 */
374 private function convert_404( $key ) {
375 global $wpdb;
376
377 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table, update operation.
378 return $wpdb->query(
379 $wpdb->prepare(
380 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
381 "UPDATE {$this->table} SET is_404 = %d WHERE gone_key = %s LIMIT 1",
382 0,
383 $key
384 )
385 );
386 }
387
388 /**
389 * Delete a stored link (410 or 404) by its key.
390 *
391 * Retained for single-record use; bulk operations use bulk_delete_ids()
392 * instead of calling this in a loop.
393 *
394 * @param string $key URL key to remove.
395 * @return int|false Rows deleted or false on error.
396 */
397 private function remove_link( $key ) {
398 global $wpdb;
399
400 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table, delete operation.
401 return $wpdb->query(
402 $wpdb->prepare(
403 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
404 "DELETE FROM {$this->table} WHERE gone_key = %s",
405 $key
406 )
407 );
408 }
409
410 /**
411 * Promote a batch of logged 404 entries (by primary key) to 410 entries.
412 *
413 * IDs are split into chunks of self::BULK_CHUNK_SIZE and processed with a
414 * single `UPDATE ... WHERE gone_id IN (...)` query per chunk, rather than one
415 * query per row. Only rows that are currently logged 404s (is_404 = 1) are
416 * affected, so IDs belonging to existing 410 entries are silently ignored.
417 *
418 * @param int[] $ids Sanitised, non-zero, de-duplicated primary keys.
419 * @return array{requested:int,updated:int} Number of IDs requested and rows actually updated.
420 */
421 private function bulk_promote_404_ids( array $ids ) {
422 global $wpdb;
423
424 $result = array(
425 'requested' => count( $ids ),
426 'updated' => 0,
427 );
428
429 foreach ( array_chunk( $ids, self::BULK_CHUNK_SIZE ) as $chunk ) {
430 $placeholders = implode( ',', array_fill( 0, count( $chunk ), '%d' ) );
431
432 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table, bulk update restricted to primary keys.
433 $updated = $wpdb->query(
434 $wpdb->prepare(
435 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $placeholders contains only %d tokens generated from count( $chunk ); values are bound via prepare() below.
436 "UPDATE {$this->table} SET is_404 = 0 WHERE is_404 = 1 AND gone_id IN ( {$placeholders} )",
437 $chunk
438 )
439 );
440
441 if ( false === $updated ) {
442 $this->log_db_error( 'bulk_promote_404_ids' );
443 continue;
444 }
445
446 $result['updated'] += $updated;
447 }
448
449 return $result;
450 }
451
452 /**
453 * Delete a batch of links (by primary key), optionally restricted to
454 * wildcard or non-wildcard entries.
455 *
456 * IDs are split into chunks of self::BULK_CHUNK_SIZE and processed with a
457 * single `DELETE ... WHERE gone_id IN (...)` query per chunk. Only rows with
458 * is_404 = 0 (i.e. entries shown in the Obsolete URLs / Wildcard Patterns
459 * tables, not the logged-404 table) are ever affected.
460 *
461 * @param int[] $ids Sanitised, non-zero, de-duplicated primary keys.
462 * @param bool|null $wildcard True to restrict to wildcard patterns, false to restrict
463 * to plain URLs, null for no additional restriction.
464 * @return array{requested:int,deleted:int} Number of IDs requested and rows actually deleted.
465 */
466 private function bulk_delete_ids( array $ids, $wildcard = null ) {
467 global $wpdb;
468
469 $result = array(
470 'requested' => count( $ids ),
471 'deleted' => 0,
472 );
473
474 $like_clause = '';
475 $like_args = array();
476
477 if ( null !== $wildcard ) {
478 $like_clause = $wildcard ? ' AND gone_key LIKE %s' : ' AND gone_key NOT LIKE %s';
479 $like_args = array( '%*%' );
480 }
481
482 foreach ( array_chunk( $ids, self::BULK_CHUNK_SIZE ) as $chunk ) {
483 $placeholders = implode( ',', array_fill( 0, count( $chunk ), '%d' ) );
484 $args = array_merge( $chunk, $like_args );
485
486 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table, bulk delete restricted to primary keys.
487 $deleted = $wpdb->query(
488 $wpdb->prepare(
489 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $placeholders/$like_clause contain only fixed tokens generated internally; values are bound via prepare() below.
490 "DELETE FROM {$this->table} WHERE is_404 = 0 AND gone_id IN ( {$placeholders} ){$like_clause}",
491 $args
492 )
493 );
494
495 if ( false === $deleted ) {
496 $this->log_db_error( 'bulk_delete_ids' );
497 continue;
498 }
499
500 $result['deleted'] += $deleted;
501 }
502
503 return $result;
504 }
505
506 /**
507 * Sanitise a submitted list of primary keys.
508 *
509 * Accepts only arrays (a scalar submitted where an array was expected is
510 * treated as "nothing submitted" rather than causing a fatal error), casts
511 * every value with absint(), and discards zero and duplicate values.
512 *
513 * @param mixed $raw Raw value from $_POST, already unslashed.
514 * @return int[] List of unique, positive integer IDs.
515 */
516 private function sanitize_id_list( $raw ) {
517 if ( ! is_array( $raw ) ) {
518 return array();
519 }
520
521 $ids = array();
522
523 foreach ( $raw as $value ) {
524 $id = absint( $value );
525 if ( $id > 0 ) {
526 $ids[ $id ] = $id; // Keyed by ID to de-duplicate.
527 }
528 }
529
530 return array_values( $ids );
531 }
532
533 /**
534 * Log a database error for site-owner debugging, without exposing it to the browser.
535 *
536 * @param string $context Short label identifying which operation failed.
537 * @return void
538 */
539 private function log_db_error( $context ) {
540 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
541 global $wpdb;
542 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Only triggered when WP_DEBUG is enabled, for site-owner diagnostics.
543 error_log( sprintf( '[HTTP 410] %1$s failed: %2$s', $context, $wpdb->last_error ) );
544 }
545 }
546
547 /**
548 * Checks whether the plugin's stored database/version options need upgrading,
549 * and performs required migrations when moving between older plugin versions.
550 *
551 * This handles:
552 * - Installing the custom 410 table when upgrading from versions before DB version 5.
553 * - Migrating legacy stored links (options-based) into the database when upgrading from
554 * versions prior to DB version 3.
555 * - Removing deprecated options once migration is complete.
556 *
557 * @return void
558 */
559 public function upgrade_check() {
560 $options_version = (int) get_option( 'mclv_410_options_version', 0 );
561
562 if ( self::DB_VERSION === $options_version ) {
563 return;
564 }
565
566 // last db change was in version 5.
567 if ( $options_version < 5 ) {
568 $this->install_table();
569 }
570
571 if ( $options_version < 3 ) {
572 $old_links = get_option( 'mclv_410_links_list', array() );
573 $new_links = array(); // just a simple array of links.
574
575 if ( 0 === $options_version ) { // links were stored just as links.
576 $new_links = array_map( 'rawurldecode', $old_links );
577 } elseif ( 1 === $options_version ) { // links were stored as array( link => regex ). We only need the link.
578 $new_links = array_map( 'rawurldecode', array_keys( $old_links ) );
579 } else { // moved to using the database in DB_VERSION 3.
580 $new_links = array_keys( $old_links );
581 }
582
583 foreach ( $new_links as $link ) {
584 $this->add_link( $link );
585 }
586
587 delete_option( 'mclv_410_links_list' ); // remove old option.
588 }
589
590 update_option( 'mclv_410_options_version', self::DB_VERSION );
591 }
592
593 /**
594 * Registers the 410 plugin settings page within the WordPress admin Plugins menu.
595 *
596 * Adds a submenu item under "Plugins" that links to the management screen for
597 * obsolete URLs, recent 404s, and other plugin configuration options. Form
598 * submissions are processed on the page's `load-{hook}` action, which runs
599 * before any admin HTML has been output, so the handler can safely redirect.
600 *
601 * @return void
602 */
603 public function settings_menu() {
604 $hook_suffix = add_submenu_page( 'plugins.php', 'HTTP 410 (Gone) responses', 'HTTP 410 (Gone) responses', 'manage_options', 'mclv_410_settings', array( $this, 'settings_page' ) );
605
606 if ( $hook_suffix ) {
607 add_action( 'load-' . $hook_suffix, array( $this, 'handle_settings_form_submissions' ) );
608 }
609 }
610
611 /**
612 * Enqueue admin styles and scripts for the plugin settings page.
613 *
614 * @param string $hook_suffix The current admin page hook suffix.
615 * @return void
616 */
617 public function enqueue_admin_assets( $hook_suffix ) {
618 // Only load on our settings page.
619 if ( 'plugins_page_mclv_410_settings' !== $hook_suffix ) {
620 return;
621 }
622
623 // Enqueue admin CSS.
624 wp_enqueue_style(
625 'mclv-410-admin',
626 plugin_dir_url( __FILE__ ) . 'css/admin.css',
627 array(),
628 '1.2.1'
629 );
630
631 // Enqueue admin JavaScript.
632 wp_enqueue_script(
633 'mclv-410-admin',
634 plugin_dir_url( __FILE__ ) . 'js/admin.js',
635 array(),
636 '1.2.1',
637 array( 'in_footer' => true )
638 );
639 }
640
641 /**
642 * Render the plugin settings page.
643 *
644 * Form submissions are handled earlier, on the `load-{hook}` action (see
645 * settings_menu()), so this method only has to gather paginated data for
646 * display and load the view template.
647 *
648 * @return void
649 */
650 public function settings_page() {
651 $notice = $this->consume_notice();
652 $per_page = $this->per_page();
653
654 $regular_result = $this->get_links_page( false, $this->get_current_page( 'mclv_410_page' ), $per_page );
655 $wildcard_result = $this->get_links_page( true, $this->get_current_page( 'mclv_wild_page' ), $per_page );
656 $logged_result = $this->get_404s_page( $this->get_current_page( 'mclv_404_page' ), $per_page );
657
658 // Prepare variables for the view.
659 $max_404_length = $this->max_404_list_length();
660 $has_410_template = (bool) locate_template( '410.php' );
661 $cache_notice = $this->get_cache_notice();
662 $plugin = $this;
663
664 // Load the view template.
665 include plugin_dir_path( __FILE__ ) . 'views/admin-settings.php';
666 }
667
668 /**
669 * Handle settings-page form submissions.
670 *
671 * Runs on `load-{hook}`, before any admin HTML is output, so it is free to
672 * redirect. Every operation is identified by an explicit `mclv_410_action`
673 * hidden field (checked against an allow-list) rather than by which submit
674 * button was clicked, and both the action field and the nonce are emitted
675 * before any variable-length checkbox list in the form markup. This avoids
676 * the original failure mode, where a very large selection could push the
677 * nonce/action fields past PHP's max_input_vars limit, causing them to be
678 * silently dropped and the request to be misread as "nothing submitted".
679 *
680 * @return void
681 */
682 public function handle_settings_form_submissions() {
683 if ( empty( $_SERVER['REQUEST_METHOD'] ) || 'POST' !== $_SERVER['REQUEST_METHOD'] ) {
684 return;
685 }
686
687 $redirect_args = $this->collect_redirect_page_args();
688
689 if ( ! current_user_can( 'manage_options' ) ) {
690 $this->set_notice( 'error', __( 'You do not have permission to perform this action.', 'wp-410' ) );
691 $this->redirect_to_settings( $redirect_args );
692 }
693
694 $action = isset( $_POST['mclv_410_action'] ) ? sanitize_key( wp_unslash( $_POST['mclv_410_action'] ) ) : '';
695 $allowed_actions = array( 'promote_404s', 'delete_regular', 'delete_wildcard', 'add_manual_urls', 'set_max_404s' );
696
697 if ( '' === $action || ! in_array( $action, $allowed_actions, true ) ) {
698 // Nothing recognised was submitted (e.g. a plain page load) - nothing to do.
699 return;
700 }
701
702 $nonce = isset( $_POST['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ) : '';
703
704 if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) {
705 $this->set_notice( 'error', __( 'The submitted request was incomplete or has expired. Please try a smaller selection, or reload the page and try again.', 'wp-410' ) );
706 $this->redirect_to_settings( $redirect_args );
707 }
708
709 switch ( $action ) {
710 case 'promote_404s':
711 $this->handle_promote_404s();
712 break;
713 case 'delete_regular':
714 $this->handle_delete_links( false );
715 break;
716 case 'delete_wildcard':
717 $this->handle_delete_links( true );
718 break;
719 case 'add_manual_urls':
720 $this->handle_add_manual_urls();
721 break;
722 case 'set_max_404s':
723 $this->handle_set_max_404s();
724 break;
725 }
726
727 $this->redirect_to_settings( $redirect_args );
728 }
729
730 /**
731 * Promote selected logged-404 entries (submitted as IDs) to 410 entries.
732 *
733 * @return void
734 */
735 private function handle_promote_404s() {
736 // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Nonce already verified in handle_settings_form_submissions() before dispatch; value sanitised immediately below via sanitize_id_list().
737 $raw = isset( $_POST['add_404s'] ) ? wp_unslash( $_POST['add_404s'] ) : array();
738 $ids = $this->sanitize_id_list( $raw );
739
740 if ( empty( $ids ) ) {
741 $this->set_notice( 'error', __( 'No entries were selected.', 'wp-410' ) );
742 return;
743 }
744
745 $result = $this->bulk_promote_404_ids( $ids );
746 $success_message = sprintf(
747 /* translators: %s: number of entries. */
748 _n( '%s logged 404 entry was added to the 410 list.', '%s logged 404 entries were added to the 410 list.', $result['updated'], 'wp-410' ),
749 number_format_i18n( $result['updated'] )
750 );
751
752 list( $type, $message ) = $this->notice_for_bulk_result( $result, 'updated', $success_message );
753
754 $this->set_notice( $type, $message );
755 }
756
757 /**
758 * Delete selected regular or wildcard 410 entries (submitted as IDs).
759 *
760 * @param bool $wildcard True to operate on wildcard patterns, false for plain URLs.
761 * @return void
762 */
763 private function handle_delete_links( $wildcard ) {
764 $field = $wildcard ? 'wildcard_links_to_remove' : 'regular_links_to_remove';
765 // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Nonce already verified in handle_settings_form_submissions() before dispatch; value sanitised immediately below via sanitize_id_list().
766 $raw = isset( $_POST[ $field ] ) ? wp_unslash( $_POST[ $field ] ) : array();
767 $ids = $this->sanitize_id_list( $raw );
768
769 if ( empty( $ids ) ) {
770 $this->set_notice( 'error', __( 'No entries were selected.', 'wp-410' ) );
771 return;
772 }
773
774 $result = $this->bulk_delete_ids( $ids, $wildcard );
775 $success_message = sprintf(
776 /* translators: %s: number of entries. */
777 _n( '%s entry was deleted.', '%s entries were deleted.', $result['deleted'], 'wp-410' ),
778 number_format_i18n( $result['deleted'] )
779 );
780
781 list( $type, $message ) = $this->notice_for_bulk_result( $result, 'deleted', $success_message );
782
783 $this->set_notice( $type, $message );
784 }
785
786 /**
787 * Build a (type, message) pair describing the outcome of a bulk operation.
788 *
789 * @param array $result Result array containing 'requested' and either 'updated' or 'deleted'.
790 * @param string $count_key Which key in $result holds the affected-row count ('updated' or 'deleted').
791 * @param string $success_message Pre-built, already-translated message to use when every requested row was affected.
792 * @return array{0:string,1:string} Notice type ('success'|'warning'|'error') and message.
793 */
794 private function notice_for_bulk_result( array $result, $count_key, $success_message ) {
795 $requested = $result['requested'];
796 $affected = isset( $result[ $count_key ] ) ? $result[ $count_key ] : 0;
797 $skipped = $requested - $affected;
798
799 if ( $affected === $requested ) {
800 return array( 'success', $success_message );
801 }
802
803 if ( 0 === $affected ) {
804 return array( 'error', __( 'None of the selected entries could be processed. They may already have been changed or removed by another request.', 'wp-410' ) );
805 }
806
807 return array(
808 'warning',
809 sprintf(
810 /* translators: 1: number processed, 2: number skipped. */
811 __( '%1$s entries were updated, but %2$s could not be processed. They may already have been changed or removed by another request.', 'wp-410' ),
812 number_format_i18n( $affected ),
813 number_format_i18n( $skipped )
814 ),
815 );
816 }
817
818 /**
819 * Process the manual "add URLs" textarea, kept separate from logged-404 promotion.
820 *
821 * @return void
822 */
823 private function handle_add_manual_urls() {
824 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce already verified in handle_settings_form_submissions() before dispatch.
825 $raw = isset( $_POST['links_to_add'] ) ? sanitize_textarea_field( wp_unslash( $_POST['links_to_add'] ) ) : '';
826 $raw = trim( $raw );
827
828 if ( '' === $raw ) {
829 $this->set_notice( 'error', __( 'No URLs were entered.', 'wp-410' ) );
830 return;
831 }
832
833 $lines = preg_split( '/(\r?\n)+/', $raw, -1, PREG_SPLIT_NO_EMPTY );
834
835 if ( count( $lines ) > self::MAX_MANUAL_URLS ) {
836 $this->set_notice(
837 'error',
838 sprintf(
839 /* translators: 1: number of URLs submitted, 2: maximum allowed per submission. */
840 esc_html__( 'You submitted %1$s URLs, but only %2$s can be added at a time. Please split your list into smaller batches and try again.', 'wp-410' ),
841 number_format_i18n( count( $lines ) ),
842 number_format_i18n( self::MAX_MANUAL_URLS )
843 )
844 );
845 return;
846 }
847
848 $added = 0;
849 $duplicates = 0;
850 $failed = 0;
851 $invalid = array();
852
853 foreach ( $lines as $link ) {
854 $link = sanitize_text_field( $link );
855
856 if ( '' === $link ) {
857 continue;
858 }
859
860 if ( ! $this->is_valid_url( $link ) ) {
861 $invalid[] = $link;
862 continue;
863 }
864
865 $result = $this->add_link( $link );
866
867 if ( false === $result ) {
868 ++$failed;
869 } elseif ( 0 === $result ) {
870 ++$duplicates;
871 } else {
872 ++$added;
873 }
874 }
875
876 $parts = array();
877
878 if ( $added > 0 ) {
879 /* translators: %s: number of URLs. */
880 $parts[] = sprintf( _n( '%s URL was added to the 410 list.', '%s URLs were added to the 410 list.', $added, 'wp-410' ), number_format_i18n( $added ) );
881 }
882
883 if ( $duplicates > 0 ) {
884 /* translators: %s: number of URLs. */
885 $parts[] = sprintf( _n( '%s URL was already on the list.', '%s URLs were already on the list.', $duplicates, 'wp-410' ), number_format_i18n( $duplicates ) );
886 }
887
888 if ( $failed > 0 ) {
889 /* translators: %s: number of URLs. */
890 $parts[] = sprintf( _n( '%s URL could not be saved due to a database error.', '%s URLs could not be saved due to a database error.', $failed, 'wp-410' ), number_format_i18n( $failed ) );
891 }
892
893 $type = 'success';
894 if ( $failed > 0 || ! empty( $invalid ) ) {
895 $type = $added > 0 ? 'warning' : 'error';
896 }
897
898 if ( empty( $parts ) ) {
899 $parts[] = __( 'No valid URLs were found to add.', 'wp-410' );
900 $type = 'error';
901 }
902
903 $message = implode( ' ', array_map( 'esc_html', $parts ) );
904
905 if ( ! empty( $invalid ) ) {
906 $message .= ' ' . esc_html__( 'The following entries could not be recognised as URLs that your WordPress site handles, and were not added. This can be because the domain name and path does not match that of your WordPress site, or because pretty permalinks are disabled.', 'wp-410' );
907 $message .= ' <code>' . implode( '</code>, <code>', array_map( 'esc_html', $invalid ) ) . '</code>';
908 }
909
910 $this->set_notice( $type, $message );
911 }
912
913 /**
914 * Update the maximum-404s-to-keep option.
915 *
916 * @return void
917 */
918 private function handle_set_max_404s() {
919 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce already verified in handle_settings_form_submissions() before dispatch.
920 $max = isset( $_POST['max_404_list_length'] ) ? absint( wp_unslash( $_POST['max_404_list_length'] ) ) : 50;
921 $max = min( $max, 10000 );
922 update_option( 'mclv_410_max_404s', $max );
923
924 $this->set_notice(
925 'success',
926 sprintf(
927 /* translators: %s: maximum number of logged 404 entries. */
928 esc_html__( 'The maximum number of logged 404 entries was set to %s.', 'wp-410' ),
929 number_format_i18n( $max )
930 )
931 );
932 }
933
934 /**
935 * Collect the current per-section page numbers from POST, for use as
936 * redirect query args so the user lands back on the page they were on.
937 *
938 * @return array<string,int>
939 */
940 private function collect_redirect_page_args() {
941 $args = array();
942
943 foreach ( array( 'mclv_410_page', 'mclv_wild_page', 'mclv_404_page' ) as $key ) {
944 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Read-only pagination bookkeeping used only to build a redirect URL, does not change state.
945 $page = isset( $_POST[ $key ] ) ? absint( wp_unslash( $_POST[ $key ] ) ) : 0;
946 if ( $page > 1 ) {
947 $args[ $key ] = $page;
948 }
949 }
950
951 return $args;
952 }
953
954 /**
955 * Redirect back to the plugin's own settings page and stop execution.
956 *
957 * Implements POST/Redirect/GET so that reloading the settings page after a
958 * successful operation does not resubmit and repeat it.
959 *
960 * @param array<string,int> $args Extra query args (e.g. pagination) to preserve.
961 * @return void
962 */
963 private function redirect_to_settings( array $args = array() ) {
964 $url = add_query_arg( array_merge( array( 'page' => 'mclv_410_settings' ), $args ), admin_url( 'plugins.php' ) );
965 wp_safe_redirect( $url );
966 exit;
967 }
968
969 /**
970 * Store a one-time admin notice for the current user, to be displayed after redirect.
971 *
972 * @param string $type One of 'success', 'warning', 'error'.
973 * @param string $message Notice message. May contain a small safe HTML subset (e.g. <code>).
974 * @return void
975 */
976 private function set_notice( $type, $message ) {
977 set_transient(
978 $this->notice_transient_key(),
979 array(
980 'type' => $type,
981 'message' => $message,
982 ),
983 60
984 );
985 }
986
987 /**
988 * Retrieve and clear the current user's pending admin notice, if any.
989 *
990 * @return array{type:string,message:string}|null
991 */
992 private function consume_notice() {
993 $key = $this->notice_transient_key();
994 $notice = get_transient( $key );
995
996 if ( false !== $notice ) {
997 delete_transient( $key );
998 return $notice;
999 }
1000
1001 return null;
1002 }
1003
1004 /**
1005 * Build the per-user transient key used to pass a one-time notice across a redirect.
1006 *
1007 * @return string
1008 */
1009 private function notice_transient_key() {
1010 return 'mclv_410_notice_' . get_current_user_id();
1011 }
1012
1013 /**
1014 * Determine what, if anything, to tell the admin about page caching.
1015 *
1016 * Only ever reports that page caching is enabled (from WP_CACHE) - it does
1017 * not claim to have detected an unsupported plugin, since WP_CACHE alone
1018 * cannot distinguish between W3 Total Cache, WP Super Cache, another plugin,
1019 * or host-level caching.
1020 *
1021 * @return array{detected:string[],has_drop_in:bool}|null Null when WP_CACHE is not enabled.
1022 */
1023 private function get_cache_notice() {
1024 if ( ! defined( 'WP_CACHE' ) || ! WP_CACHE ) {
1025 return null;
1026 }
1027
1028 $active_plugins = (array) get_option( 'active_plugins', array() );
1029 $known_plugins = array(
1030 'w3-total-cache/w3-total-cache.php' => 'W3 Total Cache',
1031 'wp-super-cache/wp-cache.php' => 'WP Super Cache',
1032 );
1033
1034 $detected = array();
1035 foreach ( $known_plugins as $file => $name ) {
1036 if ( in_array( $file, $active_plugins, true ) ) {
1037 $detected[] = $name;
1038 }
1039 }
1040
1041 return array(
1042 'detected' => $detected,
1043 'has_drop_in' => file_exists( WP_CONTENT_DIR . '/advanced-cache.php' ),
1044 );
1045 }
1046
1047 /**
1048 * Determine if a URL can be handled by the current WordPress install.
1049 *
1050 * Checks path prefix and, when permalinks are off, ensures the URL is not
1051 * a pretty permalink format.
1052 *
1053 * @param string $link Fully qualified URL to validate.
1054 * @return bool
1055 */
1056 private function is_valid_url( $link ) {
1057 // Determine whether WP will handle a request for this URL.
1058 $wp_path = wp_parse_url( home_url( '/' ), PHP_URL_PATH );
1059 $link_path = wp_parse_url( $link, PHP_URL_PATH );
1060
1061 if ( 0 !== strpos( $link_path, $wp_path ) ) {
1062 return false;
1063 }
1064
1065 if ( ! $this->permalinks ) {
1066 $req = preg_replace( '|' . preg_quote( $wp_path, '|' ) . '/?|', '', $link_path );
1067 if ( strlen( $req ) && '?' !== $req[0] ) { // this is a pretty permalink, but pretty permalinks are disabled.
1068 return false;
1069 }
1070 }
1071
1072 return true;
1073 }
1074
1075 /**
1076 * Render a paginated table of URLs for the admin interface.
1077 *
1078 * Checkbox `id`/`value` attributes use the row's `gone_id` primary key
1079 * rather than the URL itself, avoiding both invalid/duplicate HTML IDs and
1080 * (together with the pagination in get_links_page()/get_404s_page()) the
1081 * unbounded request size that previously broke bulk submission on large lists.
1082 *
1083 * @param object[] $items Row objects with gone_id/gone_key, e.g. from get_links_page().
1084 * @param string $table_id HTML ID for the table.
1085 * @param string $checkbox_id Prefix for checkbox IDs.
1086 * @param string $select_all_id ID for the select-all checkbox.
1087 * @param string $checkbox_name Name attribute for checkboxes (default: 'old_links_to_remove[]').
1088 * @return bool Whether any invalid URLs were found.
1089 */
1090 public function render_url_table( $items, $table_id, $checkbox_id, $select_all_id, $checkbox_name = 'old_links_to_remove[]' ) {
1091 $invalid_links_exist = false;
1092
1093 echo '<div class="mclv-410-table-wrap"><table id="' . esc_attr( $table_id ) . '" class="wp-list-table widefat fixed">';
1094 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>';
1095 echo '<tbody>';
1096
1097 foreach ( $items as $item ) {
1098 $valid = $this->is_valid_url( $item->gone_key );
1099
1100 if ( ! $valid ) {
1101 $invalid_links_exist = true;
1102 }
1103
1104 $id_attr = absint( $item->gone_id );
1105 $class = $valid ? '' : ' class="invalid"';
1106
1107 $row_html = '<tr' . $class . '>';
1108 $row_html .= '<td><input type="checkbox" name="' . esc_attr( $checkbox_name ) . '" id="' . esc_attr( $checkbox_id ) . '-' . $id_attr . '" value="' . $id_attr . '" /></td>';
1109 $row_html .= '<td><label for="' . esc_attr( $checkbox_id ) . '-' . $id_attr . '"><code>' . esc_html( $item->gone_key ) . '</code></label></td>';
1110 $row_html .= '</tr>';
1111
1112 echo wp_kses(
1113 $row_html,
1114 array(
1115 'tr' => array( 'class' => true ),
1116 'td' => array(),
1117 'input' => array(
1118 'type' => true,
1119 'name' => true,
1120 'id' => true,
1121 'value' => true,
1122 ),
1123 'label' => array( 'for' => true ),
1124 'code' => array(),
1125 )
1126 );
1127 }
1128
1129 echo '</tbody></table></div>';
1130
1131 return $invalid_links_exist;
1132 }
1133
1134 /**
1135 * Render simple previous/next pagination controls for a paginated result.
1136 *
1137 * Deliberately avoids rendering one link per page (which would not scale to
1138 * very large lists); works without JavaScript since it is plain links.
1139 *
1140 * @param object $result Result object from get_links_page()/get_404s_page().
1141 * @param string $page_param Query string key to set for the target page (e.g. 'mclv_410_page').
1142 * @return void
1143 */
1144 public function render_pagination( $result, $page_param ) {
1145 if ( $result->total_pages <= 1 ) {
1146 return;
1147 }
1148
1149 $base_url = remove_query_arg( array( 'mclv_410_page', 'mclv_wild_page', 'mclv_404_page' ) );
1150
1151 echo '<div class="tablenav"><div class="tablenav-pages">';
1152 echo '<span class="displaying-num">' . esc_html(
1153 sprintf(
1154 /* translators: 1: current page, 2: total pages, 3: total items. */
1155 __( 'Page %1$s of %2$s (%3$s items)', 'wp-410' ),
1156 number_format_i18n( $result->page ),
1157 number_format_i18n( $result->total_pages ),
1158 number_format_i18n( $result->total )
1159 )
1160 ) . '</span> ';
1161
1162 if ( $result->page > 1 ) {
1163 echo '<a class="button" href="' . esc_url( add_query_arg( $page_param, $result->page - 1, $base_url ) ) . '">&laquo; ' . esc_html__( 'Previous', 'wp-410' ) . '</a> ';
1164 }
1165
1166 if ( $result->page < $result->total_pages ) {
1167 echo '<a class="button" href="' . esc_url( add_query_arg( $page_param, $result->page + 1, $base_url ) ) . '">' . esc_html__( 'Next', 'wp-410' ) . ' &raquo;</a>';
1168 }
1169
1170 echo '</div></div>';
1171 }
1172
1173 /**
1174 * Remove matching obsolete links when a post is created or updated.
1175 *
1176 * @param int $id Post ID.
1177 * @return void
1178 */
1179 public function note_inserted_post( $id ) {
1180 $post = get_post( $id );
1181
1182 if ( ! $post instanceof WP_Post ) {
1183 return;
1184 }
1185
1186 if ( 'revision' === $post->post_type || 'draft' === $post->post_status ) {
1187 return;
1188 }
1189
1190 // Check our list of URLs against the new/updated post's permalink, and if they match, scratch it from our list.
1191 $created_links = array();
1192
1193 $created_links[] = rawurldecode( get_permalink( $id ) );
1194 $created_links[] = get_post_comments_feed_link( $id ); // back compat.
1195
1196 if ( $this->permalinks ) {
1197 $created_links[] = $created_links[0] . '*';
1198 }
1199
1200 foreach ( $created_links as $link ) {
1201 $this->remove_link( $link );
1202 }
1203 }
1204
1205 /**
1206 * Intercept 404 requests and emit a 410 response for known obsolete URLs.
1207 *
1208 * Logs unknown 404s when logging is enabled.
1209 *
1210 * @return void
1211 */
1212 public function check_for_410() {
1213 // Don't mess if WordPress has found something to display.
1214 if ( ! is_404() ) {
1215 return;
1216 }
1217
1218 $links = $this->get_links();
1219
1220 // Sanitize server variables.
1221 $http_host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '';
1222 $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
1223
1224 $req = ( is_ssl() ? 'https://' : 'http://' ) . $http_host . $request_uri;
1225 $req = rawurldecode( $req );
1226
1227 foreach ( $links as $link ) {
1228 $match_result = preg_match( $link->gone_regex, $req );
1229
1230 if ( false === $match_result ) {
1231 // Invalid regex – skip this pattern rather than breaking the request.
1232 continue;
1233 }
1234
1235 if ( 1 === $match_result ) {
1236 define( 'DONOTCACHEPAGE', true );
1237 status_header( 410 );
1238
1239 /**
1240 * Fires when a 410 response is about to be sent.
1241 *
1242 * @since 1.0.0
1243 */
1244 do_action( 'mclv_410_response' );
1245
1246 /**
1247 * Fires when a 410 response is about to be sent.
1248 *
1249 * @since 0.4
1250 * @deprecated 1.0.0 Use 'mclv_410_response' instead.
1251 */
1252 do_action_deprecated( 'wp_410_response', array(), '1.0.0', 'mclv_410_response' );
1253
1254 if ( ! locate_template( '410.php', true ) ) {
1255 echo 'Sorry, the page you requested has been permanently removed.';
1256 }
1257
1258 exit;
1259 }
1260 }
1261
1262 // no hit, log 404.
1263 $this->add_link( $req, true );
1264 }
1265 }
1266
1267 // Bootstrap the plugin.
1268 new MCLV_410_Plugin();
1269