diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 4f9ce81f..2504f797 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1869,307 +1869,19 @@ parameters: - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$user\.$#' identifier: property.notFound - count: 10 + count: 5 path: src/Controllers/User/ResetPassword.php - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$_responseCode\.$#' identifier: property.notFound - count: 1 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$active_user\.$#' - identifier: property.notFound - count: 4 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$biography\.$#' - identifier: property.notFound - count: 3 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$biography_error\.$#' - identifier: property.notFound - count: 3 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$biography_max_len\.$#' - identifier: property.notFound - count: 1 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$discord_username\.$#' - identifier: property.notFound - count: 3 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$discord_username_error\.$#' - identifier: property.notFound - count: 3 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$discord_username_max_len\.$#' - identifier: property.notFound - count: 1 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$display_name\.$#' - identifier: property.notFound - count: 2 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$display_name_error\.$#' - identifier: property.notFound - count: 4 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$email_1\.$#' - identifier: property.notFound - count: 2 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$email_2\.$#' - identifier: property.notFound - count: 2 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$email_error\.$#' - identifier: property.notFound - count: 8 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$facebook_username\.$#' - identifier: property.notFound - count: 3 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$facebook_username_error\.$#' - identifier: property.notFound - count: 3 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$facebook_username_max_len\.$#' - identifier: property.notFound - count: 1 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$github_username\.$#' - identifier: property.notFound - count: 3 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$github_username_error\.$#' - identifier: property.notFound - count: 3 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$github_username_max_len\.$#' - identifier: property.notFound - count: 1 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$instagram_username\.$#' - identifier: property.notFound - count: 3 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$instagram_username_error\.$#' - identifier: property.notFound count: 3 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$instagram_username_max_len\.$#' - identifier: property.notFound - count: 1 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$phone\.$#' - identifier: property.notFound - count: 3 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$phone_error\.$#' - identifier: property.notFound - count: 3 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$phone_max_len\.$#' - identifier: property.notFound - count: 1 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$profile\.$#' - identifier: property.notFound - count: 24 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$reddit_username\.$#' - identifier: property.notFound - count: 3 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$reddit_username_error\.$#' - identifier: property.notFound - count: 3 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$reddit_username_max_len\.$#' - identifier: property.notFound - count: 1 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$skype_username\.$#' - identifier: property.notFound - count: 3 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$skype_username_error\.$#' - identifier: property.notFound - count: 3 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$skype_username_max_len\.$#' - identifier: property.notFound - count: 1 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$steam_id\.$#' - identifier: property.notFound - count: 3 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$steam_id_error\.$#' - identifier: property.notFound - count: 3 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$steam_id_max_len\.$#' - identifier: property.notFound - count: 1 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$twitter_username\.$#' - identifier: property.notFound - count: 3 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$twitter_username_error\.$#' - identifier: property.notFound - count: 3 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$twitter_username_max_len\.$#' - identifier: property.notFound - count: 1 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$user\.$#' - identifier: property.notFound - count: 20 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$username\.$#' - identifier: property.notFound - count: 2 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$username_error\.$#' - identifier: property.notFound - count: 7 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$username_max_len\.$#' - identifier: property.notFound - count: 1 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$website\.$#' - identifier: property.notFound - count: 3 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$website_error\.$#' - identifier: property.notFound - count: 3 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$website_max_len\.$#' - identifier: property.notFound - count: 1 - path: src/Controllers/User/Update.php - - - - message: '#^Call to function is_null\(\) with null will always evaluate to true\.$#' - identifier: function.alreadyNarrowedType - count: 1 - path: src/Controllers/User/Update.php - - - - message: '#^Expression on left side of \?\? is not nullable\.$#' - identifier: nullCoalesce.expr - count: 1 - path: src/Controllers/User/Update.php - - - - message: '#^Result of && is always false\.$#' - identifier: booleanAnd.alwaysFalse - count: 1 - path: src/Controllers/User/Update.php - - - - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$_responseCode\.$#' - identifier: property.notFound - count: 2 path: src/Controllers/User/Verify.php - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$error\.$#' identifier: property.notFound - count: 3 + count: 4 path: src/Controllers/User/Verify.php - @@ -2181,7 +1893,7 @@ parameters: - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$user\.$#' identifier: property.notFound - count: 3 + count: 4 path: src/Controllers/User/Verify.php - diff --git a/src/Controllers/User/Edit.php b/src/Controllers/User/Edit.php new file mode 100644 index 00000000..e9e56e9d --- /dev/null +++ b/src/Controllers/User/Edit.php @@ -0,0 +1,466 @@ +model = new EditModel(); + } + + public function invoke(?array $args): bool + { + $model = $this->model(); + + if (!$model->active_user) + { + $model->_responseCode = HttpCode::HTTP_FORBIDDEN; + $model->error = EditModel::ERROR_NOT_LOGGED_IN; + return true; + } + + $model->acl_allowed = $model->active_user->getOption(User::OPTION_ACL_USER_MODIFY); + if (!$model->acl_allowed) + { + $model->_responseCode = HttpCode::HTTP_FORBIDDEN; + $model->error = EditModel::ERROR_ACL_NOT_SET; + return true; + } + + $model->form_fields = Router::query(); + $id = $model->form_fields['id'] ?? null; + $model->target_id = \is_numeric($id) ? (int) $id : null; + + try + { + if (!\is_null($model->target_id)) + { + $model->target_user = new User($model->target_id); + } + } + catch (\BNETDocs\Exceptions\UserNotFoundException | \OutOfBoundsException) + { + $model->target_user = null; + } + + if (!$model->target_user) + { + $model->_responseCode = HttpCode::HTTP_NOT_FOUND; + $model->error = EditModel::ERROR_NOT_FOUND; + return true; + } + + $editor = $model->active_user; + $target = $model->target_user; + $model->is_self = ($target->getId() === $editor->getId()); + + // Editing an account that holds permissions the editor lacks would allow taking it over + // (e.g. by changing its email and then resetting its password), so refuse it entirely. + $outranking_bits = $target->getOptions() & ~$editor->getOptions() & EditModel::ACL_MASK; + if (!$model->is_self && $outranking_bits !== 0) + { + $model->_responseCode = HttpCode::HTTP_FORBIDDEN; + $model->error = EditModel::ERROR_OUTRANKED; + return true; + } + + // Only permissions the editor holds can be granted or revoked; status flags are always editable. + $editable = ($editor->getOptions() & EditModel::ACL_MASK) | User::OPTION_DISABLED | User::OPTION_VERIFIED; + if ($model->is_self) + { + // prevent locking yourself out + $editable &= ~(User::OPTION_ACL_USER_MODIFY | User::OPTION_DISABLED); + } + $model->editable_mask = $editable; + + $req = Config::get('bnetdocs.user_register_requirements') ?? []; + $model->username_max_len = (int) ($req['username_length_max'] ?? User::MAX_USERNAME); + $model->username_min_len = (int) ($req['username_length_min'] ?? 3); + + $model->display_name = $target->getDisplayName(); + $model->email = $target->getEmail(); + $model->options = $target->getOptions(); + $model->options_before = $target->getOptions(); + $model->username = $target->getUsername(); + + $this->profile = $target->getUserProfile(); + if (!$this->profile) + { + $this->profile = new Profile(null); + $this->profile->setUserId($target->getId()); + } + foreach (\array_keys(Profile::FIELDS) as $field) + { + $model->profile[$field] = $this->profile->getField($field); + } + + $model->error = null; + $model->_responseCode = HttpCode::HTTP_OK; + + if (Router::requestMethod() == Router::METHOD_POST) + { + $this->handlePost(); + } + + return true; + } + + private function handlePost(): void + { + $model = $this->model(); + $f = $model->form_fields; + $target = $model->target_user; + + if (($f['action'] ?? null) === 'resend_verification') + { + $this->resendVerification(); + return; + } + + if (($f['action'] ?? null) === 'reset_password') + { + $this->resetPassword(($f['confirm'] ?? null) === '1'); + return; + } + + $username = \trim(\is_string($f['username'] ?? null) ? $f['username'] : ''); + $display_name = \trim(\is_string($f['display_name'] ?? null) ? $f['display_name'] : ''); + $email = \trim(\is_string($f['email'] ?? null) ? $f['email'] : ''); + if ($display_name === '') $display_name = null; + + $submitted = 0; + $submitted_options = $f['options'] ?? []; + if (\is_array($submitted_options)) + { + foreach ($submitted_options as $bit) + { + if (\is_string($bit) && \ctype_digit($bit)) $submitted |= (int) $bit; + } + } + + // keep every bit the editor may not change, take the rest from the form + $editable = $model->editable_mask; + $options = ($model->options_before & ~$editable) | ($submitted & $editable); + + // a new address must be verified by its owner, whatever the Verified box says + $email_changed = \strtolower($email) !== \strtolower($target->getEmail()); + if ($email_changed) $options &= ~User::OPTION_VERIFIED; + + $model->username = $username; + $model->display_name = $display_name; + $model->email = $email; + $model->options = $options; + + $username_len = \strlen($username); + if ($username_len === 0) $model->errors['username'] = 'EMPTY'; + else if ($username_len > $model->username_max_len) $model->errors['username'] = 'TOO_LONG'; + else if ($username_len < $model->username_min_len) $model->errors['username'] = 'TOO_SHORT'; + else if ($username !== $target->getUsername()) + { + $existing = User::findIdByUsername($username); + if (!\is_null($existing) && $existing !== $target->getId()) $model->errors['username'] = 'TAKEN'; + } + + if (!\is_null($display_name) && \strlen($display_name) > $model->display_name_max_len) + { + $model->errors['display_name'] = 'TOO_LONG'; + } + + if ($email === '') $model->errors['email'] = 'EMPTY'; + else if (\strlen($email) > $model->email_max_len) $model->errors['email'] = 'TOO_LONG'; + else if (!\filter_var($email, \FILTER_VALIDATE_EMAIL)) $model->errors['email'] = 'INVALID'; + else if ($email_changed) + { + $existing = User::findIdByEmail($email); + if (!\is_null($existing) && $existing !== $target->getId()) $model->errors['email'] = 'TAKEN'; + } + + $submitted_profile = $f['profile'] ?? []; + if (!\is_array($submitted_profile)) $submitted_profile = []; + foreach (\array_keys(Profile::FIELDS) as $field) + { + $value = \trim(\is_string($submitted_profile[$field] ?? null) ? $submitted_profile[$field] : ''); + if ($value === '') $value = null; + $model->profile[$field] = $value; + + if (\strlen($value ?? '') > $model->profile_max_len) + { + $model->errors['profile_' . $field] = 'TOO_LONG'; + } + } + + if (!empty($model->errors)) + { + $model->_responseCode = HttpCode::HTTP_BAD_REQUEST; + return; + } + + $changes = []; + if ($username !== $target->getUsername()) $changes['username'] = [$target->getUsername(), $username]; + if ($display_name !== $target->getDisplayName()) $changes['display_name'] = [$target->getDisplayName(), $display_name]; + if ($email !== $target->getEmail()) $changes['email'] = [$target->getEmail(), $email]; + if ($options !== $target->getOptions()) $changes['options'] = [$target->getOptions(), $options]; + $user_changed = !empty($changes); + + foreach ($model->profile as $field => $value) + { + $old = $this->profile->getField($field); + if ($value !== $old) $changes['profile'][$field] = [$old, $value]; + } + + if (empty($changes)) + { + $model->saved = true; + return; + } + + if ($user_changed) + { + $target->setUsername($username); + $target->setDisplayName($display_name); + $target->setEmail($email); + // toggle verified through setVerified() so its date and verifier token stay consistent, + // after setEmail() since a new verifier token is derived from the address + $target->setOptions(($options & ~User::OPTION_VERIFIED) | ($target->getOptions() & User::OPTION_VERIFIED)); + $target->setVerified(($options & User::OPTION_VERIFIED) !== 0, $email_changed); + + if (!$target->commit()) + { + $model->_responseCode = HttpCode::HTTP_INTERNAL_SERVER_ERROR; + $model->error = EditModel::ERROR_INTERNAL; + return; + } + } + + if (isset($changes['profile'])) + { + foreach ($changes['profile'] as $field => [, $value]) + { + $this->profile->setField($field, $value); + } + + if (!$this->profile->commit()) + { + $model->_responseCode = HttpCode::HTTP_INTERNAL_SERVER_ERROR; + $model->error = EditModel::ERROR_INTERNAL; + return; + } + } + + $model->saved = true; + + $before = $model->options_before; + $granted = EditModel::optionNames($options & ~$before); + $revoked = EditModel::optionNames($before & ~$options); + + $event = Logger::initEvent( + EventTypes::USER_EDITED, + $model->active_user, + getenv('REMOTE_ADDR'), + [ + 'user_id' => $target->getId(), + 'changes' => $changes, + 'options_granted' => \array_values($granted), + 'options_revoked' => \array_values($revoked), + ] + ); + + if ($event->commit()) + { + $fields = [ + 'Edited by' => $model->active_user->getAsMarkdown(), + 'Edited user' => $target->getAsMarkdown(), + ]; + foreach (['username' => 'Username', 'display_name' => 'Display name'] as $key => $label) + { + if (!isset($changes[$key])) continue; + $fields[$label] = \sprintf('%s → %s', $changes[$key][0] ?? '(none)', $changes[$key][1] ?? '(none)'); + } + // the address itself stays in the event log, out of Discord + if (isset($changes['email'])) $fields['Email'] = 'Changed'; + if (isset($changes['profile'])) + { + $fields['Profile'] = \implode(', ', \array_map( + fn(string $field): string => Profile::FIELDS[$field][2], + \array_keys($changes['profile']) + )); + } + if (!empty($granted)) $fields['Granted'] = \implode(', ', $granted); + if (!empty($revoked)) $fields['Revoked'] = \implode(', ', $revoked); + + $embed = Logger::initDiscordEmbed($event, $target->getURI(), $fields); + Logger::logToDiscord($event, $embed); + } + + $model->options_before = $options; + + // the save itself succeeded, so a failure here is only reported on the page + if ($email_changed) $this->sendVerification(VerificationEmail::REASON_EMAIL_CHANGED); + } + + private function resendVerification(): void + { + $model = $this->model(); + $target = $model->target_user; + + if ($target->isVerified()) + { + $model->_responseCode = HttpCode::HTTP_CONFLICT; + $model->verification_email = EditModel::VERIFICATION_ALREADY_VERIFIED; + return; + } + + try + { + // an unverified user can lack a token, e.g. one unverified before tokens were kept in sync + if (empty($target->getVerifierToken())) + { + $target->setVerified(false, true); + if (!$target->commit()) throw new \RuntimeException('failed to save a new verifier token'); + } + } + catch (\Throwable $e) + { + \error_log(\sprintf('Failed to save a verifier token for user #%d: %s', $target->getId(), $e->getMessage())); + $model->_responseCode = HttpCode::HTTP_INTERNAL_SERVER_ERROR; + $model->verification_email = EditModel::VERIFICATION_FAILED; + return; + } + + if (!$this->sendVerification(VerificationEmail::REASON_STAFF_RESENT)) + { + $model->_responseCode = HttpCode::HTTP_INTERNAL_SERVER_ERROR; + } + } + + /** + * Clears the edited user's password and logs them out everywhere, then emails them a link to choose a new one. + * + * @param bool $confirmed Whether the editor ticked the box confirming it. + * @return void + */ + private function resetPassword(bool $confirmed): void + { + $model = $this->model(); + $target = $model->target_user; + + // staff change their own password on /user/changepassword instead + if ($model->is_self) + { + $model->_responseCode = HttpCode::HTTP_BAD_REQUEST; + $model->password_reset = EditModel::PASSWORD_RESET_SELF; + return; + } + + if (!$confirmed) + { + $model->_responseCode = HttpCode::HTTP_BAD_REQUEST; + $model->password_reset = EditModel::PASSWORD_RESET_UNCONFIRMED; + return; + } + + // without a password hash the user cannot log in until they finish the reset with this token + $target->setPasswordHash(null); + $target->setPasswordSalt(null); + $target->setVerifierToken(User::generateVerifierToken($target->getUsername(), $target->getEmail())); + if (!$target->commit()) + { + $model->_responseCode = HttpCode::HTTP_INTERNAL_SERVER_ERROR; + $model->error = EditModel::ERROR_INTERNAL; + return; + } + + $expired = Authentication::expireUserSessions($target); + $model->sessions_expired = $expired === false ? 0 : $expired; + + $email_sent = true; + try + { + ResetPasswordEmail::send($target, $model->active_user, ResetPasswordEmail::REASON_STAFF_RESET); + } + catch (\Throwable $e) + { + \error_log(\sprintf('Failed to send password reset email to user #%d: %s', $target->getId(), $e->getMessage())); + $email_sent = false; + } + $model->password_reset = $email_sent ? EditModel::PASSWORD_RESET_DONE : EditModel::PASSWORD_RESET_EMAIL_FAILED; + + $event = Logger::initEvent( + EventTypes::USER_PASSWORD_RESET, + $model->active_user, + \getenv('REMOTE_ADDR'), + [ + 'user_id' => $target->getId(), + 'reset_by_staff' => true, + 'sessions_expired' => $expired, + 'email_sent' => $email_sent, + ] + ); + + if ($event->commit()) + { + $fields = [ + 'Reset by' => $model->active_user->getAsMarkdown(), + 'Reset user' => $target->getAsMarkdown(), + 'Sessions expired' => (string) $model->sessions_expired, + 'Email sent' => $email_sent ? 'Yes' : 'No', + ]; + Logger::logToDiscord($event, Logger::initDiscordEmbed($event, $target->getURI(), $fields)); + } + } + + /** + * Sends the edited user a verification email, recording the outcome for the template. + * + * @param string $reason One of the VerificationEmail::REASON_* constants. + * @return bool Whether it was sent. + */ + private function sendVerification(string $reason): bool + { + $model = $this->model(); + + try + { + VerificationEmail::send($model->target_user, $model->active_user, $reason); + $model->verification_email = EditModel::VERIFICATION_SENT; + return true; + } + catch (\Throwable $e) + { + \error_log(\sprintf('Failed to send verification email to user #%d: %s', $model->target_user->getId(), $e->getMessage())); + $model->verification_email = EditModel::VERIFICATION_FAILED; + return false; + } + } + + /** + * Retrieves this controller's model as its concrete type. + * + * @return EditModel The model. + * @throws InvalidModelException if the model is not an EditModel. + */ + private function model(): EditModel + { + if (!$this->model instanceof EditModel) + { + throw new InvalidModelException($this->model ?? EditModel::class); + } + return $this->model; + } +} diff --git a/src/Controllers/User/Login.php b/src/Controllers/User/Login.php index 6601f5d7..8cdba186 100644 --- a/src/Controllers/User/Login.php +++ b/src/Controllers/User/Login.php @@ -49,6 +49,8 @@ public function invoke(?array $args): bool $this->model->error = LoginModel::ERROR_USER_NOT_FOUND; else if ($this->model->user->isDisabled()) $this->model->error = LoginModel::ERROR_USER_DISABLED; + else if (\is_null($this->model->user->getPasswordHash())) + $this->model->error = LoginModel::ERROR_PASSWORD_RESET_REQUIRED; else if (!$this->model->user->checkPassword($this->model->password)) $this->model->error = LoginModel::ERROR_INCORRECT_PASSWORD; else if (!$this->model->user->isVerified()) diff --git a/src/Controllers/User/Register.php b/src/Controllers/User/Register.php index c4d926dc..8905931f 100644 --- a/src/Controllers/User/Register.php +++ b/src/Controllers/User/Register.php @@ -6,12 +6,11 @@ use \BNETDocs\Libraries\Core\Config; use \BNETDocs\Libraries\Core\Recaptcha; use \BNETDocs\Libraries\Core\Router; -use \BNETDocs\Libraries\Core\Template; use \BNETDocs\Libraries\EventLog\EventTypes; use \BNETDocs\Libraries\EventLog\Logger; use \BNETDocs\Libraries\User\User; +use \BNETDocs\Libraries\User\VerificationEmail; use \BNETDocs\Models\User\Register as RegisterModel; -use \PHPMailer\PHPMailer\PHPMailer; class Register extends \BNETDocs\Controllers\Base { @@ -239,79 +238,11 @@ protected function tryRegister(): void Logger::logToDiscord($event, $embed); } - $mail = new PHPMailer(true); // true enables exceptions - - $state = new \StdClass(); - $state->mail = &$mail; - $state->name = $user->getName(); - $state->token = $user->getVerifierToken(); - $state->user_id = $user_id; - try { - //Server settings - $mail->Timeout = 10; // default is 300 per RFC2821 $ 4.5.3.2 - $mail->SMTPDebug = 0; - $mail->isSMTP(); - $mail->Host = Config::get('email.smtp_host') ?? 'localhost'; - $mail->Username = Config::get('email.smtp_user') ?? ''; - $mail->Password = Config::get('email.smtp_password') ?? ''; - $mail->SMTPAuth = !empty($mail->Username); - $mail->SMTPSecure = (Config::get('email.smtp_tls') ?? false) ? 'tls' : ''; - $mail->Port = Config::get('email.smtp_port') ?? 25; - - //Recipients - $recipient_from = Config::get('email.recipient_from') ?? []; - if (isset($recipient_from[0])) - { - $mail->setFrom($recipient_from[0], $recipient_from[1] ?? ''); - } - - $mail->addAddress($email, $username); - - $recipient_reply_to = Config::get('email.recipient_reply_to') ?? []; - if (isset($recipient_reply_to[0])) - { - $mail->addReplyTo($recipient_reply_to[0], $recipient_reply_to[1] ?? ''); - } - - // Content - $mail->isHTML(true); - $mail->Subject = 'Account Verification'; - $mail->CharSet = PHPMailer::CHARSET_UTF8; - - ob_start(); - (new Template($state, 'Email/User/Register.rich'))->invoke(); - $mail->Body = ob_get_clean(); - - ob_start(); - (new Template($state, 'Email/User/Register.plain'))->invoke(); - $mail->AltBody = ob_get_clean(); - - $mail->send(); - - $event = Logger::initEvent( - EventTypes::EMAIL_SENT, - $user_id, - getenv('REMOTE_ADDR'), - [ - 'from' => $mail->From, - 'to' => $mail->getToAddresses(), - 'reply_to' => $mail->getReplyToAddresses(), - 'subject' => $mail->Subject, - 'content_type' => $mail->ContentType, - 'body' => $mail->Body, - 'alt_body' => $mail->AltBody, - ] - ); - - if ($event->commit()) - { - $embed = Logger::initDiscordEmbed($event, $user->getURI()); - Logger::logToDiscord($event, $embed); - } + VerificationEmail::send($user, $user_id); } - catch (\Throwable $e) + catch (\Throwable) { $this->model->error = RegisterModel::ERROR_EMAIL_FAILURE; } diff --git a/src/Controllers/User/ResetPassword.php b/src/Controllers/User/ResetPassword.php index 5ccfa507..4e3c4424 100644 --- a/src/Controllers/User/ResetPassword.php +++ b/src/Controllers/User/ResetPassword.php @@ -3,13 +3,11 @@ use \BNETDocs\Libraries\Core\Config; use \BNETDocs\Libraries\Core\Router; -use \BNETDocs\Libraries\Core\Template; use \BNETDocs\Libraries\EventLog\EventTypes; use \BNETDocs\Libraries\EventLog\Logger; +use \BNETDocs\Libraries\User\ResetPasswordEmail; use \BNETDocs\Libraries\User\User; use \BNETDocs\Models\User\ResetPassword as ResetPasswordModel; -use \PHPMailer\PHPMailer\PHPMailer; -use \StdClass; use \UnexpectedValueException; class ResetPassword extends \BNETDocs\Controllers\Base @@ -107,82 +105,16 @@ protected function doPasswordReset(): mixed $this->model->user->setPassword($this->model->pw1); $this->model->user->setVerified(true); + // setVerified() leaves an already verified user's token alone, and the link must not work twice + $this->model->user->setVerifierToken(null); return $this->model->user->commit() ? false : ResetPasswordModel::ERROR_INTERNAL; } protected function sendEmail(): mixed { - $mail = new PHPMailer(true); // true enables exceptions - - $state = new StdClass(); - $state->email = $this->model->user->getEmail(); - $state->mail = &$mail; - $state->token = $this->model->user->getVerifierToken(); - $state->username = $this->model->user->getName(); - try { - //Server settings - $mail->Timeout = 10; // default is 300 per RFC2821 $ 4.5.3.2 - $mail->SMTPDebug = 0; - $mail->isSMTP(); - $mail->Host = Config::get('email.smtp_host') ?? 'localhost'; - $mail->Username = Config::get('email.smtp_user') ?? ''; - $mail->Password = Config::get('email.smtp_password') ?? ''; - $mail->SMTPAuth = !empty($mail->Username); - $mail->SMTPSecure = (Config::get('email.smtp_tls') ?? false) ? 'tls' : ''; - $mail->Port = Config::get('email.smtp_port') ?? 25; - - //Recipients - $recipient_from = Config::get('email.recipient_from') ?? []; - if (isset($recipient_from[0])) - { - $mail->setFrom($recipient_from[0], $recipient_from[1] ?? ''); - } - - $mail->addAddress($this->model->user->getEmail(), $this->model->user->getName()); - - $recipient_reply_to = Config::get('email.recipient_reply_to') ?? []; - if (isset($recipient_reply_to[0])) - { - $mail->addReplyTo($recipient_reply_to[0], $recipient_reply_to[1] ?? ''); - } - - // Content - $mail->isHTML(true); - $mail->Subject = 'Reset Password'; - $mail->CharSet = PHPMailer::CHARSET_UTF8; - - ob_start(); - (new Template($state, 'Email/User/ResetPassword.rich'))->invoke(); - $mail->Body = ob_get_clean(); - - ob_start(); - (new Template($state, 'Email/User/ResetPassword.plain'))->invoke(); - $mail->AltBody = ob_get_clean(); - - $mail->send(); - - $event = Logger::initEvent( - EventTypes::EMAIL_SENT, - $this->model->user, - getenv('REMOTE_ADDR'), - [ - 'from' => $mail->From, - 'to' => $mail->getToAddresses(), - 'reply_to' => $mail->getReplyToAddresses(), - 'subject' => $mail->Subject, - 'content_type' => $mail->ContentType, - 'body' => $mail->Body, - 'alt_body' => $mail->AltBody, - ] - ); - - if ($event->commit()) - { - $embed = Logger::initDiscordEmbed($event, $this->model->user->getURI()); - Logger::logToDiscord($event, $embed); - } + ResetPasswordEmail::send($this->model->user, $this->model->user); } catch (\PHPMailer\PHPMailer\Exception) { diff --git a/src/Controllers/User/Update.php b/src/Controllers/User/Update.php index 0fcf5711..7da5c46c 100644 --- a/src/Controllers/User/Update.php +++ b/src/Controllers/User/Update.php @@ -2,469 +2,339 @@ namespace BNETDocs\Controllers\User; +use \BNETDocs\Exceptions\InvalidModelException; use \BNETDocs\Libraries\Core\Config; +use \BNETDocs\Libraries\Core\HttpCode; use \BNETDocs\Libraries\Core\Router; +use \BNETDocs\Libraries\Core\UrlFormatter; +use \BNETDocs\Libraries\EventLog\EventTypes; use \BNETDocs\Libraries\EventLog\Logger; +use \BNETDocs\Libraries\User\Profile; use \BNETDocs\Libraries\User\User; -use \Throwable; -use \UnexpectedValueException; +use \BNETDocs\Libraries\User\VerificationEmail; +use \BNETDocs\Models\User\Update as UpdateModel; class Update extends \BNETDocs\Controllers\Base { + private ?Profile $profile = null; + public function __construct() { - $this->model = new \BNETDocs\Models\User\Update(); + $this->model = new UpdateModel(); } public function invoke(?array $args): bool { - if (!$this->model->active_user) + $model = $this->model(); + + if (!$model->active_user) { - $this->model->_responseCode = \BNETDocs\Libraries\Core\HttpCode::HTTP_FORBIDDEN; + $model->_responseCode = HttpCode::HTTP_FORBIDDEN; return true; } + $model->_responseCode = HttpCode::HTTP_OK; $query = Router::query(); - $id = isset($query['id']) ? ((int) $query['id']) : null; - if (\is_int($id) && $this->model->active_user->getOption(User::OPTION_ACL_USER_MODIFY)) + // this page only edits the active user's own profile, staff edit other users at /user/edit + $id = $query['id'] ?? null; + if (\is_numeric($id) && (int) $id !== $model->active_user->getId() + && $model->active_user->getOption(User::OPTION_ACL_USER_MODIFY)) { - try { $this->model->user = new User($id); } - catch (UnexpectedValueException) { $this->model->user = null; } + $model->_responseCode = HttpCode::HTTP_FOUND; + $model->_responseHeaders['Location'] = UrlFormatter::format('/user/edit?id=' . (int) $id); + return true; } - if (!$this->model->user) + + $user = $model->active_user; + $model->user = $user; + + $req = Config::get('bnetdocs.user_register_requirements') ?? []; + $model->username_max_len = (int) ($req['username_length_max'] ?? User::MAX_USERNAME); + $model->username_min_len = (int) ($req['username_length_min'] ?? 3); + + $model->display_name = $user->getDisplayName(); + $model->email = $user->getEmail(); + $model->username = $user->getUsername(); + + $this->profile = $user->getUserProfile(); + if (!$this->profile) { - $id = $this->model->active_user->getId(); - $this->model->user = $this->model->active_user; + $this->profile = new Profile(null); + $this->profile->setUserId($user->getId()); + } + foreach (\array_keys(Profile::FIELDS) as $field) + { + $model->profile[$field] = $this->profile->getField($field); } - $this->fillModel(); if (Router::requestMethod() == Router::METHOD_POST) { - $this->handlePost($query); + $this->handlePost($query, $req); } return true; } - private function fillModel(): void + private function handlePost(array $f, array $req): void { - $req = Config::get('bnetdocs.user_register_requirements') ?? []; - - $this->model->username = $this->model->user->getUsername(); - $this->model->username_error = [null, null]; - $this->model->username_max_len = $req['username_length_max'] ?? User::MAX_USERNAME; + $model = $this->model(); + $user = $model->user; - $this->model->email_1 = $this->model->user->getEmail(); - $this->model->email_2 = ''; - $this->model->email_error = [null, null]; - - $this->model->display_name = $this->model->user->getDisplayName(); - $this->model->display_name_error = [null, null]; - - $this->model->profile = $this->model->user->getUserProfile(); - if (!$this->model->profile) + if (($f['action'] ?? null) === 'resend_verification') { - $this->model->profile = new \BNETDocs\Libraries\User\Profile(null); - $this->model->profile->setBiography($this->model->biography); - $this->model->profile->setDiscordUsername($this->model->discord_username); - $this->model->profile->setFacebookUsername($this->model->facebook_username); - $this->model->profile->setGitHubUsername($this->model->github_username); - $this->model->profile->setInstagramUsername($this->model->instagram_username); - $this->model->profile->setPhone($this->model->phone); - $this->model->profile->setRedditUsername($this->model->reddit_username); - $this->model->profile->setSkypeUsername($this->model->skype_username); - $this->model->profile->setSteamId($this->model->steam_id); - $this->model->profile->setTwitterUsername($this->model->twitter_username); - $this->model->profile->setUserId($this->model->user->getId()); - $this->model->profile->setWebsite($this->model->website); + $this->resendVerification(); + return; } - $this->model->biography = $this->model->profile->getBiography(); - $this->model->discord_username = $this->model->profile->getDiscordUsername(); - $this->model->facebook_username = $this->model->profile->getFacebookUsername(); - $this->model->github_username = $this->model->profile->getGitHubUsername(); - $this->model->instagram_username = $this->model->profile->getInstagramUsername(); - $this->model->phone = $this->model->profile->getPhone(); - $this->model->reddit_username = $this->model->profile->getRedditUsername(); - $this->model->skype_username = $this->model->profile->getSkypeUsername(); - $this->model->steam_id = $this->model->profile->getSteamId(); - $this->model->twitter_username = $this->model->profile->getTwitterUsername(); - $this->model->website = $this->model->profile->getWebsite(false); - } - - private function handlePost(array $query): void - { - $this->model->biography = $query['biography'] ?? null; - $this->model->discord_username = $query['discord_username'] ?? null; - $this->model->display_name = $query['display_name'] ?? null; - $this->model->email_1 = $query['email_1'] ?? null; - $this->model->email_2 = $query['email_2'] ?? null; - $this->model->facebook_username = $query['facebook_username'] ?? null; - $this->model->github_username = $query['github_username'] ?? null; - $this->model->instagram_username = $query['instagram_username'] ?? null; - $this->model->phone = $query['phone'] ?? null; - $this->model->reddit_username = $query['reddit_username'] ?? null; - $this->model->skype_username = $query['skype_username'] ?? null; - $this->model->steam_id = $query['steam_id'] ?? null; - $this->model->twitter_username = $query['twitter_username'] ?? null; - $this->model->username = $query['username'] ?? null; - $this->model->website = $query['website'] ?? null; - - // convert empty strings to null - if (empty($this->model->biography)) $this->model->biography = null; - if (empty($this->model->discord_username)) $this->model->discord_username = null; - if (empty($this->model->display_name)) $this->model->display_name = null; - if (empty($this->model->email_1)) $this->model->email_1 = null; - if (empty($this->model->email_2)) $this->model->email_2 = null; - if (empty($this->model->facebook_username)) $this->model->facebook_username = null; - if (empty($this->model->github_username)) $this->model->github_username = null; - if (empty($this->model->instagram_username)) $this->model->instagram_username = null; - if (empty($this->model->phone)) $this->model->phone = null; - if (empty($this->model->reddit_username)) $this->model->reddit_username = null; - if (empty($this->model->skype_username)) $this->model->skype_username = null; - if (empty($this->model->steam_id)) $this->model->steam_id = null; - if (empty($this->model->twitter_username)) $this->model->twitter_username = null; - if (empty($this->model->username)) $this->model->username = null; - if (empty($this->model->website)) $this->model->website = null; + $string = fn(string $key): string => \trim(\is_string($f[$key] ?? null) ? $f[$key] : ''); + $username = $string('username'); + $display_name = $string('display_name'); + $email = $string('email'); + $email_confirm = $string('email_confirm'); + if ($display_name === '') $display_name = null; + + $model->username = $username; + $model->display_name = $display_name; + $model->email = $email; + $model->email_confirm = $email_confirm; + + $username_len = \strlen($username); + if ($username_len === 0) $model->errors['username'] = 'EMPTY'; + else if ($username_len > $model->username_max_len) $model->errors['username'] = 'TOO_LONG'; + else if ($username_len < $model->username_min_len) $model->errors['username'] = 'TOO_SHORT'; + else if ($username !== $user->getUsername()) + { + $existing = User::findIdByUsername($username); + if (!\is_null($existing) && $existing !== $user->getId()) $model->errors['username'] = 'TAKEN'; + } - $req = Config::get('bnetdocs.user_register_requirements') ?? []; + if (!\is_null($display_name) && \strlen($display_name) > $model->display_name_max_len) + { + $model->errors['display_name'] = 'TOO_LONG'; + } - // username change request - if ($this->model->username !== $this->model->user->getUsername()) + // a change of letter case alone is still the same mailbox + $email_changed = \strtolower($email) !== \strtolower($user->getEmail()); + if ($email_changed) { - $username_len = \strlen($this->model->username ?? ''); - if (empty($this->model->username)) - { - // username is empty - $this->model->username_error = ['danger', 'EMPTY']; - } - else if (\is_numeric($req['username_length_max'] ?? User::MAX_USERNAME) - && $username_len > ($req['username_length_max'] ?? User::MAX_USERNAME)) + if ($email === '') $model->errors['email'] = 'EMPTY'; + else if (\strlen($email) > $model->email_max_len) $model->errors['email'] = 'TOO_LONG'; + else if (!\filter_var($email, \FILTER_VALIDATE_EMAIL)) $model->errors['email'] = 'INVALID'; + else if (($req['email_enable_denylist'] ?? false) && $this->isEmailDenied($email)) $model->errors['email'] = 'NOT_ALLOWED'; + else { - // username too long - $this->model->username_error = ['danger', 'USERNAME_LONG']; + $existing = User::findIdByEmail($email); + if (!\is_null($existing) && $existing !== $user->getId()) $model->errors['email'] = 'TAKEN'; } - else if (\is_numeric($req['username_length_min'] ?? 3) - && $username_len < $req['username_length_min'] ?? 3) + + if (!isset($model->errors['email']) && \strtolower($email_confirm) !== \strtolower($email)) { - // username too short - $this->model->username_error = ['danger', 'USERNAME_SHORT']; + $model->errors['email_confirm'] = 'MISMATCH'; } - else + + // a new address must be verified, and the email sent to it is rate limited + if (empty($model->errors['email']) && VerificationEmail::sentRecently($user)) { - // initiate username change - try - { - $this->model->user->setUsername($this->model->username); - $this->model->user->commit(); - $this->model->username_error = ['success', 'CHANGE_SUCCESS']; - } - catch (Throwable $e) - { - $this->model->username_error = ['danger', 'CHANGE_FAILED']; - throw $e; - } + $model->errors['email'] = 'COOLDOWN'; } } - // email change request - if ($this->model->email_1 !== $this->model->user->getEmail()) + $submitted_profile = $f['profile'] ?? []; + if (!\is_array($submitted_profile)) $submitted_profile = []; + foreach (\array_keys(Profile::FIELDS) as $field) { - // email denylist check - $email_not_allowed = false; - if ($req['email_enable_denylist'] ?? null) - { - $email_denylist = Config::get('email.recipient_denylist_regexp') ?? []; - foreach ($email_denylist as $_bad_email) - { - if (\preg_match($_bad_email, $this->model->email_1)) - { - $email_not_allowed = true; - break; - } - } - } + $value = \trim(\is_string($submitted_profile[$field] ?? null) ? $submitted_profile[$field] : ''); + if ($value === '') $value = null; + $model->profile[$field] = $value; - if (\strtolower($this->model->email_1 ?? '') !== \strtolower($this->model->email_2 ?? '')) + if (\strlen($value ?? '') > $model->profile_max_len) { - // email mismatch - $this->model->email_error = ['danger', 'MISMATCH']; - } - else if (empty($this->model->email_2)) - { - // email is empty - $this->model->email_error = ['danger', 'EMPTY']; - } - else if (($req['email_validate_quick'] ?? null) && !\filter_var($this->model->email_2, FILTER_VALIDATE_EMAIL)) - { - // email is invalid; it doesn't meet RFC 822 requirements - $this->model->email_error = ['danger', 'INVALID']; - } - else if ($email_not_allowed) - { - // email is not allowed; it matches a denylist regular expression - $this->model->email_error = ['danger', 'NOT_ALLOWED']; - } - else - { - // initiate email change - try - { - $this->model->user->setEmail($this->model->email_2); - $this->model->user->commit(); - $this->model->email_error = ['success', 'CHANGE_SUCCESS']; - } - catch (Throwable $e) - { - $this->model->email_error = ['danger', 'CHANGE_FAILED']; - throw $e; - } + $model->errors['profile_' . $field] = 'TOO_LONG'; } } - // display name change request - $display_name = $this->model->display_name; - if (empty($display_name) && !\is_null($display_name)) - { - $display_name = null; // blank strings become typed null - $new_name = $this->model->user->getUsername(); - } - else + if (!empty($model->errors)) { - $new_name = $display_name; + $model->_responseCode = HttpCode::HTTP_BAD_REQUEST; + return; } - if ($this->model->user->getDisplayName() !== $display_name) + + $changes = []; + if ($username !== $user->getUsername()) $changes['username'] = [$user->getUsername(), $username]; + if ($display_name !== $user->getDisplayName()) $changes['display_name'] = [$user->getDisplayName(), $display_name]; + if ($email !== $user->getEmail()) $changes['email'] = [$user->getEmail(), $email]; + $user_changed = !empty($changes); + + foreach ($model->profile as $field => $value) { - try - { - $this->model->user->setDisplayName($display_name); - $this->model->user->commit(); - $this->model->display_name_error = ['success', 'CHANGE_SUCCESS', $new_name]; - } - catch (Throwable $e) - { - $this->model->display_name_error = ['danger', 'CHANGE_FAILED']; - throw $e; - } + $old = $this->profile->getField($field); + if ($value !== $old) $changes['profile'][$field] = [$old, $value]; } - $profile_changed = false; + $model->saved = true; + $model->email_confirm = null; + if (empty($changes)) return; - // biography change request - if ($this->model->biography !== $this->model->profile->getBiography()) + if ($user_changed) { - if (\strlen($this->model->biography ?? '') > $this->model->biography_max_len) - { - $this->model->biography_error = ['danger', 'TOO_LONG']; - } - else + $user->setUsername($username); + $user->setDisplayName($display_name); + $user->setEmail($email); + // after setEmail(), since the new verifier token is derived from the address + if ($email_changed) $user->setVerified(false, true); + + if (!$user->commit()) { - $this->model->profile->setBiography($this->model->biography); - $this->model->biography_error = ['success', 'CHANGE_SUCCESS']; - $profile_changed = true; + $this->failInternal(); + return; } } - // discord username change request - if ($this->model->discord_username !== $this->model->profile->getDiscordUsername()) + if (isset($changes['profile'])) { - if (\strlen($this->model->discord_username ?? '') > $this->model->discord_username_max_len) + foreach ($changes['profile'] as $field => [, $value]) { - $this->model->discord_username_error = ['danger', 'TOO_LONG']; + $this->profile->setField($field, $value); } - else + + if (!$this->profile->commit()) { - $this->model->profile->setDiscordUsername($this->model->discord_username); - $this->model->discord_username_error = ['success', 'CHANGE_SUCCESS']; - $profile_changed = true; + $this->failInternal(); + return; } } - // facebook username change request - if ($this->model->facebook_username !== $this->model->profile->getFacebookUsername()) + $this->logEdit($changes); + + // the save itself succeeded, so a failure here is only reported on the page + if ($email_changed) $this->sendVerification(VerificationEmail::REASON_EMAIL_CHANGED); + } + + private function resendVerification(): void + { + $model = $this->model(); + $user = $model->user; + + if ($user->isVerified()) { - if (\strlen($this->model->facebook_username ?? '') > $this->model->facebook_username_max_len) - { - $this->model->facebook_username_error = ['danger', 'TOO_LONG']; - } - else - { - $this->model->profile->setFacebookUsername($this->model->facebook_username); - $this->model->facebook_username_error = ['success', 'CHANGE_SUCCESS']; - $profile_changed = true; - } + $model->_responseCode = HttpCode::HTTP_CONFLICT; + $model->verification_email = UpdateModel::VERIFICATION_ALREADY_VERIFIED; + return; } - // github username change request - if ($this->model->github_username !== $this->model->profile->getGitHubUsername()) + if (VerificationEmail::sentRecently($user)) { - if (\strlen($this->model->github_username ?? '') > $this->model->github_username_max_len) - { - $this->model->github_username_error = ['danger', 'TOO_LONG']; - } - else - { - $this->model->profile->setGitHubUsername($this->model->github_username); - $this->model->github_username_error = ['success', 'CHANGE_SUCCESS']; - $profile_changed = true; - } + $model->_responseCode = HttpCode::HTTP_TOO_MANY_REQUESTS; + $model->verification_email = UpdateModel::VERIFICATION_COOLDOWN; + return; } - // instagram username change request - if ($this->model->instagram_username !== $this->model->profile->getInstagramUsername()) + if (empty($user->getVerifierToken())) { - if (\strlen($this->model->instagram_username ?? '') > $this->model->instagram_username_max_len) + $user->setVerified(false, true); + if (!$user->commit()) { - $this->model->instagram_username_error = ['danger', 'TOO_LONG']; - } - else - { - $this->model->profile->setInstagramUsername($this->model->instagram_username); - $this->model->instagram_username_error = ['success', 'CHANGE_SUCCESS']; - $profile_changed = true; + $this->failInternal(); + return; } } - // phone change request - if ($this->model->phone !== $this->model->profile->getPhone()) + if (!$this->sendVerification(VerificationEmail::REASON_RESENT)) { - if (\strlen($this->model->phone ?? '') > $this->model->phone_max_len) - { - $this->model->phone_error = ['danger', 'TOO_LONG']; - } - else - { - $this->model->profile->setPhone($this->model->phone); - $this->model->phone_error = ['success', 'CHANGE_SUCCESS']; - $profile_changed = true; - } + $model->_responseCode = HttpCode::HTTP_INTERNAL_SERVER_ERROR; } + } + + /** + * Sends the active user a verification email, recording the outcome for the template. + * + * @param string $reason One of the VerificationEmail::REASON_* constants. + * @return bool Whether it was sent. + */ + private function sendVerification(string $reason): bool + { + $model = $this->model(); - // reddit username change request - if ($this->model->reddit_username !== $this->model->profile->getRedditUsername()) + try { - if (\strlen($this->model->reddit_username ?? '') > $this->model->reddit_username_max_len) - { - $this->model->reddit_username_error = ['danger', 'TOO_LONG']; - } - else - { - $this->model->profile->setRedditUsername($this->model->reddit_username); - $this->model->reddit_username_error = ['success', 'CHANGE_SUCCESS']; - $profile_changed = true; - } + VerificationEmail::send($model->user, $model->active_user, $reason); + $model->verification_email = UpdateModel::VERIFICATION_SENT; + return true; } - - // skype username change request - if ($this->model->skype_username !== $this->model->profile->getSkypeUsername()) + catch (\Throwable $e) { - if (\strlen($this->model->skype_username ?? '') > $this->model->skype_username_max_len) - { - $this->model->skype_username_error = ['danger', 'TOO_LONG']; - } - else - { - $this->model->profile->setSkypeUsername($this->model->skype_username); - $this->model->skype_username_error = ['success', 'CHANGE_SUCCESS']; - $profile_changed = true; - } + \error_log(\sprintf('Failed to send verification email to user #%d: %s', $model->user->getId(), $e->getMessage())); + $model->verification_email = UpdateModel::VERIFICATION_FAILED; + return false; } + } + + private function failInternal(): void + { + $model = $this->model(); + $model->saved = false; + $model->_responseCode = HttpCode::HTTP_INTERNAL_SERVER_ERROR; + $model->error = UpdateModel::ERROR_INTERNAL; + } - // steam id change request - if ($this->model->steam_id !== $this->model->profile->getSteamId()) + private function isEmailDenied(string $email): bool + { + foreach (Config::get('email.recipient_denylist_regexp') ?? [] as $pattern) { - if (\strlen($this->model->steam_id ?? '') > $this->model->steam_id_max_len) - { - $this->model->steam_id_error = ['danger', 'TOO_LONG']; - } - else - { - $this->model->profile->setSteamId($this->model->steam_id); - $this->model->steam_id_error = ['success', 'CHANGE_SUCCESS']; - $profile_changed = true; - } + if (\preg_match($pattern, $email)) return true; } + return false; + } - // twitter username change request - if ($this->model->twitter_username !== $this->model->profile->getTwitterUsername()) + private function logEdit(array $changes): void + { + $model = $this->model(); + $user = $model->user; + + $event = Logger::initEvent( + EventTypes::USER_EDITED, + $model->active_user, + \getenv('REMOTE_ADDR'), + ['user_id' => $user->getId(), 'changes' => $changes, 'unverified' => isset($changes['email']) && !$user->isVerified()] + ); + + if (!$event->commit()) return; + + $fields = [ + 'Edited by' => $model->active_user->getAsMarkdown(), + 'Edited user' => $user->getAsMarkdown(), + ]; + foreach (['username' => 'Username', 'display_name' => 'Display name'] as $key => $label) { - if (\strlen($this->model->twitter_username ?? '') > $this->model->twitter_username_max_len) - { - $this->model->twitter_username_error = ['danger', 'TOO_LONG']; - } - else - { - $this->model->profile->setTwitterUsername($this->model->twitter_username); - $this->model->twitter_username_error = ['success', 'CHANGE_SUCCESS']; - $profile_changed = true; - } + if (!isset($changes[$key])) continue; + $fields[$label] = \sprintf('%s → %s', $changes[$key][0] ?? '(none)', $changes[$key][1] ?? '(none)'); } - - // website change request - if ($this->model->website !== $this->model->profile->getWebsite(false)) + // the address itself stays in the event log, out of Discord + if (isset($changes['email'])) $fields['Email'] = 'Changed'; + if (isset($changes['profile'])) { - if (strlen($this->model->website ?? '') > $this->model->website_max_len) - { - $this->model->website_error = ['danger', 'TOO_LONG']; - } - else - { - $this->model->profile->setWebsite($this->model->website); - $this->model->website_error = ['success', 'CHANGE_SUCCESS']; - $profile_changed = true; - } + $fields['Profile'] = \implode(', ', \array_map( + fn(string $field): string => Profile::FIELDS[$field][2], + \array_keys($changes['profile']) + )); } - if ($profile_changed && $this->model->profile->commit()) + Logger::logToDiscord($event, Logger::initDiscordEmbed($event, $user->getURI(), $fields)); + } + + /** + * Retrieves this controller's model as its concrete type. + * + * @return UpdateModel The model. + * @throws InvalidModelException if the model is not an UpdateModel. + */ + private function model(): UpdateModel + { + if (!$this->model instanceof UpdateModel) { - $event = Logger::initEvent( - \BNETDocs\Libraries\EventLog\EventTypes::USER_EDITED, - $this->model->active_user, - getenv('REMOTE_ADDR'), - [ - 'username_error' => $this->model->username_error, - 'email_error' => $this->model->email_error, - 'display_name_error' => $this->model->display_name_error, - 'biography_error' => $this->model->biography_error, - 'discord_username_error' => $this->model->discord_username_error, - 'facebook_username_error' => $this->model->facebook_username_error, - 'github_username_error' => $this->model->github_username_error, - 'instagram_username_error' => $this->model->instagram_username_error, - 'phone_error' => $this->model->phone_error, - 'reddit_username_error' => $this->model->reddit_username_error, - 'skype_username_error' => $this->model->skype_username_error, - 'steam_id_error' => $this->model->steam_id_error, - 'twitter_username_error' => $this->model->twitter_username_error, - 'website_error' => $this->model->website_error, - 'user_id' => $this->model->user->getId(), - 'username' => $this->model->username, - 'email_1' => $this->model->email_1, - 'email_2' => $this->model->email_2, - 'display_name' => $display_name, - 'profile_changed' => $profile_changed, - 'biography' => $this->model->biography, - 'discord_username' => $this->model->discord_username, - 'facebook_username' => $this->model->facebook_username, - 'github_username' => $this->model->github_username, - 'instagram_username' => $this->model->instagram_username, - 'phone' => $this->model->phone, - 'reddit_username' => $this->model->reddit_username, - 'skype_username' => $this->model->skype_username, - 'steam_id' => $this->model->steam_id, - 'twitter_username' => $this->model->twitter_username, - 'website' => $this->model->website, - ] - ); - - if ($event->commit()) - { - $embed = Logger::initDiscordEmbed($event, $this->model->active_user->getURI(), [ - 'Edited by' => $this->model->active_user->getAsMarkdown(), - 'Edited user' => $this->model->user->getAsMarkdown(), - ]); - Logger::logToDiscord($event, $embed); - } + throw new InvalidModelException($this->model ?? UpdateModel::class); } + return $this->model; } } diff --git a/src/Controllers/User/Verify.php b/src/Controllers/User/Verify.php index 25c2be25..396731c2 100644 --- a/src/Controllers/User/Verify.php +++ b/src/Controllers/User/Verify.php @@ -25,8 +25,8 @@ public function __construct() public function invoke(?array $args): bool { $q = \BNETDocs\Libraries\Core\Router::query(); - $this->model->token = $q['t'] ?? null; - $this->model->user_id = $q['u'] ?? null; + $this->model->token = \is_string($q['t'] ?? null) ? $q['t'] : null; + $this->model->user_id = \is_string($q['u'] ?? null) ? $q['u'] : null; if (is_numeric($this->model->user_id)) { @@ -34,8 +34,18 @@ public function invoke(?array $args): bool catch (\UnexpectedValueException) { $this->model->user = null; } } + // e.g. the verification link was followed a second time + if ($this->model->user && $this->model->user->isVerified()) + { + $this->model->error = VerifyModel::ERROR_ALREADY_VERIFIED; + $this->model->_responseCode = HttpCode::HTTP_CONFLICT; + return true; + } + + // a user without a pending token has nothing to verify, so a missing token must never match one $user_token = $this->model->user ? $this->model->user->getVerifierToken() : null; - if (!$this->model->user || $user_token !== $this->model->token) + $token = $this->model->token; + if (!$this->model->user || empty($user_token) || !\is_string($token) || !\hash_equals($user_token, $token)) { $this->model->error = VerifyModel::ERROR_INVALID_TOKEN; $this->model->_responseCode = HttpCode::HTTP_BAD_REQUEST; @@ -53,7 +63,7 @@ public function invoke(?array $args): bool { $event = Logger::initEvent( \BNETDocs\Libraries\EventLog\EventTypes::USER_VERIFIED, - $this->model->user_id, + $this->model->user, getenv('REMOTE_ADDR'), ['error' => $this->model->error] ); diff --git a/src/Libraries/User/Authentication.php b/src/Libraries/User/Authentication.php index c2511e4e..f2039693 100644 --- a/src/Libraries/User/Authentication.php +++ b/src/Libraries/User/Authentication.php @@ -41,20 +41,58 @@ class Authentication private function __construct() {} /** - * discard() - * Discards fingerprint server-side so lookup cannot succeed. + * expire() + * Expires a session server-side so lookup cannot succeed. The record is kept; + * removing expired sessions is left to a separate cleanup job. * - * @param string $key The secret key. + * @param string $key The secret key, hexadecimal-formatted and 64 characters in length. + * Any other value matches no session and is ignored. * @return bool Indicates if the operation succeeded. * @throws PDOException if a PDO error occurs. */ - protected static function discard(string $key): bool + protected static function expire(string $key): bool { - $q = MariaDb::instance()->prepare('DELETE FROM `user_sessions` WHERE `id` = ? LIMIT 1;'); - try { return $q && $q->execute([$key]); } + if (strlen($key) !== 64 || !ctype_xdigit($key)) return true; + + $q = MariaDb::instance()->prepare( + 'UPDATE `user_sessions` SET `expires_datetime` = :dt + WHERE `id` = UNHEX(:id) AND (`expires_datetime` IS NULL OR `expires_datetime` > :dt) + LIMIT 1;' + ); + try { return $q && $q->execute([':dt' => self::now(), ':id' => $key]); } finally { if ($q) $q->closeCursor(); } } + /** + * expireUserSessions() + * Expires every active session of a user server-side, logging them out everywhere. + * The records are kept; removing expired sessions is left to a separate cleanup job. + * + * @param User $user The user whose sessions are expired. + * @return int|false The number of active sessions expired, or false on error. + * @throws PDOException if a PDO error occurs. + */ + public static function expireUserSessions(User $user): int|false + { + $q = MariaDb::instance()->prepare( + 'UPDATE `user_sessions` SET `expires_datetime` = :dt + WHERE `user_id` = :uid AND (`expires_datetime` IS NULL OR `expires_datetime` > :dt);' + ); + try { return ($q && $q->execute([':dt' => self::now(), ':uid' => $user->getId()])) ? $q->rowCount() : false; } + finally { if ($q) $q->closeCursor(); } + } + + /** + * now() + * The current date and time, formatted for comparing with session columns. + * + * @return string The SQL-formatted date and time. + */ + protected static function now(): string + { + return (new DateTimeImmutable('now', new DateTimeZone(DatabaseObject::DATE_TZ)))->format(DatabaseObject::DATE_SQL); + } + /** * getFingerprint() * Generates a fingerprint that may be used to identify a returning client. @@ -149,7 +187,7 @@ public static function login(User $user): bool */ public static function logout(): bool { - self::discard(self::$key); + self::expire(self::$key); self::$key = ''; self::$user = null; @@ -191,7 +229,7 @@ protected static function lookup(string $key, bool $throw = true): array|false $q = MariaDb::instance()->prepare( 'SELECT `ip_address`, `user_agent`, `user_id` FROM `user_sessions` WHERE `id` = UNHEX(:id) AND - (`expires_datetime` = NULL OR `expires_datetime` > :dt) + (`expires_datetime` IS NULL OR `expires_datetime` > :dt) LIMIT 1;' ); if (!$q || !$q->execute([':dt' => $now, ':id' => $key])) return false; @@ -308,6 +346,13 @@ public static function verify(): bool self::$user = new User($lookup['user_id']); } + // logout and return if the user has since been disabled + if (self::$user && self::$user->isDisabled()) + { + self::logout(); + return false; + } + // if IP is different, update session if ($lookup['ip_address'] !== $ip) { diff --git a/src/Libraries/User/Profile.php b/src/Libraries/User/Profile.php index 9cf68dfc..1a12e6eb 100644 --- a/src/Libraries/User/Profile.php +++ b/src/Libraries/User/Profile.php @@ -9,6 +9,23 @@ class Profile implements \BNETDocs\Interfaces\DatabaseObject, \JsonSerializable { + /** + * Each editable profile field => [getter, setter, label]. Getters return the value as stored. + */ + public const FIELDS = [ + 'biography' => ['getBiography', 'setBiography', 'Biography'], + 'discord_username' => ['getDiscordUsername', 'setDiscordUsername', 'Discord username'], + 'facebook_username' => ['getFacebookUsername', 'setFacebookUsername', 'Facebook username'], + 'github_username' => ['getGitHubUsername', 'setGitHubUsername', 'GitHub username'], + 'instagram_username' => ['getInstagramUsername', 'setInstagramUsername', 'Instagram username'], + 'phone' => ['getPhone', 'setPhone', 'Phone number'], + 'reddit_username' => ['getRedditUsername', 'setRedditUsername', 'Reddit username'], + 'skype_username' => ['getSkypeUsername', 'setSkypeUsername', 'Skype username'], + 'steam_id' => ['getSteamId', 'setSteamId', 'Steam id'], + 'twitter_username' => ['getTwitterUsername', 'setTwitterUsername', 'Twitter username'], + 'website' => ['getWebsite', 'setWebsite', 'Website'], + ]; + public const MAX_LEN = 0xFF; // table design: varchar(255) public const MAX_USER_ID = User::MAX_ID; @@ -46,7 +63,6 @@ public function __construct(StdClass|int|null $value) public function allocate(): bool { $user_id = $this->getUserId(); - if (is_null($user_id)) return true; $this->setBiography(null); $this->setDiscordUsername(null); @@ -60,6 +76,9 @@ public function allocate(): bool $this->setTwitterUsername(null); $this->setWebsite(null); + // a new profile has nothing to look up + if (is_null($user_id)) return true; + $q = MariaDb::instance()->prepare(' SELECT `biography`, @@ -195,6 +214,19 @@ public function getDiscordUsername(): ?string return $this->discord_username; } + /** + * Retrieves one of the FIELDS by name, as stored (the website is not normalized to a url). + * + * @param string $field A key of FIELDS. + * @return string|null The value. + * @throws UnexpectedValueException if the field is not one of FIELDS. + */ + public function getField(string $field): ?string + { + if (!isset(self::FIELDS[$field])) throw new UnexpectedValueException(sprintf('not a profile field: %s', $field)); + return $field === 'website' ? $this->getWebsite(false) : $this->{self::FIELDS[$field][0]}(); + } + public function getFacebookURI(): ?string { $value = $this->getFacebookUsername(); @@ -327,6 +359,20 @@ public function jsonSerialize(): mixed ]; } + /** + * Sets one of the FIELDS by name. + * + * @param string $field A key of FIELDS. + * @param string|null $value The value. + * @return void + * @throws UnexpectedValueException if the field is not one of FIELDS, or the value is too long. + */ + public function setField(string $field, ?string $value): void + { + if (!isset(self::FIELDS[$field])) throw new UnexpectedValueException(sprintf('not a profile field: %s', $field)); + $this->{self::FIELDS[$field][1]}($value); + } + public function setBiography(?string $value): void { if (!is_null($value) && strlen($value) > self::MAX_LEN) diff --git a/src/Libraries/User/ResetPasswordEmail.php b/src/Libraries/User/ResetPasswordEmail.php new file mode 100644 index 00000000..736a0485 --- /dev/null +++ b/src/Libraries/User/ResetPasswordEmail.php @@ -0,0 +1,111 @@ +getVerifierToken(); + if (empty($token)) throw new UnexpectedValueException('user has no pending verifier token'); + + $mail = new PHPMailer(true); // true enables exceptions + + $state = new \StdClass(); + $state->email = $user->getEmail(); + $state->mail = &$mail; + $state->reason = $reason; + $state->token = $token; + $state->username = $user->getName(); + + //Server settings + $mail->Timeout = 10; // default is 300 per RFC2821 $ 4.5.3.2 + $mail->SMTPDebug = 0; + $mail->isSMTP(); + $mail->Host = Config::get('email.smtp_host') ?? 'localhost'; + $mail->Username = Config::get('email.smtp_user') ?? ''; + $mail->Password = Config::get('email.smtp_password') ?? ''; + $mail->SMTPAuth = !empty($mail->Username); + $mail->SMTPSecure = (Config::get('email.smtp_tls') ?? false) ? 'tls' : ''; + $mail->Port = Config::get('email.smtp_port') ?? 25; + + //Recipients + $recipient_from = Config::get('email.recipient_from') ?? []; + if (isset($recipient_from[0])) + { + $mail->setFrom($recipient_from[0], $recipient_from[1] ?? ''); + } + + $mail->addAddress($user->getEmail(), $user->getName()); + + $recipient_reply_to = Config::get('email.recipient_reply_to') ?? []; + if (isset($recipient_reply_to[0])) + { + $mail->addReplyTo($recipient_reply_to[0], $recipient_reply_to[1] ?? ''); + } + + // Content + $mail->isHTML(true); + $mail->Subject = 'Reset Password'; + $mail->CharSet = PHPMailer::CHARSET_UTF8; + + \ob_start(); + (new Template($state, 'Email/User/ResetPassword.rich'))->invoke(); + $mail->Body = \ob_get_clean(); + + \ob_start(); + (new Template($state, 'Email/User/ResetPassword.plain'))->invoke(); + $mail->AltBody = \ob_get_clean(); + + $mail->send(); + + $event = Logger::initEvent( + EventTypes::EMAIL_SENT, + $actor, + \getenv('REMOTE_ADDR'), + [ + 'from' => $mail->From, + 'to' => $mail->getToAddresses(), + 'reply_to' => $mail->getReplyToAddresses(), + 'subject' => $mail->Subject, + 'content_type' => $mail->ContentType, + 'body' => $mail->Body, + 'alt_body' => $mail->AltBody, + ] + ); + + if ($event->commit()) + { + $embed = Logger::initDiscordEmbed($event, $user->getURI()); + Logger::logToDiscord($event, $embed); + } + } +} diff --git a/src/Libraries/User/VerificationEmail.php b/src/Libraries/User/VerificationEmail.php new file mode 100644 index 00000000..2809c044 --- /dev/null +++ b/src/Libraries/User/VerificationEmail.php @@ -0,0 +1,141 @@ +modify(\sprintf('-%d seconds', self::COOLDOWN))->format(DatabaseObject::DATE_SQL); + $q = MariaDb::instance()->prepare(' + SELECT 1 FROM `event_log` + WHERE `event_type_id` = ? AND `user_id` = ? AND `event_datetime` >= ? LIMIT 1; + '); + try + { + return $q && $q->execute([EventTypes::EMAIL_SENT, $actor->getId(), $cutoff]) && $q->rowCount() > 0; + } + finally { if ($q) $q->closeCursor(); } + } + + /** + * Sends the verification email to the user's current email address, and logs it as EMAIL_SENT. + * + * @param User $user The user to verify, which must have a pending verifier token. + * @param User|int|null $actor Who the EMAIL_SENT event is attributed to. + * @param string $reason One of the REASON_* constants, which changes its wording. + * @return void + * @throws UnexpectedValueException if the user has no pending verifier token. + * @throws \PHPMailer\PHPMailer\Exception if the email could not be sent. + */ + public static function send(User $user, User|int|null $actor, string $reason = self::REASON_REGISTERED): void + { + $token = $user->getVerifierToken(); + if (empty($token)) throw new UnexpectedValueException('user has no pending verifier token'); + + $mail = new PHPMailer(true); // true enables exceptions + + $state = new \StdClass(); + $state->mail = &$mail; + $state->name = $user->getName(); + $state->reason = $reason; + $state->token = $token; + $state->user_id = $user->getId(); + + //Server settings + $mail->Timeout = 10; // default is 300 per RFC2821 $ 4.5.3.2 + $mail->SMTPDebug = 0; + $mail->isSMTP(); + $mail->Host = Config::get('email.smtp_host') ?? 'localhost'; + $mail->Username = Config::get('email.smtp_user') ?? ''; + $mail->Password = Config::get('email.smtp_password') ?? ''; + $mail->SMTPAuth = !empty($mail->Username); + $mail->SMTPSecure = (Config::get('email.smtp_tls') ?? false) ? 'tls' : ''; + $mail->Port = Config::get('email.smtp_port') ?? 25; + + //Recipients + $recipient_from = Config::get('email.recipient_from') ?? []; + if (isset($recipient_from[0])) + { + $mail->setFrom($recipient_from[0], $recipient_from[1] ?? ''); + } + + $mail->addAddress($user->getEmail(), $user->getName()); + + $recipient_reply_to = Config::get('email.recipient_reply_to') ?? []; + if (isset($recipient_reply_to[0])) + { + $mail->addReplyTo($recipient_reply_to[0], $recipient_reply_to[1] ?? ''); + } + + // Content + $mail->isHTML(true); + $mail->Subject = 'Account Verification'; + $mail->CharSet = PHPMailer::CHARSET_UTF8; + + \ob_start(); + (new Template($state, 'Email/User/Register.rich'))->invoke(); + $mail->Body = \ob_get_clean(); + + \ob_start(); + (new Template($state, 'Email/User/Register.plain'))->invoke(); + $mail->AltBody = \ob_get_clean(); + + $mail->send(); + + $event = Logger::initEvent( + EventTypes::EMAIL_SENT, + $actor, + \getenv('REMOTE_ADDR'), + [ + 'from' => $mail->From, + 'to' => $mail->getToAddresses(), + 'reply_to' => $mail->getReplyToAddresses(), + 'subject' => $mail->Subject, + 'content_type' => $mail->ContentType, + 'body' => $mail->Body, + 'alt_body' => $mail->AltBody, + ] + ); + + if ($event->commit()) + { + $embed = Logger::initDiscordEmbed($event, $user->getURI()); + Logger::logToDiscord($event, $embed); + } + } +} diff --git a/src/Models/User/Edit.php b/src/Models/User/Edit.php new file mode 100644 index 00000000..039fa088 --- /dev/null +++ b/src/Models/User/Edit.php @@ -0,0 +1,172 @@ + ['Disabled', 'Login is denied and any active sessions are expired.'], + User::OPTION_VERIFIED => ['Verified', 'The email address was confirmed by a token sent to it.'], + ]; + + /** + * Columns of the permission grid. + */ + public const ACL_COLUMNS = ['Create', 'View', 'Modify', 'Delete']; + + /** + * Grid cells that have no permission bit, shown as a note instead of a checkbox. + */ + public const ACL_NONE_AUTOMATIC = 'AUTOMATIC'; + public const ACL_NONE_PUBLIC = 'PUBLIC'; + public const ACL_NONE_LABELS = [ + self::ACL_NONE_AUTOMATIC => ['Automatic', 'Recorded automatically by the site; there is no permission for this.'], + self::ACL_NONE_PUBLIC => ['Public', 'Anyone can view these; there is no permission for this.'], + ]; + + /** + * Rows of the permission grid, one option per column, or an ACL_NONE_* note where no option exists. + */ + public const ACL_ROWS = [ + 'Comments' => [User::OPTION_ACL_COMMENT_CREATE, self::ACL_NONE_PUBLIC, User::OPTION_ACL_COMMENT_MODIFY, User::OPTION_ACL_COMMENT_DELETE], + 'Documents' => [User::OPTION_ACL_DOCUMENT_CREATE, self::ACL_NONE_PUBLIC, User::OPTION_ACL_DOCUMENT_MODIFY, User::OPTION_ACL_DOCUMENT_DELETE], + 'Event logs' => [self::ACL_NONE_AUTOMATIC, User::OPTION_ACL_EVENT_LOG_VIEW, User::OPTION_ACL_EVENT_LOG_MODIFY, User::OPTION_ACL_EVENT_LOG_DELETE], + 'News posts' => [User::OPTION_ACL_NEWS_CREATE, self::ACL_NONE_PUBLIC, User::OPTION_ACL_NEWS_MODIFY, User::OPTION_ACL_NEWS_DELETE], + 'Packets' => [User::OPTION_ACL_PACKET_CREATE, self::ACL_NONE_PUBLIC, User::OPTION_ACL_PACKET_MODIFY, User::OPTION_ACL_PACKET_DELETE], + 'Servers' => [User::OPTION_ACL_SERVER_CREATE, self::ACL_NONE_PUBLIC, User::OPTION_ACL_SERVER_MODIFY, User::OPTION_ACL_SERVER_DELETE], + 'Users' => [User::OPTION_ACL_USER_CREATE, self::ACL_NONE_PUBLIC, User::OPTION_ACL_USER_MODIFY, User::OPTION_ACL_USER_DELETE], + ]; + + /** + * Permissions that do not fit the grid. + */ + public const ACL_OTHER = [ + User::OPTION_ACL_ANALYTICS_VIEW => ['View analytics', 'Access the analytics dashboard data.'], + User::OPTION_ACL_PHPINFO => ['View PHP info', 'Access the phpinfo() page, which exposes server configuration.'], + ]; + + /** + * Every permission (non-status) bit shown on the page. + */ + public const ACL_MASK = User::OPTION_STAFF | User::OPTION_ACL_ANALYTICS_VIEW; + + /** + * Every bit shown on the page; bits outside of this are preserved as-is. + */ + public const KNOWN_MASK = self::ACL_MASK | User::OPTION_DISABLED | User::OPTION_VERIFIED; + + /** + * Bits the active user is permitted to change on the target user. + */ + public int $editable_mask = 0; + + /** + * Field name => error code, for fields that failed validation. + */ + public array $errors = []; + + public bool $is_self = false; + public bool $saved = false; + + /** + * The outcome of resending the verification email, one of VERIFICATION_*, or null if not requested. + */ + public ?string $verification_email = null; + + /** + * The outcome of resetting the password, one of PASSWORD_RESET_*, or null if not requested. + */ + public ?string $password_reset = null; + + /** + * How many sessions the password reset logged out. + */ + public int $sessions_expired = 0; + + public ?string $display_name = null; + public int $display_name_max_len = User::MAX_DISPLAY_NAME; + public ?string $email = null; + public int $email_max_len = User::MAX_EMAIL; + public int $options = 0; + public int $options_before = 0; + public ?string $username = null; + public int $username_max_len = User::MAX_USERNAME; + public int $username_min_len = 3; + + /** + * Public profile field => value, keyed by Profile::FIELDS. + */ + public array $profile = []; + public int $profile_max_len = \BNETDocs\Libraries\User\Profile::MAX_LEN; + + public ?int $target_id = null; + public ?User $target_user = null; + + /** + * Maps each set bit in a bitmask to its User::OPTION_* constant name. + * + * @param int $mask The bitmask. + * @return array Bit => name, with unrecognized bits named by their hex value. + */ + public static function optionNames(int $mask): array + { + static $names = null; + if (\is_null($names)) + { + $names = []; + foreach ((new \ReflectionClass(User::class))->getConstants() as $name => $value) + { + // single-bit OPTION_* constants only, which excludes presets like OPTION_STAFF + if (\str_starts_with($name, 'OPTION_') && \is_int($value) && $value > 0 && ($value & ($value - 1)) === 0) + { + $names[$value] = $name; + } + } + } + + $r = []; + for ($bit = 1; $bit > 0 && $bit <= $mask; $bit <<= 1) + { + if ($mask & $bit) $r[$bit] = $names[$bit] ?? \sprintf('0x%X', $bit); + } + return $r; + } + + public function jsonSerialize(): mixed + { + return \array_merge(parent::jsonSerialize(), [ + 'display_name' => $this->display_name, + 'editable_mask' => $this->editable_mask, + 'email' => $this->email, + 'errors' => $this->errors, + 'is_self' => $this->is_self, + 'options' => $this->options, + 'options_before' => $this->options_before, + 'password_reset' => $this->password_reset, + 'profile' => $this->profile, + 'saved' => $this->saved, + 'sessions_expired' => $this->sessions_expired, + 'target_id' => $this->target_id, + 'target_user' => $this->target_user, + 'username' => $this->username, + 'verification_email' => $this->verification_email, + ]); + } +} diff --git a/src/Models/User/Login.php b/src/Models/User/Login.php index 62031ee2..b2d1402f 100644 --- a/src/Models/User/Login.php +++ b/src/Models/User/Login.php @@ -8,6 +8,7 @@ class Login extends \BNETDocs\Models\ActiveUser implements \JsonSerializable public const ERROR_EMPTY_EMAIL = 'EMPTY_EMAIL'; public const ERROR_EMPTY_PASSWORD = 'EMPTY_PASSWORD'; public const ERROR_INCORRECT_PASSWORD = 'INCORRECT_PASSWORD'; + public const ERROR_PASSWORD_RESET_REQUIRED = 'PASSWORD_RESET_REQUIRED'; public const ERROR_SYSTEM_DISABLED = 'SYSTEM_DISABLED'; public const ERROR_USER_DISABLED = 'USER_DISABLED'; public const ERROR_USER_NOT_FOUND = 'USER_NOT_FOUND'; diff --git a/src/Models/User/Update.php b/src/Models/User/Update.php index 0ae0b525..abf134be 100644 --- a/src/Models/User/Update.php +++ b/src/Models/User/Update.php @@ -2,120 +2,59 @@ namespace BNETDocs\Models\User; +use \BNETDocs\Libraries\User\Profile; use \BNETDocs\Libraries\User\User; -use \BNETDocs\Libraries\User\Profile as UserProfile; class Update extends \BNETDocs\Models\ActiveUser implements \JsonSerializable { + public const VERIFICATION_ALREADY_VERIFIED = 'ALREADY_VERIFIED'; + public const VERIFICATION_COOLDOWN = 'COOLDOWN'; + public const VERIFICATION_FAILED = 'FAILED'; + public const VERIFICATION_SENT = 'SENT'; + + /** + * The user being updated, which is always the active user. + */ public ?User $user = null; + /** + * Field name => error code, for fields that failed validation. Profile fields are named 'profile_' . field. + */ + public array $errors = []; + + public bool $saved = false; + + /** + * The outcome of sending a verification email, one of VERIFICATION_*, or null if none was sent. + */ + public ?string $verification_email = null; + public ?string $display_name = null; - public ?array $display_name_error = null; public int $display_name_max_len = User::MAX_DISPLAY_NAME; - - public ?string $email_1 = null; - public ?string $email_2 = null; - public ?array $email_error = null; + public ?string $email = null; + public ?string $email_confirm = null; public int $email_max_len = User::MAX_EMAIL; - public ?string $username = null; - public ?array $username_error = null; public int $username_max_len = User::MAX_USERNAME; + public int $username_min_len = 3; - public ?UserProfile $profile = null; - - public ?string $biography = null; - public ?array $biography_error = null; - public int $biography_max_len = UserProfile::MAX_LEN; - - public ?string $discord_username = null; - public ?array $discord_username_error = null; - public int $discord_username_max_len = UserProfile::MAX_LEN; - - public ?string $facebook_username = null; - public ?array $facebook_username_error = null; - public int $facebook_username_max_len = UserProfile::MAX_LEN; - - public ?string $github_username = null; - public ?array $github_username_error = null; - public int $github_username_max_len = UserProfile::MAX_LEN; - - public ?string $instagram_username = null; - public ?array $instagram_username_error = null; - public int $instagram_username_max_len = UserProfile::MAX_LEN; - - public ?string $phone = null; - public ?array $phone_error = null; - public int $phone_max_len = UserProfile::MAX_LEN; - - public ?string $reddit_username = null; - public ?array $reddit_username_error = null; - public int $reddit_username_max_len = UserProfile::MAX_LEN; - - public ?string $skype_username = null; - public ?array $skype_username_error = null; - public int $skype_username_max_len = UserProfile::MAX_LEN; - - public ?string $steam_id = null; - public ?array $steam_id_error = null; - public int $steam_id_max_len = UserProfile::MAX_LEN; - - public ?string $twitter_username = null; - public ?array $twitter_username_error = null; - public int $twitter_username_max_len = UserProfile::MAX_LEN; - - public ?string $website = null; - public ?array $website_error = null; - public int $website_max_len = UserProfile::MAX_LEN; + /** + * Public profile field => value, keyed by Profile::FIELDS. + */ + public array $profile = []; + public int $profile_max_len = Profile::MAX_LEN; public function jsonSerialize(): mixed { return \array_merge(parent::jsonSerialize(), [ - 'user' => $this->user, 'display_name' => $this->display_name, - 'display_name_error' => $this->display_name_error, - 'display_name_max_len' => $this->display_name_max_len, - 'email_1' => $this->email_1, - 'email_2' => $this->email_2, - 'email_error' => $this->email_error, - 'email_max_len' => $this->email_max_len, - 'username' => $this->username, - 'username_error' => $this->username_error, - 'username_max_len' => $this->username_max_len, + 'email' => $this->email, + 'errors' => $this->errors, 'profile' => $this->profile, - 'biography' => $this->biography, - 'biography_error' => $this->biography_error, - 'biography_max_len' => $this->biography_max_len, - 'discord_username' => $this->discord_username, - 'discord_username_error' => $this->discord_username_error, - 'discord_username_max_len' => $this->discord_username_max_len, - 'facebook_username' => $this->facebook_username, - 'facebook_username_error' => $this->facebook_username_error, - 'facebook_username_max_len' => $this->facebook_username_max_len, - 'github_username' => $this->github_username, - 'github_username_error' => $this->github_username_error, - 'github_username_max_len' => $this->github_username_max_len, - 'instagram_username' => $this->instagram_username, - 'instagram_username_error' => $this->instagram_username_error, - 'instagram_username_max_len' => $this->instagram_username_max_len, - 'phone' => $this->phone, - 'phone_error' => $this->phone_error, - 'phone_max_len' => $this->phone_max_len, - 'reddit_username' => $this->reddit_username, - 'reddit_username_error' => $this->reddit_username_error, - 'reddit_username_max_len' => $this->reddit_username_max_len, - 'skype_username' => $this->skype_username, - 'skype_username_error' => $this->skype_username_error, - 'skype_username_max_len' => $this->skype_username_max_len, - 'steam_id' => $this->steam_id, - 'steam_id_error' => $this->steam_id_error, - 'steam_id_max_len' => $this->steam_id_max_len, - 'twitter_username' => $this->twitter_username, - 'twitter_username_error' => $this->twitter_username_error, - 'twitter_username_max_len' => $this->twitter_username_max_len, - 'website' => $this->website, - 'website_error' => $this->website_error, - 'website_max_len' => $this->website_max_len, + 'saved' => $this->saved, + 'user' => $this->user, + 'username' => $this->username, + 'verification_email' => $this->verification_email, ]); } } diff --git a/src/Models/User/Verify.php b/src/Models/User/Verify.php index 2702830e..a642b523 100644 --- a/src/Models/User/Verify.php +++ b/src/Models/User/Verify.php @@ -4,6 +4,7 @@ class Verify extends \BNETDocs\Models\ActiveUser implements \JsonSerializable { + public const ERROR_ALREADY_VERIFIED = 'ALREADY_VERIFIED'; public const ERROR_INVALID_TOKEN = 'INVALID_TOKEN'; public ?string $token = null; diff --git a/src/Static/a/user-profile.css b/src/Static/a/user-profile.css index 3bf9513c..0fc95e57 100644 --- a/src/Static/a/user-profile.css +++ b/src/Static/a/user-profile.css @@ -58,6 +58,32 @@ max-width: 100%; } .profile-links .profile-link-service { color: #8e959c; } + +/* Platform colors: a dot and tint in the brand color, with the service name + lightened toward white so it stays above 4.5:1 contrast on the dark theme. */ +.profile-links li[class^="profile-link-"] { + border-color: color-mix(in srgb, var(--brand) 55%, transparent); + background: color-mix(in srgb, var(--brand) 15%, rgba(0,0,0,0.2)); +} +.profile-links li[class^="profile-link-"]::before { + content: ""; + flex: 0 0 auto; + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: var(--brand); +} +.profile-links li[class^="profile-link-"] .profile-link-service { color: color-mix(in srgb, var(--brand) 70%, #fff); } +.profile-link-discord { --brand: #5865f2; } +.profile-link-facebook { --brand: #1877f2; } +.profile-link-github { --brand: #e6edf3; } /* GitHub's dark-mode foreground; its black brand color would vanish here */ +.profile-link-instagram { --brand: #e4405f; } +.profile-link-instagram::before { background: linear-gradient(45deg, #f09433, #dc2743 50%, #bc1888) !important; } +.profile-link-reddit { --brand: #ff4500; } +.profile-link-skype { --brand: #00aff0; } +.profile-link-steam { --brand: #66c0f4; } +.profile-link-twitter { --brand: #1da1f2; } +.profile-link-website { --brand: #8e959c; } .profile-links .profile-link-value { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .profile-stats { diff --git a/src/Templates/Email/User/Register.plain.phtml b/src/Templates/Email/User/Register.plain.phtml index c6510f9a..d87d2aa7 100644 --- a/src/Templates/Email/User/Register.plain.phtml +++ b/src/Templates/Email/User/Register.plain.phtml @@ -1,12 +1,19 @@ getContext()->name; +$intro = [ + \BNETDocs\Libraries\User\VerificationEmail::REASON_EMAIL_CHANGED => 'Your BNETDocs account email address was changed to this one, so it needs to be verified.', + \BNETDocs\Libraries\User\VerificationEmail::REASON_RESENT => 'Here is the new verification link you asked for.', + \BNETDocs\Libraries\User\VerificationEmail::REASON_STAFF_RESENT => 'A BNETDocs staff member has sent you a new link to verify your account.', +][$this->getContext()->reason ?? ''] ?? null; +$resend = !is_null($intro); $url = \BNETDocs\Libraries\Core\UrlFormatter::format('/user/verify?u=' . rawurlencode($this->getContext()->user_id) . '&t=' . rawurlencode($this->getContext()->token)); require('./Email/header.plain.inc.phtml'); ?> Hello , -Welcome to BNETDocs! + + Your account requires verification before being able to use this service. Click or copy and paste the link below into your web browser to verify your account. @@ -14,9 +21,12 @@ or copy and paste the link below into your web browser to verify your account. -**Note:** This link will only be available for 24 hours after registering. If -you wait until it expires, you will need to complete a password reset to verify -your account. +**Note:** This link is only kept for a limited time. If it stops working, you +can still verify your account by completing a password reset. + + +If you were not expecting this email, you can safely ignore it. + You may also reply to this email if you believe there is an issue. diff --git a/src/Templates/Email/User/Register.rich.phtml b/src/Templates/Email/User/Register.rich.phtml index 60b47df8..39acb891 100644 --- a/src/Templates/Email/User/Register.rich.phtml +++ b/src/Templates/Email/User/Register.rich.phtml @@ -1,6 +1,12 @@ getContext()->name; +$intro = [ + \BNETDocs\Libraries\User\VerificationEmail::REASON_EMAIL_CHANGED => 'Your BNETDocs account email address was changed to this one, so it needs to be verified.', + \BNETDocs\Libraries\User\VerificationEmail::REASON_RESENT => 'Here is the new verification link you asked for.', + \BNETDocs\Libraries\User\VerificationEmail::REASON_STAFF_RESENT => 'A BNETDocs staff member has sent you a new link to verify your account.', +][$this->getContext()->reason ?? ''] ?? null; +$resend = !is_null($intro); $url = \BNETDocs\Libraries\Core\UrlFormatter::format('/user/verify?u=' . rawurlencode($this->getContext()->user_id) . '&t=' . rawurlencode($this->getContext()->token)); ?> @@ -193,7 +199,7 @@ $url = \BNETDocs\Libraries\Core\UrlFormatter::format('/user/verify?u=' . rawurle

