| 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 ) ) . '">« ' . 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' ) . ' »</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 |
|