Invision Power Services, Inc. * @copyright (c) Invision Power Services, Inc. * @license https://www.invisioncommunity.com/legal/standards/ * @package Invision Community * @since 18 Feb 2013 */ namespace IPS; /* To prevent PHP errors (extending class does not exist) revealing path */ use BadFunctionCallException; use BadMethodCallException; use DateInterval; use DateTimeZone; use DomainException; use ErrorException; use Exception; use InvalidArgumentException; use IPS\Api\Webhook; use IPS\Application\Module; use IPS\Content\Comment; use IPS\Content\Followable; use IPS\Content\Review; use IPS\core\Achievements\Badge; use IPS\core\Achievements\Rank; use IPS\core\Achievements\Rule; use IPS\core\AdminNotification; use IPS\core\Assignments\Assignment; use IPS\core\ProfileFields\Api\Field; use IPS\core\ProfileFields\Api\FieldGroup; use IPS\core\Reports\Report; use IPS\Data\Store; use IPS\Db\Select; use IPS\Events\Event; use IPS\Helpers\CoverPhoto; use IPS\Helpers\Menu; use IPS\Http\Url; use IPS\Http\Url\Friendly; use IPS\Lang\Setup\Lang as SetupLang; use IPS\Lang\Upgrade\Lang as UpgradeLang; use IPS\Login\Handler; use IPS\Math\Number; use IPS\Member\Club; use IPS\Member\Device; use IPS\Member\Group; use IPS\Member\GroupPromotion; use IPS\Member\LetterPhoto; use IPS\Member\PrivacyAction; use IPS\Member\ProfileStep; use IPS\Member\Team; use IPS\Member\UserMenu; use IPS\Patterns\ActiveRecord; use IPS\Patterns\ActiveRecordIterator; use IPS\Platform\Bridge; use IPS\Text\Encrypt; use IPS\Xml\SimpleXML; use OutOfRangeException; use RuntimeException; use UnderflowException; use function array_key_exists; use function count; use function defined; use function get_called_class; use function in_array; use function intval; use function is_array; use function is_string; use function mb_stripos; use function strlen; if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) ) { header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' ); exit; } /** * Member Model * * @property ?int member_id * @property DateTime joined */ class Member extends ActiveRecord { use Followable { Followable::followersCount as public _followersCountBase; Followable::followers as public _followersBase; Followable::follow as public _followBase; } /** * @brief Spam Check Post Threshold */ const SPAM_CHECK_POST_THRESHOLD = 5; /** * @brief Spam Check Dormant Threshold */ const SPAM_CHECK_DORMANT_THRESHOLD = 'P6M'; /** * @brief Period for login after inactivity notification */ const LOGIN_INACTIVITY_NOTIFICATION = 'P6M'; /** * @brief Application */ public static string $application = 'core'; /* !\IPS\Patterns\ActiveRecord */ /** * @brief [ActiveRecord] Multiton Store */ protected static array $multitons; /** * @brief [ActiveRecord] Database Table */ public static ?string $databaseTable = 'core_members'; /** * @brief [ActiveRecord] ID Database Column */ public static string $databaseColumnId = 'member_id'; /** * @brief [ActiveRecord] Database ID Fields */ protected static array $databaseIdFields = array( 'name', 'email' ); /** * @brief [ActiveRecord] Multiton Map */ protected static array $multitonMap = array(); /** * @brief Bitwise values for members_bitoptions field */ public static array $bitOptions = array( 'members_bitoptions' => array( 'members_bitoptions' => array( 'bw_is_spammer' => 1, // Flagged as spam? // 2 is deprecated // 4 (bw_vnc_type) is deprecated // 8 (bw_forum_result_type) is deprecated // 16 (bw_no_status_update is deprecated // 32 (bw_status_email_mine) is deprecated // 64 (bw_status_email_all) is deprecated // 128 is deprecated (previously bw_disable_customization) // 256 is deprecated (bw_local_password_set) - used to represent if a local passwnot was set and block the change password form if not 'bw_disable_tagging' => 512, // Tags disabled for this member? 1 means they are, 0 means they aren't. 'bw_disable_prefixes' => 1024, // Tag prefixes disabled? 1 means they are, 0 means they aren't. 'bw_using_skin_gen' => 2048, // 1 means the user has the theme editor active, 0 means they do not. // 4096 (bw_disable_gravatar) is deprecated // 8192 (bw_paste_plain) is deprecated // 16384 (bw_html_sig) is deprecated // 32768 (allow_admin_mails) is deprecated 'view_sigs' => 65536, // View signatures? // 131072 (view_img) is deprecated // 262144 is deprecated 'coppa_user' => 524288, // Was the member validated using coppa? // 1048576 (login_anonymous) is deprecated // 2097152 (login_anonymous_online) is deprecated // 4194304 (members_auto_dst) is deprecated // 8388608 (members_created_remote) is deprecated // 16777216 (members_disable_pm) is deprecated 'unacknowledged_warnings' => 33554432, // 1 means the member has at least one warning they have not acknowledged. 0 means they have none. // 67108864 (pp_setting_moderate_comments) is deprecated and replaced with global setting 'pp_setting_moderate_followers' => 134217728, // Previously pp_setting_moderate_friends. Replaced with setting that toggles whether or not member can be followed. 'pp_setting_count_visitors' => 268435456, // If TRUE, last 5 visitors will be shown on profile // 536870912 (timezone_override) is deprecated 'validating' => 1073741824, // If TRUE user is validating and should have a corresponding row in core_validating ), 'members_bitoptions2' => array( 'show_pm_popup' => 1, // "Show pop-up when I have a new message" 'remove_gallery_access' => 2, // Remove access to Gallery 'remove_gallery_upload' => 4, // Remove permission to upload images in Gallery 'no_report_count' => 8, // 1 means the report count will not show 'has_no_ignored_users' => 16, // If we know the user has no ignored users, we don't have to query for them 'must_reaccept_privacy' => 32, // 1 means the member needs to re-accept the privacy policy 'must_reaccept_terms' => 64, // 1 means the member needs to re-accept the registration terms 'email_notifications_once' => 128, // 1 means the member only wants one email per notification item until they revisit the community 'disable_notification_sounds' => 256, /** @deprecated Notification sounds are no longer supported by default */ 'has_security_answers' => 512, 'security_questions_opt_out' => 1024, 'ignore_promotions' => 2048,// 1 means that the user will not be checked against group promotion rules. Set by Commerce to prevent "promoting" users incorrectly after a purchase. 'profile_completed' => 4096, 'profile_completion_dismissed' => 8192, 'created_externally' => 16384, 'is_support_account' => 32768, // Created by ACP support tool 'is_anon' => 65536, // Has chosen to log in anonymously // 131072 (has_new_acp_features) is deprecated 'password_reset_forced' => 262144, // A password reset has been forced 'link_pref_unread' => 524288, // Take me to comments I haven't read 'link_pref_last' => 1048576, // Take me to the latest comment 'mobile_notifications' => 2097152, // Has the user has enabled push notifications on at least 1 mobile device? 'link_pref_first' => 4194304, // Take me to the first comment 'datalayer_event_fired' => 8388608, 'new_device_email' => 16777216, // Send an email when a new device is used to log in. 'no_solved_reenage' => 33554432, // User does not want to get topic re-engagement emails 'datalayer_pii_optout' => 67108864, // User has opted in to having their PII collected by datalayer 'email_messages_bounce' => 134217728, // @deprecated User's email keeps returning hard bounces so they need to change it // 268435456 - cookie_optout moved to cookie preferences 'expert_user_disabled' => 536870912, // User does not want to be a community expert 'expert_user_blocked' => 1073741824 // Admin has blocked expert user ) ) ); /** * Member Changes to skip for the member_edited webhook * * @var string[] */ public static array $changesToSkipForMemberEditWebhooks = [ 'last_visit', 'last_activity', 'profilesync_lastsync' ]; /* !Follow */ /** * @brief Cached logged in member */ public static ?Member $loggedInMember = NULL; /** * @brief If we change the photo_type, then we need to record the previous photo type to determine if set_pp_main_photo should attempt removal of existing images */ protected ?string $_previousPhotoType = NULL; /** * Get logged in member * * @return Member|null */ public static function loggedIn(): ?Member { /* If we haven't loaded the member yet, or if the session member has changed since we last loaded the member, reload and cache */ if( static::$loggedInMember === NULL ) { if( Request::isCliEnvironment() ) { static::$loggedInMember = new Member; } else { static::$loggedInMember = Session::i()->member; } if ( isset( $_SESSION['logged_in_as_key'] ) ) { if ( static::$loggedInMember->isAdmin() AND static::$loggedInMember->hasAcpRestriction( 'core', 'members', 'member_login' ) ) { $key = $_SESSION['logged_in_as_key']; if ( isset( Store::i()->$key ) ) { static::$loggedInMember = static::load( Store::i()->$key ); if ( !static::$loggedInMember->member_id ) { unset( Store::i()->$key ); unset( $_SESSION['logged_in_as_key'] ); } } } } if ( !static::$loggedInMember->member_id and isset( Request::i()->cookie['ipsTimezone'] ) ) { /* Check for valid timezone identifier */ $tz = DateTime::getFixedTimezone( Request::i()->cookie['ipsTimezone'] ); if( in_array( $tz, DateTimeZone::listIdentifiers() ) ) { static::$loggedInMember->timezone = $tz; } } } return static::$loggedInMember; } /** * Load Record * We override it so we return a guest object for a non-existant member * * @param int|string|null $id ID * @param string|null $idField The database column that the $id parameter pertains to (NULL will use static::$databaseColumnId) * @param mixed $extraWhereClause Additional where clause (see \IPS\Db::build for details) * @return static|ActiveRecord *@see Db::build */ public static function load( int|string|null $id, string $idField=NULL, mixed $extraWhereClause=NULL ): ActiveRecord|static { try { if( $id === NULL OR $id === 0 OR $id === '' ) { $classname = get_called_class(); return new $classname; } else { $member = parent::load( $id, $idField, $extraWhereClause ); if ( $member->restrict_post > 0 and $member->restrict_post <= time() ) { $member->restrict_post = 0; $member->save(); } return $member; } } catch ( OutOfRangeException $e ) { $classname = get_called_class(); return new $classname; } } /** * Load record based on a URL * * @param Url $url URL to load from * @return mixed * @throws InvalidArgumentException * @throws OutOfRangeException */ public static function loadFromUrl( Url $url ): mixed { try { $member = parent::loadFromUrl( $url ); } catch( InvalidArgumentException $e ) { throw new OutOfRangeException; } if ( !$member->member_id ) { throw new OutOfRangeException; } return $member; } /** * Set Default Values * * @return void */ public function setDefaultValues() : void { /* If we're in the installer - don't do this */ if ( Dispatcher::hasInstance() and Dispatcher::i()->controllerLocation === 'setup' ) { return; } $this->member_group_id = Settings::i()->guest_group; $this->mgroup_others = ''; $this->joined = time(); $this->marked_site_read = time(); $this->ip_address = Request::i()->ipAddress(); $this->timezone = 'UTC'; $this->allow_admin_mails = ( Settings::i()->updates_consent_default == 'enabled' ); $this->pp_photo_type = ''; $this->member_posts = 0; $this->last_visit = NULL; $this->_data['pp_main_photo'] = NULL; $this->_data['pp_thumb_photo'] = NULL; $this->_data['failed_logins'] = NULL; $this->_data['mfa_details'] = NULL; $this->_data['pp_reputation_points'] = 0; $this->_data['signature'] = ''; /* We need to enable the view_sigs bit if we want to show signatures to guests */ if ( Settings::i()->signatures_guests ) { $this->members_bitoptions['view_sigs'] = TRUE; } $this->members_bitoptions['new_device_email'] = TRUE; $this->members_bitoptions['show_pm_popup'] = (bool)Settings::i()->notification_prefs_popup; $this->members_bitoptions['email_notifications_once'] = (bool)Settings::i()->notification_prefs_one_per_view; $this->_data['auto_track'] = json_encode( array( 'content' => Settings::i()->auto_follow_new_content ? 1 : 0, 'comments' => Settings::i()->auto_follow_replied_to ? 1 : 0, 'method' => 'immediate' ) ); if( isset( Request::i()->cookie['language'] ) AND Request::i()->cookie['language'] ) { $this->language = Request::i()->cookie['language']; } if( isset( Request::i()->cookie['theme'] ) AND Request::i()->cookie['theme'] ) { $this->skin = Request::i()->cookie['theme']; } } /** * Allow optional cookies * * @param bool $allow * @return bool */ public function setAllowOptionalCookies( bool $allow = TRUE ): bool { $expire = ( new DateTime )->add( new DateInterval('P6M' ) ); Request::i()->setCookie( 'cookie_consent', 1, $expire, FALSE ); if ( !$allow ) { /* Remove optional cookies */ foreach ( Request::i()->cookie as $cookieName => $value ) { if ( !in_array( $cookieName, Request::i()->getEssentialCookies() ) ) { Request::i()->setCookie( $cookieName, null ); } } Request::i()->setCookie( 'cookie_consent_optional', NULL ); } else { Request::i()->setCookie( 'cookie_consent_optional', 1, $expire, FALSE ); } return $allow; } /** * Are optional cookies allowed? * * @return bool */ public function get_optionalCookiesAllowed(): bool { return isset( Request::i()->cookie[ 'cookie_consent' ] ) AND isset( Request::i()->cookie[ 'cookie_consent_optional' ] ); } /** * [ActiveRecord] Delete Record * * @param bool $setAuthorToGuest Sets the author id of all content to 0 ( guest ) * @param bool $keepAuthorName Keeps the author name * @return void */ public function delete( bool $setAuthorToGuest = TRUE, bool $keepAuthorName = TRUE ): void { /* Is this a guest object? */ if( !$this->member_id ) { Log::log( [ $this->name, $this->email ], 'guest_delete_attempt' ); return; } if( $setAuthorToGuest ) { /* Clean up content - set to member ID 0 - We check $setAuthorToGuest because of member merging. As the member is immediately deleted we do not want to compete with the existing merge in progress. */ $this->hideOrDeleteAllContent( 'merge', array( 'merge_with_id' => 0, 'merge_with_name' => $keepAuthorName ? $this->name : '' ) ); } /* Let apps do their stuff */ Event::fire( 'onDelete', $this ); /* Actually delete from database */ parent::delete(); /* We may have deleted a member waiting validation */ if ( !Db::i()->select( 'COUNT(*)', 'core_validating', array( 'user_verified=?', TRUE ) )->first() ) { AdminNotification::remove( 'core', 'NewRegValidate' ); } if ( $latestReg = Db::i()->select( 'joined', 'core_members', array( 'email != ?', '' ), 'joined DESC', 1 )->first() ) { AdminNotification::remove( 'core', 'NewRegComplete', NULL, DateTime::ts( $latestReg ) ); } else { AdminNotification::remove( 'core', 'NewRegComplete' ); } if ( $this->members_bitoptions['is_support_account'] ) { AdminNotification::remove( 'core', 'ConfigurationError', "supportAdmin-{$this->member_id}" ); } /* Delete from member map */ Db::i()->delete( 'core_item_member_map', array( 'map_member_id=?', $this->member_id ) ); /* Reset statistics */ Widget::deleteCaches(); } /** * [ActiveRecord] Save Changed Columns * * @return void * @note We have to be careful when upgrading in case we are coming from an older version */ public function save(): void { if ( $this->member_id AND ( !Dispatcher::hasInstance() OR Dispatcher::i()->controllerLocation != 'setup' ) ) { $this->checkGroupPromotion(); } $new = $this->_new; $changes = $this->changed; /* If we don't have a name or email address, account is not 'complete' */ /* @note The completed column is added in the 4.4.0 Beta 1 routine, so we need to verify the upgrade has been performed otherwise the auto-upgrader can replace this file before the column is added, and an SQL error can prevent the admin from completing the auto-upgrade process */ $fireRegistrationCompletedWebhook = FALSE; if ( Application::load( 'core' )->long_version > 104000 ) { if( $this->completed AND ( !$this->name OR !$this->email ) ) { $this->completed = FALSE; } /* Conversely if it's flagged incomplete but we have all the details, change the flag */ elseif( !$this->completed AND $this->name AND $this->email ) { $this->completed = TRUE; $fireRegistrationCompletedWebhook = TRUE; } } parent::save(); if ( $new ) { /* Profile Fields */ Db::i()->insert( 'core_pfields_content', array( 'member_id' => $this->member_id ), TRUE ); Event::fire( 'onCreateAccount', $this ); } else { if ( $fireRegistrationCompletedWebhook ) { Webhook::fire( 'member_registration_complete', $this, $this->webhookFilters() ); } /* Run member sync, but not if the only change is the last_activity timestamp and if we're not just updating the member's language during language() init when no language is set */ if( ( Member::loggedIn()->member_id != $this->member_id or $this->_lang !== null ) and ( count( $this->changedCustomFields ) > 0 OR ( count( $changes ) > 0 AND !( count( $changes ) === 1 AND isset( $changes['last_activity'] ) ) ) ) ) { Event::fire( 'onProfileUpdate', $this, array( 'changes' => array_merge( $changes, $this->changedCustomFields ) ) ); } } /* If we have updated custom fields, make sure we don't have any cached in the class - just wipe out cache so we will refetch if needed */ if( count( $this->changedCustomFields ) ) { $this->rawProfileFieldsData = NULL; $this->profileFields = NULL; } /* Remove pending validation entries if our email address has changed */ if( !$new AND isset( $changes['email'] ) ) { /* Delete any pending validation emails */ Db::i()->delete( 'core_validating', array( 'member_id=? AND email_chg=1', $this->member_id ) ); /* Delete any pending password reset emails */ Db::i()->delete( 'core_validating', array( 'member_id=? AND lost_pass=1', $this->member_id ) ); } } /* !Getters/Setters Data */ /** * Group Data, taking into consideration secondary groups */ public ?array $_group = NULL; /** * @brief Admin CP Restrictions */ protected mixed $restrictions = NULL; /** * @brief Moderator Permissions */ protected mixed $modPermissions = NULL; /** * @brief Calculated language ID */ protected ?int $calculatedLanguageId = NULL; /** * @brief Marker Cache */ public array $markers = array(); /** * @brief Markers reset times */ public array $markersResetTimes = array(); /** * @brief We have fetched markers already */ public bool $haveAllMarkers = FALSE; /** * @brief Default stream ID */ protected mixed $defaultStreamId = FALSE; /** * @brief Keep track of any changed profile fields */ public array $changedCustomFields = array(); /** * Get name, do not return "guest" name if not set * * @return string */ public function get_real_name(): string { return ( isset( $this->_data['name'] ) ) ? $this->_data['name'] : ''; } /** * Get name * * @return string */ public function get_name(): string { if( !isset( $this->_data['name'] ) ) { return Member::loggedIn()->language()->addToStack('guest'); } return $this->member_id ? $this->_data['name'] : Member::loggedIn()->language()->addToStack( 'guest_name_shown', FALSE, array( 'sprintf' => array( $this->_data['name'] ) ) ); } /** * Get name, loading the guest_name_shown language string directly if needed * * @return string */ public function get__name(): string { if ( !isset( $this->_data['name'] ) ) { return Member::loggedIn()->language()->get('guest'); } return $this->member_id ? $this->_data['name'] : sprintf( Member::loggedIn()->language()->get('guest_name_shown'), $this->_data['name'] ); } /** * @brief Previous name - stored temporarily for display name history log */ protected ?string $previousName = NULL; /** * Set name * * @param string $value Value * @return void */ public function set_name( string $value ) : void { if( isset( $this->_data['name'] ) ) { $this->previousName = $this->_data['name']; } $this->_data['name'] = $value; $this->_data['members_seo_name'] = Friendly::seoTitle( $value ); } /** * Set group * * @param int $value Value * @return void */ public function set_member_group_id( int $value ) : void { $this->_data['member_group_id'] = $value; $this->resetGroupsCaches(); } /** * Set Secondary Groups * * @param string $value Value * @return void */ public function set_mgroup_others( string $value ) : void { $groups = array_filter( explode( ",", $value ) ); if ( in_array( Settings::i()->guest_group, $groups ) ) { throw new InvalidArgumentException; } $this->_data['mgroup_others'] = implode( ',', $groups ); $this->resetGroupsCaches(); } /** * Flag as spammer * * @return void */ public function flagAsSpammer() : void { if ( !$this->members_bitoptions['bw_is_spammer'] ) { $actions = explode( ',', Settings::i()->spm_option ); /* Hide or delete */ if ( in_array( 'unapprove', $actions ) or in_array( 'delete', $actions ) ) { /* Send to queue */ $this->hideOrDeleteAllContent( in_array( 'delete', $actions ) ? 'delete' : 'hide' ); /* Clear out their profile */ if ( in_array( 'delete', $actions ) ) { $this->signature = ''; $this->pp_main_photo = NULL; $this->pp_photo_type = NULL; $this->coverPhoto()->delete(); $this->pp_cover_photo = ''; $this->pp_cover_offset = 0; Db::i()->delete( 'core_pfields_content', array( 'member_id=?', $this->member_id ) ); } } /* Restrict from posting or ban */ if ( in_array( 'disable', $actions ) or in_array( 'ban', $actions ) ) { if ( in_array( 'ban', $actions ) ) { $this->temp_ban = -1; } else { $this->restrict_post = -1; $this->members_disable_pm = 2; } } /* Save */ $this->members_bitoptions['bw_is_spammer'] = TRUE; $this->save(); /* Run sync */ $this->logHistory( 'core', 'account', array( 'type' => 'spammer', 'set' => TRUE, 'actions' => $actions ) ); Event::fire( 'onSetAsSpammer', $this ); Webhook::fire( 'member_flagged_as_spammer', $this ); /* Notify admin */ AdminNotification::send( 'core', 'Spammer', NULL, TRUE, $this->member_id, Member::loggedIn() ); /* Feedback to Spam Monitoring Service */ if ( Settings::i()->spam_service_enabled and Settings::i()->spam_service_send_to_ips ) { $this->spamService( 'markspam' ); } } } /** * Hide/Delete All Content * * @param string $action 'hide' or 'delete' or 'merge' * @param array $extra Extra data needed by the MemberContent plugin * @return void */ public function hideOrDeleteAllContent( string $action, array $extra=array() ) : void { /* Edited member, so clear widget caches (stats, widgets that contain photos, names and so on) */ Widget::deleteCaches(); /* Send to the queue, include archived content */ foreach ( Content::routedClasses( FALSE, TRUE, FALSE ) as $class ) { if ( isset( $class::$databaseColumnMap['author'] ) and ( $action == 'delete' or IPS::classUsesTrait( $class, 'IPS\Content\Hideable' ) ) ) { /* Comments run first when merging so rebuilding topic doesn't fail with incorrect author ID */ switch( $action ) { case 'hide': case 'delete': $order = 1; break; case 'merge': $order = ( !is_subclass_of( $class, '\IPS\Content\Comment' ) ) ? 3 : 2; break; } Task::queue( 'core', 'MemberContent', array_merge( array( 'initiated_by_member_id' => Member::loggedIn()->member_id, 'member_id' => $this->member_id, 'name' => $this->name, 'class' => $class, 'action' => $action ), $extra ), $order ); } } /* And private messages */ Task::queue( 'core', 'MemberContent', array_merge( array( 'initiated_by_member_id' => Member::loggedIn()->member_id, 'member_id' => $this->member_id, 'name' => $this->name, 'class' => 'IPS\\core\\Messenger\\Conversation', 'action' => $action ), $extra ), 2 ); } /** * Unflag as spammer * * @return void */ public function unflagAsSpammer() : void { if ( $this->members_bitoptions['bw_is_spammer'] ) { /* Save */ $this->members_bitoptions['bw_is_spammer'] = FALSE; $this->save(); /* Log */ $this->logHistory( 'core', 'account', array( 'type' => 'spammer', 'set' => FALSE ) ); /* Remove any pending hide or delete content queued tasks */ foreach(Db::i()->select( '*', 'core_queue', array( '`key`=?', 'MemberContent' ) ) as $task ) { $data = json_decode( $task['data'], true ); if( $data['member_id'] == $this->member_id ) { Db::i()->delete( 'core_queue', array( 'id=?', $task['id'] ) ); } } /* Report back to spam service */ if ( Settings::i()->spam_service_enabled and Settings::i()->spam_service_send_to_ips ) { $this->spamService( 'notspam' ); } /* Run sync */ Event::fire( 'onUnSetAsSpammer', $this ); Webhook::fire( 'member_unflagged_as_spammer', $this ); } } /** * Get auto-track data * * @return array */ public function get_auto_follow(): array { return ( mb_substr( $this->_data['auto_track'], 0, 1 ) !== '{' ) ? array( 'method' => 'immediate', 'content' => 0, 'comments' => (int) $this->_data['auto_track'] ) : json_decode( $this->_data['auto_track'], TRUE ); } /** * Set banned * * @param string $value Value * @return void */ public function set_temp_ban( string $value ) : void { $this->_data['temp_ban'] = $value; if ( $value == -1 ) { Db::i()->delete( 'core_validating', array( 'member_id=?', $this->member_id ) ); if ( !Db::i()->select( 'COUNT(*)', 'core_validating', array( 'user_verified=?', TRUE ) )->first() ) { AdminNotification::remove( 'core', 'NewRegValidate' ); } } else { $this->members_bitoptions['validating'] = FALSE; } /* Revoke oAuth tokens */ if( $value ) { Db::i()->update( 'core_oauth_server_access_tokens', array( 'status' => 'revoked' ), array( 'member_id=?', $this->member_id ) ); } Webhook::fire( 'member_ban_state_changed', [$this, $value] ); } /** * Get Group Data * * @return array|null */ public function get_group(): ?array { if ( $this->_group === NULL ) { /* Load primary group */ try { $group = Group::load( $this->_data['member_group_id'] ); } catch ( OutOfRangeException $e ) { $group = Group::load( Settings::i()->member_group ); } $this->_group = array_merge( $group->data(), $group->g_bitoptions->asArray() ); /* Merge in secondary group data */ if ( !empty( $this->_data['mgroup_others'] ) ) { $groups = array_filter( explode( ',', $this->_data['mgroup_others'] ) ); $exclude = array(); $lessIsMore = array(); $neg1IsBest = array(); $zeroIsBest = array(); $callback = array(); /* Get the limits we need to work out from apps */ foreach (Application::allExtensions( 'core', 'GroupLimits', FALSE, 'core' ) as $key => $extension ) { $appLimits = $extension->getLimits(); if( !empty( $appLimits[ 'neg1IsBest' ] ) ) { $neg1IsBest = array_merge( $neg1IsBest, $appLimits[ 'neg1IsBest' ] ); } if( !empty( $appLimits[ 'zeroIsBest' ] ) ) { $zeroIsBest = array_merge( $zeroIsBest, $appLimits[ 'zeroIsBest' ] ); } if( !empty( $appLimits[ 'lessIsMore' ] ) ) { $lessIsMore = array_merge( $lessIsMore, $appLimits[ 'lessIsMore' ] ); } if( !empty( $appLimits[ 'exclude' ] ) ) { $exclude = array_merge( $exclude, $appLimits[ 'exclude' ] ); } if( !empty( $appLimits[ 'callback' ] ) ) { $callback = array_merge( $callback, $appLimits[ 'callback' ] ); } } /* Do the merging */ $skippedGroups = array(); foreach( $groups as $gid ) { try { $group = Group::load( $gid ); } catch( OutOfRangeException $e ) { $skippedGroups[] = $gid; continue; } $_data = array_merge( $group->_data, $group->g_bitoptions->asArray() ); foreach( $_data as $k => $v ) { if ( ! in_array( $k, $exclude ) ) { if ( in_array( $k, $zeroIsBest ) ) { if ( empty( $this->_group[ $k ] ) ) { continue; } else if( $v == 0 ) { $this->_group[ $k ] = 0; } else if ( $v > $this->_group[ $k ] ) { $this->_group[ $k ] = $v; } } else if( in_array( $k, $neg1IsBest ) ) { if ( $this->_group[ $k ] == -1 ) { continue; } else if( $v == -1 ) { $this->_group[ $k ] = -1; } else if ( $v > $this->_group[ $k ] ) { $this->_group[ $k ] = $v; } } else if ( in_array( $k, $lessIsMore ) ) { if ( $v < $this->_group[ $k ] ) { $this->_group[ $k ] = $v; } } else if ( array_key_exists( $k, $callback ) ) { $callbackFunction = $callback[ $k ]; $result = $callbackFunction( $this->_group, $_data, $k, $this->_data ); if( is_array( $result ) ) { $this->_group = array_merge( $this->_group, $result ); } else if( $result !== NULL ) { $this->_group[ $k ] = $result; } } else { if ( !isset( $this->_group[ $k ] ) OR $v > $this->_group[ $k ] ) { $this->_group[ $k ] = $v; } } } } } if( count( $skippedGroups ) ) { $this->mgroup_others = implode( ',', array_diff( $groups, $skippedGroups ) ); parent::save(); } } } return $this->_group; } /** * Retrieve the group name * * @return string */ public function get_groupName(): string { if ( $this->_group === NULL ) { $group = $this->group; } if( $this->_data['member_group_id'] ) { $group = Group::load( $this->_data['member_group_id'] ); $this->_group['name'] = $group->formatName( Member::loggedIn()->language()->addToStack( "core_group_{$group->g_id}" ) ); } return $this->_group['name']; } /** * @brief Cached groups check */ protected ?array $_groups = NULL; /** * Reset the group and groups cache * * @return void */ protected function resetGroupsCaches(): void { $this->_group = NULL; $this->_groups = NULL; } /** * Get an array of the group IDs (including secondary groups) this member belongs to * * @return array|null */ public function get_groups(): ?array { if ( $this->_groups !== NULL ) { return $this->_groups; } $this->_groups = array( $this->_data['member_group_id'] ); if( $this->_data['mgroup_others'] ) { foreach( array_filter( explode( ',', $this->_data['mgroup_others'] ) ) as $id ) { $this->_groups[] = intval( $id ); } } /* Sort for consistency when using permissions as part of a cache key */ sort( $this->_groups, SORT_NUMERIC ); return $this->_groups; } /** * Social Groups */ protected ?array $_socialGroups = NULL; /** * Social Groups * * @param bool $bypassCache Fetch directly from the database * @return array|null */ public function socialGroups( bool $bypassCache=FALSE ): ?array { if ( $this->_socialGroups === NULL OR $bypassCache ) { /* If this is a guest, they will not have any social groups - save the query */ if( !$this->member_id ) { $this->_socialGroups = array(); } else { if( $bypassCache ) { $this->_socialGroups = iterator_to_array( Db::i() ->select( 'group_id', 'core_sys_social_group_members', array( 'member_id=?', $this->member_id ), NULL, NULL, NULL, NULL, Db::SELECT_FROM_WRITE_SERVER ) ->setKeyField( 'group_id' ) ->setValueField( 'group_id' ) ); } else { $this->_socialGroups = array(); foreach( $this->permissionArray() as $permissionRow ) { if( mb_substr( $permissionRow, 0, 1 ) === 's' ) { $groupId = (int) mb_substr( $permissionRow, 1 ); $this->_socialGroups[ $groupId ] = $groupId; } } } } } return $this->_socialGroups; } /** * Clubs */ protected ?array $_clubs = array(); /** * Clubs * * @param bool $fromWriteServer If Read/Write separation is enabled, this flag can be used to force reading from the write server, which can be used when rebuilding cached permission strings * @param bool $moderatorOnly If true will only return clubs the member is a modertor of * @return array */ public function clubs( bool $fromWriteServer=FALSE, bool $moderatorOnly=FALSE ): array { if ( !Settings::i()->clubs ) { return array(); } if ( !isset( $this->_clubs[ intval( $moderatorOnly ) ] ) ) { /* If this is a guest, they will not have any clubs - save the query */ if( !$this->member_id ) { $this->_clubs[ intval( $moderatorOnly ) ] = array(); } else { $statuses = $moderatorOnly ? ( Club::STATUS_MODERATOR . "','" . Club::STATUS_LEADER ) : ( Club::STATUS_MEMBER . "','" . Club::STATUS_MODERATOR . "','" . Club::STATUS_LEADER ) ; $this->_clubs[ intval( $moderatorOnly ) ] = iterator_to_array( Db::i()->select( 'club_id', 'core_clubs_memberships', array( "member_id=? AND status IN('" . $statuses . "')", $this->member_id ), NULL, NULL, NULL, NULL, $fromWriteServer ? Db::SELECT_FROM_WRITE_SERVER : 0 ) ); } } return $this->_clubs[ intval( $moderatorOnly ) ]; } /** * Permission Array * * @return array */ public function permissionArray(): array { $return = $this->groups; if ( $this->member_id ) { $return[] = "m{$this->member_id}"; if ( Settings::i()->clubs ) { $return[] = "ca"; // Public clubs, which is everyone except guests } if ( $this->modPermission('can_access_all_clubs') ) { $return[] = 'cm'; } if ( $this->permission_array === NULL ) { $this->rebuildPermissionArray(); } if( $this->permission_array ) { $return = array_merge( $return, explode( ',', $this->permission_array ) ); } } return $return; } /** * Permission Array * * @return void */ public function rebuildPermissionArray() : void { $permissionArray = array(); foreach ($this->socialGroups(TRUE) as $socialGroupId ) { $permissionArray[] = "s{$socialGroupId}"; } if ( Settings::i()->clubs ) { /* Wipe club cache as when we are added, perm is rebuilt but the new club membership is not detected as we are using cached value */ $this->_clubs = NULL; $clubs = $this->clubs(TRUE); if ( $clubs ) { foreach ( $clubs as $clubId ) { $permissionArray[] = "c{$clubId}"; } foreach ($this->clubs(TRUE, TRUE) as $clubId ) { $permissionArray[] = "cm{$clubId}"; } } } $this->permission_array = implode( ',', $permissionArray ); $this->save(); } /** * Get Joined Date * * @return DateTime */ public function get_joined(): DateTime { return DateTime::ts( $this->_data['joined'] ); } /** * Get SEO Name * * @return string|null */ public function get_members_seo_name(): ?string { /* Set it so it will be saved */ if( !isset( $this->_data['members_seo_name'] ) or !$this->_data['members_seo_name'] ) { if ( !$this->name ) { return NULL; } $this->members_seo_name = Friendly::seoTitle( $this->name ); } return $this->_data['members_seo_name'] ?: Friendly::seoTitle( $this->name ); } /** * Get localized birthday, taking into account optional year * * @return string|null */ public function get_birthday(): ?string { try { if( $this->_data['bday_year'] ) { $date = new DateTime( str_pad( $this->_data['bday_year'], 4, 0, STR_PAD_LEFT ) . str_pad( $this->_data['bday_month'], 2, 0, STR_PAD_LEFT ) . str_pad( $this->_data['bday_day'], 2, 0, STR_PAD_LEFT ) ); return $date->fullYearLocaleDate(); } else if( $this->_data['bday_month'] ) { $date = new DateTime( $this->_data['bday_month'] . '/' . $this->_data['bday_day'] ); return $date->dayAndMonth(); } else { return NULL; } } catch ( Exception $e ) { Log::debug( "Member " . $this->id ." has a not valid birthday date" , 'birthday_error' ); return NULL; } } /** * Get the member's age * * @param DateTime|null $date If supplied, birthday is calculated from this point * @note If the member has not specified a birth year (which is optional), NULL is returned * @return int|null */ public function age( DateTime $date=NULL ): ?int { if( $this->_data['bday_year'] AND checkdate( $this->_data['bday_month'], $this->_data['bday_day'], $this->_data['bday_year'] ) ) { /* We use dashes because DateTime accepts two digit years with it */ $birthday = new DateTime( $this->_data['bday_year'] . '-' . $this->_data['bday_month'] . '-' . $this->_data['bday_day'] ); $birthday->setTime( 0, 0, 1 ); $today = $date ? new DateTime( $date->format('Y') . '-' . $date->format('m') . '-' . $date->format('d') ) : new DateTime(); if( Member::loggedIn()->timezone ) { $today->setTimezone( new DateTimeZone( Member::loggedIn()->timezone ) ); } $today->setTime( 23, 59, 59 ); // We want how old they'll be at the end of the provided date return $birthday->diff( $today )->y; } else { return NULL; } } /** * User's photo URL * * @param bool $thumb Use thumbnail? * @param bool $email Is the photo going to be used in an email? * @return string|null */ public function get_photo( bool $thumb=TRUE, bool $email=FALSE ): ?string { return static::photoUrl($this->_data, $thumb, $email); } /** * Set Photo Type * * @param string|null $type Photo type * @return void */ public function set_pp_photo_type( ?string $type ) : void { if ( $this->_previousPhotoType === NULL and isset( $this->_data['pp_photo_type'] ) ) { $this->_previousPhotoType = $this->_data['pp_photo_type']; } $this->_data['pp_photo_type'] = $type; } /** * Set Photo * * @param string|null $photo Photo location * @return void */ public function set_pp_main_photo( ?string $photo ) : void { $this->deletePhoto(); $this->_data['pp_main_photo'] = $photo; } /** * Delete the profile photo * * @return void */ public function deletePhoto() : void { /* It is common to update pp_photo_type before pp_main_photo */ $photoType = ( $this->_previousPhotoType !== NULL ) ? $this->_previousPhotoType : $this->_data['pp_photo_type']; /* Attempt to delete existing images if they are from a profile sync, or uploaded/imported from URL */ if ( mb_substr( $photoType, 0, 5 ) === 'sync-' or $photoType === 'custom' or $photoType === 'letter' ) { if ( $this->_data['pp_main_photo'] ) { try { File::get( 'core_Profile', $this->_data['pp_main_photo'] )->delete(); } catch ( Exception $e ) {} } if ( $this->_data['pp_thumb_photo'] ) { try { File::get( 'core_Profile', $this->_data['pp_thumb_photo'] )->delete(); $this->_data['pp_thumb_photo'] = NULL; } catch ( Exception $e ) {} } } } /** * Get reputation points * * @return int */ public function get_pp_reputation_points(): int { return isset( $this->_data['pp_reputation_points'] ) ? (int) $this->_data['pp_reputation_points'] : 0; } /** * Get warning points * * @return int */ public function get_warn_level(): int { return isset( $this->_data['warn_level'] ) ? (int) $this->_data['warn_level'] : 0; } /** * Get member title * * @return array * @deprecated Use rank() instead */ public function get_rank(): array { if ( $rank = $this->rank() ) { return [ 'title' => $rank->_title, 'image' => $rank->html() ]; } return [ 'title' => NULL, 'image' => NULL ]; } /** * Get member location * * @return string|null */ public function get_location(): ?string { return $this->location(); } /** * Get members posts for today * * @return array */ public function get_members_day_posts(): array { return explode( ',', $this->_data['members_day_posts'] ); } /** * Set members posts for today * * @param array $value Array of daily post data. Index 0 is the amount of posts posted in this time period, and optional index 1 is a timestamp of when we started counting * @return void */ public function set_members_day_posts(array $value ) : void { /* Are we updating time? */ if ( ! isset( $value[1] ) ) { $value[1] = $this->members_day_posts[1]; } $this->_data['members_day_posts'] = implode( ',', $value ); } /** * Get member's default stream * * @return int|null */ public function get_defaultStream(): ?int { if ( $this->defaultStreamId === FALSE ) { if ( $this->member_streams and $streams = json_decode( $this->member_streams, TRUE ) and count( $streams ) ) { $this->defaultStreamId = ( $streams['default'] ?? NULL ); } else { $this->defaultStreamId = NULL; } } return $this->defaultStreamId; } /** * Set member's default stream * * @param int|null $value Null or stream ID. 0 is for 'all activity' * @return void */ public function set_defaultStream( ?int $value ) : void { if ( $this->member_streams and $streams = json_decode( $this->member_streams, TRUE ) and count( $streams ) ) { $streams['default'] = $value; } else { $streams = array( 'streams' => array(), 'default' => $value ); } $this->member_streams = json_encode( $streams ); $this->save(); $this->defaultStreamId = $value; } /** * Get member location * * @return string|null */ public function location(): ?string { if( $this->sessionData === FALSE ) { return NULL; } if( $this->sessionData === NULL ) { $this->sessionData = Session\Store::i()->getLatestMemberSession( $this->member_id ); } return ( $this->sessionData ) ? Session::i()->getLocation( $this->sessionData ) : NULL; } /** * Get validating description * * @param null $validatingRow * @return string */ public function validatingDescription( $validatingRow=NULL ): string { try { $validatingRow = ( $validatingRow ) ?: Db::i()->select( '*', 'core_validating', array( 'member_id=?', $this->member_id ) )->first(); } catch( UnderflowException $ex ) { return ''; } $validatingDescription = ''; if ( $validatingRow['new_reg'] ) { if ( $validatingRow['reg_cancelled'] ) { $validatingDescription = Member::loggedIn()->language()->addToStack('members_validating_cancelled'); } elseif ( $validatingRow['user_verified'] ) { $validatingDescription = Member::loggedIn()->language()->addToStack('members_validating_admin'); } else { $validatingDescription = Member::loggedIn()->language()->addToStack('members_validating_user'); } if ( $validatingRow['coppa_user'] ) { $validatingDescription .= Member::loggedIn()->language()->addToStack('members_validating_coppa'); } if ( $validatingRow['spam_flag'] ) { $validatingDescription .= Member::loggedIn()->language()->addToStack('members_validating_spam'); } } elseif ( $validatingRow['email_chg'] ) { $validatingDescription .= Member::loggedIn()->language()->addToStack('members_validating_email_chg'); } return $validatingDescription; } /** * Followers Count * * @param int $privacy Content::FOLLOW_PUBLIC + Content::FOLLOW_ANONYMOUS * @param array $frequencyTypes array( 'none', 'immediate', 'daily', 'weekly' ) * @param DateTime|int|NULL $date Only users who started following before this date will be returned. NULL for no restriction * @return int * @throws BadMethodCallException */ public function followersCount( int $privacy=3, array $frequencyTypes=array( 'none', 'immediate', 'daily', 'weekly' ), DateTime|int|null $date=NULL ): int { if( $this->members_bitoptions['pp_setting_moderate_followers'] ) { return 0; } if( !$this->member_id ) { return 0; } /* Return the count */ return static::_followersCount( 'member', $this->member_id, $privacy, $frequencyTypes, $date ); } /** * Followers * * @param int $privacy Content::FOLLOW_PUBLIC + Content::FOLLOW_ANONYMOUS * @param array $frequencyTypes array( 'none', 'immediate', 'daily', 'weekly' ) * @param DateTime|int|NULL $date Only users who started following before this date will be returned. NULL for no restriction * @param int|array|NULL $limit LIMIT clause * @param string|NULL $order Column to order by * @param bool $countOnly Return only the count * @return Select|int * @throws BadMethodCallException */ public function followers(int $privacy=3, array $frequencyTypes=array( 'none', 'immediate', 'daily', 'weekly' ), DateTime|int|null $date=NULL, int|array|null $limit=array( 0, 25 ), string|null $order=NULL, bool $countOnly=FALSE ): Select|int { if( $this->members_bitoptions['pp_setting_moderate_followers'] ) { return 0; } return static::_followers( 'member', $this->member_id ?: 0, $privacy, $frequencyTypes, $date, $limit, $order, FALSE, null ); } /** * Follow this object * * @param string $frequency ( 'none', 'immediate', 'daily', 'weekly' ) * @param bool $public * @param Member|null $member * @return void */ public function follow( string $frequency, bool $public=true, ?Member $member=null ) : void { $member = $member ?: Member::loggedIn(); $this->_followBase( $frequency, $public, $member ); if( $public ) { $this->achievementAction( 'core', 'FollowMember', [ 'giver' => $member ] ); $notification = new Notification( Application::load( 'core' ), 'member_follow', $member, array( $member ) ); $notification->recipients->attach( $this ); $notification->send(); } } /** * Record a failed login attempt * * @return void */ public function failedLogin(): void { Db::i()->insert( 'core_login_failures', [ 'login_member_id' => $this->member_id ?: NULL, 'login_date' => ( new DateTime )->getTimestamp(), 'login_ip_address' => Request::i()->ipAddress(), 'login_email' => $this->member_id ? NULL : $this->email ]); if( $this->member_id ) { $where = [ [ 'login_date>=?', ( Settings::i()->ipb_bruteforce_unlock ? ( new DateTime() )->sub( new DateInterval( 'PT' . Settings::i()->ipb_bruteforce_period . 'M' ) )->getTimestamp() : 0 ) ] ]; $where[] = [ 'login_ip_address IS NOT NULL AND login_member_id=?', $this->member_id ]; $failedLogins = iterator_to_array( Db::i()->select( 'count(login_ip_address)', 'core_login_failures', $where, NULL, NULL, 'login_ip_address' ) ); /* We hit an issue where the query above returned an empty result set, possibly a read/write issue. Rather than switch to the writer, just assume we have 1 failure, or we wouldn't be here. */ $this->failed_login_count = count( $failedLogins ) ? max( $failedLogins ) : 1; $this->save(); } } /** * Get MFA details * * @return array */ public function get_mfa_details(): array { return $this->_data['mfa_details'] ? json_decode( $this->_data['mfa_details'], TRUE ) : array(); } /** * Set MFA details * * @param array $data Data * @return void */ public function set_mfa_details( array $data ) : void { $this->_data['mfa_details'] = json_encode( $data ); } /** * Get profile sync settings * * @return array */ public function get_profilesync(): array { $return = isset( $this->_data['profilesync'] ) ? ( json_decode( $this->_data['profilesync'], TRUE ) ?: array() ) : array(); if ( isset( $return['facebook'] ) or isset( $return['Facebook'] ) or isset( $return['google'] ) or isset( $return['Google'] ) or isset( $return['linkedin'] ) or isset( $return['Linkedin'] ) or isset( $return['LinkedIn'] ) or isset( $return['microsoft'] ) or isset( $return['Microsoft'] ) or isset( $return['live'] ) or isset( $return['Live'] ) or isset( $return['twitter'] ) or isset( $return['Twitter'] ) ) { $newVal = array(); foreach ( $return as $loginKey => $prefs ) { $classname = NULL; switch ( mb_strtolower( $loginKey ) ) { case 'internal': $classname = 'IPS\\Login\\Handler\\Standard'; break; case 'facebook': $classname = 'IPS\\Login\\Handler\\OAuth2\\Facebook'; break; case 'google': $classname = 'IPS\\Login\\Handler\\OAuth2\\Google'; break; case 'linkedin': $classname = 'IPS\\Login\\Handler\\OAuth2\\LinkedIn'; break; case 'live': case 'microsoft': $classname = 'IPS\\Login\\Handler\\OAuth2\\Microsoft'; break; case 'twitter': $classname = 'IPS\\Login\\Handler\\OAuth1\\Twitter'; break; } if ( $classname and class_exists( $classname ) ) { try { $methodId = Db::i()->select( 'login_id', 'core_login_methods', array( 'login_classname=? AND login_enabled=1', $classname ) )->first(); foreach ( $prefs as $option => $v ) { if ( $v ) { $newVal[ $option ] = array( 'handler' => $methodId, 'ref' => NULL, 'error' => NULL ); } } } catch ( UnderflowException $e ) { } } } $this->_data['profilesync'] = json_encode( $newVal ); return $newVal; } return $return; } /** * Set profile sync settings * * @param array $data Data * @return void */ public function set_profilesync( array $data ) : void { $this->_data['profilesync'] = json_encode( $data ); if ( !$data ) { $this->_data['profilesync_lastsync'] = 0; } } /** * Get timezone * * @return string|null */ public function get_timezone(): ?string { return DateTime::getFixedTimezone( $this->_data['timezone'] ); } /* !Photos */ /** * Columns needed to build photos * * @return array */ public static function columnsForPhoto(): array { return array( 'member_id', 'name', 'members_seo_name', 'member_group_id', 'mgroup_others', 'pp_photo_type', 'pp_main_photo', 'pp_thumb_photo' ); } /** * Get photo from data * * @param array $memberData Array of member data, must include values for at least the keys returned by columnsForPhoto() * @param bool $thumb Use thumbnail? * @param bool $email Is the photo going to be used in an email? * @param bool $useDefaultPhoto If there is no photo, should the default (rather than NULL) be returned? * @return string|null */ public static function photoUrl( array $memberData, bool $thumb=TRUE, bool $email=FALSE, bool $useDefaultPhoto=TRUE ): ?string { $photoUrl = NULL; /* All this only applies to members... */ if ( isset( $memberData['member_id'] ) and $memberData['member_id'] ) { /* We have an uploaded or synced photo */ if ( $memberData['pp_main_photo'] and ( mb_substr( $memberData['pp_photo_type'], 0, 5 ) === 'sync-' or $memberData['pp_photo_type'] === 'custom' ) ) { try { $photoUrl = File::get( 'core_Profile', ( $thumb and $memberData['pp_thumb_photo'] ) ? $memberData['pp_thumb_photo'] : $memberData['pp_main_photo'] )->url; } catch ( InvalidArgumentException $e ) { } } /* Other - This allows an app (such as Gallery) to set the pp_photo_type to a storage container to support custom images without duplicating them */ elseif( $memberData['pp_photo_type'] and $memberData['pp_photo_type'] != 'none' and mb_strpos( $memberData['pp_photo_type'], '_' ) !== FALSE ) { try { $photoUrl = File::get( $memberData['pp_photo_type'], $memberData['pp_main_photo'] )->url; } catch ( InvalidArgumentException $e ){} } /* Letter photos are enabled, and we do not have a photo set, but only if this isn't for email */ elseif( Settings::i()->letter_photos == 'letters' AND $email === FALSE and isset( $memberData['name'] ) ) { if( $photo = static::generateLetterPhoto($memberData) ) { $photoUrl = $photo; } } /* Return */ if( $photoUrl !== NULL ) { return (string) $photoUrl; } } /* Still here? Return default photo */ if ( !$photoUrl and $useDefaultPhoto ) { if( $email ) { return rtrim( Settings::i()->base_url, '/' ) . '/applications/core/interface/email/default_photo.png'; } else { if( Settings::i()->letter_photos == 'letters' AND isset( $memberData['member_id'] ) AND $memberData['member_id'] AND isset( $memberData['name'] ) AND $memberData['name'] ) { if( $photo = static::generateLetterPhoto($memberData) ) { return (string) $photo; } } return (string) Theme::i()->resource( 'default_photo.png', 'core', 'global' ); } } return NULL; } /** * Generate a letter photo (SVG) * * @param array $memberData Member data * @param bool $returnJson Return the letter and color as an array, instead of the SVG string * @return string|array */ public static function generateLetterPhoto(array $memberData, bool $returnJson=FALSE ): array|string { /* Get the letter we'll use */ $letter = static::getLettersForPhoto( $memberData['name'] ); /* Have we already cached the color? */ if( isset( $memberData['pp_main_photo'] ) AND $memberData['pp_main_photo'] AND strlen( $memberData['pp_main_photo'] ) === 6 ) { $color = $memberData['pp_main_photo']; } else { /* Generate a new unique color */ $photo = new LetterPhoto( $memberData['name'] ); $color = $photo->generateColorCode(); /* Save the color for next time */ if( isset( $memberData['member_id'] ) AND $memberData['member_id'] ) { $member = static::load( $memberData['member_id'] ); if( $member->member_id ) { $member->pp_photo_type = 'none'; $member->pp_main_photo = $color; $member->save(); } } } if( $returnJson === TRUE ) { return array( 'letter' => $letter, 'color' => $color ); } return "data:image/svg+xml," . rawurlencode( '' . $letter . '' ); } /** * Return the first X non-punctuation characters of the name * * @param string $name Name to use * @param int $letters Number of letters to return * @return string|NULL */ public static function getLettersForPhoto( string $name, int $letters=1 ): ?string { $name = str_replace( array( '<', '>', '=', '-', '+', '"', "'" ), '', $name ); $name = preg_replace( "/(\pP)+/u", '', trim( $name ) ); return $name ? mb_strtoupper( mb_substr( $name, 0, $letters ) ) : NULL; } /* !Get Calculated Properties */ /** * Get administrators * * @return array */ public static function administrators(): array { if ( !isset( Store::i()->administrators ) ) { Store::i()->administrators = array( 'm' => iterator_to_array( Db::i()->select( '*', 'core_admin_permission_rows', array( 'row_id_type=?', 'member' ) )->setKeyField( 'row_id' ) ), 'g' => iterator_to_array( Db::i()->select( '*', 'core_admin_permission_rows', array( 'row_id_type=?', 'group' ) )->setKeyField( 'row_id' ) ), ); } return Store::i()->administrators; } /** * Is an admin? * * @return bool */ public function isAdmin(): bool { return $this->acpRestrictions() !== FALSE; } /** * Does this member have any moderator permissions? * * @return bool */ public function isModerator() : bool { if( $this->isAdmin() ) { return true; } return (bool) $this->modShowBadge(); } /** * @brief Is this member an expert in the community? */ protected mixed $isExpert = null; /** * Is Member a community Expert? * * @return bool */ public function isExpert(): bool { if( !Bridge::i()->featureIsEnabled( 'experts' ) ) { return false; } /* Admin level blocked */ if ( $this->members_bitoptions['expert_user_blocked'] ) { return false; } /* Is the member in a group that is allowed to be an expert? This setting might have been changed. */ if( Settings::i()->ips_experts_allowed_groups !== '*' and ! $this->inGroup( explode( ',', Settings::i()->ips_experts_allowed_groups ) ) ) { $this->isExpert = false; } if ( $this->isExpert === null ) { try { $this->isExpert = (bool)Db::i()->select( 'member_id', 'core_expert_users', ['member_id=?', $this->member_id] )->first(); } catch ( UnderflowException $e ) { $this->isExpert = false; } } return $this->isExpert; } /** * Does this member want to be an expert? * * @return bool */ public function showAsExpert(): bool { return ( $this->members_bitoptions['expert_user_disabled'] and $this->isExpert ); } /** * @brief Cache the session data if we pull it for location, etc. */ protected mixed $sessionData = NULL; /** * Is online? * * @return bool */ public function isOnline(): bool { if( !$this->member_id ) { return FALSE; } if ( $this->sessionData === NULL ) { $this->sessionData = Session\Store::i()->getLatestMemberSession( $this->member_id ); } if( $this->sessionData === FALSE ) { return FALSE; } $diff = DateTime::ts( $this->last_activity )->diff( DateTime::create() ); if ( $diff->y or $diff->m or $diff->d or $diff->h or $diff->i > 15 ) { return FALSE; } return TRUE; } /** * Is Online Anonymously * * @return bool */ public function isOnlineAnonymously(): bool { if ( !$this->member_id ) { return FALSE; } /* If the group is forced to be anonymous */ if( $this->group['g_hide_online_list'] == 1 ) { return TRUE; } /* Anonymous status is not allowed */ if( $this->group['g_hide_online_list'] == 2 ) { return FALSE; } return $this->members_bitoptions['is_anon']; } /** * Are any of these memberIds following me? * * @param array $memberIds * @return array */ public function isFollowing( array $memberIds ): array { $memberIds = array_unique( array_filter( $memberIds ) ); if ( !$memberIds ) { return array(); } $follows = array(); foreach ( $memberIds as $memberId ) { $follows[ $memberId ] = false; } foreach( Db::i()->select( 'follow_rel_id', 'core_follow', array( "follow_app='core' AND follow_area='member' AND follow_rel_id IN (" . implode( ',', $memberIds ) . ") AND follow_member_id=?", $this->member_id ) ) as $member ) { $follows[ $member ] = true; } return $follows; } /** * Is banned? * If is banned until a certain time, returns an \IPS\DateTime object * * @return bool|DateTime */ public function isBanned(): bool|DateTime { if ( $this->temp_ban != 0 ) { if ( $this->temp_ban != -1 and time() >= $this->temp_ban ) { $this->temp_ban = 0; $this->save(); return FALSE; } elseif ( $this->temp_ban > 0 ) { return DateTime::ts( $this->temp_ban ); } return TRUE; } if( !$this->group['g_view_board'] ) { return TRUE; } return FALSE; } /** * Is the member in a certain group (including secondary groups) * * @param array|int|Group $group The group, or array of groups * @param bool $permissionArray If TRUE, checks the permission array rather than the groups * @return bool */ public function inGroup( array|int|Group $group, bool $permissionArray=FALSE ): bool { $group = array_filter( is_array( $group ) ? $group : array( $group ) ); $check = array_filter( $permissionArray ? $this->permissionArray() : $this->groups ); foreach ( $group as $_group ) { $groupId = ( $_group instanceof Group ) ? $_group->g_id : $_group; if ( in_array( $groupId, $check ) ) { return TRUE; } } return FALSE; } /** * Is this member editing the theme? * @note This is only used in the front-end * * @return bool */ public function isEditingTheme(): bool { return (bool) $this->members_bitoptions['bw_using_skin_gen']; } /** * Store a reference to the language object */ protected Lang|UpgradeLang|SetupLang|null $_lang = NULL; /** * Return the language object to use for this member - returns default if member has not selected a language * * @param bool $frontOnly If TRUE, will only look at the the langauge for the front-end, not the AdminCP * @return Lang|UpgradeLang|SetupLang|null */ public function language(bool $frontOnly=FALSE ): Lang|UpgradeLang|SetupLang|null { /* Did we already load the language object? */ if( $this->_lang !== NULL ) { return $this->_lang; } /* If in API use the lang set by dispatcher */ if ( Dispatcher::hasInstance() and class_exists( 'IPS\Dispatcher', FALSE ) AND Dispatcher::i()->controllerLocation === 'api' ) { $this->_lang = Dispatcher::i()->_setLanguage(); return $this->_lang; } /* If in setup, create a "dummy" language */ if ( Dispatcher::hasInstance() and class_exists( 'IPS\Dispatcher', FALSE ) AND Dispatcher::i()->controllerLocation === 'setup' AND Dispatcher::i()->setupLocation === 'install' ) { $this->_lang = Lang::setupLanguage(); return $this->_lang; } else if ( Dispatcher::hasInstance() and class_exists( 'IPS\Dispatcher', FALSE ) AND Dispatcher::i()->controllerLocation === 'setup' AND Dispatcher::i()->setupLocation === 'upgrade' ) { $this->_lang = Lang::upgraderLanguage(); return $this->_lang; } /* Work out if we are getting the ACP language or the normal language */ $column = 'language'; if ( !$frontOnly and Dispatcher::hasInstance() and Dispatcher::i()->controllerLocation == 'admin' and $this->member_id and $this->member_id == static::loggedIn()->member_id ) { $column = 'acp_language'; } /* If the member has a language set, try that */ if( $this->calculatedLanguageId !== NULL or $this->$column ) { try { $this->_lang = Lang::load( $this->calculatedLanguageId ?: $this->$column ); /* Disabled Languages are allowed to be used in the ACP */ if( $this->_lang->enabled OR $column == 'acp_language' ) { return $this->_lang; } } catch ( OutOfRangeException $e ) { } } /* Otherwise, if this is us, try looking at HTTP_ACCEPT_LANGUAGE, if enabled */ if ( Dispatcher::hasInstance() and $this->member_id == static::loggedIn()->member_id and Settings::i()->lang_auto_detect ) { /* Work out what's in HTTP_ACCEPT_LANGUAGE */ $preferredLanguage = isset( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ? Lang::autoDetectLanguage( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) : NULL; /* If we worked one out, use that and save it on the account so it gets used for emails etc */ if ( $preferredLanguage ) { $this->calculatedLanguageId = $preferredLanguage; if ( $this->member_id ) { $this->$column = $preferredLanguage; $this->save(); } } /* Otherwise, just use the default */ else { $this->calculatedLanguageId = Lang::defaultLanguage(); } } else { /* Just return the default language */ $this->calculatedLanguageId = Lang::defaultLanguage(); } /* Set it */ $this->_lang = Lang::load( $this->calculatedLanguageId ); /* Add upgrader language bits if appropriate */ if ( Dispatcher::hasInstance() AND class_exists( 'IPS\Dispatcher', FALSE ) AND Dispatcher::i()->controllerLocation === 'setup' AND Dispatcher::i()->setupLocation === 'upgrade' ) { $this->_lang->upgraderLanguage(); } /* Return */ return $this->_lang; } /** * @brief Cached URL */ protected mixed $_url = NULL; /** * Get URL * * @return Url|string|null */ function url(): Url|string|null { if( $this->_url === NULL ) { $this->_url = Url::internal( "app=core&module=members&controller=profile&id={$this->member_id}", 'front', 'profile', ( $this->members_seo_name ?? '-' ) ); } return $this->_url; } /** * URL to ACP "Edit Member" * * @return Url */ public function acpUrl(): Url { return Url::internal( "app=core&module=members&controller=members&do=view&id={$this->member_id}", 'admin' ); } /** * HTML link to profile with hovercard * * @param string|null $warningRef The reference key for warnings * @param boolean|null $groupFormatting Apply the group prefix/suffix to the name? * @param boolean|null $anonymous Is this shown with anonymous content? * @return string */ public function link( string $warningRef=NULL, bool $groupFormatting=NULL, ?bool $anonymous=FALSE ): string { $groupFormatting = ( $groupFormatting === NULL ) ? !( ( Settings::i()->group_formatting == 'legacy' ) ) : $groupFormatting; if ( !Settings::i()->warn_on ) { $warningRef = NULL; } return Theme::i()->getTemplate( 'global', 'core', 'front' )->userLink( $this, $warningRef, $groupFormatting, $anonymous ); } /** * Profile Fields shown next to users content */ public ?array $rawProfileFieldsData = NULL; /** * Profile Fields * * @param int $location \IPS\core\ProfileFields\Field::PROFILE for profile, \IPS\core\ProfileFields\Field::REG for registration screen, \IPS\core\ProfileFields\Field::STAFF for ModCP/ACP, \IPS\core\ProfileFields\Field::EDIT for member editing * @param bool $raw Returns the raw value if true or the display value if false. Useful for comparisons for field types like Yes/NO to see if a value is set. * @return array|null */ public function profileFields( int $location = 0, bool $raw=FALSE ): ?array { if ( !$this->member_id ) { return array(); } if( $this->rawProfileFieldsData !== NULL ) { return $this->rawProfileFieldsData; } $this->rawProfileFieldsData = array(); $values = array(); try { $values = Db::i()->select( '*', 'core_pfields_content', array( 'member_id = ?', $this->member_id ) )->first(); } catch ( UnderflowException $e ) {} if( !empty( $values ) ) { foreach (core\ProfileFields\Field::values( $values, $location, $raw ) as $group => $fields ) { $this->rawProfileFieldsData[ 'core_pfieldgroups_' . $group ] = $fields; } } return $this->rawProfileFieldsData; } /** * Profile Fields shown next to users content */ public ?array $profileFields = NULL; /** * Profile Fields shown next to users content * * @param array|null $fieldValues Field values already fetched from the database * * @return array|null */ public function contentProfileFields( array $fieldValues=NULL ): ?array { if ( $this->profileFields === NULL ) { $this->profileFields = array(); if ( $this->member_id AND core\ProfileFields\Field::fieldsForContentView() ) { $select = '*'; /* Can we view private fields? */ if( !Dispatcher::hasInstance() OR !( Member::loggedIn()->isAdmin() OR Member::loggedIn()->member_id === $this->member_id ) ) { $select = 'member_id'; $publicFields = Db::i()->select( '*', 'core_pfields_data', array( 'pf_topic_hide != ?', 'hide' ) ); foreach( $publicFields as $field ) { if( $field['pf_topic_hide'] == 'all' OR ( $field['pf_topic_hide'] == 'staff' AND ( Member::loggedIn()->isAdmin() OR Member::loggedIn()->modPermissions() ) ) ) { $select .= ", field_{$field['pf_id']}"; } } } if ( $fieldValues !== NULL and is_array( $fieldValues ) ) { if ( $select == '*' ) { $values = $fieldValues; } else { $fields = explode( ', ', $select ); $values = array(); foreach( $fields as $f ) { if ( isset( $fieldValues[ $f ] ) ) { $values[ $f ] = $fieldValues[ $f ]; } } } } else { try { $values = Db::i()->select( $select, 'core_pfields_content', array( 'member_id = ?', $this->member_id ) )->first(); } catch ( UnderflowException $e ) {} } if ( is_array( $values ) ) { $this->setProfileFieldValuesInMemory( $values ); } } } return $this->profileFields; } /** * Store profile field values in memory * * @param array $values * @return void */ public function setProfileFieldValuesInMemory( array $values ) : void { $this->profileFields = array(); $values = array_filter( $values, function ( $val) { return ( $val !== '' AND $val !== NULL ); } ); if( !empty( $values ) ) { /* Make sure member_id is set */ $values['member_id'] = $this->member_id; foreach (core\ProfileFields\Field::values( $values, core\ProfileFields\Field::CONTENT ) as $group => $fields ) { $this->profileFields[ 'core_pfieldgroups_' . $group ] = $fields; } } } /** * IP Addresses * * @code return array( '::1' => array( 'count' => ... // int (number of times this member has used this IP) 'first' => ... // \IPS\DateTime (first use) 'last' => ... // \IPS\DateTime (last use) ), ... ); * @endcode * @return array */ public function ipAddresses(): array { $return = array(); foreach ( Application::allExtensions( 'core', 'IpAddresses' ) as $class ) { $results = $class->findByMember( $this ); if( $results === NULL ) { continue; } foreach ( $results as $ip => $data ) { if ( isset( $return[ $ip ] ) ) { $return[ $ip ]['count'] += $data['count']; if ( $data['first'] < $return[ $ip ]['first'] ) { $return[ $ip ]['first'] = $data['first']; } if ( $data['last'] > $return[ $ip ]['last'] ) { $return[ $ip ]['last'] = $data['last']; } } else { if ( $ip ) { $return[ $ip ] = $data; } } } } return $return; } /** * Mark the entire site as read * * @return void */ public function markAllAsRead() : void { /* Delete all member markers */ Db::i()->delete( 'core_item_markers', array( 'item_member_id=?', $this->member_id ) ); $this->marked_site_read = time(); $this->save(); } /** * Get read/unread markers * * @param string $app Application key * @param string $key Marker key * @return array */ public function markersItems( string $app, string $key ): array { if ( !isset( $this->markers[ $app ] ) or !array_key_exists( $key, $this->markers[ $app ] ) ) { try { $marker = Db::i()->select( '*', 'core_item_markers', array( 'item_key=? AND item_member_id=? AND item_app=?', $key, $this->member_id, $app ) )->first(); $this->markers[ $app ][ $key ] = $marker; } catch ( UnderflowException $e ) { $this->markers[ $app ][ $key ] = NULL; } } return $this->markers[ $app ][ $key ] ? json_decode( $this->markers[ $app ][ $key ]['item_read_array'], TRUE ) : array(); } /** * Get read/unread markers for containers * * @param string|NULL $app Application key or NULL for all applications * @return array|int */ public function markersResetTimes( ?string $app ): array|int { if ( ( !$app and !$this->haveAllMarkers ) or ( $app and !isset( $this->markersResetTimes[ $app ] ) ) ) { try { $where = array( array( 'item_member_id=?', $this->member_id ) ); if ( $app ) { $this->markersResetTimes[ $app ] = array(); $where[] = array( 'item_app=?', $app ); } else { $this->markersResetTimes = array(); } foreach (Db::i()->select( '*', 'core_item_markers', $where ) as $row ) { $this->setMarkerResetTimes( $row ); } if ( !$app ) { $this->haveAllMarkers = TRUE; } } catch ( UnderflowException $e ) { if ( $app ) { $this->markersResetTimes[ $app ] = array(); } else { $this->markersResetTimes = array(); } } } if ( $app ) { return $this->markersResetTimes[ $app ]; } else { return $this->markersResetTimes; } } /** * Set read/unread markers for containers * * @param array $row Row of marker data * @return void */ public function setMarkerResetTimes( array $row ) : void { if( !isset( $this->markersResetTimes[ $row['item_app'] ] ) or !is_array( $this->markersResetTimes[ $row['item_app'] ] ) ) { $this->markersResetTimes[ $row['item_app'] ] = array(); } if ( $row['item_app_key_1'] ) { if ( $row['item_app_key_2'] ) { if( !isset( $this->markersResetTimes[ $row['item_app'] ][ $row['item_app_key_1'] ] ) OR !is_array( $this->markersResetTimes[ $row['item_app'] ][ $row['item_app_key_1'] ] ) ) { $this->markersResetTimes[ $row['item_app'] ][ $row['item_app_key_1'] ] = array(); } if ( $row['item_app_key_3'] ) { if( !isset( $this->markersResetTimes[ $row['item_app'] ][ $row['item_app_key_1'] ][ $row['item_app_key_2'] ] ) OR !is_array( $this->markersResetTimes[ $row['item_app'] ][ $row['item_app_key_1'] ][ $row['item_app_key_2'] ] ) ) { $this->markersResetTimes[ $row['item_app'] ][ $row['item_app_key_1'] ][ $row['item_app_key_2'] ] = array(); } $this->markersResetTimes[ $row['item_app'] ][ $row['item_app_key_1'] ][ $row['item_app_key_2'] ][ $row['item_app_key_3'] ] = $row['item_global_reset']; } else { $this->markersResetTimes[ $row['item_app'] ][ $row['item_app_key_1'] ][ $row['item_app_key_2'] ] = $row['item_global_reset']; } } else { $this->markersResetTimes[ $row['item_app'] ][ $row['item_app_key_1'] ] = $row['item_global_reset']; } } else { $this->markersResetTimes[ $row['item_app'] ] = $row['item_global_reset']; } $this->markers[ $row['item_app'] ][ $row['item_key'] ] = $row; } /** * Get Warnings * * @param int|null $limit The number to get * @param bool|null $acknowledged If true, will only get warnings that have been acknowledged, if false will only get warnings that have not been knowledged. If NULL, will get both. * @param string|null $type If specified, will only pull warnings that applied a specific action. * @return ActiveRecordIterator|array */ public function warnings( int|null $limit, bool $acknowledged=NULL, string $type=NULL ): ActiveRecordIterator|array { if ( !$this->member_id ) { return array(); } if ( !Settings::i()->warn_on ) { return array(); } $where = array( array( 'wl_member=?', $this->member_id ) ); if ( $acknowledged !== NULL ) { $where[] = array( 'wl_acknowledged=?', $acknowledged ); } switch ( $type ) { case 'mq': $where[] = array( 'wl_mq IS NOT NULL' ); break; case 'rpa': $where[] = array( 'wl_rpa IS NOT NULL' ); break; case 'suspend': $where[] = array( 'wl_suspend IS NOT NULL' ); break; } return new ActiveRecordIterator( Db::i()->select( '*', 'core_members_warn_logs', $where, 'wl_date DESC', $limit, NULL, NULL, Db::SELECT_DISTINCT ), 'IPS\core\Warnings\Warning' ); } /** * @brief Cached reputation data */ protected ?array $_reputationData = NULL; /** * Calculate and cache the member's reputation level data * * @return array */ protected function getReputationData() : array { if( $this->_reputationData === NULL ) { $this->_reputationData = array(); if ( isset( Store::i()->reputationLevels ) ) { $reputationLevels = Store::i()->reputationLevels; } else { $reputationLevels = iterator_to_array( Db::i()->select( '*', 'core_reputation_levels', NULL, 'level_points DESC' ) ); Store::i()->reputationLevels = $reputationLevels; } foreach ( $reputationLevels as $level ) { if ( $this->pp_reputation_points >= $level['level_points'] ) { $this->_reputationData = $level; break; } } } return $this->_reputationData; } /** * @brief Cached reputation last day won */ protected array|null|false $_reputationLastDayWon = NULL; /** * Return the 'date' of the last day won, along with the 'rep_total'. * * @return array|bool|null ( 'date' => \IPS\DateTime, 'rep_total' => int )|FALSE */ public function getReputationLastDayWon(): bool|array|null { if ( $this->_reputationLastDayWon === NULL ) { try { $dayWon = Db::i()->select( 'leader_date, leader_rep_total', 'core_reputation_leaderboard_history', array( 'leader_position=1 AND leader_member_id=?', $this->member_id ), 'leader_date DESC', array( 0, 1 ) )->first(); /* The 'day won' must be in the leaderboard timezone otherwise it will be off for people in significantly different timezones */ $this->_reputationLastDayWon = array( 'date' => DateTime::ts( $dayWon['leader_date'], true )->setTimezone( new DateTimeZone( Settings::i()->reputation_timezone ) ), 'rep_total' => $dayWon['leader_rep_total'] ); } catch( UnderflowException $ex ) { $this->_reputationLastDayWon = FALSE; } } return $this->_reputationLastDayWon; } /** * @brief Cached reputation days won count */ protected ?int $_reputationDaysWonCount = NULL; /** * Return the total number of days won * * @return int|null */ public function getReputationDaysWonCount(): ?int { if ( $this->_reputationDaysWonCount === NULL ) { $this->_reputationDaysWonCount = Db::i()->select( 'COUNT(*)', 'core_reputation_leaderboard_history', array( 'leader_position=1 AND leader_member_id=?', $this->member_id ) )->first(); } return $this->_reputationDaysWonCount; } /** * Reputation level description * * @return string|NULL */ public function reputation(): ?string { $level = $this->getReputationData(); if( isset( $level['level_id'] ) ) { return Member::loggedIn()->language()->addToStack( 'core_reputation_level_' . $level['level_id'] ); } else { return NULL; } } /** * Reputation image * * @return string|NULL */ public function reputationImage(): ?string { $level = $this->getReputationData(); if( isset( $level['level_id'] ) ) { return $level['level_image']; } else { return NULL; } } /** * Verify legacy password * * @param object $password The (legacy) password to verify, wrapped in an object that can be cast to a string so it doesn't show in any logs * @return bool */ public function verifyLegacyPassword( object $password ): bool { return Login::compareHashes( $this->members_pass_hash, md5( md5( $this->members_pass_salt ) . md5( Request::legacyEscape( $password ) ) ) ); } /** * Set local password * * BE CAREFUL: The standard login handler may be disabled, only call this method * if you have alreadu checked it is enabled. In most cases, it is better to let * the available login handlers handle password management * * @param object|string $password Password to encrypt, can be wrapped in an object that can be cast to a string so it doesn't show in any logs * @return void */ public function setLocalPassword( string|object $password ) : void { /* We can safeuly assume that no matter what, this doesn't need to be set anymore */ $this->members_bitoptions['password_reset_forced'] = FALSE; $this->members_pass_hash = password_hash( $password, PASSWORD_DEFAULT ); $this->members_pass_salt = NULL; } /** * Change member's password for all applicable login handlers * * @param object $newPassword The new password in plaintext, wrapped in an object that can be cast to a string so it doesn't show in any logs * @param string $type Type of change for log * @return bool */ public function changePassword( object $newPassword, string $type='manual' ): bool { $return = FALSE; foreach (Login::methods() as $method ) { if ( $method->canChangePassword( $this ) ) { try { $method->changePassword( $this, $newPassword ); $return = TRUE; } catch( BadMethodCallException $e ){} } } Event::fire( 'onPassChange', $this, array( $newPassword ) ); $this->logHistory( 'core', 'password_change', $type ); return $return; } /** * Force user to reset their password. * * @return void */ public function forcePasswordReset() : void { /* If the user is already forced, but hasn't yet, just return */ if ( $this->members_bitoptions['password_reset_forced'] ) { return; } $this->members_pass_hash = NULL; # Intentionally bypasses setLocalPassword() as it will try and hash the NULL value. $this->members_bitoptions['password_reset_forced'] = TRUE; $this->save(); /* Invalidate login keys and sessions for all devices */ $this->invalidateSessionsAndLogins(); $key = md5( SUITE_UNIQUE_KEY . $this->email . $this->real_name ); $email = Email::buildFromTemplate( 'core', 'password_reset_forced', array( $this, $key ), Email::TYPE_TRANSACTIONAL ); $email->send( $this ); } /** * Password Reset Forced * * @param Url|null $ref Referrer, or NULL for no referrer. * @return Url|null */ public function passwordResetForced( ?Url $ref = NULL ): ?Url { if ( $this->members_bitoptions['password_reset_forced'] AND !$this->members_pass_hash ) { return Url::internal( 'app=core&module=system&controller=settings&area=password', 'front', 'settings_password' )->setQueryString( 'ref', base64_encode( $ref ) ); } return NULL; } /** * Change the member's email address * * @param string $newEmail * @return void */ public function changeEmail( string $newEmail ) : void { /* Disable syncing */ $profileSync = $this->profilesync; if ( isset( $profileSync['email'] ) ) { unset( $profileSync['email'] ); $this->profilesync = $profileSync; $this->save(); } /* Change the email */ $oldEmail = $this->email; $this->email = $newEmail; $this->save(); foreach ( Login::methods() as $method ) { try { $method->changeEmail( $this, $oldEmail, $newEmail ); } catch( BadMethodCallException $e ){} } $this->logHistory( 'core', 'email_change', array( 'old' => $oldEmail, 'new' => $newEmail, 'by' => 'manual' ) ); Db::i()->delete( 'core_validating', [ 'member_id=? and email_chg=?', $this->member_id, 1 ] ); Event::fire( 'onEmailChange', $this, array( $newEmail, $oldEmail ) ); } /** * @brief Cached notifications configuration * @note This property is public so it can be populated en-masse in areas that may benefit from doing so */ public ?array $notificationsConfiguration = NULL; static protected array $extCache = []; /** * Notifications Configuration * * @return array|null */ public function notificationsConfiguration(): ?array { if( $this->notificationsConfiguration === NULL ) { $this->notificationsConfiguration = array(); $defaultNotifications = []; foreach ( Db::i()->select( 'd.*, p.preference', array( 'core_notification_defaults', 'd' ) )->join( array( 'core_notification_preferences', 'p' ), array( 'd.notification_key=p.notification_key AND p.member_id=?', $this->member_id ) ) as $row ) { if ( $row['preference'] === NULL or !$row['editable'] ) { $defaultNotifications[ $row['notification_key'] ] = explode( ',', $row['default'] ); } else { $this->notificationsConfiguration[ $row['notification_key'] ] = array_diff( explode( ',', $row['preference'] ), explode( ',', $row['disabled'] ) ); } } /* Only cycle through if this member has any non-default notifications */ if( count( $this->notificationsConfiguration ) ) { /* Cache extension references */ if ( !count( static::$extCache ) ) { static::$extCache = Application::allExtensions( 'core', 'Notifications' ); } foreach ( $defaultNotifications as $k => $v ) { foreach ( static::$extCache as $extension ) { foreach ( $extension->configurationOptions( null ) as $optionKey => $option ) { if ( $option['type'] == 'standard' and in_array( $k, $option['notificationTypes'] ) and count( $option['notificationTypes'] ) > 1 ) { if ( !empty( $this->notificationsConfiguration[ $option['notificationTypes'][0] ] ) ) { $defaultNotifications[ $k ] = $this->notificationsConfiguration[ $option['notificationTypes'][0] ]; Db::i()->insert( 'core_notification_preferences', [ 'member_id' => $this->member_id, 'notification_key' => $k, 'preference' => implode( ',', $this->notificationsConfiguration[ $option['notificationTypes'][0] ] ) ] ); } continue 2; } } } } } $this->notificationsConfiguration = array_merge( $this->notificationsConfiguration, $defaultNotifications ); } return $this->notificationsConfiguration; } /** * @brief Cached PWA Authorizations */ protected ?array $_pwa = NULL; /** * Get members PWA Authorizations * * @return array */ public function getPwaAuths(): array { if ( $this->_pwa === NULL ) { $this->_pwa = array(); foreach(Db::i()->select( '*', 'core_notifications_pwa_keys', array( "`member`=?", $this->member_id ) ) AS $auth ) { $this->_pwa[ $auth['id'] ] = $auth; } } return $this->_pwa; } /** * Get members PWA Authorizations * * @return void */ public function clearPwaAuths() : void { $this->_pwa = NULL; Db::i()->delete( 'core_notifications_pwa_keys', array( "`member`=?", $this->member_id ) ); } /** * @brief Following? */ protected array $_following = array(); /** * Following * * @param string $app Application key * @param string $area Area * @param int $id Item ID * @return bool */ public function following( string $app, string $area, int $id ): bool { $_key = md5( $app . $area . $id ); if( isset( $this->_following[ $_key ] ) ) { return $this->_following[ $_key ]; } try { Db::i()->select( 'follow_id', 'core_follow', array( 'follow_app=? AND follow_area=? AND follow_rel_id=? AND follow_member_id=?', $app, $area, $id, $this->member_id ) )->first(); $this->_following[ $_key ] = TRUE; } catch ( UnderflowException $e ) { $this->_following[ $_key ] = FALSE; } return $this->_following[ $_key ]; } /** * Clear the cached result used by the following method * * @param string $app Application key * @param string $area Area * @param int $id Item ID * @return void */ public function clearFollowingCache( string $app, string $area, int $id ): void { $key = md5( $app . $area . $id ); unset( $this->_following[ $key ] ); } /** * Admin CP Restrictions * * @return bool|array|string */ protected function acpRestrictions(): bool|array|string { if ( !$this->member_id ) { return FALSE; } if ( $this->restrictions === NULL ) { $administrators = static::administrators(); $rows = array(); if ( isset( $administrators['m'][ $this->member_id ] ) ) { $rows[] = $administrators['m'][ $this->member_id ]; } foreach ( $this->groups as $id ) { if ( isset( $administrators['g'][ $id ] ) ) { $rows[] = $administrators['g'][ $id ]; } } $this->restrictions = FALSE; if ( count( $rows ) > 0 ) { $this->restrictions = array(); foreach ( $rows as $row ) { if ( $row['row_perm_cache'] === '*' ) { $this->restrictions = '*'; break; } $perms = json_decode( $row['row_perm_cache'], TRUE ); if ( $row['row_id_type'] === 'member' ) { $this->restrictions = $perms; break; } else if( is_array( $perms ) ) { if ( empty( $this->restrictions ) ) { $this->restrictions = $perms; } else { if( isset( $perms['applications'] ) ) { foreach ( $perms['applications'] as $app => $modules ) { if ( !isset( $this->restrictions['applications'][ $app ] ) ) { $this->restrictions['applications'][ $app ] = array(); } foreach ( $modules as $module ) { if ( !isset( $this->restrictions['applications'][ $app ][ $module ] ) ) { $this->restrictions['applications'][ $app ][ $module ] = $module; } } } } if( isset( $perms['items'] ) ) { foreach ( $perms['items'] as $app => $modules ) { if ( !isset( $this->restrictions['items'][ $app ] ) ) { $this->restrictions['items'][ $app ] = array(); } foreach ( $modules as $module => $items ) { if ( !isset( $this->restrictions['items'][ $app ][ $module ] ) ) { $this->restrictions['items'][ $app ][ $module ] = array(); } foreach ( $items as $item ) { if ( !in_array( $item, $this->restrictions['items'][ $app ][ $module ] ) ) { $this->restrictions['items'][ $app ][ $module ][] = $item; } } } } } } } } } } return $this->restrictions; } /** * @brief Cache moderator badge results */ protected static array $modBadge = array(); /** * Show moderator badge? * * @return boolean|array */ public function modShowBadge(): bool|array { if ( ! $this->member_id ) { return FALSE; } if ( ! array_key_exists( $this->member_id, static::$modBadge ) ) { static::$modBadge[ $this->member_id ] = false; if ( $this->modPermission() ) { if ( isset( Store::i()->moderators['m'][ $this->member_id ] ) ) { static::$modBadge[ $this->member_id ] = Store::i()->moderators['m'][ $this->member_id ]['show_badge']; } else { foreach ( $this->groups as $id ) { if ( isset( Store::i()->moderators['g'][ $id ] ) and Store::i()->moderators['g'][ $id ]['show_badge'] ) { static::$modBadge[ $this->member_id ] = Store::i()->moderators['g'][ $id ]; } } } } } return static::$modBadge[ $this->member_id ]; } /** * Moderator Permissions * * @return bool|array|string */ public function modPermissions(): bool|array|string { /* Only members can be moderators of course */ if ( !$this->member_id ) { return FALSE; } /* Work out the permissions... */ if ( $this->modPermissions === NULL ) { /* Start with FALSE (no moderator permissions) */ $this->modPermissions = FALSE; /* If we don't have a datastore of moderator configuration, load that now */ if ( !isset( Store::i()->moderators ) ) { Store::i()->moderators = array( 'm' => iterator_to_array( Db::i()->select( '*', 'core_moderators', array( 'type=?', 'm' ) )->setKeyField( 'id' ) ), 'g' => iterator_to_array( Db::i()->select( '*', 'core_moderators', array( 'type=?', 'g' ) )->setKeyField( 'id' ) ), ); } /* Member-level moderator permissions override all group-level permissions, so if this member is a moderator at a member-level, just use that */ if ( isset( Store::i()->moderators['m'][ $this->member_id ] ) ) { $perms = Store::i()->moderators['m'][ $this->member_id ]['perms']; $this->modPermissions = $perms == '*' ? '*' : json_decode( $perms, TRUE ); } /* Otherwise, examine the groups and combine the permissions each group awards */ else { /* Get all the groups the member is in which have moderator permissions... */ $rows = array(); foreach ( $this->groups as $id ) { if ( isset( Store::i()->moderators['g'][ $id ] ) ) { $rows[] = Store::i()->moderators['g'][ $id ]; } } /* And if we have any... */ if ( count( $rows ) > 0 ) { /* Start with an empty array (indicates they are a moderator, but haven't get given them any permissions) */ $this->modPermissions = array(); /* Loop the groups and combine the permissions... */ foreach ( $rows as $row ) { /* If any group has all permissions, this user has all moderator permissions and we don't need to go further */ if ( $row['perms'] === '*' ) { $this->modPermissions = '*'; break; } /* Otherwise, examine what permissions this group has... */ $perms = json_decode( $row['perms'], TRUE ); if( !empty( $perms ) ) { foreach ( $perms as $k => $v ) { /* If we haven't seen this permission key at all, give them the value */ if ( !isset( $this->modPermissions[ $k ] ) ) { $this->modPermissions[ $k ] = $v; } /* If it's an array, combine the values */ elseif ( is_array( $this->modPermissions[ $k ] ) AND is_array( $v ) ) { $this->modPermissions[ $k ] = array_merge( $this->modPermissions[ $k ], $v ); } /* If it's a number, they get the higher one, or -1 is best */ elseif ( $v == -1 or ( $this->modPermissions[ $k ] != -1 and $v > $this->modPermissions[ $k ] ) ) { $this->modPermissions[ $k ] = $v; } } } } } } } /* Return */ return $this->modPermissions; } /** * @brief Report count */ protected ?int $reportCount = NULL; /** * Get number of open reports that this member can see * * @param bool $force Fetch the count even if the notification is off * @return int */ public function reportCount( bool $force=FALSE ): int { if ( $this->reportCount === NULL OR $force === TRUE ) { if ( $this->canAccessModule( Module::get( 'core', 'modcp' ) ) ) { if( !$this->members_bitoptions['no_report_count'] OR $force === TRUE ) { $where = [ Report::where( $this ), [ 'status IN( 1,2 )' ] ]; $reportCount = Db::i()->select( 'COUNT(*)', 'core_rc_index', $where )->first(); if( $force === FALSE ) { $this->reportCount = $reportCount; } else { return $reportCount; } } } else { $this->reportCount = 0; } } return (int) $this->reportCount; } /** * Can the member view any reported content? * * @return bool */ public function canAccessReportCenter() : bool { if( $this->modPermission( 'can_view_reports' ) ) { return TRUE; } $perms = $this->modPermissions(); if ( $perms === false ) { return false; } foreach ( Content::routedClasses( $this, FALSE, TRUE ) as $_class ) { if( isset( $perms[ "can_view_reports_{$_class::$title}" ] ) and $perms[ "can_view_reports_{$_class::$title}" ] ) { return TRUE; } } return FALSE; } /** * @var int|null */ protected ?int $_assignments = null; /** * Return total number of content items assigned to this member * * @return int */ public function totalAssignments() : int { if( $this->_assignments === null ) { $clause = [ "(assign_type=? and assign_to=?)" ]; $binds = [ Assignment::ASSIGNMENT_MEMBER, $this->member_id ]; if( $teams = $this->teams() ) { $clause[] = "(assign_type=? and " . Db::i()->in( 'assign_to', array_keys( $teams ) ) . ")"; $binds[] = Assignment::ASSIGNMENT_TEAM; } $where = array_merge( array( implode( " OR ", $clause ) ), $binds ); $this->_assignments = (int) Db::i()->select( 'count(assign_id)', 'core_assignments', $where )->first(); } return $this->_assignments; } /** * Get all teams to which this member belongs * * @return array|null */ public function teams() : array|null { $teams = []; foreach( Team::teams() as $t ) { if( in_array( $this->member_id, $t->members ) ) { $teams[$t->id] = $t; } } return count( $teams ) ? $teams : null; } /** * @brief Ignore Preferences * @see isIgnoring */ protected ?array $ignorePreferences = NULL; /** * Is this member ignoring another member? * * @param array|Member $member The member * @param string $type The type (topics, messages, signatures) * @return bool */ public function isIgnoring( array|Member $member, string $type ): bool { if( is_array( $member ) ) { $member = Member::load( $member['member_id'] ); } $group = $member->group; $id = $member->member_id; $cannotBeIgnored = !$member->canBeIgnored(); if ( $cannotBeIgnored or !$this->member_id ) { return FALSE; } /* We cannot ignore ourself */ if( $this->member_id == $member->member_id ) { return FALSE; } if ( $this->ignorePreferences === NULL ) { if ( $this->members_bitoptions['has_no_ignored_users'] ) { $this->ignorePreferences = array(); } else { $this->ignorePreferences = iterator_to_array( Db::i()->select( '*', 'core_ignored_users', array( 'ignore_owner_id=?', $this->member_id ) )->setKeyField( 'ignore_ignore_id' ) ); if ( empty( $this->ignorePreferences ) ) { $this->members_bitoptions['has_no_ignored_users'] = TRUE; $this->save(); } } } if ( isset( $this->ignorePreferences[ $id ] ) ) { return (bool) $this->ignorePreferences[ $id ][ 'ignore_' . $type ]; } return FALSE; } /** * Build a menu for this member * @param string $menuType (account|mobile|profile) * @return Menu|null */ public function menu( string $menuType ) : Menu|null { $method = $menuType . 'Menu'; $menu = UserMenu::$method( $this ); if( $menu instanceof Menu AND $menu->hasContent() ) { return $menu; } return null; } /** * Insert extra content in the user navigation bar * * @return string */ public function userNav() : string { $return = ""; foreach( Application::allExtensions( 'core', 'UserMenu', $this, 'core' ) as $ext ) { $return .= $ext->userNav(); } return $return; } /** * Insert extra content in the mobile navigation bar * * @param string $position (header/footer) * @return string */ public function mobileNav( string $position = 'footer' ) : string { $return = ""; foreach( Application::allExtensions( 'core', 'UserMenu', $this, 'core' ) as $ext ) { $return .= $ext->mobileNav( $position, Theme::i()->getParsedCssVariableFromKey( 'set__i-mobile-icons-location' ) ); } return $return; } /** * Moderate New Content * * @param bool $considerPostBeforeRegistering If TRUE, and $member is a guest, will check if a newly registered member would be moderated * @return bool */ public function moderateNewContent( ?bool $considerPostBeforeRegistering=FALSE ): bool { $modQueued = FALSE; /* Check the group */ if ( $this->group['g_mod_preview'] ) { if ( $this->group['g_mod_post_unit'] ) { /* Days since joining */ if ( $this->group['gbw_mod_post_unit_type'] ) { $modQueued = $this->joined->add( new DateInterval( "P{$this->group['g_mod_post_unit']}D" ) )->getTimestamp() > time(); } /* Content items */ else { $modQueued = $this->member_posts < $this->group['g_mod_post_unit']; } } else { $modQueued = TRUE; } } /* If we're not group moderated what about individual member */ if ( !$modQueued ) { if( $this->mod_posts == -1 or ( $this->mod_posts > 0 and $this->mod_posts > time() ) ) { $modQueued = TRUE; } } /* Post before register check */ if ( !$modQueued and $considerPostBeforeRegistering and $this->member_group_id == Settings::i()->guest_group ) { $modQueued = (bool) Group::load( Settings::i()->member_group )->g_mod_preview; } /* Return */ return $modQueued; } /** * Cover Photo * * @return Coverphoto */ public function coverPhoto(): Coverphoto { $photo = new CoverPhoto; if ( $this->pp_cover_photo ) { $photo->file = File::get( 'core_Profile', $this->pp_cover_photo ); $photo->offset = $this->pp_cover_offset; } $photo->editable = ( Member::loggedIn()->modPermission('can_modify_profiles') or ( Member::loggedIn()->member_id == $this->member_id and $this->group['g_edit_profile'] and $this->group['gbw_allow_upload_bgimage'] ) ); $photo->maxSize = $this->group['g_max_bgimg_upload']; $photo->object = $this; return $photo; } /** * Get HTML for search result display * * @return string */ public function searchResultHtml(): string { return Theme::i()->getTemplate('search')->member( $this ); } /** * Should a reply made by this member be highlighted? * * @return boolean */ public function hasHighlightedReplies(): bool { return (boolean) $this->group['gbw_post_highlight']; } /** * Return the group ID that has highlighting enabled * * @return int|null */ public function highlightedGroup() : ?int { if( !$this->hasHighlightedReplies() ) { return null; } foreach( $this->groups as $groupId ) { $options = Group::load( $groupId )->g_bitoptions->asArray(); if( $options['gbw_post_highlight'] ) { return $groupId; } } return null; } /** * Get output for API * * @param Member|NULL $authorizedMember The member making the API request or NULL for API Key / client_credentials * @param array|null $otherFields Array of additional fields to return (raw values) * @return array * @apiresponse int id ID number * @apiresponse string name Username * @apiresponse string title Member title * @clientapiresponse string timezone Member timezone * @apiresponse string formattedName Username with group formatting * @apiresponse \IPS\Member\Group primaryGroup Primary group * @clientapiresponse [\IPS\Member\Group] secondaryGroups Secondary groups * @clientapiresponse string email Email address * @apiresponse datetime joined Registration date * @clientapiresponse string registrationIpAddress IP address when registered * @clientapiresponse int warningPoints Number of active warning points * @apiresponse int reputationPoints Number of reputation points * @apiresponse string photoUrl URL to photo (which will be the site's default if they haven't set one) * @apiresponse bool photoUrlIsDefault Indicates if the value of photoUrl is the site's default * @apiresponse string coverPhotoUrl URL to profile cover photo (will be blank if there isn't one) * @apiresponse string|null profileUrl URL to profile * @clientapiresponse bool validating Whether or not the validating flag is set on the member account * @apiresponse int posts Number of content item submissions member has made * @clientapiresponse datetime|null lastActivity Last activity date on the site. * @clientapiresponse datetime|null lastVisit Last distinct visit date on the site. * @clientapiresponse datetime|null lastPost Latest content submission date. * @apiresponse int profileViews Number of times member's profile has been viewed * @clientapiresponse bool spammer Is the member flagged as a spammer? * @clientapiresponse bool banned Is the member currently banned? * @apiresponse string birthday Member birthday in MM/DD/YYYY format (or MM/DD format if no year has been supplied). * @apiresponse array login_methods Array of login methods used , key is login method ID. * @apiresponse [\IPS\core\ProfileFields\Api\FieldGroup] customFields Custom profile fields. For requests using an OAuth Access Token for a particular member, only fields the authorized user can view will be included * @apiresponse [\IPS\core\Achievements\Rank] rank Rank * @apiresponse int achievements_points Points * @clientapiresponse bool allowAdminEmails Whether or not this member wants to receive admin emails * @apiresponse bool completed Whether or not the registration is completed * @clientapiresponse int totalMessages Total number of Personal Messages * @clientapiresponse int unreadMessages Total number of unread Personal Messages * @apiresponse [IPS\core\Achievements\Badge] badges All badges received by this member */ public function apiOutput( Member $authorizedMember = NULL, array $otherFields = NULL ): array { try { $group = Group::load( $this->_data['member_group_id'] ); } catch( OutOfRangeException $e ) { Log::log( "{$this->name} has an invalid group ({$this->_data['member_group_id']}) during API call. Resetting.", 'api_invalid_group' ); $group = Group::load( Settings::i()->member_group ); // Intentially no catch here as that means things are really broken. $this->member_group_id = Settings::i()->member_group; $this->save(); } $secondaryGroups = array(); foreach ( array_filter( array_map( "intval", explode( ',', $this->_data['mgroup_others'] ) ) ) as $secondaryGroupId ) { try { $secondaryGroups[] = Group::load( $secondaryGroupId )->apiOutput( $authorizedMember ); } catch ( OutOfRangeException $e ) { } } /* Figure out custom fields if any */ $fields = array(); try { $fieldData = core\ProfileFields\Field::fieldData(); $fieldValues = Db::i()->select( '*', 'core_pfields_content', array( 'member_id=?', $this->member_id ) )->first(); foreach( $fieldData as $profileFieldGroup => $profileFields ) { $groupValues = array(); foreach( $profileFields as $field ) { if ( !$authorizedMember or $field['pf_contains_pii'] == 1 AND ( $authorizedMember->member_id == $this->member_id ) or $field['pf_member_hide'] == 'all' or ( $field['pf_member_hide'] == 'owner' and ( $authorizedMember->member_id == $this->member_id OR $authorizedMember->modPermissions() OR $authorizedMember->isAdmin() ) ) or ( $field['pf_member_hide'] == 'staff' and ( $authorizedMember->modPermissions() OR $authorizedMember->isAdmin() ) ) ) { $groupValues[ $field['pf_id'] ] = new Field( $this->language()->get( 'core_pfield_' . $field['pf_id'] ), $fieldValues[ 'field_' . $field['pf_id'] ] ); } } $fields[ $profileFieldGroup ] = ( new FieldGroup( $this->language()->get( 'core_pfieldgroups_' . $profileFieldGroup ), $groupValues ) )->apiOutput( $authorizedMember ); } } catch( UnderflowException $e ) { } # Guests will not have any profile field information $return = array(); $return['id'] = $this->member_id; $return['name'] = $this->_name; $return['title'] = null; if ( !$authorizedMember ) { $return['timeZone'] = $this->timezone; } $return['formattedName'] = $group->formatName( $this->_name ); $return['primaryGroup'] = $group->apiOutput( $authorizedMember ); if ( !$authorizedMember ) { $return['secondaryGroups'] = $secondaryGroups; $return['email'] = $this->email; } $return['joined'] = $this->joined->rfc3339(); if ( !$authorizedMember ) { $return['registrationIpAddress']= $this->ip_address; $return['warningPoints'] = $this->warn_level; } $return['reputationPoints'] = $this->pp_reputation_points; $return['photoUrl'] = static::photoUrl($this->_data, FALSE); $return['photoUrlIsDefault'] = in_array( $this->pp_photo_type, [ 'none', 'letter' ] ) or static::photoUrl($this->_data, FALSE, FALSE, FALSE) != $return['photoUrl']; $return['coverPhotoUrl'] = $this->pp_cover_photo ? ( (string) File::get( 'core_Profile', $this->pp_cover_photo )->url ) : ''; $return['profileUrl'] = ( $this->member_id ) ? (string) $this->url() : NULL; if ( !$authorizedMember ) { $return['validating'] = (bool) $this->members_bitoptions['validating']; } $return['posts'] = $this->member_posts; if( !$authorizedMember ) { $return['lastActivity'] = ( $this->last_activity AND !$this->isOnlineAnonymously() ) ? DateTime::ts( $this->last_activity )->rfc3339() : NULL; $return['lastVisit'] = $this->last_visit ? DateTime::ts( (int)$this->last_visit )->rfc3339() : NULL; $return['lastPost'] = $this->member_last_post ? DateTime::ts( $this->member_last_post )->rfc3339() : NULL; } if( !$authorizedMember OR $authorizedMember->member_id === $this->member_id ) { $return['birthday'] = $this->bday_month ? ( $this->bday_month . '/' . $this->bday_day . ( $this->bday_year ? '/' . $this->bday_year : '' ) ) : NULL; /* Login Links */ $return['login_methods'] = []; foreach( Db::i()->select( '*', 'core_login_links', [ 'token_member=? and token_linked=?', $this->member_id, 1 ] ) as $link ) { /* Only include enabled methods */ if( isset( Login::methods()[ $link['token_login_method'] ] ) ) { $return['login_methods'][ $link['token_login_method'] ] = [ 'name' => Login::methods()[ $link['token_login_method'] ]->_title, 'id' => $link['token_identifier'] ]; } } } $return['profileViews'] = $this->members_profile_views; if( $authorizedMember === null or $authorizedMember->member_id == $this->member_id ) { $return['spammer'] = (bool) $this->members_bitoptions['bw_is_spammer']; $return['banned'] = (bool) $this->isBanned(); } $return['customFields'] = $fields; if ( !$authorizedMember ) { if( $otherFields !== NULL AND is_array( $otherFields ) ) { foreach( $otherFields as $property ) { $return[ $property ] = $this->$property; } } } $return['rank'] = $this->rank() ? $this->rank()->apiOutput( $authorizedMember ) : NULL; $return['achievements_points'] = $this->achievements_points; if( $authorizedMember === null or $authorizedMember->member_id == $this->member_id ) { $return['allowAdminEmails'] = $this->allow_admin_mails; } $return['completed'] = $this->completed; $counts = \IPS\Db::i()->select( 'COUNT(map_id) as total_count, COUNT(CASE WHEN map_has_unread = 1 THEN 1 END) as unread_messages', 'core_message_topic_user_map', [ 'map_user_id=?', $this->member_id ] )->first(); if( $authorizedMember === null or $authorizedMember->member_id == $this->member_id ) { $return['totalMessages'] = $counts['total_count']; $return['unreadMessages'] = $counts['unread_messages']; } /* Add member badges */ $return['badges'] = array(); foreach( $this->recentBadges( NULL ) as $badge ) { $return['badges'][] = $badge->apiOutput(); } return $return; } /** * Answers to security questions * * @return Select */ public function securityAnswers(): Select { return Db::i()->select( array( 'answer_question_id', 'answer_answer' ), 'core_security_answers', array( 'answer_member_id=?', $this->member_id ) )->setKeyField('answer_question_id')->setValueField('answer_answer'); } /** * Last used device * * @return Device|NULL */ public function lastUsedDevice(): ?Device { try { return Device::constructFromData( Db::i()->select( '*', 'core_members_known_devices', array( 'member_id=?', $this->member_id ), 'last_seen DESC', 1 )->first() ); } catch ( Exception $e ) { return NULL; } } /** * Last used IP address * * @return string|NULL */ public function lastUsedIp(): ?string { try { return Db::i()->select( 'ip_address', 'core_members_known_ip_addresses', array( 'member_id=?', $this->member_id ), 'last_seen DESC', 1 )->first(); } catch ( Exception $e ) { return NULL; } } /** * Device count * * @return int */ public function deviceCount(): int { return Db::i()->select( 'COUNT(*)', 'core_members_known_devices', array( 'member_id=?', $this->member_id ) )->first(); } protected array $_failedLoginCache; /** * Check if account is locked - returns FALSE if account is unlocked, an \IPS\DateTime object if the account is locked until a certain time, or TRUE if account is locked indefinitely * * @return DateTime|bool */ public function unlockTime() : DateTime|bool { if( !Settings::i()->ipb_bruteforce_attempts ) { return FALSE; } if( !isset( $this->_failedLoginCache[ Request::i()->ipAddress() ] ) ) { $where = [ [ 'login_date>=? AND login_ip_address=?', ( new DateTime() )->sub( new DateInterval( 'PT' . Settings::i()->ipb_bruteforce_period . 'M' ) )->getTimestamp(), Request::i()->ipAddress() ] ]; if ( $this->member_id ) { $where[] = [ 'login_member_id=?', $this->member_id ]; } else { $where[] = [ 'login_email=?', $this->email ]; } $this->_failedLoginCache[ Request::i()->ipAddress() ] = iterator_to_array( Db::i()->select( '*', 'core_login_failures', $where ) ); } if( count( $this->_failedLoginCache[ Request::i()->ipAddress() ] ) > Settings::i()->ipb_bruteforce_attempts ) { if ( Settings::i()->ipb_bruteforce_period and Settings::i()->ipb_bruteforce_unlock ) { return DateTime::ts( max( array_column( $this->_failedLoginCache[ Request::i()->ipAddress() ], 'login_date' ) ) )->add( new DateInterval( 'PT' . Settings::i()->ipb_bruteforce_period . 'M' ) ); } return TRUE; } return FALSE; } /** * Return the number of failed logins for a given IP address * * @param string $ipAddress IP Address * @return int */ /** * Return the number of failed logins for a given IP address * * @param string $ipAddress IP Address * @return int */ public function failedLoginCount( string $ipAddress ): int { if( !isset( $this->_failedLoginCache[ $ipAddress ] ) ) { $where = [ [ 'login_date>=? AND login_ip_address=?', ( new DateTime() )->sub( new DateInterval( 'PT' . Settings::i()->ipb_bruteforce_period . 'M' ) )->getTimestamp(), Request::i()->ipAddress() ] ]; if( $this->member_id ) { $where[] = [ 'login_member_id=?', $this->member_id ]; } else { $where[] = [ 'login_email=?', $this->email ]; } $this->_failedLoginCache[ $ipAddress ] = Db::i()->select( '*', 'core_login_failures', $where )->first(); } return count( $this->_failedLoginCache[ $ipAddress ] ); } /* !Permissions */ /** * @var array ACP restriction keys which are hardcoded to FALSE when on CiC (even if admin has no restrictions) */ public static array $cicBlockedAcpRestrictions = [ 'core.membersettings.member_history_prune', 'core.settings.datastore', 'core.membersettings.notifications_prune' ]; /** * Has access to a restricted ACP area? * * @param string|Application $app Application * @param string|Module|null $module Module * @param string|null $key Restriction Key */ public function hasAcpRestriction( Application|string $app, Module|string $module=NULL, string $key=NULL ): bool { /* Load our ACP restrictions */ $restrictions = $this->acpRestrictions(); if ( $restrictions === FALSE ) { return FALSE; } /* CiC Block */ $appKey = is_string( $app ) ? $app : $app->directory; $moduleKey = ( $module === NULL or is_string( $module ) ) ? $module : $module->key; if ( CIC and in_array( $appKey . ( $moduleKey ? ( ".{$moduleKey}" . ( $key ? ".{$key}" : '' ) ) : '' ), static::$cicBlockedAcpRestrictions ) ) { return FALSE; } /* If we have all permissions, return true */ if ( $restrictions === '*' ) { return TRUE; } /* If we don't have any permissions, return false */ if( !count( $restrictions ) ) { return FALSE; } /* Otherwise, check 'em! */ if ( array_key_exists( $appKey, $restrictions['applications'] ) ) { if ( $module === NULL ) { return TRUE; } else { if ( in_array( $moduleKey, $restrictions['applications'][ $appKey ] ) ) { if ( $key === NULL ) { return TRUE; } elseif ( isset( $restrictions['items'][ $appKey ][ $moduleKey ] ) and in_array( $key, $restrictions['items'][ $appKey ][ $moduleKey ] ) ) { return TRUE; } } } } return FALSE; } /** * Get moderator permission * * @param string|null $key Permission Key to check, or NULL to just test if they have any moderator permissions. * @return mixed */ public function modPermission( string $key=NULL ): mixed { /* Load our permissions */ $permissions = $this->modPermissions(); if ( $permissions == FALSE ) { return FALSE; } /* If we have all permissions, return true */ if ( $permissions === '*' or $key === NULL ) { return TRUE; } /* Otherwise return it */ return $permissions[$key] ?? false; } /** * Can warn * * @param Member $member The member to warn * @return bool */ public function canWarn( Member $member ): bool { if( !$this->modPermission('mod_can_warn') OR !$this->modPermission('mod_see_warn') ) { return FALSE; } if( $member->inGroup( explode( ',', Settings::i()->warn_protected ) ) or $member->member_id == Member::loggedIn()->member_id ) { return FALSE; } if ( $this->modPermission('warn_mod_day') !== TRUE and $this->modPermission('warn_mod_day') != -1 ) { $oneDayAgo = DateTime::create()->sub( new DateInterval( 'P1D' ) ); $warningsGivenInTheLastDay = Db::i()->select( 'COUNT(*)', 'core_members_warn_logs', array( 'wl_moderator=? AND wl_date>?', $this->member_id, $oneDayAgo->getTimestamp() ) )->first(); if( $warningsGivenInTheLastDay >= $this->modPermission('warn_mod_day') ) { return FALSE; } } return TRUE; } /* !Recounting */ /** * Recalculate notification count * * @return void */ public function recountNotifications() : void { $this->notification_cnt = Db::i()->select( 'COUNT(*)', 'core_notifications', array( '`member`=? AND read_time IS NULL', $this->member_id ), NULL, NULL, NULL, NULL, Db::SELECT_FROM_WRITE_SERVER )->first(); $this->save(); } /** * Recounts content for this member * * @return void */ public function recountContent() : void { $this->member_posts = 0; foreach ( Content::routedClasses( $this, TRUE, FALSE ) as $class ) { $this->member_posts += $class::memberPostCount( $this ); } $this->save(); } /** * Recounts reputation for this member * * @return void */ public function recountReputation() : void { $this->pp_reputation_points = Db::i()->select( 'SUM(rep_rating)', 'core_reputation_index', array( 'member_received=?', $this->member_id ) )->first(); $this->save(); } /** * Removes reputation for this member * * @param string $type given|received The type of reputation to remove * @return void */ public function removeReputation( string $type ) : void { $where = ( $type == 'given' ) ? array( 'member_id=?', $this->member_id ) : array( 'member_received=?', $this->member_id ); Db::i()->delete( 'core_reputation_index', $where ); if( $type == 'received' ) { $this->recountReputation(); } } /* !Do Stuff */ /** * Can use module * * @param Module $module The module to test * @return bool * @throws InvalidArgumentException */ public function canAccessModule( Module $module ): bool { if ( !( $module instanceof Module ) ) { throw new InvalidArgumentException; } return Application::load( $module->application )->canAccess( $this ) and ( $module->protected or $module->can( 'view', $this ) ); } /** * @brief Store whitelist filters */ public static ?array $whitelistFilters = NULL; /** * Check Spam Defense Whitelist * * @param string|null $emailAddress Email address to check, NULL for existing email address * @return boolean */ public function spamDefenseWhitelist( string $emailAddress=NULL ): bool { $email = $emailAddress ?: $this->email; if( static::$whitelistFilters === NULL ) { static::$whitelistFilters = iterator_to_array( Db::i()->select( 'whitelist_type, whitelist_content', 'core_spam_whitelist' ) ); } foreach( static::$whitelistFilters as $whitelist ) { if( $whitelist['whitelist_type'] == 'ip' ) { if( preg_match( '/^' . str_replace( '\*', '.*', preg_quote( $whitelist['whitelist_content'], '/' ) ) . '$/i', $this->ip_address ) ) { return TRUE; } } else { if( preg_match( '/' . str_replace( '\*', '.*', preg_quote( '@' . $whitelist['whitelist_content'], '/' ) ) . '$/i', $email ) ) { return TRUE; } } } return FALSE; } /** * IPS Spam Defense Service * * @param string $type Request type * @param string|null $emailAddress Email address to check, NULL for existing email address * @param int|null $spamCode If set, will modify by reference with the raw value from the spam service * @param bool $disposable If set, will modify by reference if the user used a disposable email. * @param bool $geoBlock If set, will modify by reference if the user is in a moderated / blocked country. * @return int|NULL Action code based on spam service response, or NULL for no action */ public function spamService( string $type='register', string $emailAddress=NULL, int &$spamCode=NULL, bool &$disposable=FALSE, bool &$geoBlock=FALSE ): ?int { $email = $emailAddress ?: $this->email; /* Check Whitelist */ if( $this->spamDefenseWhitelist( $email ) ) { /* Account details were whitelisted */ return NULL; } try { $response = Url::ips( 'spam/' . $type )->request()->login( Settings::i()->ipb_reg_number, '' )->post( array( 'email' => $email, 'ip' => $this->ip_address, ) ); if ( $response->httpResponseCode != 200 ) { throw new DomainException( print_r( $response, TRUE ) ); } $spamCode = intval( (string) $response ); } catch ( Exception $e ) { Log::debug( $e, 'spam-service' ); $spamCode = 0; } $action = NULL; if( $type == 'register' and $spamCode ) { /* Log Request */ Db::i()->insert( 'core_spam_service_log', array( 'log_date' => time(), 'log_code' => $spamCode, 'log_msg' => '', // No value is returned unless it's a developer account making the call 'email_address' => $email, 'ip_address' => $this->ip_address ) ); /* Action to perform */ $key = "spam_service_action_{$spamCode}"; $action = Settings::i()->$key; /* Perform Action */ switch( $action ) { /* Proceed with registration */ case 1: break; /* Flag for admin approval */ case 2: Settings::i()->reg_auth_type = 'admin'; break; /* Approve the account, but ban it */ case 3: $this->temp_ban = -1; $this->members_bitoptions['bw_is_spammer'] = TRUE; break; /* Deny registration - we return the code and the controller is expected to show an error */ case 4: break; /* Moderate posts */ case 5: $days = json_decode( Settings::i()->spam_service_days, TRUE ); if ( isset( $days[ $spamCode ] ) ) { if ( $days[ $spamCode ] == -1 ) { $this->mod_posts = -1; } else if ( $days[ $spamCode ] > 0 ) { $this->mod_posts = DateTime::ts( time() )->add( new DateInterval( "P{$days[ $spamCode ]}D" ) )->getTimestamp(); } } break; } } /* GeoLocation */ if ( $type === 'register' AND $action == 1 ) { $action = $this->geoSpamCheck( $geoBlock ); } /* If the normal spam service and geolocation didn't pick up anything, check for a disposable email. */ if ( $type === 'register' AND $action == 1 ) { $action = $this->disposableEmailCheck( $email, $disposable ); } return $action; } /** * GeoLocation Block Check * * @param bool $geoBlock If set, will modify by reference if the user is in a moderated / blocked country. * @return int|NULL */ public function geoSpamCheck( bool &$geoBlock ): ?int { /* If Geo is enabled, don't bother. */ if ( !Settings::i()->ipsgeoip ) { return 1; } /* See we have anything configured. If we don't, don't bother. */ $settings = Settings::i()->spam_geo_settings ? json_decode( Settings::i()->spam_geo_settings, true ) : array(); if ( !count( $settings ) ) { return 1; } try { $location = GeoLocation::getRequesterLocation(); } catch( BadFunctionCallException | BadMethodCallException| Http\Request\Exception | RuntimeException |OutOfRangeException $e ) { /* If it fails for any reason, don't bother. */ Log::debug( $e, 'spam-service' ); return 1; } /* All good, start checking. */ if ( !isset( $settings[ $location->country ] ) ) { /* Not in the list, we're good. */ return 1; } /* See what action we're taking. */ switch( $settings[$location->country] ) { case 'moderate': Settings::i()->reg_auth_type = 'admin'; $geoBlock = TRUE; return 2; case 'block': return 4; } return 1; } /** * Check for disposable email address domain * * @param string $emailAddress Email to check, or NULL for current. * @param bool $disposable If set, will modify by reference if the user used a disposable email. * @return int|NULL */ public function disposableEmailCheck( string $emailAddress, bool &$disposable=FALSE ): ?int { $email = $emailAddress ?: $this->email; try { $response = Url::ips( 'spam/disposable' )->request()->login( Settings::i()->ipb_reg_number, '' )->post( array( 'email' => $email, ) ); if ( $response->httpResponseCode != 200 ) { throw new DomainException( print_r( $response, TRUE ) ); } $result = (bool) $response->decodeJson()['result']; } catch ( Exception $e ) { Log::debug( $e, 'spam-service' ); $result = false; } $action = NULL; if ( $result ) { $disposable = TRUE; $action = Settings::i()->spam_service_disposable; /* Perform Action */ switch( $action ) { /* Proceed with registration */ case 1: break; /* Flag for admin approval */ case 2: Settings::i()->reg_auth_type = 'admin'; break; /* Approve the account, but ban it */ case 3: $this->temp_ban = -1; $this->members_bitoptions['bw_is_spammer'] = TRUE; break; /* Deny registration - we return the code and the controller is expected to show an error */ case 4: break; /* Moderate posts */ case 5: $days = json_decode( Settings::i()->spam_service_days, TRUE ); if ( isset( $days['disposable'] ) ) { if ( $days['disposable'] == -1 ) { $this->mod_posts = -1; } else if ( $days['disposable'] > 0 ) { $this->mod_posts = DateTime::ts( time() )->add( new DateInterval( "P{$days['disposable']}D" ) )->getTimestamp(); } } break; } } return $action; } /** * Member Sync * * Use Event::fire instead * * @param string $method Method * @param array $params Additional parameters to pass * @return void * @throws Exception * @deprecated */ public function memberSync( string $method, array $params=array() ) : void { /* Just in case someone calls this, redirect to the Event::fire */ Event::fire( $method, $this, $params ); } /** * Merge * * @param Member $otherMember Member to merge with * @return void */ public function merge( Member $otherMember ) : void { if ( $this === $otherMember ) { throw new InvalidArgumentException( 'merge_self_error' ); } /* Merge content */ $otherMember->hideOrDeleteAllContent( 'merge', array( 'merge_with_id' => $this->member_id, 'merge_with_name' => $this->name ) ); /* Log */ $this->logHistory( 'core', 'account', array( 'type' => 'merge', 'id' => $otherMember->member_id, 'name' => $otherMember->name, 'email' => $otherMember->email ) ); /* Let apps do their stuff */ Event::fire( 'onMerge', $this, array( $otherMember ) ); } /** * Add profile visitor * * @param Member $visitor Member that viewed profile * @return void */ public function addVisitor( Member $visitor ) : void { $visitors = json_decode( $this->pp_last_visitors, TRUE ); /* If this member is already in the visitor list remove the entry so we can add back in the correct order */ if( isset( $visitors[ $visitor->member_id ] ) ) { unset( $visitors[ $visitor->member_id ] ); } /* We want to limit to 5 members */ else if ( is_array( $visitors ) AND count( $visitors ) >= 5 ) { $visitors = array_reverse( $visitors, TRUE ); array_pop( $visitors ); $visitors = array_reverse( $visitors, TRUE ); } /* Add the new entry */ $visitors[ $visitor->member_id ] = time(); /* Encode and save*/ $this->pp_last_visitors = json_encode( $visitors ); $this->save(); } /** * @brief Posts Per Day Storage */ protected ?int $_ppdLimit = NULL; /** * Check posts per day to see if this member can post. * * @return bool */ public function checkPostsPerDay(): bool { /* Fetch our PPD limit - we should only need to do this once */ if ( $this->_ppdLimit === NULL ) { $this->_ppdLimit = $this->group['g_ppd_limit']; } /* We can't actually check guests as we can't store how often they have posted - simply counting content is not viable */ if ( ! $this->member_id ) { return TRUE; } /* Is there any limit at all? */ if ( ! $this->_ppdLimit ) { return TRUE; } $count = $this->members_day_posts[0]; $time = $this->members_day_posts[1]; /* Have we posted at all yet? */ if ( ! $count OR ! $time ) { return TRUE; } /* Are we beyond our 24 hours? */ if ( $time AND $time < DateTime::create()->sub( new DateInterval( 'P1D' ) )->getTimestamp() ) { /* Update member immediately */ $this->members_day_posts = array( 0, 0 ); $this->save(); return TRUE; } /* Still within 24 hours... have we hit the limit? */ if ( $count >= $this->_ppdLimit ) { if ( $this->group['g_ppd_unit'] ) { /* The limit may have been removed due to number of total posts or days since joining */ if( $this->group['gbw_ppd_unit_type'] ) { /* Days */ if ( $this->joined->add( new DateInterval( "P{$this->group['g_ppd_unit']}D" ) )->getTimestamp() < time() ) { return TRUE; } } else { /* Posts */ if ( $this->member_posts >= $this->group['g_ppd_unit'] ) { return TRUE; } } } return FALSE; } /* Still here? */ return TRUE; } /** * Check Group Promotion * * @return void */ public function checkGroupPromotion() : void { /* If we should ignore promotions for this member, do so */ if( $this->members_bitoptions['ignore_promotions'] ) { return; } /* Default to member group if none. This shouldn't happen but can if Redis/MySQL is temporarily unavailable. The next save event will re-check promotion. */ try { $primaryGroup = Group::load( $this->member_group_id ); } catch ( OutOfRangeException $e ) { /* Log the error as part of the failure audit trail */ Log::log( "Group promotion found a member (#{$this->member_id}: {$this->name} [Group ID {$this->member_group_id}] with an invalid member group", 'group_promotion' ); return; } /* Just check the primary group, secondary groups should not prevent promoting */ if( Group::load( $this->member_group_id )->g_promote_exclude ) { return; } $ruleToUse = NULL; /* Loop over all group promotion rules and get the last one that matches us */ foreach( GroupPromotion::roots() as $rule ) { if( $rule->enabled and $rule->matches( $this ) ) { $ruleToUse = $rule->id; } } /* If there's no rule, return now */ if( $ruleToUse === NULL ) { return; } /* If we matched a rule, get that rule now */ $ruleToUse = GroupPromotion::load( $ruleToUse ); /* Set the primary and secondary groups as appropriate */ $action = $ruleToUse->_actions; if( $action['primary_group'] AND $this->member_group_id != $action['primary_group'] ) { try { $group = Group::load( $action['primary_group'] ); /* Need to store the history before we actually change the group, otherwise history shows "promoted from group X to X" */ $this->logHistory( 'core', 'group', array( 'type' => 'primary', 'by' => 'promotion', 'id' => $ruleToUse->id, 'old' => $this->member_group_id, 'new' => $action['primary_group'] ) ); $this->member_group_id = $action['primary_group']; } catch ( OutOfRangeException $e ) { Log::debug( 'Promotion ' . $ruleToUse->id . ' tried to set not existing member group' ); } } if( count( $action['secondary_group'] ) OR count( $action['secondary_remove'] ) ) { $secondaryGroups = array_filter( explode( ',', $this->_data['mgroup_others'] ) ); $oldSecondaryGroups = $secondaryGroups; foreach ( $action['secondary_group'] as $key => $group ) { try { $group = Group::load( $group ); } catch ( OutOfRangeException $e ) { unset( $action['secondary_group'][$key] ); Log::debug( 'Promotion ' . $ruleToUse->id . ' tried to set not existing member group' ); } } if( count( $action['secondary_group'] ) ) { if( array_diff( $action['secondary_group'], $secondaryGroups ) ) { $secondaryGroups = array_merge( $secondaryGroups, $action['secondary_group'] ); } } if( count( $action['secondary_remove'] ) ) { foreach( $action['secondary_remove'] as $groupToRemove ) { while( in_array( $groupToRemove, $secondaryGroups ) ) { $key = array_search( $groupToRemove, $secondaryGroups ); unset( $secondaryGroups[ $key ] ); } } } if ( array_diff( $secondaryGroups, $oldSecondaryGroups ) or array_diff( $oldSecondaryGroups, $secondaryGroups ) ) { $this->logHistory( 'core', 'group', array( 'type' => 'secondary', 'by' => 'promotion', 'id' => $ruleToUse->id, 'old' => $oldSecondaryGroups, 'new' => $secondaryGroups ) ); } $this->mgroup_others = implode( ',', array_unique( $secondaryGroups ) ); } } /** * Is the current user allowed to use the contact us form * * @return bool */ public function canUseContactUs(): bool { try { $module = Module::get( 'core', 'contact', 'front' ); } catch ( OutOfRangeException $e ) { return FALSE; } if ( !$this->canAccessModule( $module ) ) { return FALSE; } /* If all groups have access, we can */ if( Settings::i()->contact_access != '*' ) { /* Check member */ $memberGroups = array_merge( array( $this->member_group_id ), array_filter( explode( ',', $this->mgroup_others ) ) ); $accessGroups = explode( ',', Settings::i()->contact_access ); /* Are we in an allowed group? */ if( count( array_intersect( $accessGroups, $memberGroups ) ) ) { return TRUE; } else { return FALSE; } } return TRUE; } /** * Can use the messenger system * * @return bool */ public function canUseMessenger(): bool { /* Test member */ if ( $this->members_disable_pm == 2 ) { return false; } /* Test module permissions */ return $this->canAccessModule( Module::get( 'core', 'messaging' ) ); } /** * Get a unique member hash for use in RSS keys and such. * * If the member has a current style password hash, the md5() of that value will be returned. Otherwise, * one will be generated, stored in a new column in the members table, and its md5() will be returned. * * @return string */ public function getUniqueMemberHash(): string { /* If this is a guest, just return a random string. */ if ( !$this->member_id ) { return Login::generateRandomString(); } /* Return the password hash if we already have it */ if ( !empty( $this->members_pass_hash ) ) { return md5( ( $this->members_pass_hash ?: $this->email ) . $this->members_pass_salt ); } /* Do we already have one? */ if ( !empty( $this->unique_hash ) ) { return $this->unique_hash; } /* Otherwise, generate one */ $this->unique_hash = md5( Login::generateRandomString(32) ); $this->save(); return $this->unique_hash; } /** * Perform a database update on all members * * Typically, updating the entire table locks the table which makes other queries stack up. * On busy sites this is a real problem. We mitigate this by updating in batches via a background task * * We replace if the fields we want to update are the exact same on a subsequent call to this method, so if you had ( 'skin' => 2 ) and then ( 'skin' => 3 ), * the row matching 'skin' => 2 will be removed from the queue table and the 'skin' => 3 row will replace it. This is to ensure that if you update the same fields while * an existing task is running, it will update the members with the latest data. * * @note This task can be processing or waiting to process and the member can still change the value, so it could be possible for the member to set a 'skin' parameter and then have this * overwritten when this task processes. * * @param array $update array( field => value ) pairs to be used directly in a \IPS\Db::i()->update( 'core_members', $update ) query * @param int $severity Severity level. 1 being highest, 5 lowest * @return void */ public static function updateAllMembers( array $update, int $severity=3 ) : void { Task::queue( 'core', 'UpdateMembers', array( 'update' => $update ), $severity, array( 'update' ) ); } /** * Invalidate all sessions and auto-login-keys. Called after the user changes their email address or password. * * @param bool|string $frontEndSessions Boolean value indicating if front-end sessions should be cleared, or a string containing a session ID to wipe all except that one * @param bool|string $acpSessions Boolean value indicating if acp sessions should be cleared, or a string containing a session ID to wipe all except that one * @param bool $loginKeys Boolean value indicating if login keys (used for "Remember Me" logins) should be wiped * @return void */ public function invalidateSessionsAndLogins( bool|string $frontEndSessions=TRUE, bool|string $acpSessions=TRUE, bool $loginKeys=TRUE ) : void { /* Terminate any active sessions */ if ( $frontEndSessions !== FALSE ) { Session\Store::i()->deleteByMember( $this->member_id, NULL, $frontEndSessions !== TRUE ? array( $frontEndSessions ) : NULL ); } if ( $acpSessions !== FALSE ) { $where = array( array( 'session_member_id=?', $this->member_id ) ); if ( is_string( $acpSessions ) ) { $where[] = array( 'session_id<>?', $acpSessions ); } Db::i()->delete( 'core_sys_cp_sessions', $where ); } /* Wipe login keys to stop "Remember Me" cookies automatically logging us in */ if ( $loginKeys ) { Db::i()->update( 'core_members_known_devices', array( 'login_key' => NULL ), array( 'member_id=?', $this->member_id ) ); } /* Invalidate any pending "Forgot Password" or 2FA recovery emails, because they provide a doorway into accessing the account */ Db::i()->delete( 'core_validating', array( 'member_id=? AND ( lost_pass=1 OR forgot_security=1 )', $this->member_id ) ); } /* !Registration/Validation */ /** * Call after completed registration to send email for validation if required or flag for admin validation * * @param bool $noEmailValidationRequired If the user's email is implicitly trusted (for example, provided by a third party), set this to TRUE to bypass email validation * @param bool $doNotDelete If TRUE, the account will not be deleted in the normal cleanup of unvalidated accounts. Used for accounts created in Commerce checkout. * @param array|null $postBeforeRegister The row from core_post_before_registering if applicable * @param Url|null $refUrl The URL the user should be redirected to after validation * @return void */ public function postRegistration( bool $noEmailValidationRequired=FALSE, bool $doNotDelete=FALSE, array $postBeforeRegister = NULL, Url $refUrl = NULL ) : void { /* Work out validation type */ $validationType = Settings::i()->reg_auth_type; if ( $noEmailValidationRequired ) { switch ( $validationType ) { case 'user': $validationType = 'none'; break; case 'admin_user': $validationType = 'admin'; break; } } /* Validation */ if ( $validationType != 'none' ) { /* Set the flag */ $this->members_bitoptions['validating'] = TRUE; $this->save(); /* Prevent duplicates from double clicking, etc */ Db::i()->delete( 'core_validating', array( 'member_id=? and new_reg=1', $this->member_id ) ); /* Insert a record */ $vid = md5( $this->members_pass_hash . Login::generateRandomString() ); $plainSecurityKey = Login::generateRandomString(); Db::i()->insert( 'core_validating', array( 'vid' => $vid, 'member_id' => $this->member_id, 'entry_date' => time(), 'new_reg' => 1, 'ip_address' => $this->ip_address, 'spam_flag' => ( $this->members_bitoptions['bw_is_spammer'] ) ?: FALSE, 'user_verified' => $validationType == 'admin', 'email_sent' => ( $validationType != 'admin' ) ? time() : NULL, 'do_not_delete' => $doNotDelete, 'ref' => $refUrl ? ( (string) $refUrl ) : NULL, 'security_key' => Encrypt::fromPlaintext( $plainSecurityKey )->tag() ) ); /* Send email for validation */ if ( $validationType != 'admin' ) { Email::buildFromTemplate( 'core', 'registration_validate', array( $this, $vid, $plainSecurityKey, '' ), Email::TYPE_TRANSACTIONAL )->send( $this ); } /* Update core_post_before_registering */ if ( $postBeforeRegister ) { Db::i()->update( 'core_post_before_registering', array( 'member' => $this->member_id ), array( 'secret=?', $postBeforeRegister['secret'] ) ); } } /* If no email-related validation is required, send admin notification */ if ( $validationType == 'admin' ) { AdminNotification::send( 'core', 'NewRegValidate', NULL, TRUE, $this ); } elseif ( $validationType == 'none' ) { AdminNotification::send( 'core', 'NewRegComplete', NULL, TRUE, $this ); } /* Send emails and handle post before register if validation is disabled */ if( $validationType == 'none' ) { $secret = $postBeforeRegister['secret'] ?? NULL ; $this->_processPostBeforeRegistering( $secret ); $this->_sendWelcomeEmail(); $this->_sendReferralNotification(); } } /** * Email Validation Confirmed * * @param array $record validating record * @return void */ public function emailValidationConfirmed( array $record ) : void { /* Log */ $this->logHistory( 'core', 'account', array( 'type' => 'email_validated' ) ); /* If admin validation is required, set the flag and send an admin notification */ if ( Settings::i()->reg_auth_type == 'admin_user' ) { AdminNotification::send( 'core', 'NewRegValidate', NULL, TRUE, $this ); Db::i()->update( 'core_validating', array( 'user_verified' => TRUE ), array( 'member_id=?', $this->member_id ) ); } /* Otherwise, validation is complete */ else { $this->validationComplete(); } } /** * Final Validation Complete * If no validation is enabled: this is never called * If email-only validation is enabled: this is called after the user has validated their email address or if the admin manually validates the account * If admin (including email and admin) validation is enabled: this is called after the admin has validated the account * * @param FALSE|Member $initiatedByAdmin If an administrator performed the validation, the admin that did so * @param string|null $postBeforeRegisterSecret The secret from core_post_before_register, if applicable * @return void */ public function validationComplete( Member|bool $initiatedByAdmin=FALSE, string $postBeforeRegisterSecret=NULL ) : void { /* Process any posts made before registering */ $this->_processPostBeforeRegistering( $postBeforeRegisterSecret ); /* Send a success email */ $this->_sendWelcomeEmail(); /* Send referral notification */ $this->_sendReferralNotification(); /* Send an admin notification */ AdminNotification::send( 'core', 'NewRegComplete', NULL, TRUE, $this, $initiatedByAdmin ); /* Delete rows */ Db::i()->delete( 'core_validating', array( 'member_id=?', $this->member_id ) ); /* Reset the flag */ $this->members_bitoptions['validating'] = FALSE; $this->save(); Webhook::fire( 'member_registration_complete', $this, $this->webhookFilters() ); /* Sync */ Event::fire( 'onValidate', $this ); } /** * Process any posts made before registering * * @param string|null $secret The secret, if just created (necessary for avoiding R/W separation issues if no validation is required) * @return void */ protected function _processPostBeforeRegistering( string $secret = NULL ) : void { $where = $secret ? array( '`member`=? OR secret=?', $this->member_id, $secret ) : array( '`member`=?', $this->member_id ); foreach (Db::i()->select( '*', 'core_post_before_registering', $where ) as $row ) { Db::i()->delete( 'core_post_before_registering', array( 'class=? AND id=?', $row['class'], $row['id'] ) ); try { $class = $row['class']; $content = $class::load( $row['id'] ); if ( $content->author()->member_id ) { throw new OutOfRangeException; } $content->changeAuthor( $this, FALSE ); $moderated = FALSE; if ( $content instanceof Review AND IPS::classUsesTrait( $content->item(), 'IPS\Content\Hideable' ) ) { $moderated = $content->item()->moderateNewReviews( $this ); } elseif ( $content instanceof Comment AND IPS::classUsesTrait( $content->item(), 'IPS\Content\Hideable' ) ) { $moderated = $content->item()->moderateNewComments( $this ); } elseif( IPS::classUsesTrait( $content, 'IPS\Content\Hideable' ) ) { $moderated = $content::moderateNewItems( $this, $content->containerWrapper(), FALSE ); } /* Do additional processing */ $moderated = Bridge::i()->cloudPbrProcessMember( $this, $content, $moderated ); if ( $moderated ) { if ( isset( $content::$databaseColumnMap['hidden'] ) ) { $column = $content::$databaseColumnMap['hidden']; $content->$column = 1; } elseif ( isset( $content::$databaseColumnMap['approved'] ) ) { $column = $content::$databaseColumnMap['approved']; $content->$column = 0; } $content->save(); $content->sendUnapprovedNotification(); $container = NULL; if ( $content instanceof Comment ) { if ( $content instanceof Review ) { $content->item()->resyncReviewCounts(); } else { $content->item()->resyncCommentCounts(); } $content->item()->save(); $container = $content->item()->containerWrapper(); } else { $container = $content->containerWrapper(); } if ( $container ) { $container->resetCommentCounts(); $container->save(); } } else { /* Set the value so that checkProfanityFilters() thinks it's good */ $column = NULL; if ( isset( $content::$databaseColumnMap['hidden'] ) ) { $column = $content::$databaseColumnMap['hidden']; $oldVal = $content->$column; $content->$column = 0; } elseif ( isset( $content::$databaseColumnMap['approved'] ) ) { $column = $content::$databaseColumnMap['approved']; $oldVal = $content->$column; $content->$column = 1; } /* Run through checkProfanityFilters() */ if ( $content->checkProfanityFilters( $content instanceof Comment and $content->item()::$firstCommentRequired and $content->isFirst() ) ) { $content->sendUnapprovedNotification(); } else { if ( $column ) { $content->$column = $oldVal; } $content->unhide( FALSE ); } } } catch ( OutOfRangeException $e ) { } } } /** * Send a welcome email * Called if no validation is enabled or after validation is complete * * @return void */ protected function _sendWelcomeEmail() : void { if( Settings::i()->reg_welcome_email ) { if( $this->language()->checkKeyExists( 'reg_welcome_email_message' ) ) { $message = $this->language()->get( 'reg_welcome_email_message' ); } else { $message = $this->language()->addToStack( 'email_reg_complete', false, [ 'sprintf' => [ Settings::i()->board_name ] ] ); if( $this->members_pass_hash ) { $message .= $this->language()->addToStack( 'email_reg_complete_pass' ); } } try { Email::buildFromTemplate( 'core', 'registration_complete', array( $this, $message ), Email::TYPE_TRANSACTIONAL )->send( $this ); } catch( ErrorException $e ) { } } } /** * Send a referral notification to referrer * Called if no validation is enabled or after validation is complete * * @return void */ protected function _sendReferralNotification() : void { try { $referrer = Db::i()->select( 'referred_by', 'core_referrals', array( 'member_id=?', $this->member_id ) )->first(); $notification = new Notification( Application::load( 'core' ), 'referral', $this, array( $this ) ); $notification->recipients->attach( Member::load( $referrer ) ); $notification->send(); } catch( OutOfRangeException | UnderflowException $e ) {} } /** * Get how often the member changed his name * * @return bool|int */ public function hasNameChanges(): bool|int { try { return Db::i()->select( 'count(*)', 'core_member_history', array( 'log_member=? AND log_app=? AND log_type=?', $this->member_id, 'core', 'display_name' ) )->first(); } catch ( UnderflowException $e ) { return FALSE; } } /** * Profile Sync * * @return void */ public function profileSync(): void { $profileSync = $this->profilesync; foreach ( Login::methods() as $method ) { if ( $method->canProcess( $this ) ) { foreach ( $method->forceSync() as $type ) { if ( !isset( $profileSync[ $type ] ) or $profileSync[ $type ]['handler'] !== $method->_id ) { $profileSync[ $type ] = array( 'handler' => $method->_id, 'ref' => NULL, 'error' => NULL ); } } /* Perform any extra profile sync the login handler may need */ try { if( method_exists( $method, 'extraProfileSync' ) ) { $method->extraProfileSync( $this ); } } catch( Login\Exception $e ) {} } } if ( is_array( $profileSync ) ) { foreach ( $profileSync as $k => $v ) { try { $method = Handler::load( $v['handler'] ); } catch ( OutOfRangeException $e ) { unset( $profileSync[ $k ] ); continue; } /* Check the method is enabled */ if( !$method->_enabled ) { continue; } /* Check we're syncing this item */ if ( !in_array( $k, $method->forceSync() ) and !in_array( $k, $method->syncOptions( $this ) ) ) { continue; } try { $profileSync[ $k ]['error'] = NULL; switch ( $k ) { case 'email': $email = $method->userEmail( $this ); if ( $email and $email != $this->email ) { if ( $error = Login::emailIsInUse( $email, $this ) ) { throw new DomainException('member_email_exists'); } foreach (Db::i()->select( 'ban_content', 'core_banfilters', array( "ban_type=?", 'email' ) ) as $bannedEmail ) { if ( preg_match( '/^' . str_replace( '\*', '.*', preg_quote( $bannedEmail, '/' ) ) . '$/i', $this->value ) ) { throw new DomainException( 'form_email_banned' ); } } if ( Settings::i()->allowed_reg_email !== '' AND $allowedEmailDomains = explode( ',', Settings::i()->allowed_reg_email ) ) { $matched = FALSE; foreach ( $allowedEmailDomains AS $domain ) { if( mb_stripos( $email, "@" . $domain ) !== FALSE ) { $matched = TRUE; } } if ( count( $allowedEmailDomains ) AND !$matched ) { throw new DomainException( 'form_email_banned' ); } } $this->logHistory( 'core', 'email_change', array( 'old' => $this->email, 'new' => $email, 'by' => 'profilesync', 'id' => $method->id, 'service' => $method::getTitle() ) ); $this->email = $email; } break; case 'name': $name = $method->userProfileName( $this ); if ( $name and $name != $this->name ) { if ( mb_strlen( $name ) < Settings::i()->min_user_name_length ) { throw new DomainException('form_minlength_unspecific'); } if ( mb_strlen( $name ) > Settings::i()->max_user_name_length ) { throw new DomainException('form_minlength_unspecific'); } if ( !Login::usernameIsAllowed( $name ) ) { throw new DomainException('form_name_banned'); } if ( Login::usernameIsInUse( $name, $this ) ) { throw new DomainException('member_name_exists'); } foreach(Db::i()->select( 'ban_content', 'core_banfilters', array("ban_type=?", 'name') ) as $bannedName ) { if( preg_match( '/^' . str_replace( '\*', '.*', preg_quote( $bannedName, '/' ) ) . '$/i', $this->value ) ) { throw new DomainException( 'form_name_banned' ); } } $this->logHistory( 'core', 'display_name', array( 'old' => $this->name, 'new' => $name, 'by' => 'profilesync', 'id' => $method->id, 'service' => $method::getTitle() ) ); $this->name = $name; } break; case 'photo': $photoUrl = $method->userProfilePhoto( $this ); if ( (string) $photoUrl ) { /* Make sure we have a scheme */ if( !$photoUrl->data[ Url::COMPONENT_SCHEME ] ) { $photoUrl = $photoUrl->setScheme( 'https' ); } $contents = $photoUrl->request()->get(); $md5 = md5( $contents ); if ( $contents AND ( !isset( $v['ref'] ) or $md5 != $v['ref'] ) ) { $photoVars = explode( ':', $this->group['g_photo_max_vars'] ); try { $image = Image::create( $contents ); } catch( Exception $e ) { throw new DomainException('member_photo_bad_url'); } if( $image->isAnimatedGif and !$this->group['g_upload_animated_photos'] ) { throw new DomainException('member_photo_upload_no_animated'); } if ( $image->width > $photoVars[1] or $image->height > $photoVars[2] ) { $image->resizeToMax( $photoVars[1], $photoVars[2] ); } if ( $photoVars[0] and strlen( $image ) > ( $photoVars[0] * 1024 ) ) { throw new DomainException('upload_too_big_unspecific'); } $newFile = File::create( 'core_Profile', 'imported-photo-' . $this->member_id . '.' . $image->type, (string) $image ); $this->pp_photo_type = 'custom'; $this->pp_main_photo = (string) $newFile; $thumbnail = $newFile->thumbnail( 'core_Profile', PHOTO_THUMBNAIL_SIZE, PHOTO_THUMBNAIL_SIZE, TRUE ); $this->pp_thumb_photo = (string) $thumbnail; if ( isset( $v['ref'] ) ) { $this->photo_last_update = time(); } $this->logHistory( 'core', 'photo', array( 'action' => 'new', 'type' => 'profilesync', 'id' => $method->id, 'service' => $method::getTitle() ) ); $profileSync['photo']['ref'] = $md5; } } break; case 'cover': /* Sync the cover photo only if the user has permission to have one */ if ( $this->group['gbw_allow_upload_bgimage'] ) { $coverPhotoUrl = $method->userCoverPhoto( $this ); if ( (string) $coverPhotoUrl ) { /* Make sure we have a scheme */ if( !$coverPhotoUrl->data[ Url::COMPONENT_SCHEME ] ) { $coverPhotoUrl = $coverPhotoUrl->setScheme( 'https' ); } $contents = $coverPhotoUrl->request()->get(); $md5 = md5( $contents ); if ( !isset( $v['ref'] ) or $md5 != $v['ref'] ) { try { $image = Image::create( $contents ); } catch( Exception $e ) { throw new DomainException('member_photo_bad_url'); } if ( $this->group['g_max_bgimg_upload'] != -1 and strlen( $image ) > ( $this->group['g_max_bgimg_upload'] * 1024 ) ) { throw new DomainException('upload_too_big_unspecific'); } $newFile = File::create( 'core_Profile', 'imported-cover-photo-' . $this->member_id . '.' . $image->type, (string) $image ); if ( $this->pp_cover_photo ) { try { File::get( 'core_Profile', $this->pp_cover_photo )->delete(); } catch ( Exception $e ) { } } $this->pp_cover_photo = (string) $newFile; $this->logHistory( 'core', 'photo', array( 'action' => 'new', 'type' => 'profilesync', 'id' => $method->id, 'service' => $method::getTitle() ) ); $profileSync['cover']['ref'] = $md5; } } } break; } } catch ( Login\Exception $e ) { unset( $profileSync[ $k ] ); } catch ( DomainException $e ) { $profileSync[ $k ]['error'] = $e->getMessage(); } catch ( Exception $e ) { Log::log( $e, 'profilesync' ); $profileSync[ $k ]['error'] = 'profilesync_generic_error'; } } } $this->profilesync = $profileSync; $this->profilesync_lastsync = time(); $this->save(); } /** * Can this member be ignored? * * @return bool */ public function canBeIgnored(): bool { if ( !Settings::i()->ignore_system_on ) { return FALSE; } if( !$this->member_id ) { return FALSE; } if ( $this->modPermission() AND !$this->modPermission( 'can_moderator_be_ignored' ) ) { return FALSE; } if ( $this->group['gbw_cannot_be_ignored'] ) { return FALSE; } return TRUE; } /** * Log Member Action * * @param mixed $app The application action applies to * @param string $type Log type * @param mixed|null $extra Any extra data for the type * @param mixed|null $by The member performing the action. NULL for currently logged in member or FALSE for no member * * @return void */ public function logHistory( mixed $app, string $type, mixed $extra=NULL, mixed $by=NULL ) : void { if ( $this->member_id ) { /* Set this only if this was not called by the task system, otherwise we'll set the member who triggered the task as moderator */ if ( Dispatcher::hasInstance() and isset( Dispatcher::i()->inDestructor ) and !Dispatcher::i()->inDestructor and $by === NULL ) { $by = Session::i()->member; // Not \IPS\Member::loggedIn() because if this is an admin logged in as a member, we want to log that the action was done by the admin } Db::i()->insert( 'core_member_history', array( 'log_app' => $app, 'log_member' => (int) $this->member_id, 'log_by' => $by ? $by->member_id : NULL, 'log_type' => $type, 'log_data' => json_encode( $extra ), 'log_date' => microtime( TRUE ), 'log_ip_address' => Request::i()->ipAddress() ) ); } } /* !Top Members */ /** * @brief Top members overview */ const TOP_MEMBERS_OVERVIEW = 1; /** * @brief Top members with filters */ const TOP_MEMBERS_FILTERS = 2; /** * @brief Custom count for a top member result */ public ?int $_customCount = NULL; /** * Get available Top Members options * * @param int $filter See TOP_MEMBERS_* constants * @return array */ public static function topMembersOptions( int $filter = 0 ): array { $filters = array( 'pp_reputation_points' => Member::loggedIn()->language()->addToStack('leaderboard_tab_reputation'), 'member_posts' => Member::loggedIn()->language()->addToStack('leaderboard_tab_posts') ); foreach (Application::allExtensions( 'core', 'ContentRouter', TRUE ) as $object ) { foreach( $object->classes as $item ) { $commentClass = NULL; if ( isset( $item::$commentClass ) ) { $commentClass = $item::$commentClass; } if ( $item::$firstCommentRequired and isset( $commentClass::$databaseColumnMap['author'] ) ) { $filters[ $commentClass ] = Member::loggedIn()->language()->addToStack( 'leaderboard_tab_x', FALSE, array( 'sprintf' => array( Member::loggedIn()->language()->addToStack("{$commentClass::$title}_pl_lc") ) ) ); } elseif ( isset( $item::$databaseColumnMap['author'] ) ) { $filters[ $item ] = Member::loggedIn()->language()->addToStack( 'leaderboard_tab_x', FALSE, array( 'sprintf' => array( Member::loggedIn()->language()->addToStack("{$item::$title}_pl_lc") ) ) ); } } } if ( $filter ) { $available = $filter === static::TOP_MEMBERS_OVERVIEW ? Settings::i()->reputation_top_members_overview : Settings::i()->reputation_top_members_filters; if ( $available != '*' ) { $available = explode( ',', $available ); $filters = array_filter( $filters, function( $k ) use ( $available ) { return in_array( $k, $available ); }, ARRAY_FILTER_USE_KEY ); } } return $filters; } /** * Get top members for a particular type * * @param string $type The type (as returned by topMembersOptions()) * @param int $limit Number to get * @return ActiveRecordIterator|array */ public static function topMembers( string $type, int $limit ): ActiveRecordIterator|array { if ( in_array( $type, array( 'pp_reputation_points', 'member_posts' ) ) ) { $where = array( array( "completed=1" ), array( "temp_ban != -1" ), array( $type . '>0' ), Db::i()->in( 'member_group_id', explode( ',', Settings::i()->leaderboard_excluded_groups ), TRUE ) ); $orderBy = $type . ' DESC'; return new ActiveRecordIterator( Db::i()->select( '*', 'core_members', $where, $orderBy, $limit ), 'IPS\Member' ); } else { $storeKey = 'store_' . str_replace( '\\', '-', $type ); $stored = isset( Store::i()->$storeKey ) ? Store::i()->$storeKey : NULL; if ( ! $stored or ( ( time() - $stored['time'] ) > 300 ) ) { /* @var Content|Member $type */ $members = array(); foreach( $type::topMembersQuery( $limit ) as $row ) { /* @var $databaseColumnMap array */ $members[ $row[ $type::$databasePrefix . $type::$databaseColumnMap['author'] ] ] = $row['count']; } /* Make sure these members actually exist */ $memberIds = array(); /* Make sure they're not in an excluded group */ $memberWhere = array(); $memberWhere[] = Db::i()->in( 'member_id', array_keys( $members ) ); $memberWhere[] = Db::i()->in( 'member_group_id', explode( ',', Settings::i()->leaderboard_excluded_groups ), TRUE ); foreach(Db::i()->select( 'member_id', 'core_members', $memberWhere ) AS $member_id ) { $memberIds[ $member_id ] = $members[ $member_id ]; } Store::i()->$storeKey = array( 'time' => time(), 'memberIds' => $memberIds ); $stored = Store::i()->$storeKey; } $results = array(); foreach (new ActiveRecordIterator( Db::i()->select( '*', 'core_members', Db::i()->in( 'member_id', array_keys( $stored['memberIds'] ) ), NULL, $limit ), 'IPS\Member' ) as $member ) { $member->_customCount = $stored['memberIds'][ $member->member_id ]; $results[ $member->member_id ] = $member; } /* Sort by custom value DESC */ uasort( $results, function( $a, $b ) { return ( $a->_customCount == $b->_customCount ) ? 0 : ( ( $a->_customCount < $b->_customCount ) ? 1 : -1 ); }); /* Preload follower data */ if( count( $results ) ) { Member::loggedIn()->preloadMemberFollowers( array_keys( $results ) ); } return $results; } } /** * Preload follower information for top members * * @note Allows us to run two queries instead of two per member * @param array $memberIds Member IDs to preload * @return void */ public function preloadMemberFollowers( array $memberIds ) : void { /* Preload the "are we following" flag */ foreach( $memberIds as $memberId ) { $this->_following[ md5( 'coremember' . $memberId ) ] = FALSE; } foreach(Db::i()->select( 'follow_rel_id', 'core_follow', array( 'follow_app=? AND follow_area=? AND follow_rel_id IN(' . implode( ',', $memberIds ) . ') AND follow_member_id=?', 'core', 'member', $this->member_id ) ) as $followerId ) { $this->_following[ md5( 'coremember' . $followerId ) ] = TRUE; } /* And then preload the total follower counts */ static::_followersCount( 'member', $memberIds ); } /* !Profile Completion */ /** * @brief Profile Completion Cache */ public ?array $_profileCompletion = NULL; /** * Returns suggested profile items * * @return array|null */ public function profileCompletion(): ?array { if ( $this->_profileCompletion === NULL ) { $this->_profileCompletion = array(); $this->_profileCompletion['required'] = array(); $this->_profileCompletion['suggested'] = array(); if ( $this->member_id AND !$this->members_bitoptions['profile_completed'] ) { foreach( Member\ProfileStep::loadAll() AS $id => $step ) { if( !$step->canComplete( $this ) ) { continue; } $this->_profileCompletion[ ( $step->required ) ? 'required' : 'suggested' ][ $step->id ] = $step->completed( $this ); } } } return $this->_profileCompletion; } /** * Profile Completion Percentage * * @return Number */ public function profileCompletionPercentage(): Number { if ( $this->members_bitoptions['profile_completed'] ) { return new Number( '100' ); } $total = 0; $done = 0; /* Check Required Ones */ $completion = $this->profileCompletion(); foreach( $completion['required'] AS $key => $value ) { $total++; if ( $value ) { $done++; } } /* Now Suggested */ foreach( $completion['suggested'] AS $key => $value ) { $total++; if ( $value ) { $done++; } } /* Are we actually done? */ if ( $done === $total AND $this->members_bitoptions['profile_completed'] === FALSE ) { $this->members_bitoptions['profile_completed'] = TRUE; $this->save(); } if ( $this->members_bitoptions['profile_completed'] ) { return new Number( '100' ); } elseif( !$total ) { return new Number( '100' ); } else { return new Number( (string) round( 100 / $total * $done ) ); } } /** * Next Profile Step * * @return ProfileStep|NULL */ public function nextProfileStep(): ?ProfileStep { if ( $this->members_bitoptions['profile_completed'] ) { return NULL; } $completed = $this->profileCompletion(); if ( !count( $completed['suggested'] ) ) { return NULL; } $nextStep = NULL; foreach( $completed['suggested'] AS $id => $complete ) { if ( !$complete ) { $nextStep = ProfileStep::load( $id ); break; } } return $nextStep; } /** * Determine if the member can use signatures * * @return bool */ public function canEditSignature(): bool { /* If signatures are globally disabled, we can't edit them */ if( !Settings::i()->signatures_enabled ) { return FALSE; } /* Are they enabled for our group? */ $sigLimits = explode( ":", $this->group['g_signature_limits'] ); if( $sigLimits[0] OR $sigLimits[5] == 0 ) { return FALSE; } /* Are there post count or day restrictions? */ if( $this->group['g_sig_unit'] ) { if( $this->group['gbw_sig_unit_type'] ) { if ( $this->joined->diff( DateTime::create() )->days < $this->group['g_sig_unit'] ) { return FALSE; } } else { if ( $this->member_posts < $this->group['g_sig_unit'] ) { return FALSE; } } } return TRUE; } /** * Produce a random hex color for a background * * @return string */ public function coverPhotoBackgroundColor(): string { return $this->staticCoverPhotoBackgroundColor( $this->name ); } /** * returns the recent profile visitors * * @return array */ public function get_profileVisitors(): array { $visitors = array(); $visitorData = array(); $visitorInfo = json_decode( $this->pp_last_visitors, TRUE ); if ( !is_array( $visitorInfo ) ) { $visitorInfo = array(); } foreach(new ActiveRecordIterator( Db::i()->select( '*', 'core_members', array( Db::i()->in( 'member_id', array_keys( array_reverse( $visitorInfo, TRUE ) ) ) ) ), 'IPS\Member' ) AS $row ) { $visitorData[$row->member_id] = $row; } foreach( array_reverse( $visitorInfo, TRUE ) as $id => $time ) { if ( isset( $visitorData[$id] ) ) { $visitors[$id]['member'] = $visitorData[$id]; $visitors[$id]['visit_time'] = $time; } } return $visitors; } /* Referrals */ protected ?Member $_referredBy = NULL; /** * Referred By * * @return Member|NULL */ public function referredBy(): Member|null { if ( $this->_referredBy === NULL ) { try { $referredByID = Db::i()->select( 'referred_by', 'core_referrals', array( 'member_id=?', $this->member_id ) )->first(); $this->_referredBy = Member::load( $referredByID ); } catch ( UnderflowException | OutOfRangeException $e ) { $this->_referredBy = NULL; } } return $this->_referredBy; } /** * Add Referral * * @param Member $member The member being referred * * @return void */ public function addReferral( Member $member ) : void { if ( $this->member_id and $member->member_id ) { Db::i()->insert( 'core_referrals', array( 'member_id' => $member->member_id, 'referred_by' => $this->member_id ) ); } } /** * Get member link preference * * @return string|null */ public function linkPref(): ?string { if ( $this->members_bitoptions['link_pref_unread'] ) { return 'unread'; } if ( $this->members_bitoptions['link_pref_last'] ) { return 'last'; } if ( $this->members_bitoptions['link_pref_first'] ) { return 'first'; } return NULL; } /** * Has this member joined recently? * * @return boolean */ public function joinedRecently() : bool { if ( $this->member_id AND $this->joined->diff( DateTime::ts( time() ) )->days < 30 ) { return TRUE; } return FALSE; } /* !Achievements */ /** * Get current rank * * @return Rank|null */ public function rank(): ?Rank { return Rank::fromPoints( (int) $this->achievements_points ); } /** * Get when the current rank was earned * * @return ?DateTime */ public function rankEarned(): ?DateTime { if ( $rank = $this->rank() ) { try { return DateTime::ts( Db::i()->select( 'datetime', 'core_points_log', [ '`member`=? AND new_rank=?', $this->member_id, $rank->id ], 'datetime DESC', 1 )->first() ); } catch ( UnderflowException $e ) { } } return NULL; } /** * Get next rank * * @return Rank|null */ public function nextRank(): ?Rank { foreach ( Rank::getStore() as $rank ) { if ( $rank->points > $this->achievements_points ) { return $rank; } } return NULL; } protected ?array $rankHistory = NULL; /** * Return rank history * * @return array */ public function rankHistory(): array { if ( ! $this->rankHistory ) { $return = [ 'earned' => NULL, 'not_earned' => NULL ]; $query = iterator_to_array( Db::i()->select( 'new_rank, datetime', 'core_points_log', ['`member`=? and new_rank IS NOT NULL', $this->member_id] )->setKeyField('new_rank')->setValueField('datetime') ); $currentRank = $this->rank(); foreach ( Rank::getStore() as $rank ) { if ( $rank->points == 0 ) { $return['earned'][] = ['rank' => $rank, 'time' => $this->joined ]; } elseif ( $rank->points <= $currentRank->points ) { $return['earned'][] = ['rank' => $rank, 'time' => isset( $query[ $rank->id ] ) ? DateTime::ts( $query[ $rank->id ] ) : NULL ]; } else { $return['not_earned'][] = ['rank' => $rank]; } } $this->rankHistory = $return; } return $this->rankHistory; } /** * Return the number of days since the last rank upgrade * * @return int */ public function daysSinceLastRank(): int { $history = $this->rankHistory(); $lastRank = NULL; foreach( $history['earned'] as $earned ) { if ( $earned['rank']->id == $this->rank()->id ) { break; } $lastRank = $earned; } $time = ( $lastRank and $lastRank['time'] ) ? $lastRank['time'] : $this->joined; return intval( DateTime::ts( time() )->diff( $time )->days ); } /** * @brief List of badge IDs that this member has earned */ protected ?array $_badgeIds = NULL; /** * Get list of badge IDs that this member has earned * * @return array */ protected function badgeIds(): array { if ( $this->_badgeIds === NULL ) { $this->_badgeIds = iterator_to_array( Db::i()->select( 'badge', 'core_member_badges', [ '`member`=?', $this->member_id ] ) ); } return $this->_badgeIds; } /** * @brief Count of badges this member has earned */ public ?int $_badgeCount = NULL; /** * Get count of badges that this member has earned * * @return int */ public function badgeCount(): int { if ( $this->_badgeCount === NULL ) { $this->_badgeCount = Db::i()->select( 'count(*)', 'core_member_badges', [ '`member`=?', $this->member_id ] )->first(); } return $this->_badgeCount; } /** * Recent Badges * * @param int|null $numberToGet Number to get * @return ActiveRecordIterator */ public function recentBadges( ?int $numberToGet ): ActiveRecordIterator { return new ActiveRecordIterator( Db::i()->select( 'core_badges.*, core_member_badges.member, core_member_badges.badge, core_member_badges.datetime, core_member_badges.rule, core_member_badges.action_log, core_member_badges.actor, core_member_badges.recognize', 'core_member_badges', [ '`member`=?', $this->member_id ], 'datetime DESC', $numberToGet ) ->join( 'core_badges', 'core_member_badges.badge=core_badges.id' ), 'IPS\core\Achievements\Badge' ); } /** * @var array|null Cache for excluded groups for rules */ protected static ?array $rulesExcluded = NULL; /** * Can this member partake in the fun an frivolity of achievements? * * @return bool */ public function canHaveAchievements(): bool { if ( ! Settings::i()->achievements_enabled ) { return FALSE; } if ( ! $this->member_id ) { return FALSE; } if ( static::$rulesExcluded === NULL ) { static::$rulesExcluded = json_decode( Settings::i()->rules_exclude_groups, TRUE ); } if ( ! is_array( static::$rulesExcluded ) or ! count( static::$rulesExcluded ) ) { return TRUE; } return ! $this->inGroup(static::$rulesExcluded); } /** * User has done something which could earn Points / Badges * * @param string $app Application key for the action being performed * @param string $extension Extension key for the action being performed * @param mixed|null $extra Any additional information to be passed along to the extension (e.g. if a post is being made: the post object) * @param DateTime|null $date Optional DateTime object to be used as the date the achievement was earned * @return void */ public function achievementAction( string $app, string $extension, mixed $extra = NULL, DateTime $date = NULL ) : void { if ( ! $this->canHaveAchievements() ) { return; } if ( isset( Rule::getStore()["{$app}_{$extension}"] ) ) { $enabled = FALSE; foreach ( Rule::getStore()["{$app}_{$extension}"] as $ruleId => $ruleData ) { if ( $ruleData['enabled'] ) { $enabled = TRUE; } } if ( $enabled === TRUE ) { $extensionObject = Application::load( $app )->extensions( 'core', 'AchievementAction' )[$extension]; /* Log it first, because doing so allows us to tell if we have already ran the rules for this action (e.g. if someone unlikes and relikes a post, we don't want to keep doing the actions for that every time: only once) */ $logId = Db::i()->insert( 'core_achievements_log', [ 'action' => "{$app}_{$extension}", 'identifier' => $extensionObject->identifier( $this, $extra ), 'datetime' => ( $date ) ? $date->getTimestamp() : time() ], FALSE, TRUE ); if ( !$logId ) { return; } } else { /* There are no enabled rules with this extension */ return; } /* Work out what we have to dish out */ $awards = []; $milestoneHitWithRules = []; foreach ( Rule::getStore()["{$app}_{$extension}"] as $ruleId => $ruleData ) { if ( ! $ruleData['enabled'] ) { continue; } /* Cloudy stuff */ if( !Bridge::i()->checkAchievementRule( array_merge( [ 'action' => "{$app}_{$extension}" ], $ruleData ), $this ) ) { continue; } if ( !$ruleData['filters'] or $extensionObject->filtersMatch( $this, $ruleData['filters'], $extra ) ) { /* Did we already award for this milestone? */ if ( Rule::ruleHasMilestone( $ruleData['filters'] ) ) { $milestoneHitWithRules[ $ruleId ] = []; } if ( $ruleData['points_subject'] or $ruleData['badge_subject'] ) { if ( array_key_exists( $ruleId, $milestoneHitWithRules ) ) { $milestoneHitWithRules[ $ruleId ][ 'subject-' . $this->member_id ] = 'subject-' . $this->member_id; } if ( !isset( $awards[ $this->member_id ] ) ) { $awards[ $this->member_id ] = [ 'points' => [], 'badges' => [], 'actor' => [] ]; } if ( $ruleData['points_subject'] ) { $awards[ $this->member_id ]['actor']['subject'] = 'subject'; $awards[ $this->member_id ]['points'][ $ruleId ] = $ruleData['points_subject']; } if ( $ruleData['badge_subject'] and !in_array( $ruleData['badge_subject'], $this->badgeIds() ) ) { $awards[ $this->member_id ]['actor']['subject'] = 'subject'; $awards[ $this->member_id ]['badges'][ $ruleId ] = $ruleData['badge_subject']; } } if ( $ruleData['points_other'] or $ruleData['badge_other'] ) { foreach ( $extensionObject->awardOther( $extra, $ruleData['filters'] ) as $member ) { /* If the "other" member is also the subject member (e.g. member is replying to their own topic), we only give them the "subject" points */ if ( $member->member_id == $this->member_id ) { continue; } if ( array_key_exists( $ruleId, $milestoneHitWithRules ) ) { $milestoneHitWithRules[ $ruleId ][ 'other-' . $member->member_id ] = 'other-' . $member->member_id; } /* Otherwise carry on as usual... */ if ( !isset( $awards[ $member->member_id ] ) ) { $awards[ $member->member_id ] = [ 'points' => [], 'badges' => [], 'actor' => [] ]; // So given the above comment, why is actor an array? In case we change our minds. } if ( $ruleData['points_other'] ) { $awards[ $member->member_id ]['actor']['other'] = 'other'; $awards[ $member->member_id ]['points'][ $ruleId ] = $ruleData['points_other']; } if ( $ruleData['badge_other'] and !in_array( $ruleData['badge_other'], $member->badgeIds() ) ) { $awards[ $member->member_id ]['actor']['other'] = 'other'; $awards[ $member->member_id ]['badges'][ $ruleId ] = $ruleData['badge_other']; } } } /* Fire the post-achievement here */ Bridge::i()->postAchievement( $ruleId, array_merge( [ 'action' => "{$app}_{$extension}" ], $ruleData ), $this, $extra ); } } /* If we have stuff to dish out... */ if ( $awards ) { $alreadyHitMilestone = []; /* Insert these milestone maps */ foreach( $milestoneHitWithRules as $ruleId => $members ) { foreach( $members as $data ) { [ $type, $memberId ] = explode( '-', $data ); $insertId = Db::i()->insert( 'core_achievements_log_milestones', [ 'milestone_member_id' => $memberId, 'milestone_rule' => $ruleId, 'milestone_log_id' => $logId, 'milestone_type' => $type, ], FALSE, TRUE ); if ( ! $insertId ) { $alreadyHitMilestone[ $memberId ][] = $ruleId; } } } foreach ( $awards as $memberId => $memberAwards ) { if ( $memberAwards['points'] ) { foreach( $memberAwards['points'] as $ruleId => $data ) { if ( isset( $alreadyHitMilestone[ $memberId ] ) and in_array( $ruleId, $alreadyHitMilestone[ $memberId ] ) ) { unset( $memberAwards['points'][ $ruleId ] ); } } if ( array_key_exists( 'points', $memberAwards ) and count( $memberAwards['points'] ) ) { Member::load( $memberId )->awardPoints( array_sum( $memberAwards['points'] ), $logId, array_keys( $memberAwards['points'] ), $memberAwards['actor'], 0, $date ); } } if ( $memberAwards['badges'] ) { foreach ( array_unique( $memberAwards['badges'] ) as $ruleId => $badgeId ) { if ( ! isset( $alreadyHitMilestone[ $memberId ] ) or ! in_array( $ruleId, $alreadyHitMilestone[ $memberId ] ) ) { try { Member::load( $memberId )->awardBadge( Badge::load( $badgeId ), $logId, $ruleId, $memberAwards['actor'], 0, $date ); } catch ( OutOfRangeException $e ) { } } } } } } } } /** * Award Points * * @param int $points Number of points to award * @param int $logId The ID number of the log in core_achievements_log which will tell us specifically what ACTION caused this (eg what post was made) * @param array $ruleIds The ID number of the rules which matched that action which contributed to this number of points being awarded * @param array $actor If member is "subject", "other" or both * @param int $recognize The ID of the recognize table row (optional) * @param DateTime|null $date Optional DateTime object to be used as the date the achievement was earned */ public function awardPoints(int $points, int $logId, array $ruleIds, array $actor, int $recognize=0, DateTime $date=NULL ) : void { if ( ! $this->canHaveAchievements() ) { return; } /* What are they on now? */ $oldPointsBalance = $this->achievements_points; $oldRank = $this->rank(); /* Increase it */ $this->achievements_points += $points; $newPointsBalance = $this->achievements_points; $newRank = $this->rank(); if ( $oldRank and $newRank and ( $oldRank->id == $newRank->id ) ) { $newRank = NULL; } Db::i()->update( 'core_members', "achievements_points = achievements_points + " . $points, [ 'member_id=?', $this->member_id ] ); /* Log it */ $logId = Db::i()->insert( 'core_points_log', [ 'member' => $this->member_id, 'points' => $points, 'datetime' => ( $date ) ? $date->getTimestamp() : time(), 'balance' => $newPointsBalance, 'rules' => implode( ',', $ruleIds ), 'action_log'=> $logId, 'actor' => implode( ',', $actor ), 'new_rank' => $newRank?->id, 'time_to_new_rank' => $newRank ? time() - $this->joined->getTimestamp() : NULL, 'recognize' => $recognize ], FALSE, TRUE ); /* Did that push them over threshold for a new rank? Don't send a notification if rebuilds are in progress, and a random post made live triggers a new badge or award */ if ( $newRank and ! Settings::i()->achievements_rebuilding ) { $notification = new Notification( Application::load( 'core' ), 'new_rank', $this, [ $newRank, $this ], [ $newRank->id ] ); $notification->recipients->attach( $this ); $notification->send(); } $this->save(); if ( isset( static::$_memberRecognizedPoints[ $this->member_id ] ) ) { unset( static::$_memberRecognizedPoints[ $this->member_id ] ); } } /** * Award Badge * * @param Badge $badge The badge to award * @param int $logId The ID number of the log in core_achievements_log which will tell us specifically what ACTION caused this (eg what post was made) * @param int $ruleId The ID number of the rule which matched that action which caused this badge to be awarded * @param array $actor If member is "subject", "other" or both * @param int $recognize The ID of the recognize table row (optional) * @param DateTime|null $date Optional DateTime object to be used as the date the achievement was earned */ public function awardBadge( Badge $badge, int $logId, int $ruleId, array $actor, int $recognize=0, DateTime $date=NULL ) : void { if ( ! $this->canHaveAchievements() ) { return; } $insertId = Db::i()->insert( 'core_member_badges', [ 'member' => $this->member_id, 'badge' => $badge->id, 'datetime' => ( $date ) ? $date->getTimestamp() : time(), 'rule' => $ruleId, 'action_log' => $logId, 'actor' => implode( ',', $actor ), 'recognize' => $recognize ], FALSE, TRUE ); if ( !$insertId ) { // Member has already earned this badge return; } /* Don't send a notification if rebuilds are in progress, and a random post made live triggers a new badge or award */ if ( ! Settings::i()->achievements_rebuilding ) { $notification = new Notification( Application::load( 'core' ), 'new_badge', $this, [ $this, $badge ], [ $badge->id ] ); $notification->recipients->attach( $this ); $notification->send(); } $data = [ 'member' => $this->apiOutput(), 'badge' => $badge->apiOutput(), ]; Webhook::fire( 'member_badge_awarded', $data ); $this->save(); } public static array $_memberRecognizedPoints = array(); /** * Grab todays recognized points * * @return int */ public function todaysRecognizePoints(): int { if ( ! isset( static::$_memberRecognizedPoints[ $this->member_id ] ) ) { $timezone = new DateTimeZone( Settings::i()->reputation_timezone ); $today = DateTime::create()->setTimezone( $timezone )->setTime( 0, 0 ); $sum = Db::i()->select( 'SUM(r_points)', 'core_member_recognize', [ 'r_member_id=? and r_added >=?', $this->member_id, $today->getTimestamp() ] )->first(); static::$_memberRecognizedPoints[ $this->member_id ] = $sum !== NULL ? $sum : 0; } return static::$_memberRecognizedPoints[ $this->member_id ]; } public static array $_memberRecognizedBadges = array(); /** * Grab todays recognized points * * @return int */ public function todaysRecognizeBadges(): int { if ( ! isset( static::$_memberRecognizedBadges[ $this->member_id ] ) ) { $timezone = new DateTimeZone( Settings::i()->reputation_timezone ); $today = DateTime::create()->setTimezone( $timezone )->setTime( 0, 0 ); $sum = Db::i()->select( 'COUNT(*)', 'core_member_recognize', [ 'r_member_id=? and r_badge > 0 and r_added >=?', $this->member_id, $today->getTimestamp() ] )->first(); static::$_memberRecognizedBadges[ $this->member_id ] = $sum !== NULL ? $sum : 0; } return static::$_memberRecognizedBadges[ $this->member_id ]; } /** * @brief Count of solutions member has given */ public ?int $_solutionCount = NULL; /** * Get count of solutions member has given * * @return int */ public function solutionCount(): int { if ( $this->_solutionCount === NULL ) { $this->_solutionCount = Db::i()->select( 'COUNT(*)', 'core_solved_index', array( 'member_id=? AND type=?', $this->member_id, 'solved' ) )->first(); } return $this->_solutionCount; } /** * Record this log in * * @return void */ public function recordLogin() : void { $this->sendLoginAfterInactivityNotification(); try { Db::i()->insert( 'core_members_logins', [ 'member_id' => $this->member_id, 'member_timestamp' => time(), 'member_date' => date( 'Y-m-d', DateTime::ts( time() )->getTimestamp() ) ] ); } catch( Exception $e ) { } } /** * If required, send an email notification when signing in after a period of inactivity. * * @return bool */ public function sendLoginAfterInactivityNotification(): bool { if( !Settings::i()->login_after_inactivity_notification ) { return false; } try { $lastLogin = Db::i()->select( 'member_timestamp', 'core_members_logins', [ 'member_id=?', $this->member_id ], 'member_timestamp DESC', 1 )->first(); } catch( UnderflowException $e ) { /* This is the first login, or they haven't logged in since upgrading from < 106100 */ try { $lastLogin = Db::i()->select( 'MIN(upgrade_date)', 'core_upgrade_history', [ 'upgrade_app=? AND upgrade_version_id>=?', 'core', 106100 ] )->first(); if( !$lastLogin ) { return false; } } catch( UnderflowException $e ) { /* This could be a new install, or IN_DEV where upgrade history isn't available */ return false; } } $lastLoginObj = DateTime::ts( $lastLogin ); /* Likely to be the first login */ if( $this->joined > $lastLoginObj ) { return false; } /* If this login is within the period, don't send an email */ if( $lastLoginObj > DateTime::create()->sub( new DateInterval( static::LOGIN_INACTIVITY_NOTIFICATION ) ) ) { return false; } /* Device data */ $device = Device::loadOrCreate( $this, false ); try { $location = GeoLocation::getRequesterLocation(); } catch ( Exception $e ) { $location = NULL; } Email::buildFromTemplate( 'core', 'loginAfterInactivity', array( $this, $device, $location ), Email::TYPE_TRANSACTIONAL )->send( $this ); return true; } /** * Webhook filters * * @return array */ public function webhookFilters() : array { return [ 'completed' => $this->completed, 'allowAdminEmails' => $this->allow_admin_mails, ]; } /** * Get unread feature highlights * * @param bool $markRead Mark returned features as read? Useful for debugging. * @return array|NULL */ public function getFeatureHighlights( bool $markRead = TRUE ): ?array { return NULL; } /** * Get the member's personal information * * @return SimpleXML */ public function getPiiData(): SimpleXML { /* Init */ $xml = SimpleXML::create('member_export'); $xml->addAttribute( 'created', DateTime::ts( time() )->rfc3339() ); /* Get the data */ foreach( Application::allExtensions( 'core', 'MemberExportPersonalInformation', TRUE, 'core' ) AS $key => $ext ) { if ( $data = $ext->getData( $this ) and is_array( $data ) and count( $data ) ) { $child = $xml->addChild( $key ); foreach( $data as $k => $v ) { $child->addChild( $k, $v ); } } } return $xml; } /** * Is there a deletion request pending? * * @return bool */ public function get_isDeletionPending(): bool { $where = []; $where[] = ['member_id=?', $this->member_id]; $where[] = [ 'action=?', PrivacyAction::TYPE_REQUEST_DELETE ]; return (bool) Db::i()->select( 'count(*)', PrivacyAction::$databaseTable, $where )->first(); } /** * Is there a deletion request pending validation? We can still cancel it * * @return bool */ public function get_canCancelDeletion() : bool { return (bool) Db::i()->select( 'count(*)', PrivacyAction::$databaseTable, [ 'member_id=? and action=?', $this->member_id, PrivacyAction::TYPE_REQUEST_DELETE_VALIDATION ] )->first(); } /** * Delete all logged IP addresses which were logged after a certain time * * @param int $time * @return void */ public static function pruneAllLoggedIpAddresses(int $time ) : void { foreach ( Content::routedClasses( FALSE, TRUE ) as $class ) { try { $class::pruneIpAddresses( Settings::i()->ip_address_prune ); } catch( Exception $ex ) { } } /* PMs are treated extra */ Db::i()->update( 'core_message_posts', array( 'msg_ip_address' => '' ), array( "msg_ip_address != '' AND msg_date <= " . $time ) ); foreach ( Application::allExtensions( 'core', 'IpAddresses', FALSE, 'core' ) as $key => $extension ) { $extension->pruneIpAddresses( $time ); } } /** * Can this member request that his account gets deleted via the frontend * * @return bool */ public function canUseAccountDeletion(): bool { if( Settings::i()->right_to_be_forgotten_type != 'on' ) { return FALSE; } if( Member::loggedIn()->isAdmin() ) { return FALSE; } return TRUE; } /** * Determines if this member has permission to change the layout * * @return bool */ public function canChangeLayoutValue() : bool { return ( $this->member_id and $this->group['gbw_change_layouts'] ); } /** * Return the layout value based on member choice, permissions and default theme option * * @param string $what Theme constant * @return string * @throws Exception */ public function getLayoutValue( string $what ): string { $themeOption = Theme::i()->getLayoutValue( $what ); /* If we are in the theme editor mode, then return theme settings */ if ( Member::loggedIn()->isEditingTheme() ) { return $themeOption; } if ( $this->canChangeLayoutValue() and $this->layouts and $memberOptions = json_decode( $this->layouts, TRUE ) ) { if ( isset( $memberOptions[ $what ] ) ) { return $memberOptions[ $what ]; } } /* Still here? Return the theme default value */ return $themeOption; } /** * Store the layout value * * @param string|null $what * @param string|null $value * @return void */ public function setLayoutValue( string|null $what, string|null $value ): void { if( $this->canChangeLayoutValue() ) { if ( $what === null ) { /* Clear all custom layouts */ $this->layouts = null; } else { $memberOptions = json_decode( $this->layouts, true ); $memberOptions[$what] = $value; $this->layouts = json_encode( $memberOptions ); } $this->save(); } } }