array( 5, HOUR_IN_SECONDS ), // confirmation emails sent 'password' => array( 5, 15 * MINUTE_IN_SECONDS ), // wrong current password 'delete' => array( 3, DAY_IN_SECONDS ), // deletion requests ); public function register(): void { add_action( 'dashbread_register', array( $this, 'page' ) ); add_action( 'template_redirect', array( $this, 'handle' ), 5 ); add_filter( 'pre_get_avatar_data', array( $this, 'avatar_data' ), 10, 2 ); } public function page( Registry $r ): void { $r->register_page( 'profile', array( 'title' => __( 'Profile', 'dashbread' ), 'path' => '/profile', 'icon' => 'user', 'position' => 900, 'capability' => 'read', 'render' => array( $this, 'render' ), ) ); } public static function url( string $tab = 'profile', array $args = array() ): string { return add_query_arg( array_merge( array( 'tab' => $tab ), $args ), Settings::app_url( 'profile' ) ); } public static function tab(): string { $t = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'profile'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only: which tab to show; nothing is changed. return in_array( $t, self::TABS, true ) || array_key_exists( $t, (array) apply_filters( 'dashbread_profile_tabs', array() ) ) ? $t : 'profile'; } /* ---------- avatar: a member-uploaded image in the media library beats Gravatar ---------- */ public function avatar_data( $args, $id_or_email ) { $user = false; if ( is_numeric( $id_or_email ) ) { $user = get_user_by( 'id', (int) $id_or_email ); } elseif ( $id_or_email instanceof \WP_User ) { $user = $id_or_email; } elseif ( $id_or_email instanceof \WP_Comment && $id_or_email->user_id ) { $user = get_user_by( 'id', (int) $id_or_email->user_id ); } elseif ( is_string( $id_or_email ) && is_email( $id_or_email ) ) { $user = get_user_by( 'email', $id_or_email ); } if ( ! $user ) { return $args; } $att = (int) get_user_meta( $user->ID, 'dashbread_avatar_id', true ); if ( ! $att ) { return $args; } $size = (int) ( $args['size'] ?? 96 ); $src = wp_get_attachment_image_url( $att, $size <= 150 ? 'thumbnail' : 'medium' ); if ( $src ) { $args['url'] = $src; $args['found_avatar'] = true; } return $args; } /* ---------- form handling ---------- */ public function handle(): void { if ( ! Router::is_app() || 'profile' !== Router::current_route() ) { return; } $uid = get_current_user_id(); if ( ! $uid ) { return; } // Email change confirmation link: only the signed-in owner, with the secret sent to the new // address, within EMAIL_LINK_TTL. The secret is the credential (no nonce in an emailed link). if ( isset( $_GET['newuseremail'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- an emailed link carries no nonce; the 32-character secret below is checked with hash_equals() instead. $pending = get_user_meta( $uid, '_dashbread_new_email', true ); $hash = sanitize_text_field( wp_unslash( $_GET['newuseremail'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- see above. $fresh = is_array( $pending ) && time() - (int) ( $pending['at'] ?? 0 ) <= self::EMAIL_LINK_TTL; if ( $fresh && hash_equals( (string) ( $pending['hash'] ?? '' ), $hash ) && is_email( $pending['email'] ?? '' ) && ! email_exists( $pending['email'] ) ) { wp_update_user( array( 'ID' => $uid, 'user_email' => $pending['email'] ) ); delete_user_meta( $uid, '_dashbread_new_email' ); $this->go( 'profile', array( 'sui_ok' => 'email_confirmed' ) ); } $this->go( 'profile', array( 'sui_err' => 'email_link' ) ); } if ( 'POST' !== \Dashbread\Core\Router::method() ) { return; } // Which form was sent decides which nonce to check. A failed nonce stops here with an error; // nothing is saved. Each save_* method checks its nonce again before it reads the form. $action = isset( $_POST['dashbread_action'] ) ? sanitize_key( wp_unslash( $_POST['dashbread_action'] ) ) : ''; if ( ! isset( $_POST['dashbread_profile_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['dashbread_profile_nonce'] ) ), 'dashbread_profile_' . ( 'cancel_email' === $action ? 'profile' : $action ) ) ) { $this->go( self::tab(), array( 'sui_err' => 'nonce' ) ); } $user = wp_get_current_user(); switch ( $action ) { case 'profile': $this->save_profile( $user ); break; case 'password': $this->save_password( $user ); break; case 'notifications': $this->save_notifications( $user ); break; case 'appearance': $this->save_appearance( $user ); break; case 'delete': $this->request_delete( $user ); break; case 'cancel_email': delete_user_meta( $user->ID, '_dashbread_new_email' ); $this->go( 'profile', array( 'sui_ok' => 'saved' ) ); break; } $this->go( self::tab() ); } private function go( string $tab, array $args = array() ): void { wp_safe_redirect( self::url( $tab, $args ) ); exit; } private function save_profile( \WP_User $user ): void { if ( ! isset( $_POST['dashbread_profile_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['dashbread_profile_nonce'] ) ), 'dashbread_profile_profile' ) ) { $this->go( 'profile', array( 'sui_err' => 'nonce' ) ); } $data = array( 'ID' => $user->ID ); foreach ( array( 'first_name', 'last_name', 'description' ) as $k ) { if ( isset( $_POST[ $k ] ) ) { $data[ $k ] = 'description' === $k ? sanitize_textarea_field( wp_unslash( $_POST[ $k ] ) ) : sanitize_text_field( wp_unslash( $_POST[ $k ] ) ); } } $display = isset( $_POST['display_name'] ) ? sanitize_text_field( wp_unslash( $_POST['display_name'] ) ) : ''; if ( '' !== $display ) { $data['display_name'] = $display; } if ( isset( $_POST['user_url'] ) ) { $data['user_url'] = esc_url_raw( wp_unslash( $_POST['user_url'] ) ); } $result = wp_update_user( $data ); if ( is_wp_error( $result ) ) { $this->go( 'profile', array( 'sui_err' => 'save' ) ); } if ( isset( $_POST['phone'] ) ) { update_user_meta( $user->ID, 'dashbread_phone', sanitize_text_field( wp_unslash( $_POST['phone'] ) ) ); } // Custom fields (Pro field builder or add-ons): sanitized as they are read, keyed by sanitized // field key, and only the fields this site defines are looked at. Fields::sanitize() then // applies each field type's own rule. $posted = isset( $_POST['dashbread_fields'] ) && is_array( $_POST['dashbread_fields'] ) ? map_deep( wp_unslash( $_POST['dashbread_fields'] ), 'sanitize_textarea_field' ) : array(); $posted = array_combine( array_map( static fn( $k ) => sanitize_key( (string) $k ), array_keys( $posted ) ), array_values( $posted ) ); foreach ( Fields::custom() as $f ) { $key = sanitize_key( (string) $f['key'] ); $val = Fields::sanitize( $f, $posted[ $key ] ?? '' ); if ( null !== $val ) { update_user_meta( $user->ID, 'dashbread_' . $key, $val ); } } // Avatar. if ( ! empty( $_POST['remove_avatar'] ) ) { delete_user_meta( $user->ID, 'dashbread_avatar_id' ); } if ( ! empty( $_FILES['avatar']['name'] ) ) { $this->save_avatar( $user ); } // Email: verify before switching. $email = isset( $_POST['user_email'] ) ? sanitize_email( wp_unslash( $_POST['user_email'] ) ) : ''; if ( '' !== $email && strtolower( $email ) !== strtolower( $user->user_email ) ) { if ( ! is_email( $email ) ) { $this->go( 'profile', array( 'sui_err' => 'email_invalid' ) ); } if ( email_exists( $email ) ) { $this->go( 'profile', array( 'sui_err' => 'email_exists' ) ); } if ( ! RateLimit::hit( 'email', (string) $user->ID, ...self::LIMITS['email'] ) ) { $this->go( 'profile', array( 'sui_err' => 'rate_limited' ) ); } $hash = wp_generate_password( 32, false ); update_user_meta( $user->ID, '_dashbread_new_email', array( 'hash' => $hash, 'email' => $email, 'at' => time() ) ); $link = self::url( 'profile', array( 'newuseremail' => $hash ) ); wp_mail( $email, sprintf( /* translators: %s: site name */ __( '[%s] Confirm your new email address', 'dashbread' ), wp_specialchars_decode( get_bloginfo( 'name' ) ) ), sprintf( /* translators: 1: display name, 2: link */ __( "Hi %1\$s,\n\nConfirm the change of your account email by opening this link:\n%2\$s\n\nIf you didn't request this, ignore this message.", 'dashbread' ), $user->display_name, $link ) ); $this->go( 'profile', array( 'sui_ok' => 'email_pending' ) ); } $this->go( 'profile', array( 'sui_ok' => 'saved' ) ); } private function save_avatar( \WP_User $user ): void { if ( ! isset( $_POST['dashbread_profile_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['dashbread_profile_nonce'] ) ), 'dashbread_profile_profile' ) ) { $this->go( 'profile', array( 'sui_err' => 'nonce' ) ); } require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/media.php'; require_once ABSPATH . 'wp-admin/includes/image.php'; // $_FILES is not slashed by WordPress. media_handle_upload() below re-reads the upload and // checks it is a real uploaded file of an allowed type. $size = isset( $_FILES['avatar']['size'] ) ? absint( $_FILES['avatar']['size'] ) : 0; $tmp = isset( $_FILES['avatar']['tmp_name'] ) ? sanitize_text_field( $_FILES['avatar']['tmp_name'] ) : ''; $name = isset( $_FILES['avatar']['name'] ) ? sanitize_file_name( $_FILES['avatar']['name'] ) : ''; if ( $size > 2 * MB_IN_BYTES ) { $this->go( 'profile', array( 'sui_err' => 'avatar_size' ) ); } $check = wp_check_filetype_and_ext( $tmp, $name, array( 'jpg|jpeg' => 'image/jpeg', 'png' => 'image/png', 'gif' => 'image/gif', 'webp' => 'image/webp' ) ); if ( empty( $check['type'] ) ) { $this->go( 'profile', array( 'sui_err' => 'avatar_type' ) ); } add_filter( 'upload_mimes', static fn( $m ) => array( 'jpg|jpeg' => 'image/jpeg', 'png' => 'image/png', 'gif' => 'image/gif', 'webp' => 'image/webp' ) ); $att = media_handle_upload( 'avatar', 0, array( 'post_title' => sprintf( 'Avatar: %s', $user->display_name ), 'post_author' => $user->ID ) ); if ( is_wp_error( $att ) ) { $this->go( 'profile', array( 'sui_err' => 'avatar_upload' ) ); } $old = (int) get_user_meta( $user->ID, 'dashbread_avatar_id', true ); update_user_meta( $user->ID, 'dashbread_avatar_id', (int) $att ); if ( $old && $old !== (int) $att && (int) get_post_field( 'post_author', $old ) === $user->ID ) { wp_delete_attachment( $old, true ); } } private function save_password( \WP_User $user ): void { if ( ! isset( $_POST['dashbread_profile_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['dashbread_profile_nonce'] ) ), 'dashbread_profile_password' ) ) { $this->go( 'password', array( 'sui_err' => 'nonce' ) ); } $current = isset( $_POST['current_password'] ) ? (string) wp_unslash( $_POST['current_password'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- a password is used as typed (sanitizing would change it); it is only compared or hashed, never stored or printed. $new1 = isset( $_POST['pass1'] ) ? (string) wp_unslash( $_POST['pass1'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- a password is used as typed (sanitizing would change it); it is only compared or hashed, never stored or printed. $new2 = isset( $_POST['pass2'] ) ? (string) wp_unslash( $_POST['pass2'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- a password is used as typed (sanitizing would change it); it is only compared or hashed, never stored or printed. if ( RateLimit::blocked( 'password', (string) $user->ID, self::LIMITS['password'][0] ) ) { $this->go( 'password', array( 'sui_err' => 'rate_limited' ) ); } if ( ! wp_check_password( $current, $user->user_pass, $user->ID ) ) { RateLimit::hit( 'password', (string) $user->ID, ...self::LIMITS['password'] ); $this->go( 'password', array( 'sui_err' => 'current' ) ); } if ( strlen( $new1 ) < 8 || $new1 !== $new2 ) { $this->go( 'password', array( 'sui_err' => 'mismatch' ) ); } RateLimit::clear( 'password', (string) $user->ID ); // The member's own account, after re-entering the current password. wp_set_password() ends // every session of this account, so sign this browser back in as the same user; other // devices stay signed out. wp_set_password( $new1, $user->ID ); wp_set_auth_cookie( $user->ID, true ); $this->go( 'password', array( 'sui_ok' => 'password' ) ); } private function save_notifications( \WP_User $user ): void { if ( ! isset( $_POST['dashbread_profile_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['dashbread_profile_nonce'] ) ), 'dashbread_profile_notifications' ) ) { $this->go( 'notifications', array( 'sui_err' => 'nonce' ) ); } $on = isset( $_POST['notify'] ) ? array_map( 'sanitize_key', (array) wp_unslash( $_POST['notify'] ) ) : array(); $off = array(); foreach ( array_keys( Registry::instance()->notification_types() ) as $type ) { if ( ! in_array( $type, $on, true ) ) { $off[] = $type; } } update_user_meta( $user->ID, 'dashbread_notify_off', $off ); do_action( 'dashbread_profile_save_notifications', $user ); $this->go( 'notifications', array( 'sui_ok' => 'saved' ) ); } private function save_appearance( \WP_User $user ): void { if ( ! isset( $_POST['dashbread_profile_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['dashbread_profile_nonce'] ) ), 'dashbread_profile_appearance' ) ) { $this->go( 'appearance', array( 'sui_err' => 'nonce' ) ); } $mode = isset( $_POST['mode'] ) ? sanitize_key( wp_unslash( $_POST['mode'] ) ) : 'default'; if ( in_array( $mode, array( 'light', 'dark', 'auto' ), true ) ) { update_user_meta( $user->ID, 'dashbread_mode', $mode ); } else { delete_user_meta( $user->ID, 'dashbread_mode' ); } $this->go( 'appearance', array( 'sui_ok' => 'saved', 'dashbread_mode_reset' => 1 ) ); } /** Members ask; an administrator deletes. Notifies admins and marks the account. */ private function request_delete( \WP_User $user ): void { if ( ! isset( $_POST['dashbread_profile_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['dashbread_profile_nonce'] ) ), 'dashbread_profile_delete' ) ) { $this->go( 'account', array( 'sui_err' => 'nonce' ) ); } if ( empty( $_POST['confirm'] ) || 'DELETE' !== strtoupper( sanitize_text_field( wp_unslash( $_POST['confirm'] ) ) ) ) { $this->go( 'account', array( 'sui_err' => 'confirm' ) ); } if ( ! RateLimit::hit( 'delete', (string) $user->ID, ...self::LIMITS['delete'] ) ) { $this->go( 'account', array( 'sui_err' => 'rate_limited' ) ); } update_user_meta( $user->ID, 'dashbread_delete_requested', time() ); foreach ( get_users( array( 'role' => 'administrator', 'fields' => 'ID' ) ) as $admin ) { Notifications::notify( (int) $admin, 'broadcast', array( 'title' => sprintf( /* translators: %s: display name */ __( '%s asked to delete their account', 'dashbread' ), $user->display_name ), 'body' => $user->user_email, 'url' => admin_url( 'user-edit.php?user_id=' . $user->ID ), 'icon' => 'user-x', ) ); } wp_mail( get_option( 'admin_email' ), sprintf( /* translators: %s: site name */ __( '[%s] Account deletion request', 'dashbread' ), wp_specialchars_decode( get_bloginfo( 'name' ) ) ), sprintf( /* translators: 1: display name, 2: email address, 3: link to the user in wp-admin */ __( "%1\$s (%2\$s) asked to delete their account.\n%3\$s", 'dashbread' ), $user->display_name, $user->user_email, admin_url( 'user-edit.php?user_id=' . $user->ID ) ) ); $this->go( 'account', array( 'sui_ok' => 'delete_requested' ) ); } /* ---------- rendering ---------- */ public static function notice(): ?array { // Read-only: which confirmation or error message to show after a redirect; nothing is changed. $ok = isset( $_GET['sui_ok'] ) ? sanitize_key( wp_unslash( $_GET['sui_ok'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $err = isset( $_GET['sui_err'] ) ? sanitize_key( wp_unslash( $_GET['sui_err'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $oks = array( 'saved' => __( 'Saved.', 'dashbread' ), 'email_pending' => __( 'Check your new email address for a confirmation link. Your current address stays until you confirm.', 'dashbread' ), 'email_confirmed' => __( 'Your email address is updated.', 'dashbread' ), 'password' => __( 'Password changed. Other devices were signed out.', 'dashbread' ), 'delete_requested' => __( 'Your request was sent. An administrator will delete the account and confirm by email.', 'dashbread' ), ); $errs = array( 'nonce' => __( 'That form expired. Please try again.', 'dashbread' ), 'save' => __( "Couldn't save. Please try again.", 'dashbread' ), 'email_invalid' => __( "That doesn't look like a valid email address.", 'dashbread' ), 'email_exists' => __( 'Another account already uses that email.', 'dashbread' ), 'email_link' => __( 'That confirmation link is invalid, expired or was already used.', 'dashbread' ), 'rate_limited' => __( 'Too many attempts. Wait a while and try again.', 'dashbread' ), 'avatar_size' => __( 'Photos must be under 2 MB.', 'dashbread' ), 'avatar_type' => __( 'Use a JPG, PNG, GIF, or WebP image.', 'dashbread' ), 'avatar_upload' => __( "Couldn't upload that photo.", 'dashbread' ), 'current' => __( 'Your current password is incorrect.', 'dashbread' ), 'mismatch' => __( 'New passwords must match and be at least 8 characters.', 'dashbread' ), 'confirm' => __( 'Type DELETE to confirm.', 'dashbread' ), ); if ( '' !== $err ) { return array( 'type' => 'error', 'text' => $errs[ $err ] ?? __( 'Something went wrong.', 'dashbread' ) ); } if ( '' !== $ok ) { return array( 'type' => 'ok', 'text' => $oks[ $ok ] ?? __( 'Saved.', 'dashbread' ) ); } return null; } public function render(): void { $user = wp_get_current_user(); $tab = self::tab(); include DASHBREAD_PATH . 'templates/profile.php'; } }