$meta * @return array */ public function row_meta( array $meta, string $file ): array { if ( plugin_basename( \CodeBold\Redirect\FILE ) !== $file ) { return $meta; } $meta[] = sprintf( '%s %s', esc_url( add_query_arg( 'utm_campaign', 'plugins-screen', self::MAKER_URL ) ), esc_html__( 'Visit codeboldtech.com', 'codebold-redirects' ), esc_html__( '(opens in a new tab)', 'codebold-redirects' ) ); return $meta; } public function menu(): void { if ( ! Access::can_manage() ) { return; } add_menu_page( __( 'Redirects', 'codebold-redirects' ), __( 'CodeBold Redirects', 'codebold-redirects' ) . $this->bubble( $this->new_404s() ), Access::menu_capability(), self::PAGE, [ $this, 'render' ], 'dashicons-randomize', 74 ); } /** * The look of the screen is each person's own, kept with their account and saved * through WordPress's /wp/v2/users/me — so the plugin's own API has nothing to add for * it. Only the person themselves can read or change it, and only in the "edit" view of * their account: it is nobody else's business which colours somebody likes. */ public function register_appearance(): void { register_meta( 'user', self::APPEARANCE_META, [ 'type' => 'string', 'single' => true, 'default' => self::DEFAULT_APPEARANCE, 'sanitize_callback' => static fn( $value ): string => in_array( $value, self::APPEARANCES, true ) ? (string) $value : self::DEFAULT_APPEARANCE, 'auth_callback' => static fn( $allowed, $key, $user_id ): bool => (int) $user_id === get_current_user_id() && Access::can_manage(), 'show_in_rest' => [ 'schema' => [ 'type' => 'string', 'enum' => self::APPEARANCES, 'context' => [ 'edit' ], ], ], ] ); } /** The current person's appearance: "light" (the default), "dark" or "wp" (Match WordPress). */ public static function appearance(): string { $value = (string) get_user_meta( get_current_user_id(), self::APPEARANCE_META, true ); return in_array( $value, self::APPEARANCES, true ) ? $value : self::DEFAULT_APPEARANCE; } /** * The screen used to live under Tools. Bookmarks, old notices and links in emails * still point there, so they are sent on to the new place, tab and all, rather than * to WordPress's "not allowed to access this page". */ public function moved(): void { global $pagenow; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- changes nothing: it only sends a GET request on to the new address, where any prefill is checked against its own nonce. if ( 'tools.php' !== $pagenow || self::PAGE !== sanitize_key( wp_unslash( $_GET['page'] ?? '' ) ) || ! Access::can_manage() ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- passed on as query arguments, which add_query_arg() encodes. $args = array_map( 'sanitize_text_field', array_map( 'wp_unslash', array_diff_key( (array) $_GET, [ 'page' => true ] ) ) ); wp_safe_redirect( add_query_arg( array_merge( [ 'page' => self::PAGE ], $args ), admin_url( 'admin.php' ) ) ); exit; } /** * The same bubble WordPress uses for pending comments and updates, so it reads as * "things waiting for you" without needing explaining. */ private function bubble( int $count ): string { if ( $count <= 0 ) { return ''; } return sprintf( ' %3$s', min( $count, 99 ), $count > 99 ? '99+' : (string) $count, esc_html( sprintf( /* translators: %d: number of addresses */ _n( '%d new page not found', '%d new pages not found', $count, 'codebold-redirects' ), $count ) ) ); } /** * A few lines of CSS for the bubble, on every admin page, enqueued as an inline style. * Some admin colour schemes restyle WordPress's own counters inside a submenu until they * are plain text; this keeps ours the red everybody recognises, whatever scheme is chosen. */ private function bubble_style(): void { if ( ! Access::can_manage() || $this->new_404s() <= 0 ) { return; } wp_register_style( 'codebold-redirect-menu', false, [], \CodeBold\Redirect\VERSION ); wp_enqueue_style( 'codebold-redirect-menu' ); wp_add_inline_style( 'codebold-redirect-menu', '#adminmenu .cbr-menu-count{display:inline-block;box-sizing:border-box;min-width:18px;height:18px;margin:1px 0 -1px 4px;padding:0 5px;border-radius:9px;background:#d63638!important;color:#fff!important;font-size:11px;line-height:1.6;text-align:center;vertical-align:top}' ); } /** * New 404s since this person last opened that tab — the same addresses the tab's * "To fix" view lists, so the bubble and the tab never disagree. * * Counted fresh on every admin page, not cached: a cached count was minutes behind and * showed 3 while the list had 4. It is one COUNT over the first_seen index, on a table * capped at a few thousand rows, so being right costs next to nothing. It is worked * out once per request, because the menu and the bubble's style both ask. */ private function new_404s(): int { if ( ! Settings::get( 'log_404' ) ) { return 0; } if ( null !== $this->new_count ) { return $this->new_count; } $user = get_current_user_id(); $seen = (string) get_user_meta( $user, self::SEEN_META, true ); $since = '' !== $seen ? $seen : gmdate( 'Y-m-d H:i:s', time() - WEEK_IN_SECONDS ); global $wpdb; $table = $this->log->table(); $count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE ignored = 0 AND first_seen > %s", $since ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $this->new_count = $count; return $count; } public function render(): void { if ( ! Access::can_manage() ) { wp_die( esc_html__( 'You do not have permission to manage redirects.', 'codebold-redirects' ) ); } // The theme class is on the screen's own wrapper and app, never on : the admin // around the screen and other plugins' notices keep WordPress's look. $theme = 'cbr-theme-' . self::appearance(); printf( '
', esc_attr( $theme ) ); printf( '

%s

', esc_html__( 'Redirects', 'codebold-redirects' ) ); echo '
'; // The screen is drawn by admin.js. Until it is — or if scripts are blocked — this // says so, rather than leaving an empty page that looks broken. printf( '

%s

', esc_attr( $theme ), esc_html__( 'Loading redirects…', 'codebold-redirects' ) ); printf( '', esc_html__( 'This screen needs JavaScript. Please enable it, or use the codebold-redirect WP-CLI command.', 'codebold-redirects' ) ); echo '
'; } public function assets( string $hook ): void { $this->bubble_style(); // The page picker is shared by the Redirects screen and the editor box, so a page // is chosen the same way wherever somebody is. wp_register_script( 'codebold-redirect-picker', $this->asset( 'assets/picker.js' ), [ 'wp-api-fetch', 'wp-i18n' ], $this->version( 'assets/picker.js' ), true ); wp_set_script_translations( 'codebold-redirect-picker', 'codebold-redirects', dirname( \CodeBold\Redirect\FILE ) . '/languages' ); wp_add_inline_script( 'codebold-redirect-picker', 'window.codeboldRedirectShared = ' . wp_json_encode( [ 'namespace' => RestModule::NAMESPACE, 'home' => home_url( '/' ), ] ) . ';', 'before' ); if ( in_array( $hook, [ 'post.php', 'post-new.php' ], true ) ) { if ( ! Access::can_manage() ) { return; } wp_enqueue_style( 'codebold-redirect-admin', $this->asset( 'assets/admin.css' ), [], $this->version( 'assets/admin.css' ) ); wp_enqueue_script( 'codebold-redirect-postbox', $this->asset( 'assets/postbox.js' ), [ 'codebold-redirect-picker', 'wp-data' ], $this->version( 'assets/postbox.js' ), true ); wp_set_script_translations( 'codebold-redirect-postbox', 'codebold-redirects', dirname( \CodeBold\Redirect\FILE ) . '/languages' ); return; } if ( 'index.php' === $hook ) { wp_enqueue_style( 'codebold-redirect-admin', $this->asset( 'assets/admin.css' ), [], $this->version( 'assets/admin.css' ) ); return; } if ( self::HOOK !== $hook ) { return; } wp_enqueue_style( 'codebold-redirect-admin', $this->asset( 'assets/admin.css' ), [], $this->version( 'assets/admin.css' ) ); wp_enqueue_script( 'codebold-redirect-admin', $this->asset( 'assets/admin.js' ), [ 'codebold-redirect-picker', 'wp-api-fetch', 'wp-i18n', 'wp-date' ], $this->version( 'assets/admin.js' ), true ); wp_set_script_translations( 'codebold-redirect-admin', 'codebold-redirects', dirname( \CodeBold\Redirect\FILE ) . '/languages' ); wp_add_inline_script( 'codebold-redirect-admin', 'window.codeboldRedirect = ' . wp_json_encode( $this->boot_data() ) . ';', 'before' ); } /** * Everything the screen needs before its first request, so it draws at once. * * @return array */ private function boot_data(): array { // A link that fills in the add form must be one this plugin signed for this person. $signed = isset( $_GET['_cbr_nonce'] ) && wp_verify_nonce( sanitize_key( wp_unslash( $_GET['_cbr_nonce'] ) ), self::PREFILL_NONCE ); // phpcs:disable WordPress.Security.NonceVerification.Recommended -- tab, view and search only choose what the screen shows, like WordPress's own list screens. $prefill = [ 'tab' => isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : '', 'search' => isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : '', 'view' => isset( $_GET['view'] ) ? sanitize_key( wp_unslash( $_GET['view'] ) ) : '', 'source' => $signed && isset( $_GET['add_source'] ) ? sanitize_text_field( wp_unslash( $_GET['add_source'] ) ) : '', 'target' => $signed && isset( $_GET['add_target'] ) ? sanitize_text_field( wp_unslash( $_GET['add_target'] ) ) : '', 'to_post' => $signed && isset( $_GET['add_to_post'] ) ? absint( $_GET['add_to_post'] ) : 0, 'edit' => $signed && isset( $_GET['edit'] ) ? absint( $_GET['edit'] ) : 0, 'to_post_url' => '', 'to_post_title' => '', 'edit_source' => '', ]; // phpcs:enable // "Edit redirect for this address", from the admin bar: the list opens filtered to // that address, with its editor open. if ( $prefill['edit'] > 0 ) { $rule = $this->repository->get( $prefill['edit'] ); $prefill['edit_source'] = null !== $rule ? $rule->source : ''; } if ( $prefill['to_post'] > 0 && get_post( $prefill['to_post'] ) ) { $prefill['to_post_url'] = Address::parse( (string) get_permalink( $prefill['to_post'] ) )->relative(); $prefill['to_post_title'] = html_entity_decode( get_the_title( $prefill['to_post'] ), ENT_QUOTES, 'UTF-8' ); } $roles = []; foreach ( wp_roles()->get_names() as $slug => $name ) { $roles[] = [ 'value' => $slug, 'label' => translate_user_role( $name ) ]; } $types = ContentTypes::labelled(); return [ 'namespace' => 'codebold-redirect/v1', 'home' => home_url( '/' ), 'homePath' => Address::parse( home_url( '/' ) )->path, 'canConfigure' => Access::can_configure(), 'makerUrl' => add_query_arg( 'utm_campaign', 'admin-screen', self::MAKER_URL ), 'appearance' => self::appearance(), 'settings' => Access::can_configure() ? Settings::all() : null, 'codes' => self::code_labels(), 'roles' => $roles, 'postTypes' => $types, 'groups' => $this->repository->groups(), 'counts' => $this->repository->counts(), // "new" means new to this person, the same number as the menu bubble, so the // tab does not stay red for a week after somebody has already looked. 'notFound' => [ 'new' => $this->new_404s() ] + $this->log->counts(), 'sources' => Plugin::instance()->service( Sources::class )->detect(), 'prefill' => $prefill, 'permalinks' => '' !== (string) get_option( 'permalink_structure' ), 'dateFormat' => get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), 'dateOnlyFormat' => (string) get_option( 'date_format' ), 'gmtOffset' => (float) get_option( 'gmt_offset' ), /* translators: %s: today's date, in the site's date format */ 'importGroup' => sprintf( __( 'Imported %s', 'codebold-redirects' ), wp_date( (string) get_option( 'date_format' ) ) ), ]; } /** @return array */ public static function code_labels(): array { return [ 301 => __( '301 · Moved for good', 'codebold-redirects' ), 302 => __( '302 · Moved for now', 'codebold-redirects' ), 307 => __( '307 · Moved for now (keep method)', 'codebold-redirects' ), 308 => __( '308 · Moved for good (keep method)', 'codebold-redirects' ), 410 => __( '410 · Gone — deleted on purpose', 'codebold-redirects' ), 451 => __( '451 · Unavailable for legal reasons', 'codebold-redirects' ), ]; } public function notices(): void { $key = 'codebold_redirect_box_error_' . get_current_user_id(); $error = get_transient( $key ); if ( false === $error ) { return; } delete_transient( $key ); printf( '

%s %s

', esc_html__( 'The redirect was not saved.', 'codebold-redirects' ), esc_html( (string) $error ) ); } public function dashboard(): void { if ( ! Access::can_manage() ) { return; } wp_add_dashboard_widget( 'codebold_redirect', __( 'Redirects', 'codebold-redirects' ), [ new DashboardWidget( $this->repository, $this->log ), 'render' ] ); } /** * On a 404 page, an administrator gets a "Redirect this address" link in the admin * bar. The moment somebody notices a broken link is the moment they are most willing * to fix it, and that moment is usually on the front end, not in the admin. * * If a redirect for the address already exists — turned off, say, or a deliberate * 410 — the link edits that one instead of starting a duplicate the form would refuse. */ public function admin_bar( \WP_Admin_Bar $bar ): void { if ( is_admin() || ! is_404() || ! Access::can_manage() ) { return; } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- parsed into a path, then escaped by add_query_arg/esc_url. $uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_unslash( $_SERVER['REQUEST_URI'] ) : '/'; $path = Address::from_request( $uri )->path; $existing = array_values( array_filter( $this->repository->by_source( $path ), static fn( $rule ): bool => ! str_contains( $rule->source, '?' ) ) ); if ( [] !== $existing ) { $bar->add_node( [ 'id' => 'codebold-redirect', 'title' => '' . esc_html__( 'Edit redirect for this address', 'codebold-redirects' ) . '', 'href' => self::url( [ 'edit' => $existing[0]->id ] ), 'meta' => [ 'title' => esc_attr__( 'A redirect already exists for this address. Open it to change or turn it on.', 'codebold-redirects' ) ], ] ); return; } $bar->add_node( [ 'id' => 'codebold-redirect', 'title' => '' . esc_html__( 'Redirect this address', 'codebold-redirects' ) . '', 'href' => self::url( [ 'add_source' => $path ] ), 'meta' => [ 'title' => esc_attr__( 'This page was not found. Send its visitors somewhere useful.', 'codebold-redirects' ) ], ] ); } /** * @param array $links * @return array */ public function action_links( array $links ): array { if ( Access::can_manage() ) { array_unshift( $links, sprintf( '%s', esc_url( self::url() ), esc_html__( 'Manage redirects', 'codebold-redirects' ) ) ); } return $links; } /** * The URL of the screen, optionally opening a tab or prefilling the add form. * * @param array $args */ public static function url( array $args = [] ): string { $args = array_map( 'rawurlencode', array_map( 'strval', $args ) ); if ( [] !== array_intersect_key( $args, array_flip( self::PREFILL_ARGS ) ) ) { $args['_cbr_nonce'] = wp_create_nonce( self::PREFILL_NONCE ); } return add_query_arg( array_merge( [ 'page' => self::PAGE ], $args ), admin_url( 'admin.php' ) ); } /** * The plugin version plus the file's last change, as the ?ver= on its URL. * * The version alone stays the same while a release is being worked on, so a browser * that loaded the screen once keeps using its cached script — and shows bugs that * were fixed hours ago. The file's own time changes whenever the file does. */ private function version( string $path ): string { $file = dirname( \CodeBold\Redirect\FILE ) . '/' . $path; $time = is_readable( $file ) ? (int) filemtime( $file ) : 0; return \CodeBold\Redirect\VERSION . ( $time > 0 ? '.' . $time : '' ); } private function asset( string $path ): string { return plugins_url( $path, \CodeBold\Redirect\FILE ); } }