Hello ,

 

-

Welcome to BNETDocs!

+

 

Your account requires verification before being able to use this service. Click the button below to verify your account.

@@ -248,7 +254,7 @@ $url = \BNETDocs\Libraries\Core\UrlFormatter::format('/user/verify?u=' . rawurle
-

Note: This link will only be available for 24 hours after registering. If you wait until it expires, you will need to complete a password reset to verify your account.

+

Note: This link is only kept for a limited time. If it stops working, you can still verify your account by completing a password reset.
If you were not expecting this email, you can safely ignore it.' : ''?>

diff --git a/src/Templates/Email/User/ResetPassword.plain.phtml b/src/Templates/Email/User/ResetPassword.plain.phtml index 617de0e2..b5d897aa 100644 --- a/src/Templates/Email/User/ResetPassword.plain.phtml +++ b/src/Templates/Email/User/ResetPassword.plain.phtml @@ -1,19 +1,32 @@ getContext()->username; +$staff = ($this->getContext()->reason ?? null) === \BNETDocs\Libraries\User\ResetPasswordEmail::REASON_STAFF_RESET; $url = \BNETDocs\Libraries\Core\UrlFormatter::format(sprintf('/user/resetpassword?email=%s&t=%s', rawurlencode($this->getContext()->email), rawurlencode($this->getContext()->token))); require('./Email/header.plain.inc.phtml'); ?> Hello , + +A BNETDocs staff member has reset your password, and you have been logged out +everywhere. To log in again, click or copy and paste the link below into your +web browser to choose a new password. + Someone requested your password to be reset on BNETDocs. If this was you, click or copy and paste the link below into your web browser to reset your password. + + **Note:** This link will only be available for a short duration. + + +If it stops working, use Recover Password on the login page to get a new one. + If this was not you, then you can safely ignore this email; no action will be taken. + You may also reply to this email if you believe there is an issue. diff --git a/src/Templates/Email/User/ResetPassword.rich.phtml b/src/Templates/Email/User/ResetPassword.rich.phtml index bde9286a..cda12545 100644 --- a/src/Templates/Email/User/ResetPassword.rich.phtml +++ b/src/Templates/Email/User/ResetPassword.rich.phtml @@ -1,6 +1,7 @@ getContext()->username, FILTER_SANITIZE_FULL_SPECIAL_CHARS); +$staff = ($this->getContext()->reason ?? null) === \BNETDocs\Libraries\User\ResetPasswordEmail::REASON_STAFF_RESET; $url = \BNETDocs\Libraries\Core\UrlFormatter::format(sprintf('/user/resetpassword?email=%s&t=%s', rawurlencode($this->getContext()->email), rawurlencode($this->getContext()->token))); ?> @@ -189,7 +190,9 @@ $url = \BNETDocs\Libraries\Core\UrlFormatter::format(sprintf('/user/resetpasswor

Hello ,

 

-

Someone requested your password to be reset on BNETDocs. If this was you, click the following:

+

@@ -244,7 +247,9 @@ $url = \BNETDocs\Libraries\Core\UrlFormatter::format(sprintf('/user/resetpasswor

Note: This link will only be available for a short duration.

 

-

If this was not you, then you can safely ignore this email; no action will be taken.

+

diff --git a/src/Templates/User/Edit.phtml b/src/Templates/User/Edit.phtml new file mode 100644 index 00000000..df307282 --- /dev/null +++ b/src/Templates/User/Edit.phtml @@ -0,0 +1,296 @@ +getContext(); +$title = 'Edit User'; +$description = 'This page allows staff to edit another user\'s account details and permissions.'; +$url = '/user/edit'; +switch ($model->error) +{ + case null: $message = null; break; + case EditModel::ERROR_ACL_NOT_SET: $message = 'You do not have the privilege to edit users.'; break; + case EditModel::ERROR_INTERNAL: $message = 'An internal error occurred while saving the user. Try again later.'; break; + case EditModel::ERROR_NOT_FOUND: $message = 'Cannot find a user by that id.'; break; + case EditModel::ERROR_NOT_LOGGED_IN: $message = 'You must be logged in to edit users.'; break; + case EditModel::ERROR_OUTRANKED: $message = 'This user holds permissions that you do not, so you cannot edit them. Ask a staff member who holds those permissions.'; break; + default: $message = 'Internal error: ' . filter_var($model->error, FILTER_SANITIZE_FULL_SPECIAL_CHARS); +} +$field_errors = [ + 'username' => [ + 'EMPTY' => 'The username cannot be blank.', + 'TAKEN' => 'That username is already in use by another account.', + 'TOO_LONG' => sprintf('The username must be at most %d characters.', $model->username_max_len), + 'TOO_SHORT' => sprintf('The username must be at least %d characters.', $model->username_min_len), + ], + 'display_name' => [ + 'TOO_LONG' => sprintf('The display name must be at most %d characters.', $model->display_name_max_len), + ], + 'profile' => [ + 'TOO_LONG' => sprintf('Must be at most %d characters.', $model->profile_max_len), + ], + 'email' => [ + 'EMPTY' => 'The email address cannot be blank.', + 'INVALID' => 'The email address is not formatted correctly.', + 'TAKEN' => 'That email address is already in use by another account.', + 'TOO_LONG' => sprintf('The email address must be at most %d characters.', $model->email_max_len), + ], +]; +$field_error = function(string $field) use ($model, $field_errors): string { + $code = $model->errors[$field] ?? null; + if (is_null($code)) return ''; + $messages = $field_errors[$field] ?? $field_errors['profile']; + return sprintf('
%s
', $messages[$code] ?? ('Internal error: ' . $code)); +}; +$field_invalid = fn(string $field): string => isset($model->errors[$field]) ? ' is-invalid' : ''; +$esc = fn(?string $v): string => filter_var($v ?? '', FILTER_SANITIZE_FULL_SPECIAL_CHARS); +$options = $model->options; +$editable = $model->editable_mask; +$checkbox = function(int $bit, string $id, string $label, string $label_class = '') use ($options, $editable): string { + $disabled = !($editable & $bit); + return sprintf( + '
' + . '' + . '
', + $disabled ? ' title="You cannot change this permission"' : '', + $id, $bit, $bit, + ($options & $bit) ? ' checked' : '', + $disabled ? ' disabled' : '', + $label_class ? ' ' . $label_class : '', $id, $label + ); +}; +$unknown_bits = $options & ~EditModel::KNOWN_MASK; +$locked_mask = EditModel::KNOWN_MASK & ~$editable; +$target = $model->target_user; +require('./Includes/header.inc.phtml'); ?> +
+_responseCode == HttpCode::HTTP_FORBIDDEN) { ?> +

+

+error === EditModel::ERROR_NOT_LOGGED_IN) { ?> + Log in + + Back to profile + + All users + + +
+ +
+

: getName())?>

+
User #getId()?> · View profile · Event logs
+
+
+ +is_self) { ?> +

You are editing your own account. To protect against locking yourself out, you cannot disable yourself or remove your own permission to modify users. Your public profile is edited on the update profile page.

+ + +

+errors)) { ?> +

Nothing was saved. Correct the highlighted fields and try again.

+saved) { ?> +

The user was saved.

+ +verification_email) { + case EditModel::VERIFICATION_SENT: ?> +

A new verification email was sent to getEmail())?>.

+ +

No email was sent because this user is already verified.

+ +

The verification email could not be sent. Try again later with the resend button.

+ +password_reset) { + case EditModel::PASSWORD_RESET_DONE: ?> +

The password was reset and sessions_expired === 1 ? '1 session was' : sprintf('%d sessions were', $model->sessions_expired)?> logged out. A link to choose a new password was sent to getEmail())?>.

+ +

The password was reset and sessions_expired === 1 ? '1 session was' : sprintf('%d sessions were', $model->sessions_expired)?> logged out, but the email could not be sent. They can still choose a new password with the Recover Password button on the login page.

+ +

Nothing was reset. Tick the confirmation box to reset the password.

+ +

You cannot reset your own password here. Use the change password page instead.

+ + +
+
+
+
+

Account

+
+ + + +
+
+ + + +
+
+ + + + Changing it unverifies the account and sends a verification link to the new address. +
+
+
+
+
+

Status

+ [$label, $help]) { ?> +
+ %s', $label), $bit === User::OPTION_DISABLED ? 'text-danger' : '')?> + +options_before & User::OPTION_VERIFIED) !== 0; ?> +
+ + getEmail()))?> +
+ +
+ +
+ Password +is_self) { ?> + To change your own password, use the change password page. + +
+ + +
+
+ + getEmail()))?> +
+ +
+
+
Member since
getCreatedDateTime() ? $target->getCreatedDateTime()->format('M j, Y') : 'unknown'?>
+
Record updated
getRecordUpdated() ? $target->getRecordUpdated()->format('M j, Y g:ia T') : 'unknown'?>
+
+
+
+
+ +
+

