*/ public static function get_messages(): array { return [ 'too_short' => sprintf( /* translators: %d: minimum number of characters. */ __( 'The login URL must be at least %d characters long.', 'admin-optimizer' ), self::MIN_LENGTH ), 'too_long' => sprintf( /* translators: %d: maximum number of characters. */ __( 'The login URL must be no more than %d characters long.', 'admin-optimizer' ), self::MAX_LENGTH ), 'invalid_chars' => __( 'Use only lowercase letters, numbers, hyphens and underscores.', 'admin-optimizer' ), 'reserved' => __( 'This URL is reserved by WordPress or is a common admin address, so it cannot be used.', 'admin-optimizer' ), 'collision_content' => __( 'A page, post or custom post type already uses this URL.', 'admin-optimizer' ), 'collision_base' => __( 'WordPress already uses this URL for an archive, category, tag or similar base.', 'admin-optimizer' ), 'collision_redirect' => __( 'The login URL cannot be the same as the Redirection Login URL.', 'admin-optimizer' ), 'common' => __( 'This URL is very common and easy to guess.', 'admin-optimizer' ), 'giveaway' => __( 'This URL contains a word attackers try first, such as "login" or "admin".', 'admin-optimizer' ), 'short' => __( 'This URL is very short and easy to guess.', 'admin-optimizer' ), 'numeric' => __( 'A URL made only of numbers is easy to guess.', 'admin-optimizer' ), ]; } /** * Reserved slugs: the static list, the first literal segment of every * rewrite rule WordPress has registered, and the configured bases. * Reading `rewrite_rules` is free (it is autoloaded). * * @return string[] */ public static function get_reserved_slugs(): array { global $wp_rewrite; $reserved = self::get_lists()['reserved']; // --- First segment of every registered rewrite rule. --- $rules = get_option( 'rewrite_rules' ); if ( is_array( $rules ) ) { foreach ( array_keys( $rules ) as $pattern ) { if ( preg_match( '#^([a-z][a-z0-9_-]*)(?:/|\\\\\.|\?|\$|$)#i', (string) $pattern, $matches ) ) { $reserved[] = strtolower( $matches[1] ); } } } // --- Configured bases (category_base, tag_base, author_base, …). --- foreach ( [ 'category_base', 'tag_base' ] as $base_option ) { $base = trim( (string) get_option( $base_option, '' ), '/' ); if ( '' !== $base ) { $reserved[] = strtolower( $base ); } } if ( is_object( $wp_rewrite ) && ! empty( $wp_rewrite->author_base ) ) { $reserved[] = strtolower( (string) $wp_rewrite->author_base ); } /** * Filters the slugs a login URL may never use (blocked on save). * * @param string[] $reserved Reserved slugs: the built-in list plus the first * segment of every rewrite rule WordPress registers. */ $reserved = apply_filters( 'ao_login_url_reserved_slugs', array_values( array_unique( $reserved ) ) ); return self::normalize_list( $reserved ); } /** * Common slugs: allowed, but the user must acknowledge a warning. * * @return string[] */ public static function get_common_slugs(): array { /** * Filters the "too common" login slugs (warned on save, allowed once acknowledged). * * @param string[] $common Common slugs. */ $common = apply_filters( 'ao_login_url_common_slugs', self::get_lists()['common'] ); return self::normalize_list( $common ); } /** * Giveaway words: a slug that has one as a whole part ("your-login") is * easy to guess. Parts are split on hyphens and underscores and compared * exactly, so "loginmenow" does not match "login". * * @return string[] */ public static function get_giveaway_words(): array { /** * Filters the whole words that make a login slug easy to guess (warned on save). * * @param string[] $words Giveaway words. */ return self::normalize_list( apply_filters( 'ao_login_url_giveaway_words', self::get_lists()['giveaway'] ) ); } /** * Validate a candidate login slug. * * Args: * - redirection_slug (string) The Redirection Login URL value to compare against. * - check_content (bool) Query posts and taxonomies for collisions. Default true; * pass false during early bootstrap, when post types are * not registered yet and a query would be wasted. * - purpose (string) 'login' (default) or 'redirect'. A redirect target is a * slug visitors are sent to, so existing pages are fine; it * only has to be well-formed, not a WordPress path, and * different from `login_slug`. No warnings. * - login_slug (string) Purpose 'redirect' only: the login slug to compare against. * - with_message (bool) Build the translated message (default true). The result's `code` is always set. * * @param string $candidate Raw slug. * @param array $args Optional arguments. * * @return array{status:string,message:string,slug:string,code:string} `slug` is the * normalized value to store; `code` is a key of get_messages() ('' when ok). */ public static function validate( $candidate, array $args = [] ): array { // Messages are translated, which is too early at plugins_loaded; the runtime constant check opts out. $messages = false === ( $args['with_message'] ?? true ) ? [] : self::get_messages(); $args = wp_parse_args( $args, [ 'redirection_slug' => '', 'check_content' => true, 'purpose' => 'login', 'login_slug' => '', 'with_message' => true, ] ); // --- 1. Normalize: trim, lowercase, strip leading/trailing slashes. --- $slug = strtolower( trim( trim( (string) $candidate ), '/' ) ); // --- 1b. Redirect target: well-formed, not a WordPress path, not the login slug. --- if ( 'redirect' === $args['purpose'] ) { if ( '' === $slug ) { return self::result( self::STATUS_BLOCK, 'too_short', $messages, $slug ); } if ( strlen( $slug ) > self::MAX_LENGTH ) { return self::result( self::STATUS_BLOCK, 'too_long', $messages, $slug ); } if ( ! preg_match( '/^[a-z0-9_-]+$/', $slug ) ) { return self::result( self::STATUS_BLOCK, 'invalid_chars', $messages, $slug ); } if ( in_array( $slug, self::REDIRECT_BLOCKED, true ) ) { return self::result( self::STATUS_BLOCK, 'reserved', $messages, $slug ); } if ( '' !== $args['login_slug'] && strtolower( trim( (string) $args['login_slug'], '/ ' ) ) === $slug ) { return self::result( self::STATUS_BLOCK, 'collision_redirect', $messages, $slug ); } return self::result( self::STATUS_OK, '', $messages, $slug ); } // --- 2. Length and characters. --- if ( strlen( $slug ) < self::MIN_LENGTH ) { return self::result( self::STATUS_BLOCK, 'too_short', $messages, $slug ); } if ( strlen( $slug ) > self::MAX_LENGTH ) { return self::result( self::STATUS_BLOCK, 'too_long', $messages, $slug ); } if ( ! preg_match( '/^[a-z0-9_-]+$/', $slug ) ) { return self::result( self::STATUS_BLOCK, 'invalid_chars', $messages, $slug ); } // --- 3. Blocks: reserved, redirection field, existing content. --- if ( in_array( $slug, self::get_reserved_slugs(), true ) ) { return self::result( self::STATUS_BLOCK, 'reserved', $messages, $slug ); } $redirection = strtolower( trim( (string) $args['redirection_slug'], '/ ' ) ); if ( '' !== $redirection && $redirection === $slug ) { return self::result( self::STATUS_BLOCK, 'collision_redirect', $messages, $slug ); } if ( $args['check_content'] ) { $post = get_page_by_path( $slug, OBJECT, get_post_types( [ 'public' => true ] ) ); if ( $post instanceof \WP_Post && ! in_array( $post->post_status, [ 'trash', 'auto-draft' ], true ) ) { return self::result( self::STATUS_BLOCK, 'collision_content', $messages, $slug ); } // Post type archive slugs and taxonomy bases, e.g. /product/, /product-category/. $bases = []; foreach ( get_post_types( [ 'public' => true ], 'objects' ) as $post_type ) { $rewrite = $post_type->rewrite ?? null; $bases[] = is_array( $rewrite ) && ! empty( $rewrite['slug'] ) ? $rewrite['slug'] : $post_type->name; } foreach ( get_taxonomies( [ 'public' => true ], 'objects' ) as $taxonomy ) { $rewrite = $taxonomy->rewrite ?? null; if ( is_array( $rewrite ) && ! empty( $rewrite['slug'] ) ) { $bases[] = $rewrite['slug']; } } foreach ( $bases as $base ) { if ( strtolower( trim( (string) $base, '/' ) ) === $slug ) { return self::result( self::STATUS_BLOCK, 'collision_base', $messages, $slug ); } } } // --- 4. Warnings: common, very short, purely numeric. --- $warnings = []; if ( in_array( $slug, self::get_common_slugs(), true ) ) { $warnings[] = 'common'; } if ( ! in_array( 'common', $warnings, true ) && array_intersect( preg_split( '/[-_]/', $slug ), self::get_giveaway_words() ) ) { $warnings[] = 'giveaway'; } if ( strlen( $slug ) < self::SHORT_WARN_BELOW ) { $warnings[] = 'short'; } if ( ctype_digit( $slug ) ) { $warnings[] = 'numeric'; } if ( $warnings ) { $result = self::result( self::STATUS_WARN, $warnings[0], $messages, $slug ); if ( count( $warnings ) > 1 ) { $result['message'] = implode( ' ', array_map( static fn( $code ) => $messages[ $code ] ?? '', $warnings ) ); } return $result; } return [ 'status' => self::STATUS_OK, 'message' => '', 'slug' => $slug, 'code' => '', ]; } /** * Build a result array. * * @param string $status One of the STATUS_* constants. * @param string $code Message code. * @param array $messages Message map. * @param string $slug Normalized slug. * * @return array{status:string,message:string,slug:string,code:string} */ private static function result( string $status, string $code, array $messages, string $slug ): array { return [ 'status' => $status, 'message' => $messages[ $code ] ?? '', 'slug' => $slug, 'code' => $code, ]; } /** * Lowercase, trim and de-duplicate a (possibly filtered) list. * * @param mixed $items Filtered list. * * @return string[] */ private static function normalize_list( $items ): array { if ( ! is_array( $items ) ) { return []; } $items = array_map( static fn( $item ) => strtolower( trim( (string) $item, "/ \t\n\r" ) ), $items ); return array_values( array_unique( array_filter( $items, 'strlen' ) ) ); } /** * Load the slug lists once per request. * * @return array{reserved:string[],common:string[]} */ private static function get_lists(): array { if ( null === self::$lists ) { self::$lists = require __DIR__ . '/custom-login-url-slug-lists.php'; } return self::$lists; } }