PluginProbe
Custom 404 Pro / trunk
Custom 404 Pro vtrunk
3.15.2 3.15.4 3.15.5 3.15.6 3.16.0 3.15.1 3.15.0 3.14.1 3.14.0 3.13.0 trunk 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.1.5 1.1.6 1.2.0 1.3.10 1.3.12 1.3.5 All 100 releases
custom-404-pro / admin / class-adminclass.php

class-adminclass.php in Custom 404 Pro trunk, at admin/class-adminclass.php

458 lines 18.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Admin class for the plugin.
4 *
5 * @package Custom_404_Pro
6 */
7
8 /**
9 * Admin class.
10 */
11 class AdminClass {
12
13 /**
14 * Helpers instance.
15 *
16 * @var Helpers
17 */
18 private $helpers;
19
20 /**
21 * Constructor.
22 */
23 public function __construct() {
24 $this->helpers = Helpers::singleton();
25 }
26
27 /**
28 * Registers the admin menu pages.
29 */
30 public function create_menu() {
31 if ( current_user_can( 'manage_options' ) ) {
32 add_menu_page( esc_html__( 'Custom 404 Pro', 'custom-404-pro' ), esc_html__( 'Custom 404 Pro', 'custom-404-pro' ), 'manage_options', 'c4p-main', array( $this, 'page_logs' ), 'dashicons-chart-bar' );
33 add_submenu_page( 'c4p-main', esc_html__( 'Logs', 'custom-404-pro' ), esc_html__( 'Logs', 'custom-404-pro' ), 'manage_options', 'c4p-main', array( $this, 'page_logs' ) );
34 add_submenu_page( 'c4p-main', esc_html__( 'Settings', 'custom-404-pro' ), esc_html__( 'Settings', 'custom-404-pro' ), 'manage_options', 'c4p-settings', array( $this, 'page_settings' ) );
35 add_submenu_page( 'c4p-main', esc_html__( 'About', 'custom-404-pro' ), esc_html__( 'About', 'custom-404-pro' ), 'manage_options', 'c4p-about', array( $this, 'page_about' ) );
36 }
37 }
38
39 /**
40 * Renders the Logs admin page.
41 */
42 public function page_logs() {
43 require_once __DIR__ . '/class-logsclass.php';
44 include 'views/logs.php';
45 }
46
47 /**
48 * Renders the Settings admin page.
49 */
50 public function page_settings() {
51 include 'views/settings.php';
52 }
53
54 /**
55 * Renders the About admin page.
56 */
57 public function page_about() {
58 include 'views/about.php';
59 }
60
61 /**
62 * Returns the version string used to cache-bust admin assets.
63 *
64 * Tied to the plugin version so that a release always invalidates the
65 * browser cache. The previous hardcoded '3.2.0' meant users kept running
66 * stale CSS and JS through every update since that release.
67 *
68 * @since 3.15.6
69 * @return string Asset version string.
70 */
71 public static function asset_version(): string {
72 return defined( 'CUSTOM_404_PRO_VERSION' ) ? CUSTOM_404_PRO_VERSION : '3.2.0';
73 }
74
75 /**
76 * Enqueues admin stylesheets for plugin pages.
77 */
78 public function enqueue_styles() {
79 if ( current_user_can( 'manage_options' ) ) {
80 if ( array_key_exists( 'page', $_REQUEST ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
81 $request = sanitize_text_field( wp_unslash( $_REQUEST['page'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
82 if ( 'c4p-settings' === $request || 'c4p-main' === $request || 'c4p-about' === $request ) {
83 wp_enqueue_style( 'custom-404-pro-admin-css', plugin_dir_url( __FILE__ ) . 'css/custom-404-pro-admin.css', array(), self::asset_version() );
84 }
85 }
86 }
87 }
88
89 /**
90 * Enqueues admin scripts for plugin pages.
91 */
92 public function enqueue_scripts() {
93 if ( current_user_can( 'manage_options' ) ) {
94 if ( array_key_exists( 'page', $_REQUEST ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
95 $request = sanitize_text_field( wp_unslash( $_REQUEST['page'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
96 if ( 'c4p-settings' === $request || 'c4p-main' === $request ) {
97 wp_enqueue_script( 'custom-404-pro-admin-js', plugin_dir_url( __FILE__ ) . 'js/custom-404-pro-admin.js', array( 'jquery' ), self::asset_version(), false );
98 }
99 }
100 }
101 }
102
103 /**
104 * Displays admin notices passed via query string.
105 */
106 public function custom_404_pro_notices() {
107 $message = '';
108 $message_type = 'success';
109 $html = '';
110 if ( current_user_can( 'manage_options' ) ) {
111 if ( array_key_exists( 'c4pmessage', $_REQUEST ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
112 $message = esc_html( urldecode( sanitize_text_field( wp_unslash( $_REQUEST['c4pmessage'] ) ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
113 if ( array_key_exists( 'c4pmessageType', $_REQUEST ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
114 $allowed_types = array( 'success', 'error', 'warning', 'info' );
115 $requested_type = sanitize_text_field( wp_unslash( $_REQUEST['c4pmessageType'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
116 $message_type = in_array( $requested_type, $allowed_types, true ) ? $requested_type : 'info';
117 }
118 $html .= '<div class="notice notice-' . $message_type . ' is-dismissible">';
119 $html .= '<p>' . $message . '</p>';
120 $html .= '</div>';
121 echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- content is already escaped above
122 }
123 }
124 }
125
126 /**
127 * Handles the global redirect settings form submission.
128 */
129 public function form_settings_global_redirect() {
130 if ( check_admin_referer( 'form-settings-global-redirect', 'form-settings-global-redirect' ) && current_user_can( 'manage_options' ) ) {
131 $mode = isset( $_POST['mode'] ) ? sanitize_text_field( wp_unslash( $_POST['mode'] ) ) : '';
132 $page = isset( $_POST['mode_page'] ) ? sanitize_text_field( wp_unslash( $_POST['mode_page'] ) ) : '';
133 $url = isset( $_POST['mode_url'] ) ? sanitize_text_field( wp_unslash( $_POST['mode_url'] ) ) : '';
134 self::update_mode( $mode, $page, $url );
135 $message = rawurlencode( __( 'Saved!', 'custom-404-pro' ) );
136 if ( wp_safe_redirect( admin_url( 'admin.php?page=c4p-settings&tab=global-redirect&c4pmessage=' . $message . '&c4pmessageType=success' ) ) ) {
137 exit;
138 }
139 }
140 }
141
142 /**
143 * Handles the general settings form submission.
144 */
145 public function form_settings_general() {
146 if ( check_admin_referer( 'form-settings-general', 'form-settings-general' ) && current_user_can( 'manage_options' ) ) {
147 $send_email = isset( $_POST['send_email'] ) ? sanitize_text_field( wp_unslash( $_POST['send_email'] ) ) : '';
148 $logging_enabled = isset( $_POST['logging_enabled'] ) ? sanitize_text_field( wp_unslash( $_POST['logging_enabled'] ) ) : '';
149 $log_ip = isset( $_POST['log_ip'] ) ? sanitize_text_field( wp_unslash( $_POST['log_ip'] ) ) : '';
150 $field_redirect_error_code = isset( $_POST['redirect_error_code'] ) ? absint( wp_unslash( $_POST['redirect_error_code'] ) ) : 302;
151 $allowed_codes = array( 301, 302, 307, 308 );
152 $field_redirect_error_code = in_array( $field_redirect_error_code, $allowed_codes, true ) ? $field_redirect_error_code : 302;
153 $allowed_cooldowns = array( 900, 1800, 3600, 21600, 86400 );
154 $raw_cooldown = isset( $_POST['email_cooldown'] ) ? absint( wp_unslash( $_POST['email_cooldown'] ) ) : HOUR_IN_SECONDS;
155 $field_email_cooldown = in_array( $raw_cooldown, $allowed_cooldowns, true ) ? $raw_cooldown : HOUR_IN_SECONDS;
156 $log_retention_count = isset( $_POST['log_retention_count'] ) ? absint( wp_unslash( $_POST['log_retention_count'] ) ) : 0;
157 $log_retention_days = isset( $_POST['log_retention_days'] ) ? absint( wp_unslash( $_POST['log_retention_days'] ) ) : 0;
158 $this->helpers->update_settings(
159 array(
160 'send_email' => ( 'on' === $send_email ),
161 'logging_enabled' => ( 'enabled' === $logging_enabled ),
162 'log_ip' => ( 'on' === $log_ip ),
163 'redirect_error_code' => $field_redirect_error_code,
164 'email_cooldown' => $field_email_cooldown,
165 'log_retention_count' => $log_retention_count,
166 'log_retention_days' => $log_retention_days,
167 )
168 );
169 $message = rawurlencode( __( 'Saved!', 'custom-404-pro' ) );
170 if ( wp_safe_redirect( admin_url( 'admin.php?page=c4p-settings&tab=general&c4pmessage=' . $message . '&c4pmessageType=success' ) ) ) {
171 exit;
172 }
173 }
174 }
175
176 /**
177 * Handles admin-side log actions (delete, export).
178 */
179 public function custom_404_pro_admin_init() {
180 if ( current_user_can( 'manage_options' ) ) {
181 if ( array_key_exists( 'action', $_REQUEST ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
182 $action = sanitize_text_field( wp_unslash( $_REQUEST['action'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
183 $nonce = isset( $_REQUEST['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['_wpnonce'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
184 if ( 'c4p-logs--delete' === $action && wp_verify_nonce( $nonce, 'c4p-logs--delete' ) ) {
185 if ( array_key_exists( 'path', $_REQUEST ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
186 $path = is_array( $_REQUEST['path'] ) ? array_map( 'absint', $_REQUEST['path'] ) : absint( $_REQUEST['path'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
187 $this->helpers->delete_logs( $path );
188 $message = rawurlencode( __( 'Log(s) successfully deleted!', 'custom-404-pro' ) );
189 if ( wp_safe_redirect( admin_url( 'admin.php?page=c4p-main&c4pmessage=' . $message . '&c4pmessageType=success' ) ) ) {
190 exit;
191 }
192 } else {
193 $message = rawurlencode( __( 'Please select a few logs to delete and try again.', 'custom-404-pro' ) );
194 if ( wp_safe_redirect( admin_url( 'admin.php?page=c4p-main&c4pmessage=' . $message . '&c4pmessageType=warning' ) ) ) {
195 exit;
196 }
197 }
198 } elseif ( 'c4p-logs--delete-all' === $action && wp_verify_nonce( $nonce, 'bulk-logs' ) ) {
199 $this->helpers->delete_logs( 'all' );
200 $message = rawurlencode( __( 'All Logs successfully deleted!', 'custom-404-pro' ) );
201 if ( wp_safe_redirect( admin_url( 'admin.php?page=c4p-main&c4pmessage=' . $message . '&c4pmessageType=success' ) ) ) {
202 exit;
203 }
204 } elseif ( 'c4p-logs--export-csv' === $action && wp_verify_nonce( $nonce, 'bulk-logs' ) ) {
205 $this->helpers->export_logs_csv();
206 } elseif ( 'c4p-logs--prune' === $action && wp_verify_nonce( $nonce, 'c4p-logs--prune' ) ) {
207 $deleted = $this->helpers->prune_logs();
208 $message = rawurlencode(
209 sprintf(
210 /* translators: %d: number of log rows pruned */
211 _n( 'Pruned %d log row.', 'Pruned %d log rows.', $deleted, 'custom-404-pro' ),
212 $deleted
213 )
214 );
215 if ( wp_safe_redirect( admin_url( 'admin.php?page=c4p-main&c4pmessage=' . $message . '&c4pmessageType=success' ) ) ) {
216 exit;
217 }
218 }
219 }
220 }
221 }
222
223 /**
224 * Handles 404 detection and redirects.
225 */
226 public function custom_404_pro_redirect() {
227 if ( is_404() ) {
228 $options = $this->helpers->get_settings();
229 if ( ! empty( $options['logging_enabled'] ) ) {
230 $email_cooldown = isset( $options['email_cooldown'] ) ? (int) $options['email_cooldown'] : HOUR_IN_SECONDS;
231 self::custom_404_pro_log( $options['send_email'] ?? '', $email_cooldown );
232 }
233 if ( 'page' === ( $options['mode'] ?? '' ) ) {
234 $page_id = $this->resolve_multilingual_page_id( (int) ( $options['mode_page'] ?? 0 ) );
235 $permalink = get_permalink( $page_id );
236 if ( $permalink ) {
237 if ( wp_safe_redirect( $permalink, (int) ( $options['redirect_error_code'] ?? 302 ) ) ) {
238 exit;
239 }
240 }
241 } elseif ( 'url' === ( $options['mode'] ?? '' ) ) {
242 if ( wp_safe_redirect( $options['mode_url'] ?? '', (int) ( $options['redirect_error_code'] ?? 302 ) ) ) {
243 exit;
244 }
245 }
246 }
247 }
248
249 /**
250 * Resolves the 404 redirect page ID for the current language.
251 *
252 * Checks for Polylang and WPML and returns the translated page ID when
253 * available, falling back to the original ID when no translation exists.
254 *
255 * @param int $page_id The configured page ID.
256 * @return int The resolved page ID for the current language.
257 */
258 public function resolve_multilingual_page_id( int $page_id ): int {
259 // Polylang support: redirect to the translated page for the current language.
260 if ( function_exists( 'pll_get_post' ) ) {
261 $translated_id = pll_get_post( $page_id, pll_current_language() );
262 if ( $translated_id ) {
263 $page_id = $translated_id;
264 }
265 }
266
267 // WPML support: filter resolves the translated object ID (no-op when WPML is inactive).
268 $page_id = (int) apply_filters( 'wpml_object_id', $page_id, 'page', true );
269
270 return $page_id;
271 }
272
273 /**
274 * Logs a 404 event and optionally sends a notification email.
275 *
276 * @since 3.13.0 Added $email_cooldown parameter.
277 * @param bool $is_email Whether to send a notification email.
278 * @param int $email_cooldown Cooldown period in seconds between notification emails.
279 */
280 private function custom_404_pro_log( $is_email, $email_cooldown = HOUR_IN_SECONDS ) {
281 global $wpdb;
282 if ( empty( $this->helpers->get_setting( 'log_ip' ) ) ) {
283 $ip = 'N/A';
284 } elseif ( ! empty( $_SERVER['HTTP_CLIENT_IP'] ) ) {
285 $ip = sanitize_text_field( wp_unslash( $_SERVER['HTTP_CLIENT_IP'] ) );
286 } elseif ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
287 $ip = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) );
288 } else {
289 $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
290 }
291 $path = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
292 $referer = '';
293 if ( array_key_exists( 'HTTP_REFERER', $_SERVER ) ) {
294 $referer = esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) );
295 }
296 $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
297 $sql_save = $wpdb->prepare( 'INSERT INTO ' . $wpdb->prefix . $this->helpers->table_logs . ' (ip, path, referer, user_agent) VALUES (%s, %s, %s, %s)', $ip, $path, $referer, $user_agent ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
298 $wpdb->query( $sql_save ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
299 if ( ! empty( $is_email ) && ! $this->is_email_on_cooldown() ) {
300 self::custom_404_pro_send_mail( $ip, $path, $referer, $user_agent );
301 set_transient( 'custom_404_pro_email_cooldown', true, $email_cooldown );
302 }
303 }
304
305 /**
306 * Checks whether the email notification cooldown is currently active.
307 *
308 * Returns true when a cooldown transient is set, meaning an email was already
309 * sent within the configured cooldown window and another should not be sent yet.
310 *
311 * @since 3.13.0
312 * @return bool True if cooldown is active, false if an email may be sent.
313 */
314 public function is_email_on_cooldown(): bool {
315 return (bool) get_transient( 'custom_404_pro_email_cooldown' );
316 }
317
318 /**
319 * WP-Cron callback: prunes log entries according to retention settings.
320 *
321 * Must be public so it can be registered via add_action().
322 *
323 * @since 3.14.0
324 * @return int Total rows deleted.
325 */
326 public function run_scheduled_log_prune(): int {
327 return $this->helpers->prune_logs();
328 }
329
330 /**
331 * Sends a 404 notification email to the admin.
332 *
333 * Every interpolated value is escaped. The path, referer and user agent all
334 * come straight off the request that triggered the 404, so an attacker
335 * chooses their contents; unescaped they would render as live markup inside
336 * the admin's HTML mail client.
337 *
338 * @since 3.15.5 Escapes all interpolated values.
339 * @param string $ip User IP address.
340 * @param string $path Requested 404 path.
341 * @param string $referer HTTP referer.
342 * @param string $user_agent HTTP user agent.
343 */
344 private function custom_404_pro_send_mail( $ip, $path, $referer, $user_agent ) {
345 $admin_email = get_option( 'admin_email' );
346 if ( is_multisite() ) {
347 global $blog_id;
348 $current_blog_details = get_blog_details( array( 'blog_id' => $blog_id ) );
349 $current_site_name = $current_blog_details->blogname;
350 } else {
351 $current_site_name = get_bloginfo( 'name' );
352 }
353 $headers[] = 'From: Site Admin <' . $admin_email . '>' . "\r\n";
354 $headers[] = 'Content-Type: text/html; charset=UTF-8';
355 $message = '<p>' . esc_html__( 'Here are the 404 Log Details:', 'custom-404-pro' ) . '</p>';
356 $message .= '<table>';
357 $message .= '<tr>';
358 $message .= '<th>' . esc_html__( 'Site', 'custom-404-pro' ) . '</th>';
359 $message .= '<td>' . esc_html( $current_site_name ) . '</td>';
360 $message .= '</tr>';
361 $message .= '<tr>';
362 $message .= '<th>' . esc_html__( 'User IP', 'custom-404-pro' ) . '</th>';
363 $message .= '<td>' . esc_html( $ip ) . '</td>';
364 $message .= '</tr>';
365 $message .= '<tr>';
366 $message .= '<th>' . esc_html__( '404 Path', 'custom-404-pro' ) . '</th>';
367 $message .= '<td>' . esc_html( $path ) . '</td>';
368 $message .= '</tr>';
369 $message .= '<tr>';
370 $message .= '<th>' . esc_html__( 'Referer', 'custom-404-pro' ) . '</th>';
371 $message .= '<td>' . esc_html( $referer ) . '</td>';
372 $message .= '</tr>';
373 $message .= '<tr>';
374 $message .= '<th>' . esc_html__( 'User Agent', 'custom-404-pro' ) . '</th>';
375 $message .= '<td>' . esc_html( $user_agent ) . '</td>';
376 $message .= '</tr>';
377 $message .= '</table>';
378 wp_mail(
379 $admin_email,
380 /* translators: email subject sent to the site admin when a 404 is logged */
381 __( '404 Error on Site', 'custom-404-pro' ),
382 $message,
383 $headers
384 );
385 }
386
387 /**
388 * Normalizes a page ID to its default-language equivalent.
389 *
390 * When WPML or Polylang is active the admin page dropdown is filtered to the
391 * current language, so the submitted page ID may be a translation rather than
392 * the original. Storing the default-language ID lets resolve_multilingual_page_id()
393 * derive the correct translation at redirect time, regardless of which language
394 * admin last saved the setting.
395 *
396 * @param int $page_id Page ID submitted by the settings form.
397 * @return int Default-language page ID, or the original ID when no multilingual plugin is active.
398 */
399 private function normalize_page_id_to_default_language( int $page_id ): int {
400 // WPML: translate submitted ID to the default language before storing.
401 if ( has_filter( 'wpml_object_id' ) ) {
402 $default_lang = apply_filters( 'wpml_default_language', null );
403 $page_id = (int) apply_filters( 'wpml_object_id', $page_id, 'page', true, $default_lang );
404 }
405
406 // Polylang: translate submitted ID to the default language before storing.
407 if ( function_exists( 'pll_default_language' ) && function_exists( 'pll_get_post' ) ) {
408 $translated = pll_get_post( $page_id, pll_default_language() );
409 if ( $translated ) {
410 $page_id = $translated;
411 }
412 }
413
414 return $page_id;
415 }
416
417 /**
418 * Updates the redirect mode options.
419 *
420 * @param string $mode Mode value (page, url, or empty).
421 * @param string $page Page ID for page mode.
422 * @param string $url URL for url mode.
423 */
424 private function update_mode( $mode, $page, $url ) {
425 if ( current_user_can( 'manage_options' ) ) {
426 switch ( $mode ) {
427 case 'page':
428 $this->helpers->update_settings(
429 array(
430 'mode' => 'page',
431 'mode_page' => (string) $this->normalize_page_id_to_default_language( (int) $page ),
432 'mode_url' => '',
433 )
434 );
435 break;
436 case 'url':
437 $this->helpers->update_settings(
438 array(
439 'mode' => 'url',
440 'mode_page' => '',
441 'mode_url' => $url,
442 )
443 );
444 break;
445 default:
446 $this->helpers->update_settings(
447 array(
448 'mode' => '',
449 'mode_page' => '',
450 'mode_url' => '',
451 )
452 );
453 break;
454 }
455 }
456 }
457 }
458