Public Profile

+

Shown to everyone on their profile page. Leave a field blank to remove it.

+profile; $profile_max_len = $model->profile_max_len; require('./User/ProfileFields.inc.phtml'); ?> +
+ +
+
+

Permissions

+ +
+

Public and Automatic cells have no permission to grant: anyone can view those resources, and event logs are recorded by the site.

+ +

Greyed-out permissions cannot be changed: is_self ? 'you cannot remove your own permission to modify users, and ' : ''?>only permissions you hold yourself can be granted or revoked.

+ + +
+ + + + $bits) { ?> + + $bit) { ?> + + + + + + + + + +
Resource
%s %s', EditModel::ACL_COLUMNS[$i], $row_label))?>
+
+
+ [$label, $help]) { ?> +
+ + +
+ +
+
+ Options bitmask: 0xoptions_before)?> + → + +
Unrecognized bits 0x are kept unchanged. + +
+
+ +
+ Cancel + +
+
+
+ +
+is_self) { ?> +
+ +
+ + + +
+ diff --git a/src/Templates/User/Index.phtml b/src/Templates/User/Index.phtml index b51e88cd..ee128912 100644 --- a/src/Templates/User/Index.phtml +++ b/src/Templates/User/Index.phtml @@ -52,7 +52,7 @@ require('./Includes/header.inc.phtml'); ?> ($user->getCreatedEstimate() ?? '(null)'), ($acl_user_modify || $acl_user_delete ? ( sprintf('%s%s', ( - $acl_user_modify ? sprintf('📝', UrlFormatter::format('/user/update?id=' . rawurlencode($user->getId()))) : '' + $acl_user_modify ? sprintf('🛡️', UrlFormatter::format('/user/edit?id=' . rawurlencode($user->getId()))) : '' ), ( $acl_user_delete ? sprintf('❌', UrlFormatter::format('/user/delete?id=' . rawurlencode($user->getId()))) : '' )) diff --git a/src/Templates/User/Login.phtml b/src/Templates/User/Login.phtml index 122855ca..325b62e9 100644 --- a/src/Templates/User/Login.phtml +++ b/src/Templates/User/Login.phtml @@ -14,6 +14,7 @@ switch ($error) case LoginModel::ERROR_EMPTY_PASSWORD: $message = 'The password was left blank.'; break; case LoginModel::ERROR_USER_NOT_FOUND: case LoginModel::ERROR_INCORRECT_PASSWORD: $message = 'Incorrect email address or password.'; break; + case LoginModel::ERROR_PASSWORD_RESET_REQUIRED: $message = 'The account\'s password must be reset before logging in. Use the link emailed to you, or the Recover Password button, to choose a new one.'; break; case LoginModel::ERROR_INTERNAL: $message = 'Internal error.'; break; case LoginModel::ERROR_SYSTEM_DISABLED: $message = 'Logging in has been administratively disabled indefinitely.'; break; case LoginModel::ERROR_USER_DISABLED: $message = 'The account has been administratively disabled.'; break; diff --git a/src/Templates/User/ProfileFields.inc.phtml b/src/Templates/User/ProfileFields.inc.phtml new file mode 100644 index 00000000..b91d6313 --- /dev/null +++ b/src/Templates/User/ProfileFields.inc.phtml @@ -0,0 +1,31 @@ + value), $profile_max_len, and the closures + * $esc(value), $field_invalid(id), and $field_error(id), where id is 'profile_' . field. + */ +$profile_help = [ + 'phone' => 'Not shown on the profile page.', +]; ?> +
+ [, , $pf_label]) { + $pf_id = 'profile_' . $pf; + $pf_attrs = sprintf('class="border border-primary form-control bg-dark text-light%s" name="profile[%s]" id="%s" maxlength="%d" placeholder="%s" data-profile-field="%s"', + $field_invalid($pf_id), $pf, $pf_id, $profile_max_len, $esc($pf_label), $pf); ?> +
+ + + + + value=""/> + + + + + +
+ +
diff --git a/src/Templates/User/Update.phtml b/src/Templates/User/Update.phtml index 04483df3..a4389bc8 100644 --- a/src/Templates/User/Update.phtml +++ b/src/Templates/User/Update.phtml @@ -1,265 +1,157 @@ getContext(); $title = 'Update Profile'; $description = 'This page updates your account and public profile information.'; $url = '/user/update'; -switch ($this->getContext()->username_error[1] ?? null) -{ - case null: case '': $username_error = ''; break; - case 'USERNAME_LONG': $username_error = 'Your chosen username is too long.'; break; - case 'USERNAME_SHORT': $username_error = 'Your chosen username is too short.'; break; - case 'CHANGE_FAILED': $username_error = 'Failed to change username due to an internal error.'; break; - case 'CHANGE_SUCCESS': $username_error = 'Your username has been changed.'; break; - default: $username_error = 'Internal error: ' . $this->getContext()->username_error[1]; -} -switch ($this->getContext()->email_error[1] ?? null) -{ - case null: case '': $email_error = ''; break; - case 'MISMATCH': $email_error = 'Emails do not match, check for typos.'; break; - case 'EMPTY': $email_error = 'Your new email address cannot be blank.'; break; - case 'INVALID': $email_error = 'Your new email address uses invalid formatting.'; break; - case 'NOT_ALLOWED': $email_error = 'Your new email address is not allowed, use a different one.'; break; - case 'CHANGE_FAILED': $email_error = 'Failed to change email due to an internal error.'; break; - case 'CHANGE_SUCCESS': $email_error = 'Your email has been changed.'; break; - default: $email_error = 'Internal error: ' . $this->getContext()->email_error[1]; -} -switch ($this->getContext()->display_name_error[1] ?? null) -{ - case null: case '': $display_name_error = ''; break; - case 'CHANGE_FAILED': $display_name_error = 'Failed to change display name.'; break; - case 'CHANGE_SUCCESS': $display_name_error = sprintf('Your display name has been changed to %s.', - filter_var($this->getContext()->display_name_error[2], FILTER_SANITIZE_FULL_SPECIAL_CHARS)); break; - default: $display_name_error = 'Internal error: ' . $this->getContext()->display_name_error[1]; -} -switch ($this->getContext()->biography_error[1] ?? null) -{ - case null: case '': $biography_error = ''; break; - case 'TOO_LONG': $biography_error = 'Your biography is too long.'; break; - case 'CHANGE_SUCCESS': $biography_error = 'Your biography was updated.'; break; - default: $biography_error = 'Internal error: ' . $this->getContext()->biography_error[1]; -} -switch ($this->getContext()->discord_username_error[1] ?? null) -{ - case null: case '': $discord_username_error = ''; break; - case 'TOO_LONG': $discord_username_error = 'Your Discord username is too long.'; break; - case 'CHANGE_SUCCESS': $discord_username_error = 'Your Discord username was updated.'; break; - default: $discord_username_error = 'Internal error: ' . $this->getContext()->discord_username_error[1]; -} -switch ($this->getContext()->facebook_username_error[1] ?? null) -{ - case null: case '': $facebook_username_error = ''; break; - case 'TOO_LONG': $facebook_username_error = 'Your Facebook username is too long.'; break; - case 'CHANGE_SUCCESS': $facebook_username_error = 'Your Facebook username was updated.'; break; - default: $facebook_username_error = 'Internal error: ' . $this->getContext()->facebook_username_error[1]; -} -switch ($this->getContext()->github_username_error[1] ?? null) -{ - case null: case '': $github_username_error = ''; break; - case 'TOO_LONG': $github_username_error = 'Your Github username is too long.'; break; - case 'CHANGE_SUCCESS': $github_username_error = 'Your Github username was updated.'; break; - default: $github_username_error = 'Internal error: ' . $this->getContext()->github_username_error[1]; -} -switch ($this->getContext()->instagram_username_error[1] ?? null) -{ - case null: case '': $instagram_username_error = ''; break; - case 'TOO_LONG': $instagram_username_error = 'Your Instagram username is too long.'; break; - case 'CHANGE_SUCCESS': $instagram_username_error = 'Your Instagram username was updated.'; break; - default: $instagram_username_error = 'Internal error: ' . $this->getContext()->instagram_username_error[1]; -} -switch ($this->getContext()->phone_error[1] ?? null) -{ - case null: case '': $phone_error = ''; break; - case 'TOO_LONG': $phone_error = 'Your phone number is too long.'; break; - case 'CHANGE_SUCCESS': $phone_error = 'Your phone number was updated.'; break; - default: $phone_error = 'Internal error: ' . $this->getContext()->phone_error[1]; -} -switch ($this->getContext()->reddit_username_error[1] ?? null) -{ - case null: case '': $reddit_username_error = ''; break; - case 'TOO_LONG': $reddit_username_error = 'Your Reddit username is too long.'; break; - case 'CHANGE_SUCCESS': $reddit_username_error = 'Your Reddit username was updated.'; break; - default: $reddit_username_error = 'Internal error: ' . $this->getContext()->reddit_username_error[1]; -} -switch ($this->getContext()->skype_username_error[1] ?? null) -{ - case null: case '': $skype_username_error = ''; break; - case 'TOO_LONG': $skype_username_error = 'Your Skype username is too long.'; break; - case 'CHANGE_SUCCESS': $skype_username_error = 'Your Skype username was updated.'; break; - default: $skype_username_error = 'Internal error: ' . $this->getContext()->skype_username_error[1]; -} -switch ($this->getContext()->steam_id_error[1] ?? null) -{ - case null: case '': $steam_id_error = ''; break; - case 'TOO_LONG': $steam_id_error = 'Your Steam Id is too long.'; break; - case 'CHANGE_SUCCESS': $steam_id_error = 'Your Steam Id was updated.'; break; - default: $steam_id_error = 'Internal error: ' . $this->getContext()->steam_id_error[1]; -} -switch ($this->getContext()->twitter_username_error[1] ?? null) -{ - case null: case '': $twitter_username_error = ''; break; - case 'TOO_LONG': $twitter_username_error = 'Your Twitter username is too long.'; break; - case 'CHANGE_SUCCESS': $twitter_username_error = 'Your Twitter username was updated.'; break; - default: $twitter_username_error = 'Internal error: ' . $this->getContext()->twitter_username_error[1]; -} -switch ($this->getContext()->website_error[1] ?? null) -{ - case null: case '': $website_error = ''; break; - case 'TOO_LONG': $website_error = 'Your website url is too long.'; break; - case 'CHANGE_SUCCESS': $website_error = 'Your website url was updated.'; break; - default: $website_error = 'Internal error: ' . $this->getContext()->website_error[1]; -} -$avatar = ($this->getContext()->user ? $this->getContext()->user->getAvatarURI(512) : null); +$user = $model->user; +$esc = fn(?string $v): string => filter_var($v ?? '', FILTER_SANITIZE_FULL_SPECIAL_CHARS); +$field_errors = [ + 'username' => [ + 'EMPTY' => 'Your username cannot be blank.', + 'TAKEN' => 'That username is already in use by another account.', + 'TOO_LONG' => sprintf('Your username must be at most %d characters.', $model->username_max_len), + 'TOO_SHORT' => sprintf('Your username must be at least %d characters.', $model->username_min_len), + ], + 'display_name' => [ + 'TOO_LONG' => sprintf('Your display name must be at most %d characters.', $model->display_name_max_len), + ], + 'email' => [ + 'EMPTY' => 'Your email address cannot be blank.', + 'INVALID' => 'Your new email address is not formatted correctly.', + 'NOT_ALLOWED' => 'That email address is not allowed, use a different one.', + 'TAKEN' => 'That email address is already in use by another account.', + 'TOO_LONG' => sprintf('Your email address must be at most %d characters.', $model->email_max_len), + 'COOLDOWN' => 'A verification email was sent to you a moment ago. Wait a couple of minutes before changing your email address again.', + ], + 'email_confirm' => [ + 'MISMATCH' => 'The email addresses do not match, check for typos.', + ], + 'profile' => [ + 'TOO_LONG' => sprintf('Must be at most %d characters.', $model->profile_max_len), + ], +]; +$field_error = function(string $field) use ($model, $field_errors): string { + $code = $model->errors[$field] ?? null; + if (is_null($code)) return ''; + $messages = $field_errors[$field] ?? $field_errors['profile']; + return sprintf('
%s
', $messages[$code] ?? ('Internal error: ' . $code)); +}; +$field_invalid = fn(string $field): string => isset($model->errors[$field]) ? ' is-invalid' : ''; +$input_class = 'border border-primary form-control bg-dark text-light'; require('./Includes/header.inc.phtml'); ?>
-getContext()->user || $this->getContext()->_responseCode == \BNETDocs\Libraries\Core\HttpCode::HTTP_FORBIDDEN) { ?> +_responseCode == HttpCode::HTTP_FORBIDDEN) { ?>

-
-

You are not authorized to access this page.

-
+

You must be logged in to update your profile.

Log in -

-

+
+ +
+

+
Signed in as getUsername())?> · View your profile
+
+
-getContext()->user->getId() !== $this->getContext()->active_user->getId()) { ?> -

You are changing the details for a different account than yours. Click here to change your account details.

+error === UpdateModel::ERROR_INTERNAL) { ?> +

An internal error occurred while saving your profile. Try again later.

+errors)) { ?> +

Nothing was saved. Correct the highlighted fields and try again.

+saved) { ?> +

Your profile was saved.

+ +verification_email) { + case UpdateModel::VERIFICATION_SENT: ?> +

We sent a verification link to getEmail())?>. Follow it to verify your email address.

+ +

We could not send a verification email to getEmail())?>. Try again in a few minutes using the button below.

