From cb1eac797305ef43fd1dc970eed3346b2f9e6ebe Mon Sep 17 00:00:00 2001
From: Bogdanova Olga
Date: Sun, 22 Nov 2020 15:32:33 +0300
Subject: [PATCH] Issues-108:Cleared cache for Guests
---
.../dashboard/models/class.usermodel.php | 5301 +++++++++++++++++
1 file changed, 5301 insertions(+)
create mode 100644 vanilla/applications/dashboard/models/class.usermodel.php
diff --git a/vanilla/applications/dashboard/models/class.usermodel.php b/vanilla/applications/dashboard/models/class.usermodel.php
new file mode 100644
index 0000000..78c1ddf
--- /dev/null
+++ b/vanilla/applications/dashboard/models/class.usermodel.php
@@ -0,0 +1,5301 @@
+eventManager = Gdn::getContainer()->get(EventManager::class);
+ } else {
+ $this->eventManager = $eventManager;
+ }
+
+ $this->addFilterField([
+ 'Admin', 'Deleted', 'CountVisits', 'CountInvitations', 'CountNotifications', 'Preferences', 'Permissions',
+ 'LastIPAddress', 'AllIPAddresses', 'DateFirstVisit', 'DateLastActive', 'CountDiscussions', 'CountComments',
+ 'Score'
+ ]);
+
+
+ $this->nameUnique = (bool)c('Garden.Registration.NameUnique', true);
+ $this->emailUnique = (bool)c('Garden.Registration.EmailUnique', true);
+ }
+
+ /**
+ * Should guest users be allowed to search existing users by name and email?
+ *
+ * @return bool
+ */
+ public function allowGuestUserSearch(): bool {
+ $config = Gdn::getContainer()->get(ConfigurationInterface::class);
+ $isPrivateCommunity = (bool)$config->get("Garden.PrivateCommunity", false);
+
+ $registrationMethod = $config->get("Garden.Registration.Method", "");
+ $isBasicRegistration = is_string($registrationMethod) ? strtolower($registrationMethod) === "basic" : false;
+
+ $result = !$isPrivateCommunity || $isBasicRegistration;
+ return $result;
+ }
+
+ /**
+ * Generate a random code for use in email confirmation.
+ *
+ * @return string
+ */
+ private function confirmationCode() {
+ $result = betterRandomString(32, 'Aa0');
+ return $result;
+ }
+
+ /**
+ * Whether or not we are past the user threshold.
+ *
+ * This is a useful indication that some database operations on the User table will be painfully long.
+ *
+ * @return bool
+ */
+ public function pastUserThreshold() {
+ $estimate = $this->countEstimate();
+ return $estimate > $this->UserThreshold;
+ }
+
+ /**
+ * Whether we're wandered into extreme database optimization territory with our user count.
+ *
+ * @return bool
+ */
+ public function pastUserMegaThreshold() {
+ $estimate = $this->countEstimate();
+ return $estimate > $this->UserMegaThreshold;
+ }
+
+ /**
+ * Approximate the number of users by checking the database table status.
+ *
+ * @return int
+ */
+ public function countEstimate() {
+ $px = Gdn::database()->DatabasePrefix;
+ return Gdn::database()->query("show table status like '{$px}User'")->value('Rows', 0);
+ }
+
+ /**
+ * Deprecated.
+ *
+ * @param string $message Deprecated.
+ * @param array $data Deprecated.
+ * @return string Deprecated.
+ */
+ private function addEmailHeaderFooter($message, $data) {
+ $header = t('EmailHeader', '');
+ if ($header) {
+ $message = formatString($header, $data)."\n".$message;
+ }
+
+ $footer = t('EmailFooter', '');
+ if ($footer) {
+ $message .= "\n".formatString($footer, $data);
+ }
+
+ return $message;
+ }
+
+ /**
+ * Set password strength meter on a form.
+ *
+ * @param Gdn_Controller $controller The controller to add the password strength information to.
+ */
+ public function addPasswordStrength($controller) {
+ $controller->addJsFile('password.js');
+ $controller->addDefinition('MinPassLength', c('Garden.Password.MinLength'));
+ $controller->addDefinition(
+ 'PasswordTranslations',
+ implode(',', [
+ t('Password Too Short', 'Too Short'),
+ t('Password Contains Username', 'Contains Username'),
+ t('Password Very Weak', 'Very Weak'),
+ t('Password Weak', 'Weak'),
+ t('Password Ok', 'OK'),
+ t('Password Good', 'Good'),
+ t('Password Strong', 'Strong')])
+ );
+ }
+
+ /**
+ * Reliably get the attributes from any user array or object.
+ *
+ * @param array|object $user The user to get the attributes for.
+ * @return array Returns an attribute array.
+ */
+ public static function attributes($user) {
+ $user = (array)$user;
+ $attributes = $user['Attributes'];
+ if (is_string($attributes)) {
+ $attributes = dbdecode($attributes);
+ }
+ if (!is_array($attributes)) {
+ $attributes = [];
+ }
+ return $attributes;
+ }
+
+ /**
+ * Manually ban a user.
+ *
+ * @param int $userID The ID of the user to ban.
+ * @param array $options Additional options for the ban.
+ * @throws Exception Throws an exception if something goes wrong during the banning.
+ */
+ public function ban($userID, $options) {
+ $user = $this->getID($userID);
+ $banned = val('Banned', $user, 0);
+
+ $this->setField($userID, 'Banned', BanModel::setBanned($banned, true, BanModel::BAN_MANUAL));
+
+ $logID = false;
+ if (val('DeleteContent', $options)) {
+ $options['Log'] = 'Ban';
+ $logID = $this->deleteContent($userID, $options);
+ }
+
+ if ($logID) {
+ $this->saveAttribute($userID, 'BanLogID', $logID);
+ }
+
+ $this->EventArguments['UserID'] = $userID;
+ $this->EventArguments['Options'] = $options;
+ $this->fireEvent('Ban');
+
+ if (val('AddActivity', $options, true)) {
+ switch (val('Reason', $options, '')) {
+ case '':
+ $story = null;
+ break;
+ case 'Spam':
+ $story = t('Banned for spamming.');
+ break;
+ case 'Abuse':
+ $story = t('Banned for being abusive.');
+ break;
+ default:
+ $story = $options['Reason'];
+ break;
+ }
+
+ $activity = [
+ 'ActivityType' => 'Ban',
+ 'NotifyUserID' => ActivityModel::NOTIFY_MODS,
+ 'ActivityUserID' => $userID,
+ 'RegardingUserID' => Gdn::session()->UserID,
+ 'HeadlineFormat' => t('HeadlineFormat.Ban', '{RegardingUserID,You} banned {ActivityUserID,you}.'),
+ 'Story' => $story,
+ 'Data' => ['LogID' => $logID]];
+
+ $activityModel = new ActivityModel();
+ $activityModel->save($activity);
+ }
+ }
+
+ /**
+ * Checks the specified user's for the given permission. Returns a boolean value indicating if the action is permitted.
+ *
+ * @param mixed $user The user to check.
+ * @param mixed $permission The permission (or array of permissions) to check.
+ * @param array $options
+ * @return boolean
+ */
+ public function checkPermission($user, $permission, $options = []) {
+ if (is_numeric($user)) {
+ $user = $this->getID($user);
+ }
+ $user = (object)$user;
+
+ if ($user->Banned || $user->Deleted) {
+ return false;
+ }
+
+ if ($user->Admin) {
+ return true;
+ }
+
+ // Grab the permissions for the user.
+ if ($user->UserID == 0) {
+ $permissions = $this->getPermissions(0);
+ } elseif (!Gdn::cache()->activeEnabled() && is_array($user->Permissions)) {
+ // Only attempt to use the DB field value if permissions aren't being cached elsewhere.
+ $permissions = new Vanilla\Permissions($user->Permissions);
+ } else {
+ $permissions = $this->getPermissions($user->UserID);
+ }
+
+ $id = val('ForeignID', $options, null);
+
+ return $permissions->has($permission, $id);
+ }
+
+ /**
+ * Merge the old user into the new user.
+ *
+ * @param int $oldUserID The ID of the old user.
+ * @param int $newUserID The ID of the new user.
+ */
+ public function merge($oldUserID, $newUserID) {
+ $oldUser = $this->getID($oldUserID, DATASET_TYPE_ARRAY);
+ $newUser = $this->getID($newUserID, DATASET_TYPE_ARRAY);
+
+ if (!$oldUser || !$newUser) {
+ throw new Gdn_UserException("Could not find one or both users to merge.");
+ }
+
+ $map = ['UserID', 'Name', 'Email', 'CountVisits', 'CountDiscussions', 'CountComments'];
+
+ $result = ['MergeID' => null, 'Before' => [
+ 'OldUser' => arrayTranslate($oldUser, $map),
+ 'NewUser' => arrayTranslate($newUser, $map)]];
+
+ // Start the merge.
+ $mergeID = $this->mergeStart($oldUserID, $newUserID);
+
+ // Copy all discussions from the old user to the new user.
+ $this->mergeCopy($mergeID, 'Discussion', 'InsertUserID', $oldUserID, $newUserID);
+
+ // Copy all the comments from the old user to the new user.
+ $this->mergeCopy($mergeID, 'Comment', 'InsertUserID', $oldUserID, $newUserID);
+
+ // Update the last comment user ID.
+ $this->SQL->put('Discussion', ['LastCommentUserID' => $newUserID], ['LastCommentUserID' => $oldUserID]);
+
+ // Clear the categories cache.
+ CategoryModel::clearCache();
+
+ // Copy all of the activities.
+ $this->mergeCopy($mergeID, 'Activity', 'NotifyUserID', $oldUserID, $newUserID);
+ $this->mergeCopy($mergeID, 'Activity', 'InsertUserID', $oldUserID, $newUserID);
+ $this->mergeCopy($mergeID, 'Activity', 'ActivityUserID', $oldUserID, $newUserID);
+
+ // Copy all of the activity comments.
+ $this->mergeCopy($mergeID, 'ActivityComment', 'InsertUserID', $oldUserID, $newUserID);
+
+ // Copy all conversations.
+ $this->mergeCopy($mergeID, 'Conversation', 'InsertUserID', $oldUserID, $newUserID);
+ $this->mergeCopy($mergeID, 'ConversationMessage', 'InsertUserID', $oldUserID, $newUserID, 'MessageID');
+ $this->mergeCopy($mergeID, 'UserConversation', 'UserID', $oldUserID, $newUserID, 'ConversationID');
+
+ $this->EventArguments['MergeID'] = $mergeID;
+ $this->EventArguments['OldUser'] = $oldUser;
+ $this->EventArguments['NewUser'] = $newUser;
+ $this->fireEvent('Merge');
+
+ $this->mergeFinish($mergeID);
+
+ $oldUser = $this->getID($oldUserID, DATASET_TYPE_ARRAY);
+ $newUser = $this->getID($newUserID, DATASET_TYPE_ARRAY);
+
+ $result['MergeID'] = $mergeID;
+ $result['After'] = [
+ 'OldUser' => arrayTranslate($oldUser, $map),
+ 'NewUser' => arrayTranslate($newUser, $map)];
+
+ return $result;
+ }
+
+ /**
+ * Backup user before merging.
+ *
+ * @param int $mergeID The ID of the merge table entry.
+ * @param string $table The name of the table being backed up.
+ * @param string $column The name of the column being backed up.
+ * @param int $oldUserID The ID of the old user.
+ * @param int $newUserID The ID of the new user.
+ * @param string $pK The primary key column name of the table.
+ */
+ private function mergeCopy($mergeID, $table, $column, $oldUserID, $newUserID, $pK = '') {
+ if (!$pK) {
+ $pK = $table.'ID';
+ }
+
+ // Insert the columns to the bak table.
+ $sql = "insert ignore GDN_UserMergeItem(`MergeID`, `Table`, `Column`, `RecordID`, `OldUserID`, `NewUserID`)
+ select :MergeID, :Table, :Column, `$pK`, :OldUserID, :NewUserID
+ from `GDN_$table` t
+ where t.`$column` = :OldUserID2";
+ Gdn::sql()->Database->query(
+ $sql,
+ [':MergeID' => $mergeID, ':Table' => $table, ':Column' => $column,
+ ':OldUserID' => $oldUserID, ':NewUserID' => $newUserID, ':OldUserID2' => $oldUserID]
+ );
+
+ Gdn::sql()->options('Ignore', true)->put(
+ $table,
+ [$column => $newUserID],
+ [$column => $oldUserID]
+ );
+ }
+
+ /**
+ * Start merging user accounts.
+ *
+ * @param int $oldUserID The ID of the old user.
+ * @param int $newUserID The ID of the new user.
+ * @return int|null Returns the merge table ID of the merge.
+ * @throws Gdn_UserException Throws an exception of there is a data validation error.
+ */
+ private function mergeStart($oldUserID, $newUserID) {
+ $model = new Gdn_Model('UserMerge');
+
+ // Grab the users.
+ $oldUser = $this->getID($oldUserID, DATASET_TYPE_ARRAY);
+ $newUser = $this->getID($newUserID, DATASET_TYPE_ARRAY);
+
+ // First see if there is a record with the same merge.
+ $row = $model->getWhere(['OldUserID' => $oldUserID, 'NewUserID' => $newUserID])->firstRow(DATASET_TYPE_ARRAY);
+ if ($row) {
+ $mergeID = $row['MergeID'];
+
+ // Save this merge in the log.
+ if ($row['Attributes']) {
+ $attributes = dbdecode($row['Attributes']);
+ } else {
+ $attributes = [];
+ }
+
+ $attributes['Log'][] = ['UserID' => Gdn::session()->UserID, 'Date' => Gdn_Format::toDateTime()];
+ $row = ['MergeID' => $mergeID, 'Attributes' => $attributes];
+ } else {
+ $row = [
+ 'OldUserID' => $oldUserID,
+ 'NewUserID' => $newUserID];
+ }
+
+ $userSet = [];
+ $oldUserSet = [];
+ if (dateCompare($oldUser['DateFirstVisit'], $newUser['DateFirstVisit']) < 0) {
+ $userSet['DateFirstVisit'] = $oldUser['DateFirstVisit'];
+ }
+
+ if (!isset($row['Attributes']['User']['CountVisits'])) {
+ $userSet['CountVisits'] = $oldUser['CountVisits'] + $newUser['CountVisits'];
+ $oldUserSet['CountVisits'] = 0;
+ }
+
+ if (!empty($userSet)) {
+ // Save the user information on the merge record.
+ foreach ($userSet as $key => $value) {
+ // Only save changed values that aren't already there from a previous merge.
+ if ($newUser[$key] != $value && !isset($row['Attributes']['User'][$key])) {
+ $row['Attributes']['User'][$key] = $newUser[$key];
+ }
+ }
+ }
+
+ $mergeID = $model->save($row);
+ if (val('MergeID', $row)) {
+ $mergeID = $row['MergeID'];
+ }
+
+ if (!$mergeID) {
+ throw new Gdn_UserException($model->Validation->resultsText());
+ }
+
+ // Update the user with the new user-level data.
+ $this->setField($newUserID, $userSet);
+ if (!empty($oldUserSet)) {
+ $this->setField($oldUserID, $oldUserSet);
+ }
+
+ return $mergeID;
+ }
+
+ /**
+ * Finish merging user accounts.
+ *
+ * @param int $mergeID The merge table ID.
+ */
+ protected function mergeFinish($mergeID) {
+ $row = Gdn::sql()->getWhere('UserMerge', ['MergeID' => $mergeID])->firstRow(DATASET_TYPE_ARRAY);
+
+ if (isset($row['Attributes']) && !empty($row['Attributes'])) {
+ trace(dbdecode($row['Attributes']), 'Merge Attributes');
+ }
+
+ $userIDs = [
+ $row['OldUserID'],
+ $row['NewUserID']];
+
+ foreach ($userIDs as $userID) {
+ $this->counts('countdiscussions', $userID);
+ $this->counts('countcomments', $userID);
+ }
+ }
+
+ /**
+ * User counts.
+ *
+ * @param string $column The name of the count column. (ex. CountDiscussions, CountComments).
+ * @param int|null $userID The user ID to get the counts for or **null** for the current user.
+ */
+ public function counts($column, $userID = null) {
+ if ($userID > 0) {
+ $where = ['UserID' => $userID];
+ } else {
+ $where = null;
+ }
+
+ switch (strtolower($column)) {
+ case 'countdiscussions':
+ Gdn::database()->query(
+ DBAModel::getCountSQL('count', 'User', 'Discussion', 'CountDiscussions', 'DiscussionID', 'UserID', 'InsertUserID', $where)
+ );
+ break;
+ case 'countcomments':
+ Gdn::database()->query(
+ DBAModel::getCountSQL('count', 'User', 'Comment', 'CountComments', 'CommentID', 'UserID', 'InsertUserID', $where)
+ );
+ break;
+ }
+
+ if ($userID > 0) {
+ $this->clearCache($userID);
+ }
+ }
+
+ /**
+ * Whether or not the application requires email confirmation.
+ *
+ * @return bool
+ */
+ public static function requireConfirmEmail() {
+ return c('Garden.Registration.ConfirmEmail') && !self::noEmail();
+ }
+
+ /**
+ * Whether or not users have email addresses.
+ *
+ * @return bool
+ */
+ public static function noEmail() {
+ return c('Garden.Registration.NoEmail');
+ }
+
+ /**
+ * Unban a user.
+ *
+ * @param int $userID The user to unban.
+ * @param array $options Options for the unban.
+ * @since 2.1
+ */
+ public function unBan($userID, $options = []) {
+ $user = $this->getID($userID, DATASET_TYPE_ARRAY);
+ if (!$user) {
+ throw notFoundException();
+ }
+
+ $banned = $user['Banned'];
+ if (!BanModel::isBanned($banned, BanModel::BAN_AUTOMATIC | BanModel::BAN_MANUAL)) {
+ throw new Gdn_UserException(t("The user isn't banned.", "The user isn't banned or is banned by some other function."));
+ }
+
+ // Unban the user.
+ $newBanned = BanModel::setBanned($banned, false, BanModel::BAN_AUTOMATIC | BanModel::BAN_MANUAL);
+ $this->setField($userID, 'Banned', $newBanned);
+
+ // Restore the user's content.
+ if (val('RestoreContent', $options)) {
+ $banLogID = $this->getAttribute($userID, 'BanLogID');
+
+ if ($banLogID) {
+ $logModel = new LogModel();
+
+ try {
+ $logModel->restore($banLogID);
+ } catch (Exception $ex) {
+ if ($ex->getCode() != 404) {
+ throw $ex;
+ }
+ }
+ $this->saveAttribute($userID, 'BanLogID', null);
+ }
+ }
+
+ // Add an activity for the unbanning.
+ if (val('AddActivity', $options, true)) {
+ $activityModel = new ActivityModel();
+
+ $story = val('Story', $options, null);
+
+ // Notify the moderators of the unban.
+ $activity = [
+ 'ActivityType' => 'Ban',
+ 'NotifyUserID' => ActivityModel::NOTIFY_MODS,
+ 'ActivityUserID' => $userID,
+ 'RegardingUserID' => Gdn::session()->UserID,
+ 'HeadlineFormat' => t('HeadlineFormat.Unban', '{RegardingUserID,You} unbanned {ActivityUserID,you}.'),
+ 'Story' => $story,
+ 'Data' => [
+ 'Unban' => true
+ ]
+ ];
+
+ $activityModel->queue($activity);
+
+ // Notify the user of the unban.
+ $activity['NotifyUserID'] = $userID;
+ $activity['Emailed'] = ActivityModel::SENT_PENDING;
+ $activity['HeadlineFormat'] = t('HeadlineFormat.Unban.Notification', "You've been unbanned.");
+ $activityModel->queue($activity, false, ['Force' => true]);
+
+ $activityModel->saveQueue();
+ }
+ }
+
+ /**
+ * Users respond to confirmation emails by clicking a link that takes them here.
+ *
+ * @param array|object $user The user confirming their email.
+ * @param string $emailKey The token that was emailed to the user.
+ * @return bool Returns **true** if the email was confirmed.
+ */
+ public function confirmEmail($user, $emailKey) {
+ $attributes = val('Attributes', $user);
+ $storedEmailKey = val('EmailKey', $attributes);
+ $userID = val('UserID', $user);
+
+ if (!$storedEmailKey || $emailKey != $storedEmailKey) {
+ $this->Validation->addValidationResult('EmailKey', '@'.t(
+ 'Couldn\'t confirm email.',
+ 'We couldn\'t confirm your email. Check the link in the email we sent you or try sending another confirmation email.'
+ ));
+ return false;
+ }
+
+ $confirmRoleIDs = RoleModel::getDefaultRoles(RoleModel::TYPE_UNCONFIRMED);
+ $defaultRoles = RoleModel::getDefaultRoles(RoleModel::TYPE_MEMBER);
+
+ // Update the user's roles.
+ $userRoles = $this->getRoles($userID);
+ $userRoleIDs = [];
+ while ($userRole = $userRoles->nextRow(DATASET_TYPE_ARRAY)) {
+ $userRoleIDs[] = $userRole['RoleID'];
+ }
+
+ // Sanitize result roles
+ $roles = array_diff($userRoleIDs, $confirmRoleIDs);
+ if (!sizeof($roles)) {
+ $roles = $defaultRoles;
+ }
+
+ $this->EventArguments['ConfirmUserID'] = $userID;
+ $this->EventArguments['ConfirmUserRoles'] = &$roles;
+ $this->fireEvent('BeforeConfirmEmail');
+ $this->saveRoles($userID, $roles, false);
+
+ // Remove the email confirmation attributes.
+ $this->saveAttribute($userID, ['EmailKey' => null]);
+ $this->setField($userID, 'Confirmed', 1);
+ return true;
+ }
+
+ /**
+ * Initiate an SSO connection.
+ *
+ * @param string $string
+ * @param bool $throwError
+ * @return int|void
+ */
+ public function sso($string, $throwError = false) {
+ if (!$string) {
+ return null;
+ }
+
+ $parts = explode(' ', $string);
+
+ $string = $parts[0];
+ trace($string, "SSO String");
+ $data = json_decode(base64_decode($string), true);
+ trace($data, 'RAW SSO Data');
+
+ if (!isset($parts[1])) {
+ $this->Validation->addValidationResult('sso', 'Missing SSO signature.');
+ }
+ if (!isset($parts[2])) {
+ $this->Validation->addValidationResult('sso', 'Missing SSO timestamp.');
+ }
+ if (count($this->Validation->results()) > 0) {
+ $msg = $this->Validation->resultsText();
+ if ($throwError) {
+ throw new Gdn_UserException($msg, 400);
+ }
+ return false;
+ }
+
+ $signature = $parts[1];
+ $timestamp = $parts[2];
+ $hashMethod = val(3, $parts, 'hmacsha1');
+ $clientID = val('client_id', $data);
+ if (!$clientID) {
+ $this->Validation->addValidationResult('sso', 'Missing SSO client_id');
+ return false;
+ }
+
+ $provider = Gdn_AuthenticationProviderModel::getProviderByKey($clientID);
+
+ if (!filter_var($timestamp, FILTER_VALIDATE_INT) || abs($timestamp - time()) > self::SSO_TIMEOUT) {
+ $this->Validation->addValidationResult('sso', 'The timestamp is invalid.');
+ return false;
+ }
+
+ if (!$provider) {
+ $this->Validation->addValidationResult('sso', "Unknown SSO Provider: $clientID");
+ return false;
+ }
+
+ $secret = $provider['AssociationSecret'];
+ if (!trim($secret, '.')) {
+ $this->Validation->addValidationResult('sso', 'Missing client secret');
+ return false;
+ }
+
+ // Check the signature.
+ switch ($hashMethod) {
+ case 'hmacsha1':
+ $calcSignature = hash_hmac('sha1', "$string $timestamp", $secret);
+ break;
+ default:
+ $this->Validation->addValidationResult('sso', "Invalid SSO hash method $hashMethod.");
+ return false;
+ }
+ if ($calcSignature != $signature) {
+ $this->Validation->addValidationResult('sso', "Invalid SSO signature: $signature");
+ return false;
+ }
+
+ $uniqueID = $data['uniqueid'];
+ $user = arrayTranslate($data, [
+ 'name' => 'Name',
+ 'email' => 'Email',
+ 'photourl' => 'Photo',
+ 'roles' => 'Roles',
+ 'uniqueid' => null,
+ 'client_id' => null], true);
+
+ // Remove important missing keys.
+ if (!array_key_exists('photourl', $data)) {
+ unset($user['Photo']);
+ }
+ if (!array_key_exists('roles', $data)) {
+ unset($user['Roles']);
+ }
+
+ trace($user, 'SSO User');
+
+ $userID = Gdn::userModel()->connect($uniqueID, $clientID, $user);
+ return $userID;
+ }
+
+ /**
+ * Sync user data.
+ *
+ * @param array|int $currentUser
+ * @param array $newUser Data to overwrite user with.
+ * @param bool $force
+ * @since 2.1
+ * @deprecated since 2.2.
+ */
+ public function synchUser($currentUser, $newUser, $force = false) {
+ deprecated('UserModel::synchUser', 'UserModel::syncUser');
+ $this->syncUser($currentUser, $newUser, $force);
+ }
+
+ /**
+ * Sync user data.
+ *
+ * @param array|int $currentUser
+ * @param array $newUser Data to overwrite user with.
+ * @param bool $force
+ * @param bool $isTrustedProvider
+ * @since 2.1
+ */
+ public function syncUser($currentUser, $newUser, $force = false, $isTrustedProvider = false) {
+ // Don't synchronize the user if we are configured not to.
+ if (!$force && !c('Garden.Registration.ConnectSynchronize', true)) {
+ return;
+ }
+
+ if (is_numeric($currentUser)) {
+ $currentUser = $this->getID($currentUser, DATASET_TYPE_ARRAY);
+ }
+
+ // Don't sync the user photo if they've uploaded one already.
+ $photo = val('Photo', $newUser);
+ $currentPhoto = val('Photo', $currentUser);
+ if (false
+ || ($currentPhoto && !stringBeginsWith($currentPhoto, 'http'))
+ || !is_string($photo)
+ || ($photo && !stringBeginsWith($photo, 'http'))
+ || strpos($photo, '.gravatar.') !== false
+ || stringBeginsWith($photo, url('/', true))
+ ) {
+ unset($newUser['Photo']);
+ trace('Not setting photo.');
+ }
+
+ if (c('Garden.SSO.SyncRoles') && c('Garden.SSO.SyncRolesBehavior') !== 'register') {
+ // Translate the role names to IDs.
+ $roles = val('Roles', $newUser, '');
+ $roleIDs = $this->lookupRoleIDs($roles);
+ if (empty($roleIDs)) {
+ $roleIDs = $this->newUserRoleIDs();
+ }
+ $newUser['RoleID'] = $roleIDs;
+ } else {
+ unset($newUser['Roles']);
+ unset($newUser['RoleID']);
+ }
+
+ // Save the user information.
+ $newUser['UserID'] = $currentUser['UserID'];
+ trace($newUser);
+
+ $result = $this->save($newUser, [
+ 'NoConfirmEmail' => true,
+ 'FixUnique' => true,
+ 'SaveRoles' => isset($newUser['RoleID']),
+ 'ValidateName' => !$isTrustedProvider,
+ ]);
+ if (!$result) {
+ trace($this->Validation->resultsText());
+ }
+ }
+
+ /**
+ * Connect a user with a foreign authentication system.
+ *
+ * @param string $uniqueID The user's unique key in the other authentication system.
+ * @param string $providerKey The key of the system providing the authentication.
+ * @param array $userData Data to go in the user table.
+ * @param array $options Additional connect options.
+ * @return int The new/existing user ID.
+ */
+ public function connect($uniqueID, $providerKey, $userData, $options = []) {
+ trace('UserModel->Connect()');
+ $provider = Gdn_AuthenticationProviderModel::getProviderByKey($providerKey);
+
+ $isTrustedProvider = $provider['Trusted'] ?? false;
+
+ $saveRoles = $saveRolesRegister = false;
+
+ // Trusted providers can sync roles.
+ if ($isTrustedProvider && !empty($userData['Roles'])) {
+ saveToConfig('Garden.SSO.SyncRoles', true, false);
+ $saveRoles = $saveRolesRegister = true;
+ }
+
+ $userID = false;
+ if (!isset($userData['UserID'])) {
+ // Check to see if the user already exists.
+ $auth = $this->getAuthentication($uniqueID, $providerKey);
+ $userID = val('UserID', $auth);
+
+ if ($userID) {
+ $userData['UserID'] = $userID;
+ }
+ }
+
+ if ($userID) {
+ // Save the user.
+ $this->syncUser($userID, $userData, false, $isTrustedProvider);
+ return $userID;
+ } else {
+ // The user hasn't already been connected. We want to see if we can't find the user based on some critera.
+
+ // Check to auto-connect based on email address.
+ if (c('Garden.SSO.AutoConnect', c('Garden.Registration.AutoConnect')) && isset($userData['Email'])) {
+ $user = $this->getByEmail($userData['Email']);
+ trace($user, "Autoconnect User");
+ if ($user) {
+ $user = (array)$user;
+ // Save the user.
+ $this->syncUser($user, $userData, false, $isTrustedProvider);
+ $userID = $user['UserID'];
+ }
+ }
+
+ if (!$userID) {
+ // Create a new user.
+ $userData['Password'] = md5(microtime());
+ $userData['HashMethod'] = 'Random';
+
+ // Translate SSO style roles to an array of role IDs suitable for registration.
+ if (!empty($userData['Roles']) && !isset($userData['RoleID'])) {
+ $userData['RoleID'] = $this->lookupRoleIDs($userData['Roles']);
+ }
+
+ $options['CheckCaptcha'] = $options['CheckCaptcha'] ?? false;
+ $options['NoConfirmEmail'] = isset($userData['Email']) || !UserModel::requireConfirmEmail();
+ $options['NoActivity'] = $options['NoActivity'] ?? true;
+ $options['SaveRoles'] = $saveRolesRegister;
+ $options['ValidateName'] = !$isTrustedProvider;
+
+ trace($userData, 'Registering User');
+ $userID = $this->register($userData, $options);
+ }
+
+ if ($userID) {
+ // Save the authentication.
+ $this->saveAuthentication([
+ 'UniqueID' => $uniqueID,
+ 'Provider' => $providerKey,
+ 'UserID' => $userID
+ ]);
+ } else {
+ trace($this->Validation->resultsText(), TRACE_ERROR);
+ }
+ }
+
+ return $userID;
+ }
+
+ /**
+ * Filter dangerous fields out of user-submitted data.
+ *
+ * @param array $data The data to filter.
+ * @param bool $register Whether or not this is a registration.
+ * @return array Returns a filtered version of {@link $data}.
+ */
+ public function filterForm($data, $register = false) {
+ if (!$register && !Gdn::session()->checkPermission('Garden.Users.Edit') && !c("Garden.Profile.EditUsernames")) {
+ $this->removeFilterField('Name');
+ }
+
+ if (!Gdn::session()->checkPermission('Garden.Moderation.Manage')) {
+ $this->addFilterField(['Banned', 'Verified', 'Confirmed', 'RankID']);
+ }
+
+ $data = parent::filterForm($data);
+ return $data;
+
+ }
+
+ /**
+ * Force gender to be a verified value.
+ *
+ * @param string $value The gender string.
+ * @return string
+ */
+ public static function fixGender($value) {
+ if (!$value || !is_string($value)) {
+ return 'u';
+ }
+
+ if ($value) {
+ $value = strtolower(substr(trim($value), 0, 1));
+ }
+
+ if (!in_array($value, ['u', 'm', 'f'])) {
+ $value = 'u';
+ }
+
+ return $value;
+ }
+
+ /**
+ * A convenience method to be called when inserting users.
+ *
+ * Users are inserted in various methods depending on registration setups.
+ *
+ * @param array $fields The user to insert.
+ * @param array $options Insert options.
+ * @return int|false Returns the new ID of the user or **false** if there was an error.
+ */
+ private function insertInternal($fields, $options = []) {
+ $this->EventArguments['InsertFields'] =& $fields;
+ $this->fireEvent('BeforeInsertUser');
+
+ if (!val('Setup', $options)) {
+ unset($fields['Admin']);
+ }
+
+ $roles = val('Roles', $fields);
+ unset($fields['Roles']);
+
+ // Massage the roles for email confirmation.
+ if (self::requireConfirmEmail() && !val('NoConfirmEmail', $options)) {
+ $confirmRoleIDs = RoleModel::getDefaultRoles(RoleModel::TYPE_UNCONFIRMED);
+
+ if (!empty($confirmRoleIDs)) {
+ touchValue('Attributes', $fields, []);
+ $confirmationCode = $this->confirmationCode();
+ $fields['Attributes']['EmailKey'] = $confirmationCode;
+ $fields['Confirmed'] = 0;
+ $roles = array_merge($roles, $confirmRoleIDs);
+ }
+ }
+
+ // Make sure to encrypt the password for saving...
+ if (array_key_exists('Password', $fields) && !val('HashMethod', $fields)) {
+ $passwordHash = new Gdn_PasswordHash();
+ $fields['Password'] = $passwordHash->hashPassword($fields['Password']);
+ $fields['HashMethod'] = 'Vanilla';
+ }
+
+ // Certain configurations can allow blank email addresses.
+ if (val('Email', $fields, null) === null) {
+ $fields['Email'] = '';
+ }
+
+ if (array_key_exists('Attributes', $fields) && !is_string($fields['Attributes'])) {
+ $fields['Attributes'] = dbencode($fields['Attributes']);
+ }
+
+ $userID = $this->SQL->insert($this->Name, $fields);
+ if (is_array($roles)) {
+ $this->saveRoles($userID, $roles, false);
+ }
+
+ // Approval registration requires an email confirmation.
+ if ($userID && isset($confirmationCode) && strtolower(c('Garden.Registration.Method')) == 'approval') {
+ // Send the confirmation email.
+ $this->sendEmailConfirmationEmail($userID);
+ }
+
+ // Fire an event for user inserts
+ $this->EventArguments['InsertUserID'] = $userID;
+ $this->EventArguments['InsertFields'] = $fields;
+ $this->fireEvent('AfterInsertUser');
+
+ return $userID;
+ }
+
+ /**
+ * Add user data to a result set.
+ *
+ * @param array|Gdn_DataSet $data Results we need to associate user data with.
+ * @param array $columns Database columns containing UserIDs to get data for.
+ * @param array $options Optionally pass list of user data to collect with key 'Join'.
+ */
+ public function joinUsers(&$data, $columns, $options = []) {
+ if ($data instanceof Gdn_DataSet) {
+ $data2 = $data->result();
+ } else {
+ $data2 = &$data;
+ }
+
+ // Grab all of the user fields that need to be joined.
+ $userIDs = [];
+ foreach ($data as $row) {
+ foreach ($columns as $columnName) {
+ $iD = (is_object($row))? ($row->$columnName ?? false) : ($row[$columnName] ?? false);
+ if (is_numeric($iD)) {
+ $userIDs[$iD] = 1;
+ }
+ }
+ }
+
+ // Get the users.
+ $users = $this->getIDs(array_keys($userIDs));
+
+ // Get column name prefix (ex: 'Insert' from 'InsertUserID')
+ $prefixes = [];
+ foreach ($columns as $columnName) {
+ $prefixes[] = stringEndsWith($columnName, 'UserID', true, true);
+ }
+
+ // Join the user data using prefixes (ex: 'Name' for 'InsertUserID' becomes 'InsertName')
+ $join = ($options['Join'] ?? ['Name', 'Email', 'Photo']);
+
+ foreach ($data2 as &$row) {
+ $isObj = is_object($row);
+ foreach ($prefixes as $px) {
+ $pxUserId = $px.'UserID';
+ $iD = $isObj ? ($row->$pxUserId ?? false) : ($row[$pxUserId] ?? false);
+ if (is_numeric($iD)) {
+ $user = $users[$iD] ?? false;
+ foreach ($join as $column) {
+ $value = $user[$column];
+ if ($column == 'Photo') {
+ if ($value && !isUrl($value)) {
+ $value = Gdn_Upload::url(changeBasename($value, 'n%s'));
+ } elseif (!$value) {
+ $value = UserModel::getDefaultAvatarUrl($user);
+ }
+ }
+ setValue($px.$column, $row, $value);
+ }
+ } else {
+ foreach ($join as $column) {
+ setValue($px.$column, $row, null);
+ }
+ }
+
+
+ }
+ }
+ }
+
+ /**
+ * Add multi-dimensional user data to an array.
+ *
+ * @param array $rows Results we need to associate user data with.
+ * @param array $columns Database columns containing UserIDs to get data for.
+ * @param array $options Additional options. Passed to filter event.
+ */
+ public function expandUsers(array &$rows, array $columns, array $options = []) {
+ // How are we supposed to lookup users by column if we don't have any columns?
+ if (count($rows) === 0 || count($columns) === 0) {
+ return;
+ }
+
+ reset($rows);
+ $single = is_string(key($rows));
+
+ $userIDs = [];
+
+ $extractUserIDs = function(array $row) use ($columns, &$userIDs) {
+ foreach ($columns as $key) {
+ if (array_key_exists($key, $row)) {
+ $id = $row[$key];
+ $userIDs[$id] = true;
+ }
+ }
+ };
+
+ // Fetch the users we'll be injecting into the rows.
+ if ($single) {
+ $extractUserIDs($rows);
+ } else {
+ foreach ($rows as $row) {
+ $extractUserIDs($row);
+ }
+ }
+ $users = !empty($userIDs) ? $this->getIDs(array_keys($userIDs)) : [];
+
+ $populate = function(array &$row) use ($users, $columns) {
+ foreach ($columns as $key) {
+ $destination = stringEndsWith($key, 'ID', true, true);
+ $id = val($key, $row);
+ $user = null;
+ if (is_numeric($id)) {
+ // Massage the data, before injecting it into the results.
+ $user = array_key_exists($id, $users) ? $users[$id] : false;
+ if ($user) {
+ // Make sure all user records have a valid photo.
+ $photo = val('Photo', $user);
+ if ($photo && !isUrl($photo)) {
+ $photoBase = changeBasename($photo, 'n%s');
+ $photo = Gdn_Upload::url($photoBase);
+ }
+ if (empty($photo)) {
+ $photo = UserModel::getDefaultAvatarUrl($user);
+ }
+ setValue('Photo', $user, $photo);
+ // Add an alias to Photo. Currently only used in API calls.
+ setValue('PhotoUrl', $user, $photo);
+ } else {
+ $user = [
+ 'userID' => 0,
+ 'name' => 'unknown',
+ 'email' => 'unknown@example.com'
+ ];
+ $user['photoUrl'] = self::getDefaultAvatarUrl($user);
+ }
+ }
+
+ setValue($destination, $row, $user);
+ }
+ };
+
+ // Inject those user records.
+ if ($single) {
+ $populate($rows);
+ } else {
+ foreach ($rows as &$row) {
+ $populate($row);
+ }
+ }
+
+ // Don't bother addons with whether or not this is a single row. Pack and unpack it here, as necessary.
+ if ($single) {
+ $rows = [$rows];
+ }
+ $rows = $this->eventManager->fireFilter('userModel_expandUsers', $rows, $options);
+ if ($single) {
+ $rows = reset($rows);
+ }
+ }
+
+ /**
+ * Returns the url to the default avatar for a user.
+ *
+ * @param array $user The user to get the default avatar for.
+ * @param string $size The size of avatar to return (only respected for dashboard-uploaded default avatars).
+ * @return string The url to the default avatar image.
+ */
+ public static function getDefaultAvatarUrl($user = [], $size = 'thumbnail') {
+ if (!empty($user) && function_exists('UserPhotoDefaultUrl')) {
+ return userPhotoDefaultUrl($user);
+ }
+ if ($avatar = c('Garden.DefaultAvatar', false)) {
+ if (strpos($avatar, 'defaultavatar/') !== false) {
+ if ($size == 'thumbnail') {
+ return Gdn_UploadImage::url(changeBasename($avatar, 'n%s'));
+ } elseif ($size == 'profile') {
+ return Gdn_UploadImage::url(changeBasename($avatar, 'p%s'));
+ }
+ }
+ return $avatar;
+ }
+ return asset('applications/dashboard/design/images/defaulticon.png', true);
+ }
+
+ /**
+ * Query the user table.
+ *
+ * @param bool $safeData Makes sure that the query does not return any sensitive information about the user.
+ * (password, attributes, preferences, etc).
+ */
+ public function userQuery($safeData = false) {
+ if ($safeData) {
+ $this->SQL->select('u.UserID, u.Name, u.Photo, u.CountVisits, u.DateFirstVisit, u.DateLastActive, u.DateInserted, u.DateUpdated, u.Score, u.Deleted, u.CountDiscussions, u.CountComments');
+ } else {
+ $this->SQL->select('u.*');
+ }
+ $this->SQL->from('User u');
+ }
+
+ /**
+ * Load and compile user permissions
+ *
+ * @deprecated Use UserModel::getPermissions instead.
+ * @param integer $userID
+ * @param boolean $serialize
+ * @return array
+ */
+ public function definePermissions($userID, $serialize = false) {
+ if ($serialize) {
+ deprecated("UserModel->definePermissions(id, true)", "UserModel->definePermissions(id)");
+ }
+
+ $permissions = $this->getPermissions($userID);
+
+ return $serialize ? dbencode($permissions->getPermissions()) : $permissions->getPermissions();
+ }
+
+ /**
+ * Take raw permission definitions and create.
+ *
+ * @param array $rawPermissions Database rows from the permissions table.
+ * @return array Compiled permissions
+ */
+ public static function compilePermissions($rawPermissions) {
+ $permissions = new Vanilla\Permissions();
+ $permissions->compileAndLoad($rawPermissions);
+ return $permissions->getPermissions();
+ }
+
+ /**
+ * Default Gdn_Model::get() behavior.
+ *
+ * Prior to 2.0.18 it incorrectly behaved like GetID.
+ * This method can be deleted entirely once it's been deprecated long enough.
+ *
+ * @return object DataSet
+ */
+ public function get($orderFields = '', $orderDirection = 'asc', $limit = false, $offset = false) {
+ if (is_numeric($orderFields)) {
+ // They're using the old version that was a misnamed getID()
+ deprecated('UserModel->get()', 'UserModel->getID()');
+ $result = $this->getID($orderFields);
+ } else {
+ $result = parent::get($orderFields, $orderDirection, $limit, $offset);
+ }
+ return $result;
+ }
+
+ /**
+ * Get a user by their username.
+ *
+ * @param string $username The username of the user.
+ * @return bool|object Returns the user or **false** if they don't exist.
+ */
+ public function getByUsername($username) {
+ if ($username == '') {
+ return false;
+ }
+
+ // Check page cache, then memcached
+ $user = $this->getUserFromCache($username, 'name');
+
+ if ($user === Gdn_Cache::CACHEOP_FAILURE) {
+ $this->userQuery();
+ $user = $this->SQL->where('u.Name', $username)->get()->firstRow(DATASET_TYPE_ARRAY);
+ if ($user) {
+ // If success, cache user
+ $this->userCache($user);
+ }
+ }
+
+ // Apply calculated fields
+ $this->setCalculatedFields($user);
+
+ // By default, firstRow() gives stdClass
+ if ($user !== false) {
+ $user = (object)$user;
+ }
+
+ return $user;
+ }
+
+ /**
+ * Get user by email address.
+ *
+ * @param string $email The email address of the user.
+ * @return array|bool|stdClass Returns the user or **false** if they don't exist.
+ */
+ public function getByEmail($email) {
+ $this->userQuery();
+ $user = $this->SQL->where('u.Email', $email)->get()->firstRow();
+ $this->setCalculatedFields($user);
+ return $user;
+ }
+
+ /**
+ * Get users by role.
+ *
+ * @param int|string $role The ID or name of the role.
+ * @return Gdn_DataSet Returns the users with the given role.
+ */
+ public function getByRole($role) {
+ $roleID = $role; // Optimistic
+ if (is_string($role)) {
+ $roleModel = new RoleModel();
+ $roles = $roleModel->getArray();
+ $rolesByName = array_flip($roles);
+
+ $roleID = val($role, $rolesByName, null);
+
+ // No such role
+ if (is_null($roleID)) {
+ return new Gdn_DataSet();
+ }
+ }
+
+ return $this->SQL->select('u.*')
+ ->from('User u')
+ ->join('UserRole ur', 'u.UserID = ur.UserID')
+ ->where('ur.RoleID', $roleID, true, false)
+ ->orderBy('DateInserted', 'desc')
+ ->get();
+ }
+
+ /**
+ * Get the most recently active users.
+ *
+ * @param int $limit The number of users to return.
+ * @return Gdn_DataSet Returns a list of users.
+ */
+ public function getActiveUsers($limit = 5) {
+ $userIDs = $this->SQL
+ ->select('UserID')
+ ->from('User')
+ ->orderBy('DateLastActive', 'desc')
+ ->limit($limit, 0)
+ ->get()->resultArray();
+ $userIDs = array_column($userIDs, 'UserID');
+
+ $data = $this->SQL->getWhere('User', ['UserID' => $userIDs], 'DateLastActive', 'desc');
+ return $data;
+ }
+
+ /**
+ * Get the current number of applicants waiting to be approved.
+ *
+ * @return int Returns the number of applicants or 0 if the registration method isn't set to approval.
+ */
+ public function getApplicantCount() {
+ $roleModel = new RoleModel();
+ $result = $roleModel->getApplicantCount();
+ return $result;
+ }
+
+ /**
+ * Returns all users in the applicant role.
+ *
+ * @param int|bool $limit
+ * @param int|bool $offset
+ * @return Gdn_DataSet Returns a data set of the users who are applicants.
+ */
+ public function getApplicants($limit = false, $offset = false) {
+ $applicantRoleIDs = RoleModel::getDefaultRoles(RoleModel::TYPE_APPLICANT);
+
+ if (empty($applicantRoleIDs)) {
+ return new Gdn_DataSet();
+ }
+
+ $this->SQL->select('u.*')
+ ->from('User u')
+ ->join('UserRole ur', 'u.UserID = ur.UserID')
+ ->where('ur.RoleID', $applicantRoleIDs)
+ ->orderBy('DateInserted', 'desc');
+
+ if ($limit) {
+ $this->SQL->limit($limit, $offset);
+ }
+
+ $result = $this->SQL->get();
+ return $result;
+ }
+
+ /**
+ * Get the a user authentication row.
+ *
+ * @param string $uniqueID The unique ID of the user in the foreign authentication scheme.
+ * @param string $provider The key of the provider.
+ * @return array|false
+ */
+ public function getAuthentication($uniqueID, $provider) {
+ return $this->SQL->getWhere(
+ 'UserAuthentication',
+ ['ForeignUserKey' => $uniqueID, 'ProviderKey' => $provider]
+ )->firstRow(DATASET_TYPE_ARRAY);
+ }
+
+ /**
+ * Get the user authentication row by user ID.
+ *
+ * @param int $userID The ID of the user to get the authentication for.
+ * @param string $provider The key of the provider.
+ * @return array|false Returns the authentication row or **false** if there isn't one.
+ */
+ public function getAuthenticationByUser($userID, $provider) {
+ return $this->SQL->getWhere(
+ 'UserAuthentication',
+ ['UserID' => $userID, 'ProviderKey' => $provider]
+ )->firstRow(DATASET_TYPE_ARRAY);
+ }
+
+ /**
+ *
+ *
+ * @param array|bool $like
+ * @return int
+ */
+ public function getCountLike($like = false) {
+ $this->SQL
+ ->select('u.UserID', 'count', 'UserCount')
+ ->from('User u');
+
+ if (is_array($like)) {
+ $this->SQL
+ ->beginWhereGroup()
+ ->orLike($like, '', 'right')
+ ->endWhereGroup();
+ }
+ $this->SQL
+ ->where('u.Deleted', 0);
+
+ $data = $this->SQL->get()->firstRow();
+
+ return $data === false ? 0 : $data->UserCount;
+ }
+
+ /**
+ *
+ *
+ * @param array|false $where
+ * @return int
+ */
+ public function getCountWhere($where = false) {
+ $this->SQL
+ ->select('u.UserID', 'count', 'UserCount')
+ ->from('User u');
+
+ if (is_array($where)) {
+ $this->SQL->where($where);
+ }
+
+ $data = $this->SQL
+ ->where('u.Deleted', 0)
+ ->get()
+ ->firstRow();
+
+ return $data === false ? 0 : $data->UserCount;
+ }
+
+ /**
+ * Get a user by ID.
+ *
+ * @param int $iD The ID of the user.
+ * @param string|false $datasetType Whether to return an array or object.
+ * @param array $options Additional options to affect fetching. Currently unused.
+ * @return array|object|false Returns the user or **false** if the user wasn't found.
+ */
+ public function getID($iD, $datasetType = false, $options = []) {
+ if (!$iD) {
+ return false;
+ }
+ $datasetType = $datasetType ?: DATASET_TYPE_OBJECT;
+
+ // Check page cache, then memcached
+ $user = $this->getUserFromCache($iD, 'userid');
+ // If not, query DB
+ if ($user === Gdn_Cache::CACHEOP_FAILURE) {
+ $user = parent::getID($iD, DATASET_TYPE_ARRAY);
+
+ // We want to cache a non-existent user no-matter what.
+ if (!$user) {
+ $user = null;
+ }
+
+ $this->userCache($user, $iD);
+ } elseif (!$user) {
+ return false;
+ }
+
+ // Allow FALSE returns
+ if ($user === false || is_null($user)) {
+ return false;
+ } else {
+ // Apply calculated fields
+ $this->setCalculatedFields($user);
+ }
+
+ if (is_array($user) && $datasetType == DATASET_TYPE_OBJECT) {
+ $user = (object)$user;
+ }
+
+ if (is_object($user) && $datasetType == DATASET_TYPE_ARRAY) {
+ $user = (array)$user;
+ }
+
+ $this->EventArguments['LoadedUser'] = &$user;
+ $this->fireEvent('AfterGetID');
+
+ return $user;
+ }
+
+ /**
+ *
+ *
+ * @param array $iDs
+ * @param bool $skipCacheQuery
+ * @return array
+ * @throws Exception
+ */
+ public function getIDs($iDs, $skipCacheQuery = false) {
+ $databaseIDs = $iDs;
+ $data = [];
+
+ if (!$skipCacheQuery) {
+ $keys = [];
+ // Make keys for cache query
+ foreach ($iDs as $userID) {
+ if (!$userID) {
+ continue;
+ }
+ $keys[] = formatString(self::USERID_KEY, ['UserID' => $userID]);
+ }
+
+ // Query cache layer
+ $cacheData = Gdn::cache()->get($keys);
+ if (!is_array($cacheData)) {
+ $cacheData = [];
+ }
+
+ foreach ($cacheData as $realKey => $user) {
+ if ($user === null) {
+ $resultUserID = trim(strrchr($realKey, '.'), '.');
+ } else {
+ $resultUserID = val('UserID', $user);
+ }
+ $this->setCalculatedFields($user);
+ $data[$resultUserID] = $user;
+ }
+
+ //echo "from cache:\n";
+ //print_r($Data);
+
+ $databaseIDs = array_diff($databaseIDs, array_keys($data));
+ unset($cacheData);
+ }
+
+ // Clean out bogus blank entries
+ $databaseIDs = array_diff($databaseIDs, [null, '']);
+
+ // If we are missing any users from cache query, fill em up here
+ if (sizeof($databaseIDs)) {
+ $databaseData = $this->SQL->whereIn('UserID', $databaseIDs)->getWhere('User')->result(DATASET_TYPE_ARRAY);
+ $databaseData = Gdn_DataSet::index($databaseData, 'UserID');
+
+ //echo "from DB:\n";
+ //print_r($DatabaseData);
+
+ foreach ($databaseIDs as $iD) {
+ if (isset($databaseData[$iD])) {
+ $user = $databaseData[$iD];
+ $this->userCache($user, $iD);
+ // Apply calculated fields
+ $this->setCalculatedFields($user);
+ $data[$iD] = $user;
+ } else {
+ $user = null;
+ $this->userCache($user, $iD);
+ }
+ }
+ }
+
+ $this->EventArguments['RequestedIDs'] = $iDs;
+ $this->EventArguments['LoadedUsers'] = &$data;
+ $this->fireEvent('AfterGetIDs');
+
+ return $data;
+ }
+
+ /**
+ * Retrieve IP addresses associated with a user.
+ *
+ * @param int $userID Unique ID for a user.
+ * @return array IP addresses for the user.
+ */
+ public function getIPs($userID) {
+ $iPs = [];
+
+ try {
+ $packedIPs = Gdn::sql()->getWhere('UserIP', ['UserID' => $userID])->resultArray();
+ } catch (\Exception $e) {
+ return $iPs;
+ }
+
+ foreach ($packedIPs as $userIP) {
+ if ($unpackedIP = ipDecode($userIP['IPAddress'])) {
+ $iPs[] = $unpackedIP;
+ }
+ }
+
+ return $iPs;
+ }
+
+ /**
+ *
+ *
+ * @param bool $like
+ * @param string $orderFields
+ * @param string $orderDirection
+ * @param bool $limit
+ * @param bool $offset
+ * @return Gdn_DataSet
+ * @throws Exception
+ */
+ public function getLike($like = false, $orderFields = '', $orderDirection = 'asc', $limit = false, $offset = false) {
+ $this->userQuery();
+ $this->SQL
+ ->join('UserRole ur', "u.UserID = ur.UserID", 'left');
+
+ if (is_array($like)) {
+ $this->SQL
+ ->beginWhereGroup()
+ ->orLike($like, '', 'right')
+ ->endWhereGroup();
+ }
+
+ return $this->SQL
+ ->where('u.Deleted', 0)
+ ->orderBy($orderFields, $orderDirection)
+ ->limit($limit, $offset)
+ ->get();
+ }
+
+ /**
+ * Retries UserMeta information for a UserID / Key pair.
+ *
+ * This method takes a $userID or array of $userIDs, and a $key. It converts the
+ * $key to fully qualified format and then queries for the associated value(s). $key
+ * can contain SQL wildcards, in which case multiple results can be returned.
+ *
+ * If $userID is an array, the return value will be a multi dimensional array with the first
+ * axis containing UserIDs and the second containing fully qualified UserMetaKeys, associated with
+ * their values.
+ *
+ * If $userID is a scalar, the return value will be a single dimensional array of $UserMetaKey => $Value
+ * pairs.
+ *
+ * @param int $userID UserID or array of UserIDs.
+ * @param string $key Relative user meta key.
+ * @param string $prefix
+ * @param string $default
+ * @return array results or $default
+ */
+ public static function getMeta($userID, $key, $prefix = '', $default = '') {
+ $sql = Gdn::sql()
+ ->select('*')
+ ->from('UserMeta u');
+
+ if (is_array($userID)) {
+ $sql->whereIn('u.UserID', $userID);
+ } else {
+ $sql->where('u.UserID', $userID);
+ }
+
+ if (strpos($key, '%') !== false) {
+ $sql->like('u.Name', $key, 'none');
+ } else {
+ $sql->where('u.Name', $key);
+ }
+
+ $data = $sql->get()->resultArray();
+
+ if (is_array($userID)) {
+ $result = array_fill_keys($userID, []);
+ } else {
+ if (strpos($key, '%') === false) {
+ $result = [stringBeginsWith($key, $prefix, false, true) => $default];
+ } else {
+ $result = [];
+ }
+ }
+
+ foreach ($data as $row) {
+ $name = stringBeginsWith($row['Name'], $prefix, false, true);
+
+ if (is_array($userID)) {
+ $result[$row['UserID']][$name] = $row['Value'];
+ } else {
+ $result[$name] = $row['Value'];
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Get the roles for a user.
+ *
+ * @param int $userID The user to get the roles for.
+ * @return Gdn_DataSet Returns the roles as a dataset (with array values).
+ */
+ public function getRoles($userID) {
+ $userRolesKey = formatString(self::USERROLES_KEY, ['UserID' => $userID]);
+ $rolesDataArray = Gdn::cache()->get($userRolesKey);
+
+ if ($rolesDataArray === Gdn_Cache::CACHEOP_FAILURE) {
+ $rolesDataArray = $this->SQL->getWhere('UserRole', ['UserID' => $userID])->resultArray();
+ $rolesDataArray = array_column($rolesDataArray, 'RoleID');
+ // Add result to cache
+ $this->userCacheRoles($userID, $rolesDataArray);
+ }
+
+ $result = [];
+ foreach ($rolesDataArray as $roleID) {
+ $result[] = RoleModel::roles($roleID, true);
+ }
+
+ return new Gdn_DataSet($result, DATASET_TYPE_ARRAY);
+ }
+
+ /**
+ *
+ *
+ * @param int $userID
+ * @param bool $refresh
+ * @return array|object|false
+ */
+ public function getSession($userID, $refresh = false) {
+ // Ask for the user. This will check cache first.
+ $user = $this->getID($userID, DATASET_TYPE_OBJECT);
+
+ if (!$user) {
+ return false;
+ }
+
+ // If we require confirmation and user is not confirmed
+ $confirmEmail = self::requireConfirmEmail();
+ $confirmed = val('Confirmed', $user);
+ if ($confirmEmail && !$confirmed) {
+ // Replace permissions with those of the ConfirmEmailRole
+ $confirmEmailRoleID = RoleModel::getDefaultRoles(RoleModel::TYPE_UNCONFIRMED);
+
+ if (!is_array($confirmEmailRoleID) || count($confirmEmailRoleID) == 0) {
+ throw new Exception(sprintf(t('No role configured with a type of "%s".'), RoleModel::TYPE_UNCONFIRMED), 400);
+ }
+
+ $roleModel = new RoleModel();
+ $permissionsModel = new Vanilla\Permissions();
+ $rolePermissions = $roleModel->getPermissions($confirmEmailRoleID);
+ $permissionsModel->compileAndLoad($rolePermissions);
+
+ // Ensure Confirm Email role can always sign in
+ if (!$permissionsModel->has('Garden.SignIn.Allow')) {
+ $permissionsModel->set('Garden.SignIn.Allow', true);
+ }
+
+ $user->Permissions = $permissionsModel->getPermissions();
+
+ // Otherwise normal loadings!
+ } else {
+ if ($user && ($user->Permissions == '' || Gdn::cache()->activeEnabled())) {
+ $userPermissions = $this->getPermissions($userID);
+ $user->Permissions = $userPermissions->getPermissions();
+ }
+ }
+
+ // Remove secret info from session
+ unset($user->Password, $user->HashMethod);
+
+ return $user;
+ }
+
+ /**
+ * Retrieve a summary of "safe" user information for external API calls.
+ *
+ * @param string $orderFields
+ * @param string $orderDirection
+ * @param bool $limit
+ * @param bool $offset
+ * @return array|null
+ * @throws Exception
+ */
+ public function getSummary($orderFields = '', $orderDirection = 'asc', $limit = false, $offset = false) {
+ $this->userQuery(true);
+ $data = $this->SQL
+ ->where('u.Deleted', 0)
+ ->orderBy($orderFields, $orderDirection)
+ ->limit($limit, $offset)
+ ->get();
+
+ // Set corrected PhotoUrls.
+ $result = &$data->result();
+ foreach ($result as &$row) {
+ if ($row->Photo && !isUrl($row->Photo)) {
+ $row->Photo = Gdn_Upload::url(changeBasename($row->Photo, 'p%s'));
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Retrieves a "system user" id that can be used to perform non-real-person tasks.
+ *
+ * @return int Returns a user ID.
+ */
+ public function getSystemUserID() {
+ $systemUserID = c('Garden.SystemUserID');
+ if (!$systemUserID) {
+ $systemUser = $this->SQL
+ ->select('UserID')
+ ->from('User u')
+ ->where('u.Name', 'System')
+ ->get()->firstRow(DATASET_TYPE_ARRAY);
+ if($systemUser) {
+ $systemUserID = $systemUser['UserID'];
+ } else {
+ $systemUser = [
+ 'Name' => t('System'),
+ 'Photo' => asset('/applications/dashboard/design/images/usericon.png', true),
+ 'Password' => randomString('20'),
+ 'HashMethod' => 'Random',
+ 'Email' => 'system@stub.vanillacommunity.example',
+ 'DateInserted' => Gdn_Format::toDateTime(),
+ 'Admin' => '2'
+ ];
+
+ $this->EventArguments['SystemUser'] = &$systemUser;
+ $this->fireEvent('BeforeSystemUser');
+
+ $systemUserID = $this->SQL->insert($this->Name, $systemUser);
+ }
+ saveToConfig('Garden.SystemUserID', $systemUserID);
+ }
+ return $systemUserID;
+ }
+
+ /**
+ * Add points to a user's total.
+ *
+ * @param int $userID
+ * @param int $points
+ * @param string $source
+ * @param int|false $timestamp
+ * @since 2.1.0
+ */
+ public static function givePoints($userID, $points, $source = 'Other', $timestamp = false) {
+ if (!$timestamp === false) {
+ $timestamp = time();
+ }
+
+ if (is_array($source)) {
+ $categoryID = val('CategoryID', $source, 0);
+ $source = $source[0];
+ } else {
+ $categoryID = 0;
+ }
+
+ if ($categoryID > 0) {
+ $categoryIDs = [$categoryID, 0];
+ } else {
+ $categoryIDs = [$categoryID];
+ }
+
+ foreach ($categoryIDs as $iD) {
+ // Increment source points for the user.
+ self::givePointsInternal($userID, $points, 'a', $source, $iD);
+
+ // Increment total points for the user.
+ self::givePointsInternal($userID, $points, 'w', 'Total', $iD, $timestamp);
+ self::givePointsInternal($userID, $points, 'm', 'Total', $iD, $timestamp);
+ self::givePointsInternal($userID, $points, 'a', 'Total', $iD, $timestamp);
+
+ // Increment global daily points.
+ self::givePointsInternal(0, $points, 'd', 'Total', $iD, $timestamp);
+ }
+
+ // Grab the user's total points.
+ $totalPoints = Gdn::sql()->getWhere('UserPoints', ['UserID' => $userID, 'SlotType' => 'a', 'Source' => 'Total', 'CategoryID' => 0])->value('Points');
+
+ Gdn::userModel()->setField($userID, 'Points', $totalPoints);
+
+ // Fire a give points event.
+ Gdn::userModel()->EventArguments['UserID'] = $userID;
+ Gdn::userModel()->EventArguments['CategoryID'] = $categoryID;
+ Gdn::userModel()->EventArguments['TotalPoints'] = $totalPoints;
+ Gdn::userModel()->EventArguments['GivenPoints'] = $points;
+ Gdn::userModel()->EventArguments['Source'] = $source;
+ Gdn::userModel()->EventArguments['Timestamp'] = $timestamp;
+ Gdn::userModel()->EventArguments['Points'] = $totalPoints; // Deprecated in favor of TotalPoints
+ Gdn::userModel()->fireEvent('GivePoints');
+ }
+
+ /**
+ * Add points to a user's total in a specific time slot.
+ *
+ * @param int $userID
+ * @param int $points
+ * @param string $slotType
+ * @param string $source
+ * @param int $categoryID
+ * @param int|false $timestamp
+ * @since 2.1.0
+ * @see UserModel::givePoints()
+ */
+ private static function givePointsInternal($userID, $points, $slotType, $source = 'Total', $categoryID = 0, $timestamp = false) {
+ $timeSlot = gmdate('Y-m-d', Gdn_Statistics::timeSlotStamp($slotType, $timestamp));
+
+ $px = Gdn::database()->DatabasePrefix;
+ $sql = "insert {$px}UserPoints (UserID, SlotType, TimeSlot, Source, CategoryID, Points)
+ values (:UserID, :SlotType, :TimeSlot, :Source, :CategoryID, :Points)
+ on duplicate key update Points = Points + :Points1";
+
+ Gdn::database()->query($sql, [
+ ':UserID' => $userID,
+ ':Points' => $points,
+ ':SlotType' => $slotType,
+ ':Source' => $source,
+ ':CategoryID' => $categoryID,
+ ':TimeSlot' => $timeSlot,
+ ':Points1' => $points]);
+ }
+
+ /**
+ * Register a new user.
+ *
+ * @param array $formPostValues
+ * @param array $options
+ * @return bool|int|string
+ */
+ public function register($formPostValues, $options = []) {
+ $formPostValues['LastIPAddress'] = ipEncode(Gdn::request()->ipAddress());
+
+ // If the Photo added is not a URL, remove it.
+ if (isset($formPostValues['Photo']) && !isUrl($formPostValues['Photo'])) {
+ unset($formPostValues['Photo']);
+ }
+ // Check for banning first.
+ $valid = BanModel::checkUser($formPostValues, null, true);
+ if (!$valid) {
+ $this->Validation->addValidationResult('UserID', 'Sorry, permission denied.');
+ }
+
+ // Throw an event to allow plugins to block the registration.
+ unset($this->EventArguments['User']);
+ $this->EventArguments['RegisteringUser'] =& $formPostValues;
+ $this->EventArguments['Valid'] =& $valid;
+ $this->fireEvent('BeforeRegister');
+
+ if (!$valid) {
+ return false; // plugin blocked registration
+ }
+ if (array_key_exists('Gender', $formPostValues)) {
+ $formPostValues['Gender'] = self::fixGender($formPostValues['Gender']);
+ }
+
+ $method = strtolower(val('Method', $options, c('Garden.Registration.Method')));
+
+ switch ($method) {
+ case 'basic':
+ case 'captcha': // deprecated
+ $userID = $this->insertForBasic($formPostValues, val('CheckCaptcha', $options, true), $options);
+ break;
+ case 'approval':
+ $userID = $this->insertForApproval($formPostValues, $options);
+ break;
+ case 'invitation':
+ $userID = $this->insertForInvite($formPostValues, $options);
+ break;
+ case 'closed':
+ $userID = false;
+ $this->Validation->addValidationResult('Registration', 'Registration is closed.');
+ break;
+ default:
+ $userID = $this->insertForBasic($formPostValues, val('CheckCaptcha', $options, false), $options);
+ break;
+ }
+
+ if ($userID) {
+ $this->EventArguments['UserID'] = $userID;
+ $this->fireEvent('AfterRegister');
+ }
+ return $userID;
+ }
+
+ /**
+ * Remove the photo from a user.
+ *
+ * @param int $userID
+ */
+ public function removePicture($userID) {
+ // Grab the current photo.
+ $user = $this->getID($userID, DATASET_TYPE_ARRAY);
+ $photo = $user['Photo'];
+
+ // Only attempt to delete a physical file, not a URL.
+ if (!isUrl($photo)) {
+ $profilePhoto = changeBasename($photo, 'p%s');
+ $upload = new Gdn_Upload();
+ $upload->delete($profilePhoto);
+ }
+
+ // Wipe the Photo field.
+ $this->setField($userID, 'Photo', null);
+ }
+
+ /**
+ * Get a user's counter.
+ *
+ * @param int|string|object $user
+ * @param string $column
+ * @return int|false
+ */
+ public function profileCount($user, $column) {
+ if (is_numeric($user)) {
+ $user = $this->SQL->getWhere('User', ['UserID' => $user])->firstRow(DATASET_TYPE_ARRAY);
+ } elseif (is_string($user)) {
+ $user = $this->SQL->getWhere('User', ['Name' => $user])->firstRow(DATASET_TYPE_ARRAY);
+ } elseif (is_object($user)) {
+ $user = (array)$user;
+ }
+
+ if (!$user) {
+ return false;
+ }
+
+ if (array_key_exists($column, $user) && $user[$column] === null) {
+ $userID = $user['UserID'];
+ switch ($column) {
+ case 'CountComments':
+ $count = $this->SQL->getCount('Comment', ['InsertUserID' => $userID]);
+ $this->setField($userID, 'CountComments', $count);
+ break;
+ case 'CountDiscussions':
+ $count = $this->SQL->getCount('Discussion', ['InsertUserID' => $userID]);
+ $this->setField($userID, 'CountDiscussions', $count);
+ break;
+ case 'CountBookmarks':
+ $count = $this->SQL->getCount('UserDiscussion', ['UserID' => $userID, 'Bookmarked' => '1']);
+ $this->setField($userID, 'CountBookmarks', $count);
+ break;
+ default:
+ $count = false;
+ break;
+ }
+ return $count;
+ } elseif ($user[$column]) {
+ return $user[$column];
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Generic save procedure.
+ *
+ * @param array $formPostValues The user to save.
+ * @param array $settings Controls certain save functionality.
+ *
+ * - SaveRoles - Save 'RoleID' field as user's roles. Default false.
+ * - HashPassword - Hash the provided password on update. Default true.
+ * - FixUnique - Try to resolve conflicts with unique constraints on Name and Email. Default false.
+ * - ValidateEmail - Make sure the provided email addresses is formatted properly. Default true.
+ * - ValidateName - Make sure the provided name is valid. Blacklisted names will always be blocked.
+ * - NoConfirmEmail - Disable email confirmation. Default false.
+ *
+ */
+ public function save($formPostValues, $settings = []) {
+ // See if the user's related roles should be saved or not.
+ $saveRoles = val('SaveRoles', $settings);
+
+ // Define the primary key in this model's table.
+ $this->defineSchema();
+
+ // Custom Rule: This will make sure that at least one role was selected if saving roles for this user.
+ if ($saveRoles) {
+ $this->Validation->addRule('OneOrMoreArrayItemRequired', 'function:ValidateOneOrMoreArrayItemRequired');
+ $this->Validation->applyRule('RoleID', 'OneOrMoreArrayItemRequired');
+ } else {
+ $this->Validation->unapplyRule('RoleID', 'OneOrMoreArrayItemRequired');
+ }
+
+ $this->Validation->addRule('UsernameBlacklist', 'function:validateAgainstUsernameBlacklist');
+ $this->Validation->applyRule('Name', 'UsernameBlacklist');
+
+ // Make sure that checkbox values are saved as the appropriate value.
+ if (array_key_exists('ShowEmail', $formPostValues)) {
+ $formPostValues['ShowEmail'] = forceBool($formPostValues['ShowEmail'], '0', '1', '0');
+ }
+
+ if (array_key_exists('Banned', $formPostValues)) {
+ $formPostValues['Banned'] = intval($formPostValues['Banned']);
+ }
+
+ if (array_key_exists('Confirmed', $formPostValues)) {
+ $formPostValues['Confirmed'] = forceBool($formPostValues['Confirmed'], '0', '1', '0');
+ }
+
+ if (array_key_exists('Verified', $formPostValues)) {
+ $formPostValues['Verified'] = forceBool($formPostValues['Verified'], '0', '1', '0');
+ }
+
+ // Do not allowing setting this via general save.
+ unset($formPostValues['Admin']);
+
+ // This field is deprecated but included on user objects for backwards compatibility.
+ // It will absolutely break if you try to save it back to the database.
+ unset($formPostValues['AllIPAddresses']);
+
+ if (array_key_exists('Gender', $formPostValues)) {
+ $formPostValues['Gender'] = self::fixGender($formPostValues['Gender']);
+ }
+
+ if (array_key_exists('DateOfBirth', $formPostValues) && $formPostValues['DateOfBirth'] == '0-00-00') {
+ $formPostValues['DateOfBirth'] = null;
+ }
+
+ $userID = val('UserID', $formPostValues);
+ $user = [];
+ $insert = $userID > 0 ? false : true;
+ if ($insert) {
+ $this->addInsertFields($formPostValues);
+ } else {
+ $this->addUpdateFields($formPostValues);
+ $user = $this->getID($userID, DATASET_TYPE_ARRAY);
+ if (!$user) {
+ $user = [];
+ }
+
+ // Block banning the superadmin or System accounts
+ if (val('Admin', $user) == 2 && val('Banned', $formPostValues)) {
+ $this->Validation->addValidationResult('Banned', 'You may not ban a System user.');
+ } elseif (val('Admin', $user) && val('Banned', $formPostValues)) {
+ $this->Validation->addValidationResult('Banned', 'You may not ban a user with the Admin flag set.');
+ }
+ }
+
+ $this->EventArguments['FormPostValues'] = $formPostValues;
+ $this->fireEvent('BeforeSaveValidation');
+
+ $recordRoleChange = true;
+
+ if ($userID && val('FixUnique', $settings)) {
+ $uniqueValid = $this->validateUniqueFields(val('Name', $formPostValues), val('Email', $formPostValues), $userID, true);
+ if (!$uniqueValid['Name']) {
+ unset($formPostValues['Name']);
+ }
+ if (!$uniqueValid['Email']) {
+ unset($formPostValues['Email']);
+ }
+ $uniqueValid = true;
+ } else {
+ $uniqueValid = $this->validateUniqueFields(val('Name', $formPostValues), val('Email', $formPostValues), $userID);
+ }
+
+ // Add & apply any extra validation rules:
+ if (array_key_exists('Email', $formPostValues) && val('ValidateEmail', $settings, true)) {
+ $this->Validation->applyRule('Email', 'Email');
+ }
+ if (val('ValidateName', $settings, true)) {
+ $this->Validation->applyRule('Name', 'Username');
+ }
+
+ if ($this->validate($formPostValues, $insert) && $uniqueValid) {
+ // All fields on the form that need to be validated (including non-schema field rules defined above)
+ $fields = $this->Validation->validationFields();
+ $roleIDs = val('RoleID', $fields, 0);
+ $username = val('Name', $fields);
+ $email = val('Email', $fields, '');
+
+ // Only fields that are present in the schema
+ $fields = $this->Validation->schemaValidationFields();
+
+ // Remove the primary key from the fields collection before saving.
+ unset($fields[$this->PrimaryKey]);
+
+ if (!$insert && array_key_exists('Password', $fields) && val('HashPassword', $settings, true)) {
+ // Encrypt the password for saving only if it won't be hashed in _Insert()
+ $passwordHash = new Gdn_PasswordHash();
+ $fields['Password'] = $passwordHash->hashPassword($fields['Password']);
+ $fields['HashMethod'] = 'Vanilla';
+ }
+
+ // Check for email confirmation.
+ if (self::requireConfirmEmail() && !val('NoConfirmEmail', $settings)) {
+ $emailIsSet = isset($fields['Email']);
+ $emailIsNotConfirmed = array_key_exists('Confirmed', $fields) && $fields['Confirmed'] == 0;
+ $validSession = Gdn::session()->isValid();
+
+ $currentUserEmailIsBeingChanged =
+ $validSession
+ && $userID == Gdn::session()->UserID
+ && $fields['Email'] != Gdn::session()->User->Email
+ && !Gdn::session()->checkPermission('Garden.Users.Edit')
+ ;
+
+ // Email address has changed
+ if ($emailIsSet && ($emailIsNotConfirmed || $currentUserEmailIsBeingChanged)) {
+ $attributes = val('Attributes', Gdn::session()->User);
+ if (is_string($attributes)) {
+ $attributes = dbdecode($attributes);
+ }
+
+ $confirmEmailRoleID = RoleModel::getDefaultRoles(RoleModel::TYPE_UNCONFIRMED);
+ if (!empty($confirmEmailRoleID)) {
+ // The confirm email role is set and it exists so go ahead with the email confirmation.
+ $emailKey = $this->confirmationCode();
+ setValue('EmailKey', $attributes, $emailKey);
+ $fields['Attributes'] = dbencode($attributes);
+ $fields['Confirmed'] = 0;
+ }
+ }
+ }
+
+ $this->EventArguments['SaveRoles'] = &$saveRoles;
+ $this->EventArguments['RoleIDs'] = &$roleIDs;
+ $this->EventArguments['Fields'] = &$fields;
+ $this->fireEvent('BeforeSave');
+ $user = array_merge($user, $fields);
+
+ // Check the validation results again in case something was added during the BeforeSave event.
+ if (count($this->Validation->results()) == 0) {
+ // Encode any IP fields that aren't already encoded.
+ $ipCols = ['InsertIPAddress', 'LastIPAddress', 'UpdateIPAddress'];
+ foreach ($ipCols as $col) {
+ if (isset($fields[$col]) && filter_var($fields[$col], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4|FILTER_FLAG_IPV6)) {
+ $fields[$col] = ipEncode($fields[$col]);
+ }
+ }
+ unset($col);
+
+ // If the primary key exists in the validated fields and it is a
+ // numeric value greater than zero, update the related database row.
+ if ($userID > 0) {
+ // If they are changing the username & email, make sure they aren't
+ // already being used (by someone other than this user)
+ if (val('Name', $fields, '') != '' || val('Email', $fields, '') != '') {
+ if (!$this->validateUniqueFields($username, $email, $userID)) {
+ return false;
+ }
+ }
+
+ // Determine if the password reset information needs to be cleared.
+ $clearPasswordReset = false;
+ if (array_key_exists('Password', $fields)) {
+ // New password? Clear the password reset info.
+ $clearPasswordReset = true;
+ } elseif (array_key_exists('Email', $fields)) {
+ $row = $this->getID($userID, DATASET_TYPE_ARRAY);
+ if ($fields['Email'] != val('Email', $row)) {
+ // New email? Clear the password reset info.
+ $clearPasswordReset = true;
+ }
+ }
+
+ if ($clearPasswordReset) {
+ $this->clearPasswordReset($userID);
+ // The save routine could've tweaked existing attributes. Make sure fields are purged here too.
+ if (array_key_exists('Attributes', $fields)) {
+ // Attributes might be a string at this point. They'll be converted into a string before saving.
+ if (is_string($fields['Attributes'])) {
+ $fields['Attributes'] = dbdecode($fields['Attributes']);
+ }
+ if (!empty($fields['Attributes']) && is_array($fields['Attributes'])) {
+ unset($fields['Attributes']['PasswordResetKey']);
+ unset($fields['Attributes']['PasswordResetExpires']);
+ }
+ }
+ }
+
+ if (array_key_exists('Preferences', $fields) && !is_string($fields['Preferences'])) {
+ $fields['Preferences'] = dbencode($fields['Preferences']);
+ }
+
+ if (array_key_exists('Attributes', $fields) && !is_string($fields['Attributes'])) {
+ $fields['Attributes'] = dbencode($fields['Attributes']);
+ }
+
+ // Perform save DB operation
+ $this->SQL->put($this->Name, $fields, [$this->PrimaryKey => $userID]);
+
+ // Record activity if the person changed his/her photo.
+ $photo = val('Photo', $formPostValues);
+ if ($photo !== false) {
+ if (val('CheckExisting', $settings)) {
+ $user = $this->getID($userID);
+ $oldPhoto = val('Photo', $user);
+ }
+
+ if (isset($oldPhoto) && $oldPhoto != $photo) {
+ if (isUrl($photo)) {
+ $photoUrl = $photo;
+ } else {
+ $photoUrl = Gdn_Upload::url(changeBasename($photo, 'n%s'));
+ }
+
+ $activityModel = new ActivityModel();
+ if ($userID == Gdn::session()->UserID) {
+ $headlineFormat = t('HeadlineFormat.PictureChange', '{RegardingUserID,You} changed {ActivityUserID,your} profile picture.');
+ } else {
+ $headlineFormat = t('HeadlineFormat.PictureChange.ForUser', '{RegardingUserID,You} changed the profile picture for {ActivityUserID,user}.');
+ }
+
+ $activityModel->save([
+ 'ActivityUserID' => $userID,
+ 'RegardingUserID' => Gdn::session()->UserID,
+ 'ActivityType' => 'PictureChange',
+ 'HeadlineFormat' => $headlineFormat,
+ 'Story' => img($photoUrl, ['alt' => t('Thumbnail')])
+ ]);
+ }
+ }
+
+ } else {
+ $recordRoleChange = false;
+ if (!$this->validateUniqueFields($username, $email)) {
+ return false;
+ }
+
+ // Define the other required fields:
+ $fields['Email'] = $email;
+
+ // Make sure that the user is assigned to at least the default role(s).
+ if (!is_array($roleIDs)) {
+ $roleIDs = RoleModel::getDefaultRoles(RoleModel::TYPE_MEMBER);
+ }
+ $fields['Roles'] = $roleIDs;
+ $saveRoles = false; // insertInternal will take care of updating the roles.
+
+ // And insert the new user.
+ $userID = $this->insertInternal($fields, $settings);
+
+ if ($userID > 0) {
+ // Report that the user was created.
+ $activityModel = new ActivityModel();
+ $activityModel->save(
+ [
+ 'ActivityType' => 'Registration',
+ 'ActivityUserID' => $userID,
+ 'HeadlineFormat' => t('HeadlineFormat.Registration', '{ActivityUserID,You} joined.'),
+ 'Story' => t('Welcome Aboard!')],
+ false,
+ ['GroupBy' => 'ActivityTypeID']
+ );
+
+ // Report the creation for mods.
+ $activityModel->save([
+ 'ActivityType' => 'Registration',
+ 'ActivityUserID' => Gdn::session()->UserID,
+ 'RegardingUserID' => $userID,
+ 'NotifyUserID' => ActivityModel::NOTIFY_MODS,
+ 'HeadlineFormat' => t('HeadlineFormat.AddUser', '{ActivityUserID,user} added an account for {RegardingUserID,user}.')]);
+ }
+ }
+
+ // Now update the role settings if necessary.
+ if ($saveRoles) {
+ // If no RoleIDs were provided, use the system defaults
+ if (!is_array($roleIDs)) {
+ $roleIDs = RoleModel::getDefaultRoles(RoleModel::TYPE_MEMBER);
+ }
+
+ $this->saveRoles($userID, $roleIDs, $recordRoleChange);
+ }
+
+ // Send the confirmation email.
+ if (isset($emailKey)) {
+ if (!is_array($user)) {
+ $user = $this->getID($userID, DATASET_TYPE_ARRAY);
+ }
+ $this->sendEmailConfirmationEmail($user, true);
+ }
+
+ $this->clearCache($userID, ['user']);
+ $this->EventArguments['UserID'] = $userID;
+ $this->fireEvent('AfterSave');
+ } else {
+ $userID = false;
+ }
+ } else {
+ $userID = false;
+ }
+
+ return $userID;
+ }
+
+ /**
+ * Create an admin user account.
+ *
+ * @param array $formPostValues
+ */
+ public function saveAdminUser($formPostValues) {
+ $userID = 0;
+
+ // Add & apply any extra validation rules:
+ $name = val('Name', $formPostValues, '');
+ $formPostValues['Email'] = val('Email', $formPostValues, strtolower($name.'@'.Gdn_Url::host()));
+ $formPostValues['ShowEmail'] = '0';
+ $formPostValues['TermsOfService'] = '1';
+ $formPostValues['DateOfBirth'] = '1975-09-16';
+ $formPostValues['DateLastActive'] = Gdn_Format::toDateTime();
+ $formPostValues['DateUpdated'] = Gdn_Format::toDateTime();
+ $formPostValues['Gender'] = 'u';
+ $formPostValues['Admin'] = '1';
+
+ $this->addInsertFields($formPostValues);
+
+ if ($this->validate($formPostValues, true) === true) {
+ $fields = $this->Validation->schemaValidationFields(); // Only fields that are present in the schema
+
+ // Insert the new user
+ $userID = $this->insertInternal($fields, ['NoConfirmEmail' => true, 'Setup' => true]);
+
+ if ($userID > 0) {
+ $activityModel = new ActivityModel();
+ $activityModel->save(
+ [
+ 'ActivityUserID' => $userID,
+ 'ActivityType' => 'Registration',
+ 'HeadlineFormat' => t('HeadlineFormat.Registration', '{ActivityUserID,You} joined.'),
+ 'Story' => t('Welcome Aboard!')
+ ],
+ false,
+ ['GroupBy' => 'ActivityTypeID']
+ );
+ }
+
+ $this->saveRoles($userID, [16], false);
+ }
+ return $userID;
+ }
+
+ /**
+ *
+ *
+ * @param int $UserID
+ * @param array $RoleIDs
+ * @param bool $RecordEvent
+ */
+ public function saveRoles($UserID, $RoleIDs, $RecordEvent) {
+ if (is_string($RoleIDs) && !is_numeric($RoleIDs)) {
+ // The $RoleIDs are a comma delimited list of role names.
+ $RoleNames = array_map('trim', explode(',', $RoleIDs));
+ $RoleIDs = $this->SQL
+ ->select('r.RoleID')
+ ->from('Role r')
+ ->whereIn('r.Name', $RoleNames)
+ ->get()->resultArray();
+ $RoleIDs = array_column($RoleIDs, 'RoleID');
+ }
+
+ if (!is_array($RoleIDs)) {
+ $RoleIDs = [$RoleIDs];
+ }
+
+ // Get the current roles.
+ $OldRoleIDs = [];
+ $OldRoleData = $this->SQL
+ ->select('ur.RoleID, r.Name')
+ ->from('UserRole ur')
+ ->join('Role r', 'r.RoleID = ur.RoleID', 'left')
+ ->where('ur.UserID', $UserID)
+ ->get()
+ ->resultArray();
+
+ if ($OldRoleData !== false) {
+ $OldRoleIDs = array_column($OldRoleData, 'RoleID');
+ }
+
+ // 1a) Figure out which roles to delete.
+ $DeleteRoleIDs = [];
+ foreach ($OldRoleData as $row) {
+ // The role should be deleted if it is an orphan or the user has not been assigned the role.
+ if ($row['Name'] === null || !in_array($row['RoleID'], $RoleIDs)) {
+ $DeleteRoleIDs[] = $row['RoleID'];
+ }
+ }
+
+ // 1b) Remove old role associations for this user.
+ if (!empty($DeleteRoleIDs)) {
+ $this->SQL->whereIn('RoleID', $DeleteRoleIDs)->delete('UserRole', ['UserID' => $UserID]);
+ }
+
+ // 2a) Figure out which roles to insert.
+ $InsertRoleIDs = array_diff($RoleIDs, $OldRoleIDs);
+ // 2b) Insert the new role associations for this user.
+ foreach ($InsertRoleIDs as $InsertRoleID) {
+ if (is_numeric($InsertRoleID)) {
+ $this->SQL->insert('UserRole', ['UserID' => $UserID, 'RoleID' => $InsertRoleID]);
+ }
+ }
+
+ $this->clearCache($UserID, ['roles', 'permissions']);
+
+ if ($RecordEvent) {
+ $User = $this->getID($UserID);
+
+ $OldRoles = [];
+ foreach ($DeleteRoleIDs as $deleteRoleID) {
+ $role = RoleModel::roles($deleteRoleID);
+ $OldRoles[] = val('Name', $role, t('Unknown').' ('.$deleteRoleID.')');
+ }
+
+ $NewRoles = [];
+ foreach ($InsertRoleIDs as $insertRoleID) {
+ $role = RoleModel::roles($insertRoleID);
+ $NewRoles[] = val('Name', $role, t('Unknown').' ('.$insertRoleID.')');
+ }
+
+ $RemovedRoles = array_diff($OldRoles, $NewRoles);
+ $NewRoles = array_diff($NewRoles, $OldRoles);
+
+ foreach ($RemovedRoles as $RoleName) {
+ Logger::event(
+ 'role_remove',
+ Logger::INFO,
+ "{username} removed {toUsername} from the {role} role.",
+ ['touserid' => $User->UserID, 'toUsername' => $User->Name, 'role' => $RoleName]
+ );
+ }
+
+ foreach ($NewRoles as $RoleName) {
+ Logger::event(
+ 'role_add',
+ Logger::INFO,
+ "{username} added {toUsername} to the {role} role.",
+ ['touserid' => $User->UserID, 'toUsername' => $User->Name, 'role' => $RoleName]
+ );
+ }
+ }
+ }
+
+ /**
+ * Search users.
+ *
+ * @param array|string $filter
+ * @param string $orderFields
+ * @param string $orderDirection
+ * @param bool $limit
+ * @param bool $offset
+ * @return Gdn_DataSet
+ */
+ public function search($filter, $orderFields = '', $orderDirection = 'asc', $limit = false, $offset = false) {
+ $optimize = false;
+
+ if (is_array($filter)) {
+ $where = $filter;
+ $keywords = val('Keywords', $filter, '');
+ $optimize = val('Optimize', $filter);
+ unset($where['Keywords'], $where['Optimize']);
+ } else {
+ $keywords = $filter;
+ }
+ $keywords = trim($keywords);
+
+ // Check for an IPV4/IPV6 address.
+ if (filter_var($keywords, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4|FILTER_FLAG_IPV6) !== false) {
+ $ipAddress = $keywords;
+ $this->addIpFilters($ipAddress, ['LastIPAddress']);
+ } elseif (strtolower($keywords) == 'banned') {
+ $this->SQL->where('u.Banned >', 0);
+ $keywords = '';
+ } elseif (preg_match('/^\d+$/', $keywords)) {
+ $numericQuery = $keywords;
+ $keywords = '';
+ } elseif (!empty($keywords)) {
+ // Check to see if the search exactly matches a role name.
+ $roleID = $this->SQL->getWhere('Role', ['Name' => $keywords])->value('RoleID');
+ }
+
+ $this->EventArguments['Keywords'] =& $keywords;
+ $this->EventArguments['RankID'] =& $rankID;
+ $this->EventArguments['Optimize'] =& $optimize;
+ $this->fireEvent('BeforeUserQuery');
+
+ $this->userQuery();
+
+ $this->fireEvent('AfterUserQuery');
+
+ if (isset($where)) {
+ $this->SQL->where($where, null, false);
+ }
+
+ if (!empty($roleID)) {
+ $this->SQL->join('UserRole ur2', "u.UserID = ur2.UserID and ur2.RoleID = $roleID");
+ } elseif (isset($numericQuery)) {
+ // We've searched for a number. Return UserID AND any exact numeric name match.
+ $this->SQL->beginWhereGroup()
+ ->where('u.UserID', $numericQuery)
+ ->orWhere('u.Name', $numericQuery)
+ ->endWhereGroup();
+ } elseif ($keywords) {
+ if ($optimize) {
+ // An optimized search should only be done against name OR email.
+ if (strpos($keywords, '@') !== false) {
+ $this->SQL->like('u.Email', $keywords, 'right');
+ } else {
+ $this->SQL->like('u.Name', $keywords, 'right');
+ }
+ } else {
+ // Search on the user table.
+ $like = ['u.Name' => $keywords, 'u.Email' => $keywords];
+
+ $this->SQL
+ ->orOp()
+ ->beginWhereGroup()
+ ->orLike($like, '', 'right')
+ ->endWhereGroup();
+ }
+ }
+
+ // Optimized searches need at least some criteria before performing a query.
+ if ($optimize && $this->SQL->whereCount() == 0 && empty($roleID)) {
+ $this->SQL->reset();
+ return new Gdn_DataSet([]);
+ }
+
+ $data = $this->SQL
+ ->where('u.Deleted', 0)
+ ->orderBy($orderFields, $orderDirection)
+ ->limit($limit, $offset)
+ ->get();
+
+ $result = &$data->result();
+
+ foreach ($result as &$row) {
+ if ($row->Photo && !isUrl($row->Photo)) {
+ $row->Photo = Gdn_Upload::url(changeBasename($row->Photo, 'n%s'));
+ }
+
+ $row->Attributes = dbdecode($row->Attributes);
+ $row->Preferences = dbdecode($row->Preferences);
+ }
+
+ return $data;
+ }
+
+
+ /**
+ * Appends filters to the current SQL object. Filters users with a given IP Address in the UserIP table. Extends
+ * filtering to IPs in the GDN_User table for any fields passed in the $fields param.
+ *
+ * @param string $ip The IP Address to search for.
+ * @param array $fields The additional fields to check in the UserTable
+ */
+ private function addIpFilters($ip, $fields = []) {
+ // Get a clean SQL object.
+ $sql = clone $this->SQL;
+ $sql->reset();
+
+ // Get all users that matches the IP address.
+ $sql
+ ->select('UserID')
+ ->from('UserIP')
+ ->where('IPAddress', inet_pton($ip));
+
+ $matchingUserIDs = $sql->get()->resultArray();
+ $userIDs = array_column($matchingUserIDs, 'UserID');
+
+ // Add these users to search query.
+ $this->SQL
+ ->orWhereIn('u.UserID', $userIDs);
+
+ // Check the user table ip fields.
+ $allowedFields = ['LastIPAddress', 'InsertIPAddress', 'UpdateIPAddress'];
+
+ foreach ($fields as $field) {
+ if (in_array($field, $allowedFields)) {
+ $this->SQL->orWhereIn('u.'.$field, [$ip, inet_pton($ip)]);
+ }
+ }
+ }
+
+ /**
+ * Count search results.
+ *
+ * @param array|string $filter
+ * @return int
+ */
+ public function searchCount($filter = '') {
+ if (is_array($filter)) {
+ $where = $filter;
+ $keywords = $where['Keywords'];
+ unset($where['Keywords'], $where['Optimize']);
+ } else {
+ $keywords = $filter;
+ }
+ $keywords = trim($keywords);
+
+ // Check to see if the search exactly matches a role name.
+ $roleID = false;
+ if (strtolower($keywords) == 'banned') {
+ $this->SQL->where('u.Banned >', 0);
+ } else {
+ $roleID = $this->SQL->getWhere('Role', ['Name' => $keywords])->value('RoleID');
+ }
+
+ if (isset($where)) {
+ $this->SQL->where($where, null, false);
+ }
+
+ $this->SQL
+ ->select('u.UserID', 'count', 'UserCount')
+ ->from('User u');
+
+ // Check for an IPV4/IPV6 address.
+ if (filter_var($keywords, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4|FILTER_FLAG_IPV6) !== false) {
+ $fields = ['LastIPAddress'];
+ $this->addIpFilters($keywords, $fields);
+ } else if ($roleID) {
+ $this->SQL->join('UserRole ur2', "u.UserID = ur2.UserID and ur2.RoleID = $roleID");
+ } else {
+ // Search on the user table.
+ $like = trim($keywords) == '' ? false : ['u.Name' => $keywords, 'u.Email' => $keywords];
+
+ if (is_array($like)) {
+ $this->SQL
+ ->orOp()
+ ->beginWhereGroup()
+ ->orLike($like, '', 'right')
+ ->endWhereGroup();
+ }
+ }
+
+ $this->SQL
+ ->where('u.Deleted', 0);
+
+ $data = $this->SQL->get()->firstRow();
+
+ return $data === false ? 0 : $data->UserCount;
+ }
+
+ /**
+ * Search all users by username.
+ *
+ * @param string $name The username to search. Supports wildcards (e.g. user*).
+ * @param string $sortField Column to sort resutls by.
+ * @param string $sortDirection Direction used for column sort.
+ * @param int|bool $limit Maximum results to return.
+ * @param int|bool $offset Offset for result rows.
+ * @return Gdn_DataSet
+ */
+ public function searchByName($name, $sortField = 'name', $sortDirection = 'asc', $limit = false, $offset = false) {
+ $wildcardSearch = (substr($name, -1, 1) === '*');
+
+ // Preserve existing % by escaping.
+ $name = trim($name);
+ $name = str_replace('%', '\%', $name);
+ if ($wildcardSearch) {
+ $name = rtrim($name, '*');
+ }
+
+ // Avoid potential pollution by resetting.
+ $this->SQL->reset();
+ $this->SQL->from('User');
+ if ($wildcardSearch) {
+ $this->SQL->like('Name', $name, 'right');
+ } else {
+ $this->SQL->where('Name', $name);
+ }
+ $result = $this->SQL
+ ->where('Deleted', 0)
+ ->orderBy($sortField, $sortDirection)
+ ->limit($limit, $offset)
+ ->get();
+ return $result;
+ }
+
+ /**
+ *
+ *
+ * @return string
+ */
+ public static function signinLabelCode() {
+ return UserModel::noEmail() ? 'Username' : 'Email/Username';
+ }
+
+ /**
+ * A simple search for tag queries.
+ *
+ * @param string $search
+ * @param int $limit
+ * @since 2.2
+ */
+ public function tagSearch($search, $limit = 10) {
+ $search = trim(str_replace(['%', '_'], ['\%', '\_'], $search));
+
+ list($order, $direction) = $this->getMentionsSort();
+
+ return $this->SQL
+ ->select('UserID', '', 'id')
+ ->select('Name', '', 'name')
+ ->from('User')
+ ->like('Name', $search, 'right')
+ ->where('Deleted', 0)
+ ->orderBy($order, $direction)
+ ->limit($limit)
+ ->get()
+ ->resultArray();
+ }
+
+ /**
+ * To be used for invitation registration.
+ *
+ * @param array $formPostValues
+ * @param array $options
+ * - ValidateName - Make sure the provided name is valid. Blacklisted names will always be blocked.
+ * @return int UserID.
+ */
+ public function insertForInvite($formPostValues, $options = []) {
+ $roleIDs = RoleModel::getDefaultRoles(RoleModel::TYPE_MEMBER);
+ if (!is_array($roleIDs) || count($roleIDs) == 0) {
+ throw new Exception(t('The default role has not been configured.'), 400);
+ }
+
+ // Define the primary key in this model's table.
+ $this->defineSchema();
+
+ // Add & apply any extra validation rules:
+ $this->Validation->applyRule('Email', 'Email');
+
+ // Make sure that the checkbox val for email is saved as the appropriate enum
+ if (array_key_exists('ShowEmail', $formPostValues)) {
+ $formPostValues['ShowEmail'] = forceBool($formPostValues['ShowEmail'], '0', '1', '0');
+ }
+
+ if (array_key_exists('Banned', $formPostValues)) {
+ $formPostValues['Banned'] = forceBool($formPostValues['Banned'], '0', '1', '0');
+ }
+
+ $this->addInsertFields($formPostValues);
+
+ // Make sure that the user has a valid invitation code, and also grab
+ // the user's email from the invitation:
+ $invitationCode = val('InvitationCode', $formPostValues, '');
+
+ $invitation = $this->SQL->getWhere('Invitation', ['Code' => $invitationCode])->firstRow();
+
+ // If there is no invitation then bail out.
+ if (!$invitation) {
+ $this->Validation->addValidationResult('InvitationCode', 'Invitation not found.');
+ return false;
+ }
+
+ if (!empty($invitation->AcceptedUserID)) {
+ $this->Validation->addValidationResult('InvitationCode', 'Invitation has been used.');
+ return false;
+ }
+
+ // Get expiration date in timestamp. If nothing set, grab config default.
+ $inviteExpiration = $invitation->DateExpires;
+ if ($inviteExpiration != null) {
+ $inviteExpiration = Gdn_Format::toTimestamp($inviteExpiration);
+ } else {
+ $defaultExpire = '1 week';
+ $inviteExpiration = strtotime(c('Garden.Registration.InviteExpiration', '1 week'), Gdn_Format::toTimestamp($invitation->DateInserted));
+ if ($inviteExpiration === false) {
+ $inviteExpiration = strtotime($defaultExpire);
+ }
+ }
+
+ if ($inviteExpiration <= time()) {
+ $this->Validation->addValidationResult('DateExpires', 'The invitation has expired.');
+ }
+
+ $inviteUserID = $invitation->InsertUserID;
+ $formPostValues['Email'] = $invitation->Email;
+
+ if (val('ValidateName', $options, true)) {
+ $this->Validation->applyRule('Name', 'Username');
+ }
+
+ if ($this->validate($formPostValues, true)) {
+ // Check for spam.
+ $spam = SpamModel::isSpam('Registration', $formPostValues);
+ if ($spam) {
+ $this->Validation->addValidationResult('Spam', 'You are not allowed to register at this time.');
+ return;
+ }
+
+ $fields = $this->Validation->validationFields(); // All fields on the form that need to be validated (including non-schema field rules defined above)
+ $username = val('Name', $fields);
+ $email = val('Email', $fields);
+ $fields = $this->Validation->schemaValidationFields(); // Only fields that are present in the schema
+ unset($fields[$this->PrimaryKey]);
+
+ // Make sure the username & email aren't already being used
+ if (!$this->validateUniqueFields($username, $email)) {
+ return false;
+ }
+
+ // Define the other required fields:
+ if ($inviteUserID > 0) {
+ $fields['InviteUserID'] = $inviteUserID;
+ }
+
+ // And insert the new user.
+ if (!isset($options['NoConfirmEmail'])) {
+ $options['NoConfirmEmail'] = true;
+ }
+
+ // Use RoleIDs from Invitation table, if any. They are stored as a
+ // serialized array of the Role IDs.
+ $invitationRoleIDs = $invitation->RoleIDs;
+ if (strlen($invitationRoleIDs)) {
+ $invitationRoleIDs = dbdecode($invitationRoleIDs);
+
+ if (is_array($invitationRoleIDs)
+ && count(array_filter($invitationRoleIDs))
+ ) {
+ // Overwrite default RoleIDs set at top of method.
+ $roleIDs = $invitationRoleIDs;
+ }
+ }
+
+ $fields['Roles'] = $roleIDs;
+ $userID = $this->insertInternal($fields, $options);
+
+ // Associate the new user id with the invitation (so it cannot be used again)
+ $this->SQL
+ ->update('Invitation')
+ ->set('AcceptedUserID', $userID)
+ ->set('DateAccepted', Gdn_Format::toDateTime())
+ ->where('InvitationID', $invitation->InvitationID)
+ ->put();
+
+ // Report that the user was created.
+ $activityModel = new ActivityModel();
+ $activityModel->save(
+ [
+ 'ActivityUserID' => $userID,
+ 'ActivityType' => 'Registration',
+ 'HeadlineFormat' => t('HeadlineFormat.Registration', '{ActivityUserID,You} joined.'),
+ 'Story' => t('Welcome Aboard!')
+ ],
+ false,
+ ['GroupBy' => 'ActivityTypeID']
+ );
+ } else {
+ $userID = false;
+ }
+ return $userID;
+ }
+
+ /**
+ * To be used for approval registration.
+ *
+ * @param array $formPostValues
+ * @param array $options
+ * - ValidateSpam
+ * - CheckCaptcha
+ * - ValidateName - Make sure the provided name is valid. Blacklisted names will always be blocked.
+ * @return int UserID.
+ */
+ public function insertForApproval($formPostValues, $options = []) {
+ $roleIDs = RoleModel::getDefaultRoles(RoleModel::TYPE_APPLICANT);
+ if (empty($roleIDs)) {
+ throw new Exception(t('The default role has not been configured.'), 400);
+ }
+
+ // Define the primary key in this model's table.
+ $this->defineSchema();
+
+ // Add & apply any extra validation rules:
+ $this->Validation->applyRule('Email', 'Email');
+
+ // Make sure that the checkbox val for email is saved as the appropriate enum
+ if (array_key_exists('ShowEmail', $formPostValues)) {
+ $formPostValues['ShowEmail'] = forceBool($formPostValues['ShowEmail'], '0', '1', '0');
+ }
+
+ if (array_key_exists('Banned', $formPostValues)) {
+ $formPostValues['Banned'] = forceBool($formPostValues['Banned'], '0', '1', '0');
+ }
+
+ $this->addInsertFields($formPostValues);
+
+ if (val('ValidateName', $options, true)) {
+ $this->Validation->applyRule('Name', 'Username');
+ }
+
+ if ($this->validate($formPostValues, true)) {
+
+ if (val('ValidateSpam', $options, true)) {
+ // Check for spam.
+ $spam = SpamModel::isSpam('Registration', $formPostValues);
+ if ($spam) {
+ $this->Validation->addValidationResult('Spam', 'You are not allowed to register at this time.');
+ return;
+ }
+ }
+
+ $fields = $this->Validation->validationFields(); // All fields on the form that need to be validated (including non-schema field rules defined above)
+ $username = val('Name', $fields);
+ $email = val('Email', $fields);
+ $fields = $this->Validation->schemaValidationFields(); // Only fields that are present in the schema
+ unset($fields[$this->PrimaryKey]);
+
+ if (!$this->validateUniqueFields($username, $email)) {
+ return false;
+ }
+
+ // If in Captcha registration mode, check the captcha value.
+ if (val('CheckCaptcha', $options, true) && Captcha::enabled()) {
+ $captchaIsValid = Captcha::validate();
+ if ($captchaIsValid !== true) {
+ $this->Validation->addValidationResult('Garden.Registration.CaptchaPublicKey', t('The captcha was not completed correctly. Please try again.'));
+ return false;
+ }
+ }
+
+ // Define the other required fields:
+ $fields['Email'] = $email;
+ $fields['Roles'] = (array)$roleIDs;
+
+ // And insert the new user
+ $userID = $this->insertInternal($fields, $options);
+ } else {
+ $userID = false;
+ }
+ return $userID;
+ }
+
+ /**
+ * To be used for basic registration, and captcha registration.
+ *
+ * @param array $formPostValues
+ * @param bool $checkCaptcha
+ * @param array $options
+ * - ValidateName - Make sure the provided name is valid. Blacklisted names will always be blocked.
+ * @return bool|int|string
+ * @throws Exception
+ */
+ public function insertForBasic($formPostValues, $checkCaptcha = true, $options = []) {
+ $roleIDs = RoleModel::getDefaultRoles(RoleModel::TYPE_MEMBER);
+ if (!is_array($roleIDs) || count($roleIDs) == 0) {
+ throw new Exception(t('The default role has not been configured.'), 400);
+ }
+
+ if (val('SaveRoles', $options)) {
+ $roleIDs = val('RoleID', $formPostValues);
+ }
+
+ $userID = false;
+
+ // Define the primary key in this model's table.
+ $this->defineSchema();
+
+ // Add & apply any extra validation rules.
+ $this->Validation->addRule('UsernameBlacklist', 'function:validateAgainstUsernameBlacklist');
+ $this->Validation->applyRule('Name', 'UsernameBlacklist');
+ if (val('ValidateEmail', $options, true)) {
+ $this->Validation->applyRule('Email', 'Email');
+ }
+ if (val('ValidateName', $options, true)) {
+ $this->Validation->applyRule('Name', 'Username');
+ }
+
+ // TODO: DO I NEED THIS?!
+ // Make sure that the checkbox val for email is saved as the appropriate enum
+ if (array_key_exists('ShowEmail', $formPostValues)) {
+ $formPostValues['ShowEmail'] = forceBool($formPostValues['ShowEmail'], '0', '1', '0');
+ }
+
+ if (array_key_exists('Banned', $formPostValues)) {
+ $formPostValues['Banned'] = forceBool($formPostValues['Banned'], '0', '1', '0');
+ }
+
+ $this->addInsertFields($formPostValues);
+
+ if ($this->validate($formPostValues, true) === true) {
+ $fields = $this->Validation->validationFields(); // All fields on the form that need to be validated (including non-schema field rules defined above)
+ $username = val('Name', $fields);
+ $email = val('Email', $fields);
+ $fields = $this->Validation->schemaValidationFields(); // Only fields that are present in the schema
+ $fields['Roles'] = $roleIDs;
+ unset($fields[$this->PrimaryKey]);
+
+ // If in Captcha registration mode, check the captcha value.
+ if ($checkCaptcha && Captcha::enabled()) {
+ $captchaIsValid = Captcha::validate();
+ if ($captchaIsValid !== true) {
+ $this->Validation->addValidationResult('Garden.Registration.CaptchaPublicKey', t('The captcha was not completed correctly. Please try again.'));
+ return false;
+ }
+ }
+
+ if (!$this->validateUniqueFields($username, $email)) {
+ return false;
+ }
+
+ // Check for spam.
+ if (val('ValidateSpam', $options, true)) {
+ $validateSpam = $this->validateSpamRegistration($formPostValues);
+ if ($validateSpam !== true) {
+ return $validateSpam;
+ }
+ }
+
+ // Define the other required fields:
+ $fields['Email'] = $email;
+
+ // And insert the new user
+ $userID = $this->insertInternal($fields, $options);
+ if ($userID > 0 && !val('NoActivity', $options)) {
+ $activityModel = new ActivityModel();
+ $activityModel->save(
+ [
+ 'ActivityUserID' => $userID,
+ 'ActivityType' => 'Registration',
+ 'HeadlineFormat' => t('HeadlineFormat.Registration', '{ActivityUserID,You} joined.'),
+ 'Story' => t('Welcome Aboard!')
+ ],
+ false,
+ ['GroupBy' => 'ActivityTypeID']
+ );
+ }
+ }
+ return $userID;
+ }
+
+ /**
+ * Parent override.
+ *
+ * @param array &$fields
+ */
+ public function addInsertFields(&$fields) {
+ $this->defineSchema();
+
+ // Set the hour offset based on the client's clock.
+ $clientHour = val('ClientHour', $fields, '');
+ if (is_numeric($clientHour) && $clientHour >= 0 && $clientHour < 24) {
+ $hourOffset = $clientHour - date('G', time());
+ $fields['HourOffset'] = $hourOffset;
+ }
+
+ // Set some required dates.
+ $now = Gdn_Format::toDateTime();
+ $fields[$this->DateInserted] = $now;
+ touchValue('DateFirstVisit', $fields, $now);
+ $fields['DateLastActive'] = $now;
+ $fields['InsertIPAddress'] = ipEncode(Gdn::request()->ipAddress());
+ $fields['LastIPAddress'] = ipEncode(Gdn::request()->ipAddress());
+ }
+
+ /**
+ * Record an IP address for a user.
+ *
+ * @param int $userID Unique ID of the user.
+ * @param string $iP Human-readable IP address.
+ * @param string $dateUpdated Force an update timesetamp.
+ * @return bool Was the operation successful?
+ */
+ public function saveIP($userID, $iP, $dateUpdated = false) {
+ if (!filter_var($iP, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4|FILTER_FLAG_IPV6)) {
+ return false;
+ }
+
+ $packedIP = ipEncode($iP);
+ $px = Gdn::database()->DatabasePrefix;
+
+ if (!$dateUpdated) {
+ $dateUpdated = Gdn_Format::toDateTime();
+ }
+
+ $query = "insert into {$px}UserIP (UserID, IPAddress, DateInserted, DateUpdated)
+ values (:UserID, :IPAddress, :DateInserted, :DateUpdated)
+ on duplicate key update DateUpdated = :DateUpdated2";
+ $values = [
+ ':UserID' => $userID,
+ ':IPAddress' => $packedIP,
+ ':DateInserted' => Gdn_Format::toDateTime(),
+ ':DateUpdated' => $dateUpdated,
+ ':DateUpdated2' => $dateUpdated
+ ];
+
+ try {
+ Gdn::database()->query($query, $values);
+ $result = true;
+ } catch (\Exception $e) {
+ $result = false;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Updates visit level information such as date last active and the user's ip address.
+ * @param int $userID
+ * @param null|int|float $clientHour
+ * @throws Exception If the user ID is not valid.
+ * @return bool True on success, false if the user is banned or deleted.
+ */
+ public function updateVisit($userID, $clientHour = null) {
+ $userID = (int)$userID;
+ if (!$userID) {
+ throw new Exception('A valid User ID is required.');
+ }
+
+ $user = Gdn::userModel()->getID($userID, DATASET_TYPE_ARRAY);
+
+ // Do not update visit information if the user is banned or deleted.
+ if (val('Banned', $user) || val('Deleted', $user)) {
+ return false;
+ }
+
+ $fields = [];
+
+ if (Gdn_Format::toTimestamp($user['DateLastActive']) < strtotime('5 minutes ago')) {
+ // We only update the last active date once every 5 minutes to cut down on DB activity.
+ $fields['DateLastActive'] = Gdn_Format::toDateTime();
+ }
+
+ // Update session level information if necessary.
+ if ($userID == Gdn::session()->UserID) {
+ $iP = Gdn::request()->ipAddress();
+ $fields['LastIPAddress'] = ipEncode($iP);
+ $this->saveIP($userID, $iP);
+
+ if (Gdn::session()->newVisit()) {
+ $fields['CountVisits'] = val('CountVisits', $user, 0) + 1;
+ $this->fireEvent('Visit');
+ }
+ }
+
+ // Set the hour offset based on the client's clock.
+ if (is_numeric($clientHour) && $clientHour >= 0 && $clientHour < 24) {
+ $hourOffset = $clientHour - date('G', time());
+ $fields['HourOffset'] = $hourOffset;
+ }
+
+ // See if the fields have changed.
+ $set = [];
+ foreach ($fields as $name => $value) {
+ if (val($name, $user) != $value) {
+ $set[$name] = $value;
+ }
+ }
+
+ if (!empty($set)) {
+ $this->EventArguments['Fields'] = &$set;
+ $this->fireEvent('UpdateVisit');
+
+ $this->setField($userID, $set);
+ }
+
+ if ($user['LastIPAddress'] != $fields['LastIPAddress']) {
+ $user = $this->getID($userID, DATASET_TYPE_ARRAY);
+ if (!BanModel::checkUser($user, null, true, $bans)) {
+ $banModel = new BanModel();
+ $ban = array_pop($bans);
+ $banModel->saveUser($user, true, $ban);
+ $banModel->setCounts($ban);
+ }
+ }
+
+ return true;
+ }
+
+
+ /**
+ * Returns a list of lowercase, blacklisted usernames. Currently profileController endpoints,
+ * in core or in plugins, are blacklisted.
+ */
+ public static function getUsernameBlacklist() {
+ $pluginEndpoints = [
+ 'addons',
+ 'applyrank',
+ 'avatar',
+ 'card',
+ 'comments',
+ 'deletenote',
+ 'discussions',
+ 'facebookconnect',
+ 'following',
+ 'githubconnect',
+ 'hubsso',
+ 'ignore',
+ 'jsconnect',
+ 'linkedinconnect',
+ 'note',
+ 'notes',
+ 'online',
+ 'pegaconnect',
+ 'picture',
+ 'quotes',
+ 'reactions',
+ 'removepicture',
+ 'removewarning',
+ 'reversewarning',
+ 'salesforceconnect',
+ 'setlocale',
+ 'signature',
+ 'thumbnail',
+ 'twitterconnect',
+ 'usercard',
+ 'username',
+ 'viewnote',
+ 'warn',
+ 'warnings',
+ 'whosonline',
+ 'zendeskconnect'
+ ];
+
+ $profileControllerEndpoints = [];
+
+ // Get public methods on ProfileController
+ $reflection = new ReflectionClass('ProfileController');
+ foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
+ if ($method->class == $reflection->getName()) {
+ $profileControllerEndpoints[] = $method->name;
+ }
+ }
+
+ $profileControllerEndpoints = array_map(function($str) { return strtolower($str); }, $profileControllerEndpoints);
+ $endpoints = array_merge($profileControllerEndpoints, $pluginEndpoints);
+ return $endpoints;
+ }
+
+ /**
+ * Validate submitted user data.
+ *
+ * @param array $formPostValues
+ * @param bool $insert
+ * @return bool|array
+ */
+ public function validate($formPostValues, $insert = false) {
+ $this->defineSchema();
+
+ if (self::noEmail()) {
+ // Remove the email requirement.
+ $this->Validation->unapplyRule('Email', 'Required');
+ }
+
+ if (!$insert && !isset($formPostValues['Name'])) {
+ $this->Validation->unapplyRule('Name');
+ }
+
+ return $this->Validation->validate($formPostValues, $insert);
+ }
+
+ /**
+ * Validate User Credential.
+ *
+ * Fetches a user row by email (or name) and compare the password.
+ * If the password was not stored as a blowfish hash, the password will be saved again.
+ * Return the user's id, admin status and attributes.
+ *
+ * @param string $email
+ * @param string $password
+ * @return object|false Returns the user matching the credentials or **false** if the user doesn't validate.
+ */
+ public function validateCredentials($email = '', $iD = 0, $password, $throw = false) {
+ $this->EventArguments['Credentials'] = ['Email' => $email, 'ID' => $iD, 'Password' => $password];
+ $this->fireEvent('BeforeValidateCredentials');
+
+ if (!$email && !$iD) {
+ throw new Exception('The email or id is required');
+ }
+
+ try {
+ $this->SQL->select('UserID, Name, Attributes, Admin, Password, HashMethod, Deleted, Banned')
+ ->from('User');
+
+ if ($iD) {
+ $this->SQL->where('UserID', $iD);
+ } else {
+ if (strpos($email, '@') > 0) {
+ $this->SQL->where('Email', $email);
+ } else {
+ $this->SQL->where('Name', $email);
+ }
+ }
+
+ $dataSet = $this->SQL->get();
+ } catch (Exception $ex) {
+ $this->SQL->reset();
+
+ // Try getting the user information without the new fields.
+ $this->SQL->select('UserID, Name, Attributes, Admin, Password')
+ ->from('User');
+
+ if ($iD) {
+ $this->SQL->where('UserID', $iD);
+ } else {
+ if (strpos($email, '@') > 0) {
+ $this->SQL->where('Email', $email);
+ } else {
+ $this->SQL->where('Name', $email);
+ }
+ }
+
+ $dataSet = $this->SQL->get();
+ }
+
+ if ($dataSet->numRows() < 1 || val('Deleted', $dataSet->firstRow())) {
+ if ($throw) {
+ $validation = new \Garden\Schema\Validation();
+ $validation->addError('username', sprintf(t('User not found.'), strtolower(t(UserModel::signinLabelCode()))), 404);
+ throw new \Garden\Schema\ValidationException($validation);
+ }
+
+ return false;
+ }
+
+ $userData = $dataSet->firstRow();
+
+ self::rateLimit($userData);
+
+ $passwordHash = new Gdn_PasswordHash();
+ $hashMethod = val('HashMethod', $userData);
+ if (!$passwordHash->checkPassword($password, $userData->Password, $hashMethod, $userData->Name)) {
+ if ($throw) {
+ $validation = new \Garden\Schema\Validation();
+ $validation->addError('password', t('The password you entered is incorrect.'), 401);
+ throw new \Garden\Schema\ValidationException($validation);
+ }
+ return false;
+ }
+
+ if ($passwordHash->Weak || ($hashMethod && strcasecmp($hashMethod, 'Vanilla') != 0)) {
+ $pw = $passwordHash->hashPassword($password);
+ $this->SQL->update('User')
+ ->set('Password', $pw)
+ ->set('HashMethod', 'Vanilla')
+ ->where('UserID', $userData->UserID)
+ ->put();
+ }
+
+ $userData->Attributes = dbdecode($userData->Attributes);
+ return $userData;
+ }
+
+ /**
+ *
+ *
+ * @param array $user
+ * @return bool|string
+ * @since 2.1
+ */
+ public function validateSpamRegistration($user) {
+ $discoveryText = val('DiscoveryText', $user);
+ $log = validateRequired($discoveryText);
+ $spam = SpamModel::isSpam('Registration', $user, ['Log' => $log]);
+
+ if ($spam) {
+ if ($log) {
+ // The user entered discovery text.
+ return self::REDIRECT_APPROVE;
+ } else {
+ $this->Validation->addValidationResult('DiscoveryText', 'Tell us why you want to join!');
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Checks to see if $username and $email are already in use by another member.
+ *
+ * @param string $username
+ * @param string $email
+ * @param string $userID
+ * @param bool $return
+ * @return array|bool
+ */
+ public function validateUniqueFields($username, $email, $userID = '', $return = false) {
+ $valid = true;
+ $where = [];
+ if (is_numeric($userID)) {
+ $where['UserID <> '] = $userID;
+ }
+
+ $result = ['Name' => true, 'Email' => true];
+
+ // Make sure the username & email aren't already being used
+ if (c('Garden.Registration.NameUnique', true) && $username) {
+ $where['Name'] = $username;
+ $testData = $this->getWhere($where);
+ if ($testData->numRows() > 0) {
+ $result['Name'] = false;
+ $valid = false;
+ }
+ unset($where['Name']);
+ }
+
+ if (c('Garden.Registration.EmailUnique', true) && $email) {
+ $where['Email'] = $email;
+ $testData = $this->getWhere($where);
+ if ($testData->numRows() > 0) {
+ $result['Email'] = false;
+ $valid = false;
+ }
+ }
+
+ if ($return) {
+ return $result;
+ } else {
+ if (!$result['Name']) {
+ $this->Validation->addValidationResult('Name', 'The name you entered is already in use by another member.');
+ }
+ if (!$result['Email']) {
+ $this->Validation->addValidationResult('Email', 'The email you entered is in use by another member.');
+ }
+ return $valid;
+ }
+ }
+
+ /**
+ * Approve a membership applicant.
+ *
+ * @param int $userID
+ * @param string|null $email Deprecated.
+ * @return bool
+ * @throws Exception
+ */
+ public function approve($userID, $email = null) {
+ if ($email !== null) {
+ deprecated('Using the $email parameter of UserModel::approve.');
+ }
+
+ $applicantFound = $this->isApplicant($userID);
+
+ if ($applicantFound) {
+ // Retrieve the default role(s) for new users
+ $roleIDs = RoleModel::getDefaultRoles(RoleModel::TYPE_MEMBER);
+
+ // Wipe out old & insert new roles for this user
+ $this->saveRoles($userID, $roleIDs, false);
+
+ // Send out a notification to the user
+ $user = $this->getID($userID);
+ if ($user) {
+ $email = new Gdn_Email();
+ $email->subject(sprintf(t('[%1$s] Membership Approved'), c('Garden.Title')));
+ $email->to($user->Email);
+
+ $message = sprintf(t('Hello %s!'), val('Name', $user)).' '.t('You have been approved for membership.');
+ $emailTemplate = $email->getEmailTemplate()
+ ->setMessage($message)
+ ->setButton(externalUrl(signInUrl()), t('Sign In Now'))
+ ->setTitle(t('Membership Approved'));
+
+ $email->setEmailTemplate($emailTemplate);
+
+ try {
+ $email->send();
+ } catch (Exception $e) {
+ if (debug()) {
+ throw $e;
+ }
+ }
+
+ // Report that the user was approved.
+ $activityModel = new ActivityModel();
+ $activityModel->save(
+ [
+ 'ActivityUserID' => $userID,
+ 'ActivityType' => 'Registration',
+ 'HeadlineFormat' => t('HeadlineFormat.Registration', '{ActivityUserID,You} joined.'),
+ 'Story' => t('Welcome Aboard!')
+ ],
+ false,
+ ['GroupBy' => 'ActivityTypeID']
+ );
+
+ // Report the approval for moderators.
+ $activityModel->save(
+ [
+ 'ActivityType' => 'Registration',
+ 'ActivityUserID' => Gdn::session()->UserID,
+ 'RegardingUserID' => $userID,
+ 'NotifyUserID' => ActivityModel::NOTIFY_MODS,
+ 'HeadlineFormat' => t('HeadlineFormat.RegistrationApproval', '{ActivityUserID,user} approved the applications for {RegardingUserID,user}.')],
+ false,
+ ['GroupBy' => ['ActivityTypeID', 'ActivityUserID']]
+ );
+
+ Gdn::userModel()->saveAttribute($userID, 'ApprovedByUserID', Gdn::session()->UserID);
+ }
+
+
+ }
+ return true;
+ }
+
+ /**
+ * Delete a user.
+ *
+ * {@inheritdoc}
+ */
+ public function delete($where = [], $options = []) {
+ if (is_numeric($where)) {
+ deprecated('UserModel->delete(int)', 'UserModel->deleteID(int)');
+
+ $result = $this->deleteID($where, $options);
+ return $result;
+ }
+
+ throw new \BadMethodCallException("UserModel->delete() is not supported.", 400);
+ }
+
+ /**
+ * Delete a single user.
+ *
+ * @param int $userID The user to delete.
+ * @param array $options See {@link UserModel::deleteContent()}, and {@link UserModel::getDelete()}.
+ */
+ public function deleteID($userID, $options = []) {
+ if ($userID == $this->getSystemUserID()) {
+ $this->Validation->addValidationResult('', 'You cannot delete the system user.');
+ return false;
+ }
+
+ $content = [];
+
+ // Remove shared authentications.
+ $this->getDelete('UserAuthentication', ['UserID' => $userID], $content);
+
+ // Remove role associations.
+ $this->getDelete('UserRole', ['UserID' => $userID], $content);
+
+ $this->deleteContent($userID, $options, $content);
+
+ $userData = $this->getID($userID, DATASET_TYPE_ARRAY);
+
+ // Remove the user's information
+ $this->SQL->update('User')
+ ->set([
+ 'Name' => t('[Deleted User]'),
+ 'Photo' => null,
+ 'Password' => randomString('10'),
+ 'HashMethod' => 'Random',
+ 'About' => '',
+ 'Email' => 'user_'.$userID.'@deleted.invalid',
+ 'ShowEmail' => '0',
+ 'Gender' => 'u',
+ 'CountVisits' => 0,
+ 'CountInvitations' => 0,
+ 'CountNotifications' => 0,
+ 'InviteUserID' => null,
+ 'DiscoveryText' => '',
+ 'Preferences' => null,
+ 'Permissions' => null,
+ 'Attributes' => dbencode([
+ 'State' => 'Deleted',
+ // We cannot keep emails until we have a method to purge deleted users.
+ // See https://github.com/vanilla/vanilla/pull/5808 for more details.
+ 'OriginalName' => $userData['Name'],
+ 'DeletedBy' => Gdn::session()->UserID,
+ ]),
+ 'DateSetInvitations' => null,
+ 'DateOfBirth' => null,
+ 'DateUpdated' => Gdn_Format::toDateTime(),
+ 'HourOffset' => '0',
+ 'Score' => null,
+ 'Admin' => 0,
+ 'Deleted' => 1
+ ])
+ ->where('UserID', $userID)
+ ->put();
+
+ // Remove user's cache rows
+ $this->clearCache($userID);
+
+ return true;
+ }
+
+ /**
+ * Delete a user's content across many contexts.
+ *
+ * @param int $userID
+ * @param array $options
+ * @param array $content
+ * @return bool|int
+ */
+ public function deleteContent($userID, $options = [], $content = []) {
+ $log = val('Log', $options);
+ if ($log === true) {
+ $log = 'Delete';
+ }
+
+ $result = false;
+
+ // Fire an event so applications can remove their associated user data.
+ $this->EventArguments['UserID'] = $userID;
+ $this->EventArguments['Options'] = $options;
+ $this->EventArguments['Content'] = &$content;
+ $this->fireEvent('BeforeDeleteUser');
+
+ $user = $this->getID($userID, DATASET_TYPE_ARRAY);
+
+ if (!$log) {
+ $content = null;
+ }
+
+ // Remove invitations
+ $this->getDelete('Invitation', ['InsertUserID' => $userID], $content);
+ $this->getDelete('Invitation', ['AcceptedUserID' => $userID], $content);
+
+ // Remove activities
+ $this->getDelete('Activity', ['InsertUserID' => $userID], $content);
+
+ // Remove activity comments.
+ $this->getDelete('ActivityComment', ['InsertUserID' => $userID], $content);
+
+ // Remove comments in moderation queue
+ $this->getDelete('Log', ['RecordUserID' => $userID, 'Operation' => 'Pending'], $content);
+
+ // Clear out information on the user.
+ $this->setField($userID, [
+ 'About' => null,
+ 'Title' => null,
+ 'Location' => null]);
+
+ if ($log) {
+ $user['_Data'] = $content;
+ unset($content); // in case data gets copied
+
+ $result = LogModel::insert($log, 'User', $user, val('LogOptions', $options, []));
+ }
+
+ return $result;
+ }
+
+ /**
+ * Decline a user's application to join the forum.
+ *
+ * @param int $userID
+ * @return bool
+ * @throws Exception
+ */
+ public function decline($userID) {
+ $applicantRoleIDs = RoleModel::getDefaultRoles(RoleModel::TYPE_APPLICANT);
+
+ // Make sure the user is an applicant
+ $roleData = $this->getRoles($userID);
+ if ($roleData->numRows() == 0) {
+ throw new Exception(t('ErrorRecordNotFound'));
+ } else {
+ $appRoles = $roleData->result(DATASET_TYPE_ARRAY);
+ $applicantFound = false;
+ foreach ($appRoles as $appRole) {
+ if (in_array(val('RoleID', $appRole), $applicantRoleIDs)) {
+ $applicantFound = true;
+ }
+ }
+ }
+
+ if ($applicantFound) {
+ $this->deleteID($userID);
+ }
+ return true;
+ }
+
+ /**
+ * Get number of available invites a user has.
+ *
+ * @param int $userID
+ * @return int
+ */
+ public function getInvitationCount($userID) {
+ if (Gdn::config('Garden.Registration.Method') !== 'Invitation') {
+ // If registration method has been changed
+ return 0;
+ }
+
+ // If this user is master admin, they should have unlimited invites.
+ if ($this->SQL
+ ->select('UserID')
+ ->from('User')
+ ->where('UserID', $userID)
+ ->where('Admin', '1')
+ ->get()
+ ->numRows() > 0
+ ) {
+ return -1;
+ }
+
+ // Get the Registration.InviteRoles settings:
+ $inviteRoles = Gdn::config('Garden.Registration.InviteRoles', []);
+ if (!is_array($inviteRoles) || count($inviteRoles) == 0) {
+ return 0;
+ }
+
+ // Build an array of roles that can send invitations
+ $canInviteRoles = [];
+ foreach ($inviteRoles as $roleID => $invites) {
+ if ($invites > 0 || $invites == -1) {
+ $canInviteRoles[] = $roleID;
+ }
+ }
+
+ if (count($canInviteRoles) == 0) {
+ return 0;
+ }
+
+ // See which matching roles the user has
+ $userRoleData = $this->SQL->select('RoleID')
+ ->from('UserRole')
+ ->where('UserID', $userID)
+ ->whereIn('RoleID', $canInviteRoles)
+ ->get();
+
+ if ($userRoleData->numRows() == 0) {
+ return 0;
+ }
+
+ // Define the maximum number of invites the user is allowed to send
+ $inviteCount = 0;
+ foreach ($userRoleData->result() as $userRole) {
+ $count = $inviteRoles[$userRole->RoleID];
+ if ($count == -1) {
+ $inviteCount = -1;
+ } elseif ($inviteCount != -1 && $count > $inviteCount) {
+ $inviteCount = $count;
+ }
+ }
+
+ // If the user has unlimited invitations, return that value
+ if ($inviteCount == -1) {
+ return -1;
+ }
+
+ // Get the user's current invitation settings from their profile
+ $user = $this->SQL->select('CountInvitations, DateSetInvitations')
+ ->from('User')
+ ->where('UserID', $userID)
+ ->get()
+ ->firstRow();
+
+ // If CountInvitations is null (ie. never been set before) or it is a new month since the DateSetInvitations
+ if ((empty($user->CountInvitations) && $user->CountInvitations !== 0 )
+ || is_null($user->DateSetInvitations)
+ || date("m Y", strtotime($user->DateSetInvitations)) !== date('m Y')) {
+ // Reset CountInvitations and DateSetInvitations
+ $this->SQL->put(
+ $this->Name,
+ [
+ 'CountInvitations' => $inviteCount,
+ 'DateSetInvitations' => date('Y-m-01') // The first day of this month
+ ],
+ ['UserID' => $userID]
+ );
+ return $inviteCount;
+ } else {
+ // Otherwise return CountInvitations
+ // or inviteCount if it was recently downsized for the User's Role
+ return min($inviteCount, $user->CountInvitations);
+ }
+ }
+
+ /**
+ * Get rows from a table then delete them.
+ *
+ * @param string $table The name of the table.
+ * @param array $where The where condition for the delete.
+ * @param array $data The data to put the result.
+ * @since 2.1
+ */
+ public function getDelete($table, $where, &$data) {
+ if (is_array($data)) {
+ // Grab the records.
+ $result = $this->SQL->getWhere($table, $where)->resultArray();
+
+ if (empty($result)) {
+ return;
+ }
+
+ // Put the records in the result array.
+ if (isset($data[$table])) {
+ $data[$table] = array_merge($data[$table], $result);
+ } else {
+ $data[$table] = $result;
+ }
+ }
+
+ $this->SQL->delete($table, $where);
+ }
+
+ /**
+ * Reduces the user's CountInvitations value by the specified amount.
+ *
+ * @param int $userID The unique id of the user being affected.
+ * @param int $reduceBy The number to reduce CountInvitations by.
+ */
+ public function reduceInviteCount($userID, $reduceBy = 1) {
+ $currentCount = $this->getInvitationCount($userID);
+
+ // Do not reduce if the user has unlimited invitations
+ if ($currentCount == -1) {
+ return true;
+ }
+
+ // Do not reduce the count below zero.
+ if ($reduceBy > $currentCount) {
+ $reduceBy = $currentCount;
+ }
+
+ $this->SQL->update($this->Name)
+ ->set('CountInvitations', 'CountInvitations - '.$reduceBy, false)
+ ->where('UserID', $userID)
+ ->put();
+ }
+
+ /**
+ * Increases the user's CountInvitations value by the specified amount.
+ *
+ * @param int $userID The unique id of the user being affected.
+ * @param int $increaseBy The number to increase CountInvitations by.
+ */
+ public function increaseInviteCount($userID, $increaseBy = 1) {
+ $currentCount = $this->getInvitationCount($userID);
+
+ // Do not alter if the user has unlimited invitations
+ if ($currentCount == -1) {
+ return true;
+ }
+
+ $this->SQL->update($this->Name)
+ ->set('CountInvitations', 'CountInvitations + '.$increaseBy, false)
+ ->where('UserID', $userID)
+ ->put();
+ }
+
+ /**
+ * Saves the user's About field.
+ *
+ * @param int $userID The UserID to save.
+ * @param string $about The about message being saved.
+ */
+ public function saveAbout($userID, $about) {
+ $about = substr($about, 0, 1000);
+ $this->setField($userID, 'About', $about);
+ }
+
+ /**
+ * Saves a name/value to the user's specified $column.
+ *
+ * This method throws exceptions when errors are encountered. Use try catch blocks to capture these exceptions.
+ *
+ * @param string $column The name of the serialized column to save to. At the time of this writing there are three serialized columns on the user table: Permissions, Preferences, and Attributes.
+ * @param int $userID The UserID to save.
+ * @param mixed $name The name of the value being saved, or an associative array of name => value pairs to be saved. If this is an associative array, the $value argument will be ignored.
+ * @param mixed $value The value being saved.
+ */
+ public function saveToSerializedColumn($column, $userID, $name, $value = '') {
+ // Load the existing values
+ $userData = $this->getID($userID, DATASET_TYPE_OBJECT);
+
+ if (!$userData) {
+ throw new Exception(sprintf('User %s not found.', $userID));
+ }
+
+ $values = val($column, $userData);
+
+ if (!is_array($values) && !is_object($values)) {
+ $values = dbdecode($userData->$column);
+ }
+
+ // Throw an exception if the field was not empty but is also not an object or array
+ if (is_string($values) && $values != '') {
+ throw new Exception(sprintf(t('Serialized column "%s" failed to be unserialized.'), $column));
+ }
+
+ if (!is_array($values)) {
+ $values = [];
+ }
+
+ // Hook for plugins
+ $this->EventArguments['CurrentValues'] = &$values;
+ $this->EventArguments['Column'] = &$column;
+ $this->EventArguments['UserID'] = &$userID;
+ $this->EventArguments['Name'] = &$name;
+ $this->EventArguments['Value'] = &$value;
+ $this->fireEvent('BeforeSaveSerialized');
+
+ // Assign the new value(s)
+ if (!is_array($name)) {
+ $name = [$name => $value];
+ }
+
+
+ $rawValues = array_merge($values, $name);
+ $values = [];
+ foreach ($rawValues as $key => $rawValue) {
+ if (!is_null($rawValue)) {
+ $values[$key] = $rawValue;
+ }
+ }
+
+ $values = dbencode($values);
+
+ // Save the values back to the db
+ $saveResult = $this->SQL->put('User', [$column => $values], ['UserID' => $userID]);
+ $this->clearCache($userID, ['user']);
+
+ return $saveResult;
+ }
+
+ /**
+ * Saves a user preference to the database.
+ *
+ * This is a convenience method that uses $this->saveToSerializedColumn().
+ *
+ * @param int $userID The UserID to save.
+ * @param mixed $preference The name of the preference being saved, or an associative array of name => value pairs to be saved. If this is an associative array, the $value argument will be ignored.
+ * @param mixed $value The value being saved.
+ */
+ public function savePreference($userID, $preference, $value = '') {
+ // Make sure that changes to the current user become effective immediately.
+ $session = Gdn::session();
+ if ($userID == $session->UserID) {
+ $session->setPreference($preference, $value, false);
+ }
+
+ return $this->saveToSerializedColumn('Preferences', $userID, $preference, $value);
+ }
+
+ /**
+ * Saves a user attribute to the database.
+ *
+ * This is a convenience method that uses $this->saveToSerializedColumn().
+ *
+ * @param int $userID The UserID to save.
+ * @param mixed $attribute The name of the attribute being saved, or an associative array of name => value pairs to be saved. If this is an associative array, the $value argument will be ignored.
+ * @param mixed $value The value being saved.
+ */
+ public function saveAttribute($userID, $attribute, $value = '') {
+ // Make sure that changes to the current user become effective immediately.
+ $session = Gdn::session();
+ if ($userID == $session->UserID) {
+ $session->setAttribute($attribute, $value);
+ }
+
+ return $this->saveToSerializedColumn('Attributes', $userID, $attribute, $value);
+ }
+
+ /**
+ *
+ *
+ * @param array $data
+ * @return Gdn_DataSet|string
+ */
+ public function saveAuthentication($data) {
+ $cn = $this->Database->connection();
+ $px = $this->Database->DatabasePrefix;
+
+ $uID = $cn->quote($data['UniqueID']);
+ $provider = $cn->quote($data['Provider']);
+ $userID = $cn->quote($data['UserID']);
+
+ $sql = "insert {$px}UserAuthentication (ForeignUserKey, ProviderKey, UserID) values ($uID, $provider, $userID) on duplicate key update UserID = $userID";
+ $result = $this->Database->query($sql);
+ return $result;
+ }
+
+ /**
+ * Set fields that need additional manipulation after retrieval.
+ *
+ * @param array|object &$user
+ * @throws Exception
+ */
+ public function setCalculatedFields(&$user) {
+ if (is_object($user)) {
+ $this->setCalculatedFieldsObject($user);
+ return;
+ } elseif (empty($user)){
+ return;
+ }
+ if (is_string($v = ($user['Attributes'] ?? false))) {
+ $user['Attributes'] = dbdecode($v);
+ }
+ if (is_string($v = ($user['Permissions'] ?? false))) {
+ $user['Permissions'] = dbdecode($v);
+ }
+ if (is_string($v = ($user['Preferences'] ?? false))) {
+ $user['Preferences'] = dbdecode($v);
+ }
+
+ if ($v = ($user['Photo'] ?? false)) {
+ if (!isUrl($v)) {
+ $photoUrl = Gdn_Upload::url(changeBasename($v, 'n%s'));
+ } else {
+ $photoUrl = $v;
+ }
+ $user['PhotoUrl'] = $photoUrl;
+ }
+
+ $confirmed = ($user['Confirmed'] ?? null);
+ if ($confirmed !== null) {
+ $user['EmailConfirmed'] = $confirmed;
+ }
+ $verified = ($user['Verified'] ?? null);
+ if ($verified !== null) {
+ $user['BypassSpam'] = $verified;
+ }
+
+ // We store IPs in the UserIP table. To avoid unnecessary queries, the full list is not built here. Shim for BC.
+ $user['AllIPAddresses'] = [
+ $user['InsertIPAddress'] ?? false,
+ $user['LastIPAddress'] ?? false
+ ];
+
+ $user['_CssClass'] = '';
+ if ($user['Banned'] ?? false) {
+ $user['_CssClass'] = 'Banned';
+ }
+
+ $this->EventArguments['User'] = &$user;
+ $this->fireEvent('SetCalculatedFields');
+ }
+
+ /**
+ * Duplicates `setCalculatedFields()` for objects.
+ *
+ * @deprecated Call `setCalculatedFields()` with an array instead.
+ */
+ public function setCalculatedFieldsObject( &$user) {
+ deprecated(__METHOD__);
+ if ($v = val('Attributes', $user)) {
+ if (is_string($v)) {
+ setValue('Attributes', $user, dbdecode($v));
+ }
+ }
+ if ($v = val('Permissions', $user)) {
+ if (is_string($v)) {
+ setValue('Permissions', $user, dbdecode($v));
+ }
+ }
+ if ($v = val('Preferences', $user)) {
+ if (is_string($v)) {
+ setValue('Preferences', $user, dbdecode($v));
+ }
+ }
+ if ($v = val('Photo', $user)) {
+ if (!isUrl($v)) {
+ $photoUrl = Gdn_Upload::url(changeBasename($v, 'n%s'));
+ } else {
+ $photoUrl = $v;
+ }
+
+ setValue('PhotoUrl', $user, $photoUrl);
+ }
+
+ $confirmed = val('Confirmed', $user, null);
+ if ($confirmed !== null) {
+ setValue('EmailConfirmed', $user, $confirmed);
+ }
+ $verified = val('Verified', $user, null);
+ if ($verified !== null) {
+ setValue('BypassSpam', $user, $verified);
+ }
+
+ // We store IPs in the UserIP table. To avoid unnecessary queries, the full list is not built here. Shim for BC.
+ setValue('AllIPAddresses', $user, [
+ val('InsertIPAddress', $user),
+ val('LastIPAddress', $user)
+ ]);
+
+ setValue('_CssClass', $user, '');
+ if (val('Banned', $user)) {
+ setValue('_CssClass', $user, 'Banned');
+ }
+
+ $this->EventArguments['User'] = &$user;
+ $this->fireEvent('SetCalculatedFields');
+ }
+
+ /**
+ *
+ *
+ * @param int $userID
+ * @param array $meta
+ * @param string $prefix
+ */
+ public static function setMeta($userID, $meta, $prefix = '') {
+ $deletes = [];
+ $px = Gdn::database()->DatabasePrefix;
+ $sql = "insert {$px}UserMeta (UserID, Name, Value) values(:UserID, :Name, :Value) on duplicate key update Value = :Value1";
+
+ foreach ($meta as $name => $value) {
+ $name = $prefix.$name;
+ if ($value === null || $value == '') {
+ $deletes[] = $name;
+ } else {
+ Gdn::database()->query($sql, [':UserID' => $userID, ':Name' => $name, ':Value' => $value, ':Value1' => $value]);
+ }
+ }
+ if (count($deletes)) {
+ Gdn::sql()->whereIn('Name', $deletes)->where('UserID', $userID)->delete('UserMeta');
+ }
+ }
+
+ /**
+ * Set the TransientKey attribute on a user.
+ *
+ * @param int $userID
+ * @param string $explicitKey
+ * @return string
+ */
+ public function setTransientKey($userID, $explicitKey = '') {
+ $key = $explicitKey == '' ? betterRandomString(16, 'Aa0') : $explicitKey;
+ $this->saveAttribute($userID, 'TransientKey', $key);
+ return $key;
+ }
+
+ /**
+ * Get an Attribute from a single user.
+ *
+ * @param int $userID
+ * @param string $attribute
+ * @param mixed $defaultValue
+ * @return mixed
+ */
+ public function getAttribute($userID, $attribute, $defaultValue = false) {
+ $user = $this->getID($userID, DATASET_TYPE_ARRAY);
+ $result = val($attribute, $user['Attributes'], $defaultValue);
+
+ return $result;
+ }
+
+ /**
+ * Send the confirmation email.
+ *
+ * @param int|string|null $user
+ * @param bool $force
+ * @throws Exception
+ */
+ public function sendEmailConfirmationEmail($user = null, $force = false) {
+
+ if (!$user) {
+ $user = Gdn::session()->User;
+ } elseif (is_numeric($user)) {
+ $user = $this->getID($user);
+ } elseif (is_string($user)) {
+ $user = $this->getByEmail($user);
+ }
+
+ if (!$user) {
+ throw notFoundException('User');
+ }
+
+ $user = (array)$user;
+
+ if (is_string($user['Attributes'])) {
+ $user['Attributes'] = dbdecode($user['Attributes']);
+ }
+
+ // Make sure the user needs email confirmation.
+ if ($user['Confirmed'] && !$force) {
+ $this->Validation->addValidationResult('Role', 'Your email doesn\'t need confirmation.');
+
+ // Remove the email key.
+ if (isset($user['Attributes']['EmailKey'])) {
+ unset($user['Attributes']['EmailKey']);
+ $this->saveAttribute($user['UserID'], $user['Attributes']);
+ }
+
+ return;
+ }
+
+ // Make sure there is a confirmation code.
+ $code = valr('Attributes.EmailKey', $user);
+ if (!$code) {
+ $code = $this->confirmationCode();
+ $attributes = $user['Attributes'];
+ if (!is_array($attributes)) {
+ $attributes = ['EmailKey' => $code];
+ } else {
+ $attributes['EmailKey'] = $code;
+ }
+
+ $this->saveAttribute($user['UserID'], $attributes);
+ }
+
+ $appTitle = Gdn::config('Garden.Title');
+ $email = new Gdn_Email();
+ $email->subject(sprintf(t('[%s] Confirm Your Email Address'), $appTitle));
+ $email->to($user['Email']);
+
+ $emailUrlFormat = '{/entry/emailconfirm,exurl,domain}/{User.UserID,rawurlencode}/{EmailKey,rawurlencode}';
+ $data = [];
+ $data['EmailKey'] = $code;
+ $data['User'] = arrayTranslate((array)$user, ['UserID', 'Name', 'Email']);
+
+ $url = formatString($emailUrlFormat, $data);
+ $message = formatString(t('Hello {User.Name}!'), $data).' '.t('You need to confirm your email address before you can continue.');
+
+ $emailTemplate = $email->getEmailTemplate()
+ ->setTitle(t('Confirm Your Email Address'))
+ ->setMessage($message)
+ ->setButton($url, t('Confirm My Email Address'));
+
+ $email->setEmailTemplate($emailTemplate);
+
+ try {
+ $email->send();
+ } catch (Exception $e) {
+ if (debug()) {
+ throw $e;
+ }
+ }
+ }
+
+ /**
+ * Send welcome email to user.
+ *
+ * @param int $userID
+ * @param string $password
+ * @param string $registerType
+ * @param array|null $additionalData
+ * @throws Exception
+ */
+ public function sendWelcomeEmail($userID, $password, $registerType = 'Add', $additionalData = null) {
+ $session = Gdn::session();
+ $sender = $this->getID($session->UserID);
+ $user = $this->getID($userID);
+
+ if (!validateEmail($user->Email)) {
+ return;
+ }
+
+ $appTitle = Gdn::config('Garden.Title');
+ $email = new Gdn_Email();
+ $email->subject(sprintf(t('[%s] Welcome Aboard!'), $appTitle));
+ $email->to($user->Email);
+ $emailTemplate = $email->getEmailTemplate();
+
+ $data = [];
+ $data['User'] = arrayTranslate((array)$user, ['UserID', 'Name', 'Email']);
+ $data['Sender'] = arrayTranslate((array)$sender, ['Name', 'Email']);
+ $data['Title'] = $appTitle;
+ if (is_array($additionalData)) {
+ $data = array_merge($data, $additionalData);
+ }
+
+ $data['EmailKey'] = valr('Attributes.EmailKey', $user);
+
+ $message = ''.formatString(t('Hello {User.Name}!'), $data).' ';
+
+ $message .= $this->getEmailWelcome($registerType, $user, $data, $password);
+
+ // Add the email confirmation key.
+ if ($data['EmailKey']) {
+ $emailUrlFormat = '{/entry/emailconfirm,exurl,domain}/{User.UserID,rawurlencode}/{EmailKey,rawurlencode}';
+ $url = formatString($emailUrlFormat, $data);
+ $message .= '
'.t('You need to confirm your email address before you can continue.').'
';
+ $emailTemplate->setButton($url, t('Confirm My Email Address'));
+ } else {
+ $emailTemplate->setButton(externalUrl('/'), t('Access the Site'));
+ }
+
+ $emailTemplate->setMessage($message);
+ $emailTemplate->setTitle(t('Welcome Aboard!'));
+
+ $email->setEmailTemplate($emailTemplate);
+
+ try {
+ $email->send();
+ } catch (Exception $e) {
+ if (debug()) {
+ throw $e;
+ }
+ }
+ }
+
+ /**
+ * Resolves the welcome email format. Maintains backwards compatibility with the 'EmailWelcome*' translations
+ * for overriding.
+ *
+ * @param string $registerType The registration type. One of 'Connect', 'Register' or 'Add'.
+ * @param object|array $user The user to send the email to.
+ * @param array $data The email data.
+ * @param string $password The user's password.
+ * @return string The welcome email for the registration type.
+ */
+ protected function getEmailWelcome($registerType, $user, $data, $password = '') {
+ $appTitle = c('Garden.Title', c('Garden.HomepageTitle'));
+
+ // Backwards compatability. See if anybody has overridden the EmailWelcome string.
+ if (($emailFormat = t('EmailWelcome'.$registerType, ''))) {
+ $welcome = formatString($emailFormat, $data);
+ } elseif (t('EmailWelcome', '')) {
+ $welcome = sprintf(
+ t('EmailWelcome'),
+ val('Name', $user),
+ val('Name', val('Sender', $data)),
+ $appTitle,
+ externalUrl('/'),
+ $password,
+ val('Email', $user)
+ );
+ } else {
+ switch ($registerType) {
+ case 'Connect' :
+ $welcome = formatString(t('You have successfully connected to {Title}.'), $data).' '.
+ t('Find your account information below.').'
'.
+ ''.sprintf(t('%s: %s'), t('Username'), val('Name', $user)).'
'.
+ formatString(t('Connected With: {ProviderName}'), $data).'
';
+ break;
+ case 'Register' :
+ $welcome = formatString(t('You have successfully registered for an account at {Title}.'), $data).' '.
+ t('Find your account information below.').'
'.
+ ''.sprintf(t('%s: %s'), t('Username'), val('Name', $user)).'
'.
+ sprintf(t('%s: %s'), t('Email'), val('Email', $user)).'
';
+ break;
+ default :
+ $welcome = sprintf(t('%s has created an account for you at %s.'), val('Name', val('Sender', $data)), $appTitle).' '.
+ t('Find your account information below.').'
'.
+ ''.sprintf(t('%s: %s'), t('Email'), val('Email', $user)).'
'.
+ sprintf(t('%s: %s'), t('Password'), $password).'
';
+ }
+ }
+ return $welcome;
+ }
+
+ /**
+ * Send password email.
+ *
+ * @param int $userID
+ * @param string $password
+ */
+ public function sendPasswordEmail($userID, $password) {
+ $session = Gdn::session();
+ $sender = $this->getID($session->UserID);
+ $user = $this->getID($userID);
+ $appTitle = Gdn::config('Garden.Title');
+ $email = new Gdn_Email();
+ $email->subject('['.$appTitle.'] '.t('Reset Password'));
+ $email->to($user->Email);
+ $greeting = formatString(t('Hello %s!'), val('Name', $user));
+ $message = ''.$greeting.' '.sprintf(t('%s has reset your password at %s.'), val('Name', $sender), $appTitle).' '.
+ t('Find your account information below.').'
'.
+ ''.sprintf(t('%s: %s'), t('Email'), val('Email', $user)).'
'.
+ sprintf(t('%s: %s'), t('Password'), $password).'
';
+
+ $emailTemplate = $email->getEmailTemplate()
+ ->setTitle(t('Reset Password'))
+ ->setMessage($message)
+ ->setButton(externalUrl('/'), t('Access the Site'));
+
+ $email->setEmailTemplate($emailTemplate);
+
+ try {
+ $email->send();
+ } catch (Exception $e) {
+ if (debug()) {
+ throw $e;
+ }
+ }
+ }
+
+ /**
+ * Synchronizes the user based on a given UserKey.
+ *
+ * @param string $userKey A string that uniquely identifies this user.
+ * @param array $data Information to put in the user table.
+ * @return int The ID of the user.
+ */
+ public function synchronize($userKey, $data) {
+ $userID = 0;
+
+ $attributes = val('Attributes', $data);
+ if (is_string($attributes)) {
+ $attributes = dbdecode($attributes);
+ }
+
+ if (!is_array($attributes)) {
+ $attributes = [];
+ }
+
+ // If the user didnt log in, they won't have a UserID yet. That means they want a new
+ // account. So create one for them.
+ if (!isset($data['UserID']) || $data['UserID'] <= 0) {
+ // Prepare the user data.
+ $userData = [];
+ $userData['Name'] = $data['Name'];
+ $userData['Password'] = randomString(16);
+ $userData['Email'] = val('Email', $data, 'no@email.com');
+ $userData['Gender'] = strtolower(substr(val('Gender', $data, 'u'), 0, 1));
+ $userData['HourOffset'] = val('HourOffset', $data, 0);
+ $userData['DateOfBirth'] = val('DateOfBirth', $data, '');
+ $userData['CountNotifications'] = 0;
+ $userData['Attributes'] = $attributes;
+ $userData['InsertIPAddress'] = ipEncode(Gdn::request()->ipAddress());
+ if ($userData['DateOfBirth'] == '') {
+ $userData['DateOfBirth'] = '1975-09-16';
+ }
+
+ // Make sure there isn't another user with this username.
+ if ($this->validateUniqueFields($userData['Name'], $userData['Email'])) {
+ if (!BanModel::checkUser($userData, $this->Validation, true)) {
+ throw permissionException('Banned');
+ }
+
+ // Insert the new user.
+ $this->addInsertFields($userData);
+ $userID = $this->insertInternal($userData);
+ }
+
+ if ($userID > 0) {
+ $newUserRoleIDs = $this->newUserRoleIDs();
+
+ // Save the roles.
+ $roles = val('Roles', $data, false);
+ if (empty($roles)) {
+ $roles = $newUserRoleIDs;
+ }
+
+ $this->saveRoles($userID, $roles, false);
+ }
+ } else {
+ $userID = $data['UserID'];
+ }
+
+ // Synchronize the transientkey from the external user data source if it is present (eg. WordPress' wpnonce).
+ if (array_key_exists('TransientKey', $attributes) && $attributes['TransientKey'] != '' && $userID > 0) {
+ $this->setTransientKey($userID, $attributes['TransientKey']);
+ }
+
+ return $userID;
+ }
+
+ /**
+ *
+ *
+ * @return array
+ * @throws Gdn_UserException
+ */
+ public function newUserRoleIDs() {
+ // Registration method
+ $registrationMethod = c('Garden.Registration.Method', 'Basic');
+ $defaultRoleID = RoleModel::getDefaultRoles(RoleModel::TYPE_MEMBER);
+ switch ($registrationMethod) {
+
+ case 'Approval':
+ $roleID = RoleModel::getDefaultRoles(RoleModel::TYPE_APPLICANT);
+ break;
+
+ case 'Invitation':
+ throw new Gdn_UserException(t('This forum is currently set to invitation only mode.'));
+ case 'Basic':
+ case 'Captcha':
+ default:
+ $roleID = $defaultRoleID;
+ break;
+ }
+
+ if (empty($roleID)) {
+ trace("You don't have any default roles defined.", TRACE_WARNING);
+ }
+ return $roleID;
+ }
+
+ /**
+ * Send forgot password email.
+ *
+ * @param string $input
+ * @param array $options
+ * @return bool
+ */
+ public function passwordRequest($input, $options = []) {
+ $this->Validation->reset();
+ if (!$input) {
+ return false;
+ }
+ $log = $options['log'] ?? true;
+
+ $users = $this->getWhere(['Email' => $input])->resultObject();
+ if (empty($users)) {
+ // Don't allow username reset unless usernames are unique.
+ if (($this->isEmailUnique() || !$this->isNameUnique()) && filter_var($input, FILTER_VALIDATE_EMAIL) === false) {
+ $this->Validation->addValidationResult('Email', 'You must enter a valid email address.');
+ return false;
+ }
+
+ // Check for the username.
+ $users = $this->getWhere(['Name' => $input])->resultObject();
+ }
+
+ $this->EventArguments['Users'] =& $users;
+ $this->EventArguments['Email'] = $input;
+ $this->fireEvent('BeforePasswordRequest');
+
+ if (count($users) == 0) {
+ $this->Validation->addValidationResult('email', "Couldn't find an account associated with that email/username.");
+ if ($log) {
+ Logger::event(
+ 'password_reset_failure',
+ Logger::INFO,
+ 'Can\'t find account associated with email/username {input}.',
+ ['input' => $input]
+ );
+ }
+ return false;
+ }
+
+ $noEmail = true;
+
+ foreach ($users as $user) {
+ if (!$user->Email) {
+ continue;
+ }
+ $email = new Gdn_Email(); // Instantiate in loop to clear previous settings
+ $passwordResetKey = betterRandomString(20, 'Aa0');
+ $passwordResetExpires = strtotime('+1 hour');
+ $this->saveAttribute($user->UserID, 'PasswordResetKey', $passwordResetKey);
+ $this->saveAttribute($user->UserID, 'PasswordResetExpires', $passwordResetExpires);
+ $appTitle = c('Garden.Title');
+ $email->subject('['.$appTitle.'] '.t('Reset Your Password'));
+ $email->to($user->Email);
+
+ $emailTemplate = $email->getEmailTemplate()
+ ->setTitle(t('Reset Your Password'))
+ ->setMessage(sprintf(t('We\'ve received a request to change your password.'), $appTitle))
+ ->setButton(externalUrl('/entry/passwordreset/'.$user->UserID.'/'.$passwordResetKey), t('Change My Password'));
+ $email->setEmailTemplate($emailTemplate);
+
+ try {
+ $email->send();
+ if ($log) {
+ Logger::event(
+ 'password_reset_request',
+ Logger::INFO,
+ '{email} has been sent a password reset email.',
+ ['input' => $input, 'email' => $user->Email, 'forUserID' => $user->UserID]
+ );
+ }
+ } catch (Exception $ex) {
+ if ($log) {
+ if ($ex->getCode() === Gdn_Email::ERR_SKIPPED) {
+ Logger::event(
+ 'password_reset_skipped',
+ Logger::INFO,
+ $ex->getMessage(),
+ ['input' => $input, 'email' => $user->Email]
+ );
+ } else {
+ Logger::event(
+ 'password_reset_failure',
+ Logger::ERROR,
+ 'The password reset email to {email} failed to send.',
+ ['input' => $input, 'email' => $user->Email]
+ );
+ }
+ }
+ if (debug()) {
+ throw $ex;
+ }
+ }
+
+ $noEmail = false;
+ }
+
+ if ($noEmail) {
+ $this->Validation->addValidationResult('Name', 'There is no email address associated with that account.');
+ if ($log) {
+ Logger::event(
+ 'password_reset_failure',
+ Logger::INFO,
+ 'Can\'t find account associated with email/username {input}.',
+ ['input' => $input]
+ );
+ }
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Do a password reset.
+ *
+ * @param int $userID
+ * @param string $password
+ * @return array|false Returns the user or **false** if the user doesn't exist.
+ */
+ public function passwordReset($userID, $password) {
+ // Encrypt the password before saving
+ $passwordHash = new Gdn_PasswordHash();
+ $password = $passwordHash->hashPassword($password);
+
+ // Set the new password on the user row.
+ $this->SQL
+ ->update('User')
+ ->set('Password', $password)
+ ->set('HashMethod', 'Vanilla')
+ ->where('UserID', $userID)
+ ->put();
+
+ // Clear any password reset information.
+ $this->clearPasswordReset($userID);
+
+ $this->EventArguments['UserID'] = $userID;
+ $this->fireEvent('AfterPasswordReset');
+
+ return $this->getID($userID);
+ }
+
+ /**
+ * Check and apply login rate limiting
+ *
+ * @param array $user
+ */
+ public static function rateLimit($user) {
+ if (Gdn::cache()->activeEnabled()) {
+ // Rate limit using Gdn_Cache.
+ $userRateKey = formatString(self::LOGIN_RATE_KEY, ['Source' => $user->UserID]);
+ $userRate = (int)Gdn::cache()->get($userRateKey);
+ $userRate += 1;
+ Gdn::cache()->store($userRateKey, 1, [
+ Gdn_Cache::FEATURE_EXPIRY => self::LOGIN_RATE
+ ]);
+
+ $sourceRateKey = formatString(self::LOGIN_RATE_KEY, ['Source' => Gdn::request()->ipAddress()]);
+ $sourceRate = (int)Gdn::cache()->get($sourceRateKey);
+ $sourceRate += 1;
+ Gdn::cache()->store($sourceRateKey, 1, [
+ Gdn_Cache::FEATURE_EXPIRY => self::LOGIN_RATE
+ ]);
+
+ } elseif (c('Garden.Apc', false) && function_exists('apc_store')) {
+ // Rate limit using the APC data store.
+ $userRateKey = formatString(self::LOGIN_RATE_KEY, ['Source' => $user->UserID]);
+ $userRate = (int)apc_fetch($userRateKey);
+ $userRate += 1;
+ apc_store($userRateKey, 1, self::LOGIN_RATE);
+
+ $sourceRateKey = formatString(self::LOGIN_RATE_KEY, ['Source' => Gdn::request()->ipAddress()]);
+ $sourceRate = (int)apc_fetch($sourceRateKey);
+ $sourceRate += 1;
+ apc_store($sourceRateKey, 1, self::LOGIN_RATE);
+
+ } else {
+ // Rate limit using user attributes.
+ $now = time();
+ $userModel = Gdn::userModel();
+ $lastLoginAttempt = $userModel->getAttribute($user->UserID, 'LastLoginAttempt', 0);
+ $userRate = $userModel->getAttribute($user->UserID, 'LoginRate', 0);
+ $userRate += 1;
+
+ if ($lastLoginAttempt + self::LOGIN_RATE < $now) {
+ $userRate = 0;
+ }
+
+ $userModel->saveToSerializedColumn(
+ 'Attributes',
+ $user->UserID,
+ ['LastLoginAttempt' => $now, 'LoginRate' => 1]
+ );
+
+ // IP rate limiting is not available without an active cache.
+ $sourceRate = 0;
+
+ }
+
+ // Put user into cooldown mode.
+ if ($userRate > 1) {
+ throw new Gdn_UserException(t('LoginUserCooldown', 'You are trying to log in too often. Slow down!.'));
+ }
+ if ($sourceRate > 1) {
+ throw new Gdn_UserException(t('LoginSourceCooldown', 'Your IP is trying to log in too often. Slow down!'));
+ }
+
+ return true;
+ }
+
+ /**
+ * Clear out the password reset values for a user.
+ *
+ * @param int $userID
+ */
+ private function clearPasswordReset($userID) {
+ $this->saveAttribute($userID, [
+ 'PasswordResetKey' => null,
+ 'PasswordResetExpires' => null
+ ]);
+ }
+
+ /**
+ * Set a single user property.
+ *
+ * @param int $rowID
+ * @param array|string $property
+ * @param bool $value
+ * @return bool
+ */
+ public function setField($rowID, $property, $value = false) {
+ if (!is_array($property)) {
+ $property = [$property => $value];
+ }
+
+ $this->defineSchema();
+ $fields = $this->Schema->fields();
+
+ $set = array_intersect_key($property, $fields);
+ self::serializeRow($set);
+
+ $this->SQL
+ ->update($this->Name)
+ ->set($set)
+ ->where('UserID', $rowID)
+ ->put();
+
+ if (in_array($property, ['Permissions'])) {
+ $this->clearCache($rowID, ['permissions']);
+ } else {
+ $this->updateUserCache($rowID, $property, $value);
+ }
+
+ if (!is_array($property)) {
+ $property = [$property => $value];
+ }
+
+ $this->EventArguments['UserID'] = $rowID;
+ $this->EventArguments['Fields'] = $property;
+ $this->fireEvent('AfterSetField');
+
+ return $value;
+ }
+
+ /**
+ * Get a user from the cache by name or ID
+ *
+ * @param string|int $userToken either a userid or a username
+ * @param string $tokenType either 'userid' or 'name'
+ * @return array|false Returns a user array or **false** if the user isn't in the cache.
+ */
+ public function getUserFromCache($userToken, $tokenType) {
+ if ($tokenType == 'name') {
+ $userNameKey = formatString(self::USERNAME_KEY, ['Name' => md5($userToken)]);
+ $userID = Gdn::cache()->get($userNameKey);
+
+ if ($userID === Gdn_Cache::CACHEOP_FAILURE) {
+ return false;
+ }
+ $userToken = $userID;
+ $tokenType = 'userid';
+ }
+
+ if ($tokenType != 'userid') {
+ return false;
+ }
+
+ // Get from memcached
+ $userKey = formatString(self::USERID_KEY, ['UserID' => $userToken]);
+ $user = Gdn::cache()->get($userKey);
+
+ return $user;
+ }
+
+ /**
+ *
+ *
+ * @param int $userID
+ * @param string|array $field
+ * @param mixed|null $value
+ */
+ public function updateUserCache($userID, $field, $value = null) {
+ // Try and get the user from the cache.
+ $user = $this->getUserFromCache($userID, 'userid');
+
+ if (!$user) {
+ return;
+ }
+
+ if (!is_array($field)) {
+ $field = [$field => $value];
+ }
+
+ foreach ($field as $f => $v) {
+ $user[$f] = $v;
+ }
+ $this->userCache($user);
+ }
+
+ /**
+ * Cache a user.
+ *
+ * @param array $user The user to cache.
+ * @return bool Returns **true** if the user was cached or **false** otherwise.
+ */
+ public function userCache($user, $userID = null) {
+ if (!$userID) {
+ $userID = val('UserID', $user, null);
+ }
+ if (is_null($userID) || !$userID) {
+ return false;
+ }
+
+ $cached = true;
+
+ $userKey = formatString(self::USERID_KEY, ['UserID' => $userID]);
+ $cached = $cached & Gdn::cache()->store($userKey, $user, [
+ Gdn_Cache::FEATURE_EXPIRY => 3600
+ ]);
+
+ $userNameKey = formatString(self::USERNAME_KEY, ['Name' => md5(val('Name', $user))]);
+ $cached = $cached & Gdn::cache()->store($userNameKey, $userID, [
+ Gdn_Cache::FEATURE_EXPIRY => 3600
+ ]);
+ return $cached;
+ }
+
+ /**
+ * Cache a user's roles.
+ *
+ * @param int $userID The ID of a user to cache roles for.
+ * @param array $roleIDs A collection of role IDs with the specified user.
+ * @return bool Was the caching operation successful?
+ */
+ public function userCacheRoles($userID, $roleIDs) {
+ if ($userID !== 0 && !$userID) {
+ return false;
+ }
+
+ $userRolesKey = formatString(self::USERROLES_KEY, ['UserID' => $userID]);
+ $cached = Gdn::cache()->store(
+ $userRolesKey,
+ $roleIDs,
+ [Gdn_Cache::FEATURE_EXPIRY => 3600]
+ );
+ return $cached;
+ }
+
+ /**
+ * Delete cached data for user.
+ *
+ * @param int|null $userID The user to clear the cache for.
+ * @return bool Returns **true** if the cache was cleared or **false** otherwise.
+ */
+ public function clearCache($userID, $cacheTypesToClear = null) {
+ //FIX: Clear cache for Guest
+ if(!Gdn::cache()->activeEnabled() && ((is_null($userID) || !$userID))) {
+ return false;
+ }
+
+
+ if (is_null($cacheTypesToClear)) {
+ $cacheTypesToClear = ['user', 'roles', 'permissions'];
+ }
+
+ if (in_array('user', $cacheTypesToClear)) {
+ $userKey = formatString(self::USERID_KEY, ['UserID' => $userID]);
+ Gdn::cache()->remove($userKey);
+ }
+
+ if (in_array('roles', $cacheTypesToClear)) {
+ $userRolesKey = formatString(self::USERROLES_KEY, ['UserID' => $userID]);
+ Gdn::cache()->remove($userRolesKey);
+ }
+
+ if (in_array('permissions', $cacheTypesToClear)) {
+ Gdn::sql()->put('User', ['Permissions' => ''], ['UserID' => $userID]);
+
+ $permissionsIncrement = $this->getPermissionsIncrement();
+ $userPermissionsKey = formatString(self::USERPERMISSIONS_KEY, ['UserID' => $userID, 'PermissionsIncrement' => $permissionsIncrement]);
+ Gdn::cache()->remove($userPermissionsKey);
+ }
+ return true;
+ }
+
+ /**
+ * Clear the permission cache.
+ */
+ public function clearPermissions() {
+ if (!Gdn::cache()->activeEnabled()) {
+ $this->SQL->put('User', ['Permissions' => ''], ['Permissions <>' => '']);
+ }
+
+ $permissionsIncrementKey = self::INC_PERMISSIONS_KEY;
+ $permissionsIncrement = $this->getPermissionsIncrement();
+ if ($permissionsIncrement == 0) {
+ Gdn::cache()->store($permissionsIncrementKey, 1);
+ } else {
+ Gdn::cache()->increment($permissionsIncrementKey);
+ }
+ }
+
+ /**
+ * Get a user's permissions.
+ *
+ * @param int $userID Unique ID of the user.
+ * @return Vanilla\Permissions
+ */
+ public function getPermissions($userID) {
+ $permissions = new Vanilla\Permissions();
+ $permissionsKey = '';
+
+ if (Gdn::cache()->activeEnabled()) {
+ $permissionsIncrement = $this->getPermissionsIncrement();
+ $permissionsKey = formatString(self::USERPERMISSIONS_KEY, [
+ 'UserID' => $userID,
+ 'PermissionsIncrement' => $permissionsIncrement
+ ]);
+
+ $cachedPermissions = Gdn::cache()->get($permissionsKey);
+ if ($cachedPermissions !== Gdn_Cache::CACHEOP_FAILURE) {
+ $permissions->setPermissions($cachedPermissions);
+ return $permissions;
+ }
+ }
+
+ $data = Gdn::permissionModel()->cachePermissions($userID);
+ $permissions->compileAndLoad($data);
+
+ $this->EventArguments['UserID'] = $userID;
+ $this->EventArguments['Permissions'] = $permissions;
+ $this->fireEvent('loadPermissions');
+
+ if (Gdn::cache()->activeEnabled()) {
+ Gdn::cache()->store($permissionsKey, $permissions->getPermissions());
+ } else {
+ // Save the permissions to the user table
+ if ($userID > 0) {
+ $this->SQL->put(
+ 'User',
+ ['Permissions' => dbencode($permissions->getPermissions())],
+ ['UserID' => $userID]
+ );
+ }
+ }
+
+ return $permissions;
+ }
+
+ /**
+ *
+ *
+ * @return bool|int|mixed
+ */
+ public function getPermissionsIncrement() {
+ $permissionsIncrementKey = self::INC_PERMISSIONS_KEY;
+ $permissionsKeyValue = Gdn::cache()->get($permissionsIncrementKey);
+
+ if (!$permissionsKeyValue) {
+ $stored = Gdn::cache()->store($permissionsIncrementKey, 1);
+ return $stored ? 1 : false;
+ }
+
+ return $permissionsKeyValue;
+ }
+
+ /**
+ *
+ *
+ * @param array $roles
+ * @return array
+ */
+ protected function lookupRoleIDs($roles) {
+ if (is_string($roles)) {
+ $roles = explode(',', $roles);
+ } elseif (!is_array($roles)) {
+ $roles = [];
+ }
+ $roles = array_map('trim', $roles);
+ $roles = array_map('strtolower', $roles);
+
+ $allRoles = RoleModel::roles();
+ $roleIDs = [];
+ foreach ($allRoles as $roleID => $role) {
+ $name = strtolower($role['Name']);
+ if (in_array($name, $roles) || in_array($roleID, $roles)) {
+ $roleIDs[] = $roleID;
+ }
+ }
+ return $roleIDs;
+ }
+
+ /**
+ * Clears navigation preferences for a user.
+ *
+ * @param string $userID Optional - defaults to sessioned user
+ */
+ public function clearNavigationPreferences($userID = '') {
+ if (!$userID) {
+ $userID = Gdn::session()->UserID;
+ }
+
+ $this->savePreference($userID, 'DashboardNav.Collapsed', []);
+ $this->savePreference($userID, 'DashboardNav.SectionLandingPages', []);
+ $this->savePreference($userID, 'DashboardNav.DashboardLandingPage', '');
+ }
+
+ /**
+ * Checks if a url is saved as a navigation preference and if so, deletes it.
+ * Also optionally resets the section dashboard landing page, which may be desirable if a user no longer has
+ * permission to access pages in that section.
+ *
+ * @param string $url The url to search the user navigation preferences for, defaults to the request
+ * @param string $userID The ID of the user to clear the preferences for, defaults to the sessioned user
+ * @param bool $resetSectionPreference Whether to reset the dashboard section landing page
+ */
+ public function clearSectionNavigationPreference($url = '', $userID = '', $resetSectionPreference = true) {
+ if (!$userID) {
+ $userID = Gdn::session()->UserID;
+ }
+
+ if ($url == '') {
+ $url = Gdn::request()->url();
+ }
+
+ $user = $this->getID($userID);
+ $preferences = val('Preferences', $user, []);
+ $landingPages = val('DashboardNav.SectionLandingPages', $preferences, []);
+
+ // Run through the user's saved landing page per section and if the url matches the passed url,
+ // remove that preference.
+ foreach ($landingPages as $section => $landingPage) {
+ $url = strtolower(trim($url, '/'));
+ $landingPage = strtolower(trim($landingPage, '/'));
+ if ($url == $landingPage || stringEndsWith($url, $landingPage)) {
+ unset($landingPages[$section]);
+ }
+ }
+
+ $this->savePreference($userID, 'DashboardNav.SectionLandingPages', $landingPages);
+
+ if ($resetSectionPreference) {
+ $this->savePreference($userID, 'DashboardNav.DashboardLandingPage', '');
+ }
+ }
+
+ /**
+ * @param int $userID
+ * @throws Exception
+ * @return bool
+ */
+ public function isApplicant($userID) {
+ $result = false;
+
+ $applicantRoleIDs = RoleModel::getDefaultRoles(RoleModel::TYPE_APPLICANT);
+
+ // Make sure the user is an applicant.
+ $roleData = $this->getRoles($userID);
+ if (count($roleData) == 0) {
+ throw new Exception(t('ErrorRecordNotFound'));
+ } else {
+ foreach ($roleData as $appRole) {
+ if (in_array(val('RoleID', $appRole), $applicantRoleIDs)) {
+ $result = true;
+ break;
+ }
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Do the registration values indicate SPAM?
+ *
+ * @param array $formPostValues
+ * @throws Gdn_UserException if the values trigger a positive SPAM match.
+ * @return bool
+ */
+ public function isRegistrationSpam(array $formPostValues) {
+ $result = (bool)SpamModel::isSpam('Registration', $formPostValues, ['Log' => false]);
+ return $result;
+ }
+
+ /**
+ * Validate the strength of a user's password.
+ *
+ * @param string $password A password to test.
+ * @param string $username The name of the user. Used to verify the password doesn't contain this value.
+ * @throws Gdn_UserException if the password is too weak.
+ * @return bool
+ */
+ public function validatePasswordStrength($password, $username) {
+ $strength = passwordStrength($password, $username);
+ $result = (bool)$strength['Pass'];
+
+ if ($result === false) {
+ throw new Gdn_UserException('The password is too weak.');
+ }
+ return $result;
+ }
+
+ /**
+ * Get the proper sort column and direction for a user query, based on the Garden.MentionsOrder config.
+ *
+ * @return array An array of two elements representing a sort: column and direction.
+ */
+ public function getMentionsSort() {
+ $mentionsOrder = c('Garden.MentionsOrder');
+ switch ($mentionsOrder) {
+ case 'Name':
+ $column = 'Name';
+ $direction = 'asc';
+ break;
+ case 'DateLastActive':
+ $column = 'DateLastActive';
+ $direction = 'desc';
+ break;
+ case 'CountComments':
+ default:
+ $column = 'CountComments';
+ $direction = 'desc';
+ break;
+ }
+
+ $result = [$column, $direction];
+ return $result;
+ }
+
+ /**
+ * Whether or not usernames have to be unique.
+ *
+ * @return bool Returns the setting.
+ */
+ public function isNameUnique() {
+ return $this->nameUnique;
+ }
+
+ /**
+ * Whether or not usernames have to be unique.
+ *
+ * @param bool $nameUnique The new setting.
+ * @return $this
+ */
+ public function setNameUnique(bool $nameUnique) {
+ $this->nameUnique = $nameUnique;
+ return $this;
+ }
+
+ /**
+ * Whether or not email addresses have to be unique.
+ *
+ * @return bool Returns the setting.
+ */
+ public function isEmailUnique() {
+ return $this->emailUnique;
+ }
+
+ /**
+ * Whether or not email addresses have to be unique.
+ *
+ * @param bool $emailUnique The new setting.
+ * @return $this
+ */
+ public function setEmailUnique(bool $emailUnique) {
+ $this->emailUnique = $emailUnique;
+ return $this;
+ }
+}