Version 4.7.23

This commit is contained in:
Neo committed 2025-12-19 16:21:27 -08:00
1 parent 7124a02564
commit 25ddeb65d6
1791 files changed
+76990 -44452

No files matched your search

+185 -87
View File
@@ -10,6 +10,11 @@
namespace IPS;
use BadFunctionCallException;
use IPS\Db;
use IPS\Settings;
use IPS\Text\Encrypt;
/* To prevent PHP errors (extending class does not exist) revealing path */
if ( !\defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
@@ -120,8 +125,7 @@ class _Member extends \IPS\Patterns\ActiveRecord
'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, // User's email keeps returning hard bounces so they need to change it
// 268435456 - cookie_optout moved to cookie preferences
'email_messages_bounce' => 134217728, // @deprecated
)
)
);
@@ -153,6 +157,11 @@ class _Member extends \IPS\Patterns\ActiveRecord
* @brief Following anonymously
*/
const FOLLOW_ANONYMOUS = 2;
/**
* @brief Period for login after inactivity notification
*/
const LOGIN_INACTIVITY_NOTIFICATION = 'P6M';
/**
* @brief Cached logged in member
@@ -305,7 +314,6 @@ class _Member extends \IPS\Patterns\ActiveRecord
$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'] = '';
@@ -474,12 +482,6 @@ class _Member extends \IPS\Patterns\ActiveRecord
}
}
/* If the email was changed, make sure that the bounce flag is unset so the member no longer sees the error message */
if ( isset( $changes['email'] ) )
{
$this->members_bitoptions['email_messages_bounce'] = 0;
}
parent::save();
if ( $new )
@@ -645,7 +647,7 @@ class _Member extends \IPS\Patterns\ActiveRecord
public function set_member_group_id( $value )
{
$this->_data['member_group_id'] = (int) $value;
$this->_group = NULL;
$this->resetGroupsCaches();
}
/**
@@ -663,7 +665,7 @@ class _Member extends \IPS\Patterns\ActiveRecord
}
$this->_data['mgroup_others'] = implode( ',', $groups );
$this->_group = NULL;
$this->resetGroupsCaches();
}
/**
@@ -1036,7 +1038,17 @@ class _Member extends \IPS\Patterns\ActiveRecord
* @brief Cached groups check
*/
protected $_groups = NULL;
/**
* Reset the group and groups cache
*
* @return void
*/
protected function resetGroupsCaches()
{
$this->_group = NULL;
$this->_groups = NULL;
}
/**
* Get an array of the group IDs (including secondary groups) this member belongs to
*
@@ -1078,7 +1090,7 @@ class _Member extends \IPS\Patterns\ActiveRecord
*/
public function socialGroups( $bypassCache = FALSE )
{
if ( $this->_socialGroups === NULL )
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 )
@@ -1089,6 +1101,7 @@ class _Member extends \IPS\Patterns\ActiveRecord
{
if( $bypassCache )
{
/* @note SELECT_FROM_WRITE_SERVER added in Pull #2341 @todo check if needed */
$this->_socialGroups = iterator_to_array( \IPS\Db::i()
->select( 'group_id', 'core_sys_social_group_members', array( 'member_id=?', $this->member_id ), NULL, NULL, NULL, NULL, \IPS\Db::SELECT_FROM_WRITE_SERVER )
->setKeyField( 'group_id' )
@@ -1141,7 +1154,8 @@ class _Member extends \IPS\Patterns\ActiveRecord
else
{
$statuses = $moderatorOnly ? ( \IPS\Member\Club::STATUS_MODERATOR . "','" . \IPS\Member\Club::STATUS_LEADER ) : ( \IPS\Member\Club::STATUS_MEMBER . "','" . \IPS\Member\Club::STATUS_MODERATOR . "','" . \IPS\Member\Club::STATUS_LEADER ) ;
/* @note SELECT_FROM_WRITE_SERVER honors $fromWriteServer */
$this->_clubs[ \intval( $moderatorOnly ) ] = iterator_to_array( \IPS\Db::i()->select( 'club_id', 'core_clubs_memberships', array( "member_id=? AND status IN('" . $statuses . "')", $this->member_id ), NULL, NULL, NULL, NULL, $fromWriteServer ? \IPS\Db::SELECT_FROM_WRITE_SERVER : 0 ) );
}
}
@@ -1408,16 +1422,6 @@ class _Member extends \IPS\Patterns\ActiveRecord
{
return isset( $this->_data['warn_level'] ) ? (int) $this->_data['warn_level'] : 0;
}
/**
* Get failed login details
*
* @return array
*/
public function get_failed_logins()
{
return json_decode( $this->_data['failed_logins'], TRUE ) ?: array();
}
/**
* Get member title
@@ -1626,25 +1630,41 @@ class _Member extends \IPS\Patterns\ActiveRecord
return static::_followers( 'member', $this->member_id, $privacy, $frequencyTypes, $date, $limit, $order );
}
/**
* Set failed login details
* Record a failed login attempt
*
* @param array $data Data
* @return void
*/
public function set_failed_logins( $data )
public function failedLogin()
{
$this->_data['failed_logins'] = json_encode( $data );
$highest = 0;
foreach ( $data as $ipAddress => $times )
\IPS\Db::i()->insert( 'core_login_failures', [
'login_member_id' => $this->member_id ?: NULL,
'login_date' => ( new \IPS\DateTime )->getTimestamp(),
'login_ip_address' => \IPS\Request::i()->ipAddress(),
'login_email' => $this->member_id ? NULL : $this->email
]);
if( $this->member_id )
{
if ( $highest < \count( $times ) )
{
$highest = \count( $times );
}
$this->recountFailedLogins();
}
$this->failed_login_count = $highest;
}
/**
* Recount failed logins.
*
* @return void
* @throws \Exception
*/
public function recountFailedLogins()
{
$where = [ [ 'login_date>=?', ( \IPS\Settings::i()->ipb_bruteforce_unlock ? ( new \IPS\DateTime() )->sub( new \DateInterval( 'PT' . \IPS\Settings::i()->ipb_bruteforce_period . 'M' ) )->getTimestamp() : 0 ) ] ];
$where[] = [ 'login_ip_address IS NOT NULL AND login_member_id=?', $this->member_id ];
$failures = iterator_to_array( \IPS\Db::i()->select( 'count(login_ip_address)', 'core_login_failures', $where, NULL, NULL, 'login_ip_address' ) );
$this->failed_login_count = count( $failures ) ? max( $failures ) : 0;
$this->save();
}
/**
@@ -2278,7 +2298,7 @@ class _Member extends \IPS\Patterns\ActiveRecord
if( !\IPS\Dispatcher::hasInstance() OR !( \IPS\Member::loggedIn()->isAdmin() OR \IPS\Member::loggedIn()->member_id === $this->member_id ) )
{
$select = 'member_id';
$publicFields = \IPS\Db::i()->select( '*', 'core_pfields_data', array( 'pf_member_hide != ?', 'hide' ) );
$publicFields = \IPS\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 ( \IPS\Member::loggedIn()->isAdmin() OR \IPS\Member::loggedIn()->modPermissions() ) )
@@ -3502,7 +3522,7 @@ class _Member extends \IPS\Patterns\ActiveRecord
* @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
* @apiresponse datetime|null lastActivity Last activity date on the site.
* @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
@@ -3598,10 +3618,10 @@ class _Member extends \IPS\Patterns\ActiveRecord
$return['validating'] = (bool) $this->members_bitoptions['validating'];
}
$return['posts'] = $this->member_posts;
$return['lastActivity'] = ( $this->last_activity AND !$this->isOnlineAnonymously() ) ? \IPS\DateTime::ts( $this->last_activity )->rfc3339() : NULL;
if( !$authorizedMember )
{
$return['lastActivity'] = ( $this->last_activity ) ? \IPS\DateTime::ts( $this->last_activity )->rfc3339() : NULL;
$return['lastVisit'] = $this->last_visit ? \IPS\DateTime::ts( $this->last_visit )->rfc3339() : NULL;
$return['lastPost'] = $this->member_last_post ? \IPS\DateTime::ts( $this->member_last_post )->rfc3339() : NULL;
}
@@ -3626,7 +3646,7 @@ class _Member extends \IPS\Patterns\ActiveRecord
$return['rank'] = $this->rank() ? $this->rank()->apiOutput( $authorizedMember ) : NULL;
$return['achievements_points'] = $this->achievements_points;
$return['allowAdminEmails'] = (bool) $this->allow_admin_emails;
$return['allowAdminEmails'] = (bool) $this->allow_admin_mails;
$return['completed'] = (bool) $this->completed;
return $return;
@@ -3685,6 +3705,8 @@ class _Member extends \IPS\Patterns\ActiveRecord
{
return \IPS\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
@@ -3693,33 +3715,33 @@ class _Member extends \IPS\Patterns\ActiveRecord
*/
public function unlockTime()
{
$failedLogins = $this->member_id ? $this->failed_logins : ( ( isset( \IPS\Data\Store::i()->failedLogins[ $this->email ] ) ) ? \IPS\Data\Store::i()->failedLogins[ $this->email ] : array() );
if( !\IPS\Settings::i()->ipb_bruteforce_attempts )
{
return FALSE;
}
if ( \IPS\Settings::i()->ipb_bruteforce_attempts and isset( $failedLogins[ \IPS\Request::i()->ipAddress() ] ) and \count( $failedLogins[ \IPS\Request::i()->ipAddress() ] ) >= \IPS\Settings::i()->ipb_bruteforce_attempts )
if( !isset( $this->_failedLoginCache[ \IPS\Request::i()->ipAddress() ] ) )
{
$where = [ [ 'login_date>=? AND login_ip_address=?', ( new \IPS\DateTime() )->sub( new \DateInterval( 'PT' . \IPS\Settings::i()->ipb_bruteforce_period . 'M' ) )->getTimestamp(), \IPS\Request::i()->ipAddress() ] ];
if ( $this->member_id )
{
$where[] = [ 'login_member_id=?', $this->member_id ];
}
else
{
$where[] = [ 'login_email=?', $this->email ];
}
$this->_failedLoginCache[ \IPS\Request::i()->ipAddress() ] = iterator_to_array( \IPS\Db::i()->select( '*', 'core_login_failures', $where ) );
}
if( count( $this->_failedLoginCache[ \IPS\Request::i()->ipAddress() ] ) > \IPS\Settings::i()->ipb_bruteforce_attempts )
{
if ( \IPS\Settings::i()->ipb_bruteforce_period and \IPS\Settings::i()->ipb_bruteforce_unlock )
{
$failedLogins = $failedLogins[ \IPS\Request::i()->ipAddress() ];
sort( $failedLogins );
while ( \count( $failedLogins ) > \IPS\Settings::i()->ipb_bruteforce_attempts )
{
/* We want to remove the oldest logins here so the most recent are checked */
array_shift( $failedLogins );
}
$unlockTime = \IPS\DateTime::ts( array_pop( $failedLogins ) );
$unlockTime = $unlockTime->add( new \DateInterval( 'PT' . \IPS\Settings::i()->ipb_bruteforce_period . 'M' ) );
/* If Unlock Time is in the past, return FALSE to avoid the exception and allow login */
if ( $unlockTime->getTimestamp() < time() )
{
return FALSE;
}
/* Otherwise that is what we're returning */
return $unlockTime;
return \IPS\DateTime::ts( max( array_column( $this->_failedLoginCache[ \IPS\Request::i()->ipAddress() ], 'login_date' ) ) )->add( new \DateInterval( 'PT' . \IPS\Settings::i()->ipb_bruteforce_period . 'M' ) );
}
return TRUE;
}
@@ -3732,18 +3754,24 @@ class _Member extends \IPS\Patterns\ActiveRecord
* @param string $ipAddress IP Address
* @return int
*/
public function failedLoginCount( $ipAddress )
public function failedLoginCount( $ipAddress ): int
{
if( $this->member_id )
if( !isset( $this->_failedLoginCache[ $ipAddress ] ) )
{
return ( isset( $this->failed_logins[ $ipAddress ] ) ) ? \count( $this->failed_logins[ $ipAddress ] ) : 0;
}
elseif( $this->email )
{
$failedLogins = ( isset( \IPS\Data\Store::i()->failedLogins[ $this->email ] ) ) ? \IPS\Data\Store::i()->failedLogins[ $this->email ] : array();
$where = [ [ 'login_date>=? AND login_ip_address=?', ( new \IPS\DateTime() )->sub( new \DateInterval( 'PT' . \IPS\Settings::i()->ipb_bruteforce_period . 'M' ) )->getTimestamp(), \IPS\Request::i()->ipAddress() ] ];
if( $this->member_id )
{
$where[] = [ 'login_member_id=?', $this->member_id ];
}
else
{
$where[] = [ 'login_email=?', $this->email ];
}
return ( isset( $failedLogins[ $ipAddress ] ) ) ? \count( $failedLogins[ $ipAddress ] ) : 0;
$this->_failedLoginCache[ $ipAddress ] = \IPS\Db::i()->select( '*', 'core_login_failures', $where )->first();
}
return count( $this->_failedLoginCache[ $ipAddress ] );
}
/* !Permissions */
@@ -3880,6 +3908,7 @@ class _Member extends \IPS\Patterns\ActiveRecord
*/
public function recountNotifications()
{
/* @note SELECT_FROM_WRITE_SERVER Race condition, need true value from write server as read can be slightly out */
$this->notification_cnt = \IPS\Db::i()->select( 'COUNT(*)', 'core_notifications', array( '`member`=? AND read_time IS NULL', $this->member_id ), NULL, NULL, NULL, NULL, \IPS\Db::SELECT_FROM_WRITE_SERVER )->first();
$this->save();
}
@@ -4129,7 +4158,7 @@ class _Member extends \IPS\Patterns\ActiveRecord
{
$location = \IPS\GeoLocation::getRequesterLocation();
}
catch( \BadMethodCallException | \IPS\Http\Request\Exception | \RuntimeException | \OutOfRangeException $e )
catch( \BadFunctionCallException | \BadMethodCallException | \IPS\Http\Request\Exception | \RuntimeException | \OutOfRangeException $e )
{
/* If it fails for any reason, don't bother. */
\IPS\Log::debug( $e, 'spam-service' );
@@ -4419,15 +4448,16 @@ class _Member extends \IPS\Patterns\ActiveRecord
return;
}
/* Default to member group if none. This shouldn't happen but can and the user error should be prevented. */
/* 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 = \IPS\Member\Group::load( $this->member_group_id );
}
catch ( \OutOfRangeException $e )
{
$this->member_group_id = \IPS\Settings::i()->member_group;
\IPS\Log::debug( "Group promotion found a member (#{$this->member_id}: {$this->name}) with an invalid member group", 'group_promotion' );
/* Log the error as part of the failure audit trail */
\IPS\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 */
@@ -4724,6 +4754,7 @@ class _Member extends \IPS\Patterns\ActiveRecord
/* Insert a record */
$vid = md5( $this->members_pass_hash . \IPS\Login::generateRandomString() );
$plainSecurityKey = \IPS\Login::generateRandomString();
\IPS\Db::i()->insert( 'core_validating', array(
'vid' => $vid,
'member_id' => $this->member_id,
@@ -4734,13 +4765,14 @@ class _Member extends \IPS\Patterns\ActiveRecord
'user_verified' => ( $validationType == 'admin' ) ?: FALSE,
'email_sent' => ( $validationType != 'admin' ) ? time() : NULL,
'do_not_delete' => $doNotDelete,
'ref' => $refUrl ? ( (string) $refUrl ) : NULL
'ref' => $refUrl ? ( (string) $refUrl ) : NULL,
'security_key' => Encrypt::fromPlaintext( $plainSecurityKey )->tag()
) );
/* Send email for validation */
if ( $validationType != 'admin' )
{
\IPS\Email::buildFromTemplate( 'core', 'registration_validate', array( $this, $vid ), \IPS\Email::TYPE_TRANSACTIONAL )->send( $this );
\IPS\Email::buildFromTemplate( 'core', 'registration_validate', array( $this, $vid, $plainSecurityKey ), \IPS\Email::TYPE_TRANSACTIONAL )->send( $this );
}
/* Update core_post_before_registering */
@@ -4871,6 +4903,12 @@ class _Member extends \IPS\Patterns\ActiveRecord
{
$moderated = $content::moderateNewItems( $this, $content->containerWrapper(), FALSE );
}
/* Do additional processing */
if( method_exists( $this, '_cloudPbrProcess' ) )
{
$moderated = $this->_cloudPbrProcess( $content, $moderated );
}
if ( $moderated )
{
@@ -5363,7 +5401,8 @@ class _Member extends \IPS\Patterns\ActiveRecord
{
if ( $this->member_id )
{
if ( $by === NULL and \IPS\Dispatcher::hasInstance() )
/* 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 ( \IPS\Dispatcher::hasInstance() and !\IPS\Dispatcher::i()->inDestructor and $by === NULL )
{
$by = \IPS\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
}
@@ -6391,6 +6430,8 @@ class _Member extends \IPS\Patterns\ActiveRecord
*/
public function recordLogin()
{
$this->sendLoginAfterInactivityNotification();
try
{
\IPS\Db::i()->insert( 'core_members_logins', [ 'member_id' => $this->member_id, 'member_timestamp' => time(), 'member_date' => date( 'Y-m-d', \IPS\DateTime::ts( time() )->getTimestamp() ) ] );
@@ -6398,6 +6439,66 @@ class _Member extends \IPS\Patterns\ActiveRecord
catch( \Exception $e ) { }
}
/**
* If required, send an email notification when signing in after a period of inactivity.
*
* @return void
*/
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();
}
catch( \UnderflowException $e )
{
/* This could be a new install, or IN_DEV where upgrade history isn't available */
return false;
}
}
$lastLoginObj = \IPS\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 > \IPS\DateTime::create()->sub( new \DateInterval( static::LOGIN_INACTIVITY_NOTIFICATION ) ) )
{
return false;
}
/* Device data */
$device = \IPS\Member\Device::loadOrCreate( $this, false );
try
{
$location = \IPS\GeoLocation::getRequesterLocation();
}
catch ( \Exception $e )
{
$location = NULL;
}
\IPS\Email::buildFromTemplate( 'core', 'loginAfterInactivity', array( $this, $device, $location ), \IPS\Email::TYPE_TRANSACTIONAL )->send( $this );
return true;
}
/**
* Webhook filters
*
@@ -6407,7 +6508,7 @@ class _Member extends \IPS\Patterns\ActiveRecord
{
return [
'completed' => $this->completed,
'allowAdminEmails' => $this->allow_admin_emails,
'allowAdminEmails' => $this->allow_admin_mails,
];
}
@@ -6468,7 +6569,7 @@ class _Member extends \IPS\Patterns\ActiveRecord
* @param int $time
* @return void
*/
public static function pruneAllLoggedIpAddresses(int $time )
public static function pruneAllLoggedIpAddresses( int $time )
{
foreach ( \IPS\Content::routedClasses( FALSE, TRUE ) as $class )
{
@@ -6479,17 +6580,14 @@ class _Member extends \IPS\Patterns\ActiveRecord
catch( \Exception $ex ) { }
}
/* PMs and Ratings are treated extra */
/* PMs are treated extra */
\IPS\Db::i()->update( 'core_message_posts', array( 'msg_ip_address' => '' ), array( "msg_ip_address != '' AND msg_date <= " . $time ) );
\IPS\Db::i()->update( 'core_ratings', array( 'ip' => '' ), array( "ip != '' AND rating_date IS NOT NULL AND rating_date <= " . $time ) );
foreach ( \IPS\Application::allExtensions( 'core', 'IpAddresses', FALSE, 'core' ) as $key => $extension )
{
if( method_exists( $extension, 'pruneIpAddress'))
if( method_exists( $extension, 'pruneIpAddresses'))
{
$extension->pruneIpAddress( $time );
$extension->pruneIpAddresses( $time );
}
}
}