+ +

A verification email was sent to you a moment ago. Check your inbox, or wait a couple of minutes before asking for another.

+ +

Your email address is already verified.

+ +isVerified()) { ?> +
+

Your email address getEmail())?> is not verified yet. You can keep using this session, but you will not be able to log in again until you verify it. If the address is wrong, correct it below.

+
+ + +
+
- +

Our privacy policy page clarifies your data rights. You agree to giving (or not giving) the info contained herein on this page.

-
-

Account

-

Your account details.

-
- -

- - - -
- -

- - - -
-
- - -
- -

- - - -
- -
-

Gravatar

- -

This is your avatar around the site. It is generated using a hashed representation of your email address. To change it, go to Gravatar.com or change your email address.

-

Your Gravatar url:

-
-

Public Profile

-

This information will be displayed to everyone who views your profile page.

- -
-
- -

- - - + +
+
+
+

Account

+
+ + + +
+
+ + + +
+
+ + + +
+
+ + Changing your email address unverifies your account until you follow the link we send to the new address. + + +
+ 🔑 Change password +
-
-
-
- -

- - - -
-
- -

- - - +
+
+

Avatar

+ Your avatar +

Your avatar comes from Gravatar, based on a hash of your email address. To change it, update your image on Gravatar, or change your email address.

