*/ private static $seen = array(); /** * Cache generation the request cache belongs to. * * @var string */ private static $seen_generation = ''; /** * Hooks user lifecycle events, avatars and activity tracking. * * @return void */ public static function init() { add_action( 'user_register', array( __CLASS__, 'ensure' ) ); add_action( 'deleted_user', array( __CLASS__, 'delete_user_data' ) ); add_action( 'remove_user_from_blog', array( __CLASS__, 'delete_user_data' ) ); add_filter( 'pre_get_avatar_data', array( __CLASS__, 'avatar_data' ), 10, 2 ); add_filter( 'rest_pre_dispatch', array( __CLASS__, 'track_activity' ), 10, 3 ); } /** * Creates a profile row when it does not exist. * * @param int $user_id User ID. * @return void */ public static function ensure( $user_id ) { global $wpdb; $user_id = absint( $user_id ); if ( ! $user_id || self::get( $user_id ) ) { return; } $now = Time::now(); // INSERT IGNORE: two requests of a new member can get here at the same moment. $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. $wpdb->prepare( 'INSERT IGNORE INTO %i (user_id, status, last_activity_at, created_at, updated_at) VALUES (%d, %s, %s, %s, %s)', Tables::name( 'profiles' ), $user_id, 'active', $now, $now, $now ) ); Cache::flush( 'profiles' ); } /** * Returns a profile row or null. * * @param int $user_id User ID. * @return array|null */ public static function get( $user_id ) { $user_id = absint( $user_id ); $profiles = self::get_many( array( $user_id ) ); return $profiles[ $user_id ] ?? null; } /** * Returns profile rows for several users, keyed by user ID. * * @param int[] $user_ids User IDs. * @return array */ public static function get_many( array $user_ids ) { global $wpdb; $user_ids = array_values( array_unique( array_filter( array_map( 'absint', $user_ids ) ) ) ); if ( ! $user_ids ) { return array(); } // Request cache, reset whenever profiles change. $generation = wp_cache_get_last_changed( 'spacebay_profiles' ); if ( self::$seen_generation !== $generation ) { self::$seen = array(); self::$seen_generation = $generation; } $missing = array_values( array_diff( $user_ids, array_keys( self::$seen ) ) ); if ( $missing ) { $found = self::fetch_many( $missing ); foreach ( $missing as $user_id ) { self::$seen[ $user_id ] = $found[ $user_id ] ?? null; } } $map = array(); foreach ( $user_ids as $user_id ) { if ( null !== self::$seen[ $user_id ] ) { $map[ $user_id ] = self::$seen[ $user_id ]; } } return $map; } /** * Reads profile rows from the database or the object cache. * * @param int[] $user_ids User IDs. * @return array Rows keyed by user ID. */ private static function fetch_many( array $user_ids ) { global $wpdb; sort( $user_ids ); return Cache::remember( 'profiles', 'many_' . md5( implode( ',', $user_ids ) ), static function () use ( $wpdb, $user_ids ) { $placeholders = implode( ',', array_fill( 0, count( $user_ids ), '%d' ) ); $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table, cached by Cache::remember(). $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The IN() list has one %d per ID. 'SELECT ' . self::COLUMNS . " FROM %i WHERE user_id IN ($placeholders)", // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- Placeholders are built from %d only; COLUMNS is a class constant. array_merge( array( Tables::name( 'profiles' ) ), $user_ids ) ), ARRAY_A ); $map = array(); foreach ( (array) $rows as $row ) { $fields = json_decode( (string) $row['custom_fields'], true ); $row['custom_fields'] = is_array( $fields ) ? $fields : array(); $row['avatar_id'] = (int) $row['avatar_id']; $row['cover_id'] = (int) $row['cover_id']; $row['points'] = (int) $row['points']; $map[ (int) $row['user_id'] ] = $row; } return $map; } ); } /** * Updates the profile of a user. * * @param int $user_id User ID. * @param array $input Raw values: display_name, headline, bio, website, location, privacy, fields. * @return true|WP_Error */ public static function update( $user_id, array $input ) { global $wpdb; $user = get_userdata( $user_id ); if ( ! $user ) { return new WP_Error( 'spacebay_member_not_found', __( 'Member not found.', 'spacebay' ), array( 'status' => 404 ) ); } self::ensure( $user_id ); if ( array_key_exists( 'display_name', $input ) ) { $name = mb_substr( sanitize_text_field( (string) $input['display_name'] ), 0, 60 ); if ( '' === $name ) { return new WP_Error( 'spacebay_name_required', __( 'Please enter your name.', 'spacebay' ), array( 'status' => 400 ) ); } if ( $name !== $user->display_name ) { wp_update_user( array( 'ID' => $user->ID, 'display_name' => $name, ) ); } } $row = array(); if ( array_key_exists( 'headline', $input ) ) { $row['headline'] = mb_substr( sanitize_text_field( (string) $input['headline'] ), 0, 120 ); } if ( array_key_exists( 'bio', $input ) ) { $row['bio'] = mb_substr( sanitize_textarea_field( (string) $input['bio'] ), 0, 1000 ); } if ( array_key_exists( 'website', $input ) ) { $website = trim( (string) $input['website'] ); if ( '' !== $website && ! Url::is_web_link( $website ) ) { return new WP_Error( 'spacebay_website', __( 'Please enter a full web address that starts with https://', 'spacebay' ), array( 'status' => 400, 'field' => 'website', ) ); } $row['website'] = mb_substr( Url::clean( $website ), 0, 255 ); } if ( array_key_exists( 'location', $input ) ) { $row['location'] = mb_substr( sanitize_text_field( (string) $input['location'] ), 0, 100 ); } if ( array_key_exists( 'privacy', $input ) ) { $row['privacy'] = in_array( $input['privacy'], self::PRIVACY, true ) ? $input['privacy'] : 'members'; } if ( array_key_exists( 'fields', $input ) && is_array( $input['fields'] ) ) { $current = self::get( $user_id ); /** * Filters the answers to extra profile fields (SpaceBay Pro) before * they are saved. Without an add-on there are no extra fields, so * nothing new is stored. * * @since 1.0.0 * * @param array $clean Clean answers keyed by field key. * @param array $raw Answers as sent. * @param int $user_id Member. */ $clean = (array) apply_filters( 'spacebay_profile_field_values', array(), $input['fields'], $user_id ); $merged = array_merge( $current ? $current['custom_fields'] : array(), $clean ); $row['custom_fields'] = wp_json_encode( $merged ); } if ( $row ) { $row['updated_at'] = Time::now(); $wpdb->update( Tables::name( 'profiles' ), $row, array( 'user_id' => absint( $user_id ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. } Cache::flush( 'profiles' ); /** * Fires after a member updates their profile. * * @since 1.0.0 * * @param int $user_id User ID. * @param array $input Submitted values. */ do_action( 'spacebay_profile_updated', $user_id, $input ); return true; } /** * Replaces the profile photo or cover image. * * @param int $user_id User ID. * @param string $kind "avatar" or "cover". * @param array $file Uploaded file from $_FILES. * @return true|WP_Error */ public static function set_image( $user_id, $kind, array $file ) { global $wpdb; $kind = 'cover' === $kind ? 'cover' : 'avatar'; $crop = 'cover' === $kind ? array( 1600, 480 ) : array( 400, 400 ); $media = Media::upload( $file, $user_id, $crop ); if ( is_wp_error( $media ) ) { return $media; } self::ensure( $user_id ); Media::attach( array( $media['id'] ), $user_id, $kind, $user_id ); Media::delete_for_object( $kind, $user_id, array( $media['id'] ) ); $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. Tables::name( 'profiles' ), array( $kind . '_id' => $media['id'], 'updated_at' => Time::now(), ), array( 'user_id' => absint( $user_id ) ) ); Cache::flush( 'profiles' ); return true; } /** * Removes the profile photo or cover image. * * @param int $user_id User ID. * @param string $kind "avatar" or "cover". * @return void */ public static function remove_image( $user_id, $kind ) { global $wpdb; $kind = 'cover' === $kind ? 'cover' : 'avatar'; Media::delete_for_object( $kind, $user_id ); $wpdb->update( Tables::name( 'profiles' ), array( $kind . '_id' => 0 ), array( 'user_id' => absint( $user_id ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. Cache::flush( 'profiles' ); } /** * URL of a profile image, or an empty string. * * @param array|null $profile Profile row. * @param string $kind "avatar" or "cover". * @return string */ public static function image_url( $profile, $kind ) { $id = $profile ? (int) ( $profile[ $kind . '_id' ] ?? 0 ) : 0; if ( ! $id ) { return ''; } $media = Media::format( Media::find( $id ) ); return $media['url'] ?? ''; } /** * Uses the community photo wherever WordPress shows the member's avatar. * * @param array $args Avatar arguments. * @param mixed $id_or_email User ID, email, WP_User, WP_Post or WP_Comment. * @return array */ public static function avatar_data( $args, $id_or_email ) { $user_id = 0; if ( is_numeric( $id_or_email ) ) { $user_id = (int) $id_or_email; } elseif ( $id_or_email instanceof \WP_User ) { $user_id = (int) $id_or_email->ID; } elseif ( $id_or_email instanceof \WP_Post ) { $user_id = (int) $id_or_email->post_author; } elseif ( $id_or_email instanceof \WP_Comment ) { $user_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 ); $user_id = $user ? (int) $user->ID : 0; } if ( ! $user_id ) { return $args; } $url = self::image_url( self::get( $user_id ), 'avatar' ); if ( '' !== $url ) { $args['url'] = $url; $args['found_avatar'] = true; } return $args; } /** * Records when a member was last active, at most every five minutes. * * @param mixed $result Response to replace the request with, or null. * @param \WP_REST_Server $server Server. * @param \WP_REST_Request $request Request. * @return mixed Unchanged $result. */ public static function track_activity( $result, $server, $request ) { unset( $server ); $user_id = get_current_user_id(); if ( ! $user_id || 0 !== strpos( $request->get_route(), '/spacebay/' ) || ( is_multisite() && ! is_user_member_of_blog( $user_id ) ) ) { return $result; } $profile = self::get( $user_id ); $last = $profile ? strtotime( $profile['last_activity_at'] . ' UTC' ) : 0; if ( ! $profile ) { self::ensure( $user_id ); } elseif ( time() - (int) $last > 5 * MINUTE_IN_SECONDS ) { global $wpdb; $wpdb->update( Tables::name( 'profiles' ), array( 'last_activity_at' => Time::now() ), array( 'user_id' => $user_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. Cache::flush( 'profiles' ); } return $result; } /** * IDs of users whose profile is not active (pending or blocked). Cached. * * @return int[] */ public static function inactive_ids() { return self::ids_where( 'status', '!=', 'active' ); } /** * IDs of users with one account status. Cached. * * @param string $status One of STATUSES. * @return int[] */ public static function ids_with_status( $status ) { return in_array( $status, self::STATUSES, true ) ? self::ids_where( 'status', '=', $status ) : array(); } /** * Changes a member's account status. * * @param int $user_id User ID. * @param string $status One of STATUSES. * @return string|false Previous status, or false when nothing changed. */ public static function set_status( $user_id, $status ) { global $wpdb; $user_id = absint( $user_id ); if ( ! in_array( $status, self::STATUSES, true ) || ! get_userdata( $user_id ) ) { return false; } self::ensure( $user_id ); $profile = self::get( $user_id ); $old = $profile ? $profile['status'] : 'active'; if ( $old === $status ) { return false; } $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. Tables::name( 'profiles' ), array( 'status' => $status, 'updated_at' => Time::now(), ), array( 'user_id' => $user_id ) ); Cache::flush( 'profiles' ); return $old; } /** * IDs of members who hid their profile from the directory. Cached. * * @return int[] */ public static function hidden_ids() { return self::ids_where( 'privacy', '=', 'hidden' ); } /** * User IDs matching one column condition. Cached. * * @param string $column "status" or "privacy". * @param string $operator "=" or "!=". * @param string $value Value. * @return int[] */ private static function ids_where( $column, $operator, $value ) { if ( ! in_array( $column, array( 'status', 'privacy' ), true ) ) { return array(); } return Cache::remember( 'profiles', 'ids_' . $column . ( '=' === $operator ? '_is_' : '_not_' ) . $value, static function () use ( $column, $operator, $value ) { global $wpdb; $table = Tables::name( 'profiles' ); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table, cached by Cache::remember(). if ( '=' === $operator ) { $ids = $wpdb->get_col( $wpdb->prepare( 'SELECT user_id FROM %i WHERE %i = %s', $table, $column, $value ) ); } else { $ids = $wpdb->get_col( $wpdb->prepare( 'SELECT user_id FROM %i WHERE %i != %s', $table, $column, $value ) ); } // phpcs:enable return array_map( 'intval', (array) $ids ); } ); } /** * Removes SpaceBay data of a deleted user. * * @param int $user_id User ID. * @return void */ public static function delete_user_data( $user_id ) { global $wpdb; Media::delete_for_object( 'avatar', $user_id ); Media::delete_for_object( 'cover', $user_id ); $wpdb->delete( Tables::name( 'profiles' ), array( 'user_id' => absint( $user_id ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. Membership::remove_user( $user_id ); Cache::flush( 'profiles' ); } }