+

If you have no Gravatar, a generated pattern is shown instead.

+
-
-
- -

- - - -
-
- -

- - - -
-
-
-
- -

- - - -
-
- -

- - - -
-
-
-
- -

- - - -
-
- -

- - - -
-
-
-
- -

- - - -
-
- -

- - - -
-
-
- + +
+

Public Profile

+

Shown to everyone on your profile page. Leave a field blank to remove it.

+profile; $profile_max_len = $model->profile_max_len; require('./User/ProfileFields.inc.phtml'); ?>
+ +
+ Cancel + +
+
diff --git a/src/Templates/User/Verify.phtml b/src/Templates/User/Verify.phtml index a89e96f2..05d1d5d6 100644 --- a/src/Templates/User/Verify.phtml +++ b/src/Templates/User/Verify.phtml @@ -1,5 +1,6 @@ getContext()->token, FILTER_SANITIZE_FULL_SPECIAL_CHA $user_id = filter_var($this->getContext()->user_id, FILTER_SANITIZE_FULL_SPECIAL_CHARS); require('./Includes/header.inc.phtml'); ?>
-getContext()->error !== false) { ?> + +

Account Verified

+
+

This account is already verified, so there is nothing more to do.

+
+getContext()->active_user) { ?> + Go to your profile + + Log in + +getContext()->error !== false) { ?>

Verify Account

diff --git a/src/Templates/User/View.phtml b/src/Templates/User/View.phtml index 17e745a9..50f8aecb 100644 --- a/src/Templates/User/View.phtml +++ b/src/Templates/User/View.phtml @@ -155,7 +155,7 @@ require('./Includes/header.inc.phtml'); ?>