From 44a97c3a049dc63747fa8e01bf77e44ac4b6b4fd Mon Sep 17 00:00:00 2001 From: Carl Bennett Date: Wed, 4 Nov 2026 00:55:06 -0600 Subject: [PATCH 01/29] Expire active sessions of disabled users OPTION_DISABLED was documented as force-expiring active sessions, but only the login controller checked it. A user disabled while logged in stayed logged in until their session expired on its own. Authentication::verify() now discards the session as soon as the user is found to be disabled. Co-Authored-By: Claude Opus 5.5 --- src/Libraries/User/Authentication.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Libraries/User/Authentication.php b/src/Libraries/User/Authentication.php index c2511e4e..2a369587 100644 --- a/src/Libraries/User/Authentication.php +++ b/src/Libraries/User/Authentication.php @@ -308,6 +308,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) { From 90d7c106c7277f1795eca1975dfc9b3b307aac41 Mon Sep 17 00:00:00 2001 From: Carl Bennett Date: Wed, 4 Nov 2026 00:55:18 -0600 Subject: [PATCH 02/29] Log username, email, and display name changes as user edits The USER_EDITED event and its Discord embed were only emitted when a public profile field changed, so changing only the username, email address, or display name left no audit trail. Log the event whenever any account or profile field was saved, and record which of the two changed. The event is still attributed to the logged-in user making the change. The Discord embed now links to the edited user instead of the editor, since the editor is already shown as the embed author. Co-Authored-By: Claude Opus 5.5 --- phpstan-baseline.neon | 4 ++-- src/Controllers/User/Update.php | 14 ++++++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 4f9ce81f..209ae5c3 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1881,7 +1881,7 @@ parameters: - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$active_user\.$#' identifier: property.notFound - count: 4 + count: 3 path: src/Controllers/User/Update.php - @@ -2103,7 +2103,7 @@ parameters: - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$user\.$#' identifier: property.notFound - count: 20 + count: 21 path: src/Controllers/User/Update.php - diff --git a/src/Controllers/User/Update.php b/src/Controllers/User/Update.php index 0fcf5711..645cd883 100644 --- a/src/Controllers/User/Update.php +++ b/src/Controllers/User/Update.php @@ -129,6 +129,7 @@ private function handlePost(array $query): void if (empty($this->model->website)) $this->model->website = null; $req = Config::get('bnetdocs.user_register_requirements') ?? []; + $account_changed = false; // username change request if ($this->model->username !== $this->model->user->getUsername()) @@ -159,6 +160,7 @@ private function handlePost(array $query): void $this->model->user->setUsername($this->model->username); $this->model->user->commit(); $this->model->username_error = ['success', 'CHANGE_SUCCESS']; + $account_changed = true; } catch (Throwable $e) { @@ -214,6 +216,7 @@ private function handlePost(array $query): void $this->model->user->setEmail($this->model->email_2); $this->model->user->commit(); $this->model->email_error = ['success', 'CHANGE_SUCCESS']; + $account_changed = true; } catch (Throwable $e) { @@ -241,6 +244,7 @@ private function handlePost(array $query): void $this->model->user->setDisplayName($display_name); $this->model->user->commit(); $this->model->display_name_error = ['success', 'CHANGE_SUCCESS', $new_name]; + $account_changed = true; } catch (Throwable $e) { @@ -416,7 +420,12 @@ private function handlePost(array $query): void } } - if ($profile_changed && $this->model->profile->commit()) + if ($profile_changed && !$this->model->profile->commit()) + { + $profile_changed = false; + } + + if ($account_changed || $profile_changed) { $event = Logger::initEvent( \BNETDocs\Libraries\EventLog\EventTypes::USER_EDITED, @@ -442,6 +451,7 @@ private function handlePost(array $query): void 'email_1' => $this->model->email_1, 'email_2' => $this->model->email_2, 'display_name' => $display_name, + 'account_changed' => $account_changed, 'profile_changed' => $profile_changed, 'biography' => $this->model->biography, 'discord_username' => $this->model->discord_username, @@ -459,7 +469,7 @@ private function handlePost(array $query): void if ($event->commit()) { - $embed = Logger::initDiscordEmbed($event, $this->model->active_user->getURI(), [ + $embed = Logger::initDiscordEmbed($event, $this->model->user->getURI(), [ 'Edited by' => $this->model->active_user->getAsMarkdown(), 'Edited user' => $this->model->user->getAsMarkdown(), ]); From 0ef7444952198a1047fab9819d20b39c682bd2d0 Mon Sep 17 00:00:00 2001 From: Carl Bennett Date: Wed, 4 Nov 2026 00:59:31 -0600 Subject: [PATCH 03/29] Add a staff page for editing a user's account and permissions /user/edit?id=N lets a user with OPTION_ACL_USER_MODIFY edit another user's username, display name, email address, and options bitmask, which until now could only be changed in the database. Permissions are laid out as a resource by create/view/modify/delete grid, with the disabled and verified status flags shown separately, and presets for none, default, and staff. To keep the page from being a privilege escalation path: - Only permissions the editor holds can be granted or revoked. - Users who hold permissions the editor lacks cannot be edited at all, since changing their email address would allow taking over their account. - Editors cannot disable themselves or remove their own USER_MODIFY. - Bits without a named option are preserved as-is. Saves are validated as a whole and logged as USER_EDITED, attributed to the editor, with the changed fields and the granted and revoked option names. The profile page and user list now link to this page. Co-Authored-By: Claude Opus 5.5 --- src/Controllers/User/Edit.php | 232 +++++++++++++++++++++++++++++++++ src/Models/User/Edit.php | 128 ++++++++++++++++++ src/Templates/User/Edit.phtml | 210 +++++++++++++++++++++++++++++ src/Templates/User/Index.phtml | 2 +- src/Templates/User/View.phtml | 5 +- src/Views/User/EditHtml.php | 17 +++ src/main.php | 1 + 7 files changed, 592 insertions(+), 3 deletions(-) create mode 100644 src/Controllers/User/Edit.php create mode 100644 src/Models/User/Edit.php create mode 100644 src/Templates/User/Edit.phtml create mode 100644 src/Views/User/EditHtml.php diff --git a/src/Controllers/User/Edit.php b/src/Controllers/User/Edit.php new file mode 100644 index 00000000..03d124a3 --- /dev/null +++ b/src/Controllers/User/Edit.php @@ -0,0 +1,232 @@ +model = new EditModel(); + } + + public function invoke(?array $args): bool + { + /** @var EditModel $model */ + $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(); + + $model->error = null; + $model->_responseCode = HttpCode::HTTP_OK; + + if (Router::requestMethod() == Router::METHOD_POST) + { + $this->handlePost(); + } + + return true; + } + + private function handlePost(): void + { + /** @var EditModel $model */ + $model = $this->model; + $f = $model->form_fields; + $target = $model->target_user; + + $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); + + $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 (\strtolower($email) !== \strtolower($target->getEmail())) + { + $existing = User::findIdByEmail($email); + if (!\is_null($existing) && $existing !== $target->getId()) $model->errors['email'] = 'TAKEN'; + } + + 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]; + + if (empty($changes)) + { + $model->saved = true; + return; + } + + $target->setUsername($username); + $target->setDisplayName($display_name); + $target->setEmail($email); + $target->setOptions($options); + + if (!$target->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 (!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; + } +} diff --git a/src/Models/User/Edit.php b/src/Models/User/Edit.php new file mode 100644 index 00000000..f6dd3447 --- /dev/null +++ b/src/Models/User/Edit.php @@ -0,0 +1,128 @@ + ['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']; + + /** + * Rows of the permission grid, one option per column, null where it does not apply. + */ + public const ACL_ROWS = [ + 'Comments' => [User::OPTION_ACL_COMMENT_CREATE, null, User::OPTION_ACL_COMMENT_MODIFY, User::OPTION_ACL_COMMENT_DELETE], + 'Documents' => [User::OPTION_ACL_DOCUMENT_CREATE, null, User::OPTION_ACL_DOCUMENT_MODIFY, User::OPTION_ACL_DOCUMENT_DELETE], + 'Event logs' => [null, 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, null, User::OPTION_ACL_NEWS_MODIFY, User::OPTION_ACL_NEWS_DELETE], + 'Packets' => [User::OPTION_ACL_PACKET_CREATE, null, User::OPTION_ACL_PACKET_MODIFY, User::OPTION_ACL_PACKET_DELETE], + 'Servers' => [User::OPTION_ACL_SERVER_CREATE, null, User::OPTION_ACL_SERVER_MODIFY, User::OPTION_ACL_SERVER_DELETE], + 'Users' => [User::OPTION_ACL_USER_CREATE, null, 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; + + 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 ?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, + 'saved' => $this->saved, + 'target_id' => $this->target_id, + 'target_user' => $this->target_user, + 'username' => $this->username, + ]); + } +} diff --git a/src/Templates/User/Edit.phtml b/src/Templates/User/Edit.phtml new file mode 100644 index 00000000..49895bb7 --- /dev/null +++ b/src/Templates/User/Edit.phtml @@ -0,0 +1,210 @@ +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), + ], + '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 ''; + return sprintf('
%s
', $field_errors[$field][$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; +$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.

+ + +
+
+
+
+

Account

+
+ + + +
+
+ + + +
+
+ + + +
+
+
+
+
+

Status

+ [$label, $help]) { ?> +
+ %s', $label), $bit === User::OPTION_DISABLED ? 'text-danger' : '')?> + +
+ +
+
Member since
getCreatedDateTime() ? $target->getCreatedDateTime()->format('M j, Y') : 'unknown'?>
+
Record updated
getRecordUpdated() ? $target->getRecordUpdated()->format('M j, Y g:ia T') : 'unknown'?>
+
+
+
+
+ +
+
+

Permissions

+
+ Preset: + + + +
+
+

Permissions you do not hold yourself are shown greyed out and cannot be changed. Presets only change permissions you can change, and nothing is saved until you click Save.

+
+ + + + $bits) { ?> + + $bit) { ?> + + + + + +
Resource
—' : '
' . $checkbox($bit, 'opt_' . $bit, sprintf('%s %s', EditModel::ACL_COLUMNS[$i], $row_label)) . '
'?>
+
+
+ [$label, $help]) { ?> +
+ + +
+ +
+
+ Options bitmask: 0xoptions_before)?> + → + +
Unrecognized bits 0x are kept unchanged. + +
+
+ +
+ Cancel + +
+
+ + +
+ diff --git a/src/Templates/User/Index.phtml b/src/Templates/User/Index.phtml index b51e88cd..1bf8aa97 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/View.phtml b/src/Templates/User/View.phtml index 17e745a9..9d989335 100644 --- a/src/Templates/User/View.phtml +++ b/src/Templates/User/View.phtml @@ -173,8 +173,9 @@ require('./Includes/header.inc.phtml'); ?> 📝 Edit profile 🔑 Password - - 📝 Edit user + + + 🛡️ Edit user diff --git a/src/Views/User/EditHtml.php b/src/Views/User/EditHtml.php new file mode 100644 index 00000000..e9bdcc26 --- /dev/null +++ b/src/Views/User/EditHtml.php @@ -0,0 +1,17 @@ +invoke(); + $model->_responseHeaders['Content-Type'] = self::mimeType(); + } +} diff --git a/src/main.php b/src/main.php index 83186090..d61e9fa2 100644 --- a/src/main.php +++ b/src/main.php @@ -136,6 +136,7 @@ public static function main(int $argc, array $argv): int ['#^/user/changepassword/?$#', 'User\\ChangePassword', ['User\\ChangePasswordHtml']], ['#^/user/createpassword/?$#', 'User\\CreatePassword', ['User\\CreatePasswordHtml']], ['#^/user/delete/?#', 'User\\Delete', ['User\\DeleteHtml', 'User\\DeleteJson']], + ['#^/user/edit/?$#', 'User\\Edit', ['User\\EditHtml']], ['#^/user/index/?$#', 'User\\Index', ['User\\IndexHtml']], ['#^/user/login/?$#', 'User\\Login', ['User\\LoginHtml']], ['#^/user/logout/?$#', 'User\\Logout', ['User\\LogoutHtml']], From 9edec9ae101f58f402731293912d31b8abca229d Mon Sep 17 00:00:00 2001 From: Carl Bennett Date: Wed, 4 Nov 2026 01:00:31 -0600 Subject: [PATCH 04/29] Return HTTP 200 instead of 500 from the profile update page Models default to a 500 response code and the update controller never set another one for logged-in users, so every profile view and save was served as an internal server error despite rendering normally. Co-Authored-By: Claude Opus 5.5 --- phpstan-baseline.neon | 2 +- src/Controllers/User/Update.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 209ae5c3..dae27018 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1875,7 +1875,7 @@ parameters: - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$_responseCode\.$#' identifier: property.notFound - count: 1 + count: 2 path: src/Controllers/User/Update.php - diff --git a/src/Controllers/User/Update.php b/src/Controllers/User/Update.php index 645cd883..1bcfc336 100644 --- a/src/Controllers/User/Update.php +++ b/src/Controllers/User/Update.php @@ -24,6 +24,7 @@ public function invoke(?array $args): bool return true; } + $this->model->_responseCode = \BNETDocs\Libraries\Core\HttpCode::HTTP_OK; $query = Router::query(); $id = isset($query['id']) ? ((int) $query['id']) : null; From f129945fe98c793e769b7b37f042ce107610e2c7 Mon Sep 17 00:00:00 2001 From: Carl Bennett Date: Wed, 4 Nov 2026 01:00:42 -0600 Subject: [PATCH 05/29] Restrict the profile update page to the logged-in user /user/update?id=N let staff edit other users through the same form users edit their own profile with. Staff now edit other users at /user/edit, so /user/update always edits the active user. Existing links with another user's id redirect staff to /user/edit, without applying any posted form. Co-Authored-By: Claude Opus 5.5 --- phpstan-baseline.neon | 12 +++++++++--- src/Controllers/User/Update.php | 21 ++++++++++----------- src/Templates/User/Update.phtml | 4 ---- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index dae27018..d289cc4f 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1875,13 +1875,19 @@ parameters: - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$_responseCode\.$#' identifier: property.notFound - count: 2 + count: 3 + path: src/Controllers/User/Update.php + + - + message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$_responseHeaders\.$#' + 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: 3 + count: 5 path: src/Controllers/User/Update.php - @@ -2103,7 +2109,7 @@ parameters: - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$user\.$#' identifier: property.notFound - count: 21 + count: 19 path: src/Controllers/User/Update.php - diff --git a/src/Controllers/User/Update.php b/src/Controllers/User/Update.php index 1bcfc336..5294ec1a 100644 --- a/src/Controllers/User/Update.php +++ b/src/Controllers/User/Update.php @@ -7,7 +7,6 @@ use \BNETDocs\Libraries\EventLog\Logger; use \BNETDocs\Libraries\User\User; use \Throwable; -use \UnexpectedValueException; class Update extends \BNETDocs\Controllers\Base { @@ -27,19 +26,19 @@ public function invoke(?array $args): bool $this->model->_responseCode = \BNETDocs\Libraries\Core\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)) - { - try { $this->model->user = new User($id); } - catch (UnexpectedValueException) { $this->model->user = null; } - } - if (!$this->model->user) + // this page only edits the active user's own profile, staff edit other users at /user/edit + $this->model->user = $this->model->active_user; + $this->fillModel(); + + $id = $query['id'] ?? null; + if (\is_numeric($id) && (int) $id !== $this->model->active_user->getId() + && $this->model->active_user->getOption(User::OPTION_ACL_USER_MODIFY)) { - $id = $this->model->active_user->getId(); - $this->model->user = $this->model->active_user; + $this->model->_responseCode = \BNETDocs\Libraries\Core\HttpCode::HTTP_FOUND; + $this->model->_responseHeaders['Location'] = \BNETDocs\Libraries\Core\UrlFormatter::format('/user/edit?id=' . (int) $id); + return true; } - $this->fillModel(); if (Router::requestMethod() == Router::METHOD_POST) { $this->handlePost($query); diff --git a/src/Templates/User/Update.phtml b/src/Templates/User/Update.phtml index 04483df3..6e4f196e 100644 --- a/src/Templates/User/Update.phtml +++ b/src/Templates/User/Update.phtml @@ -122,10 +122,6 @@ require('./Includes/header.inc.phtml'); ?>

-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.

- -

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

From 3d22447aed9fe85a07915dc257e7e33ae0a28861 Mon Sep 17 00:00:00 2001 From: Carl Bennett Date: Wed, 4 Nov 2026 01:09:30 -0600 Subject: [PATCH 06/29] Label permission grid cells that have no option The grid showed an em dash where a resource has no option for a column, which read as a locked checkbox: most of the View column looked like permissions the editor was not allowed to grant. Those cells now say Public (anyone can view the resource) or Automatic (event logs are recorded by the site), with an explanation under the heading. The note about greyed-out permissions is now only shown when some permission is actually locked, and says why when editing yourself. Co-Authored-By: Claude Opus 5.5 --- src/Models/User/Edit.php | 26 ++++++++++++++++++-------- src/Templates/User/Edit.phtml | 13 +++++++++++-- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/src/Models/User/Edit.php b/src/Models/User/Edit.php index f6dd3447..63db3c89 100644 --- a/src/Models/User/Edit.php +++ b/src/Models/User/Edit.php @@ -23,16 +23,26 @@ class Edit extends \BNETDocs\Models\Core\HttpForm implements \JsonSerializable public const ACL_COLUMNS = ['Create', 'View', 'Modify', 'Delete']; /** - * Rows of the permission grid, one option per column, null where it does not apply. + * 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, null, User::OPTION_ACL_COMMENT_MODIFY, User::OPTION_ACL_COMMENT_DELETE], - 'Documents' => [User::OPTION_ACL_DOCUMENT_CREATE, null, User::OPTION_ACL_DOCUMENT_MODIFY, User::OPTION_ACL_DOCUMENT_DELETE], - 'Event logs' => [null, 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, null, User::OPTION_ACL_NEWS_MODIFY, User::OPTION_ACL_NEWS_DELETE], - 'Packets' => [User::OPTION_ACL_PACKET_CREATE, null, User::OPTION_ACL_PACKET_MODIFY, User::OPTION_ACL_PACKET_DELETE], - 'Servers' => [User::OPTION_ACL_SERVER_CREATE, null, User::OPTION_ACL_SERVER_MODIFY, User::OPTION_ACL_SERVER_DELETE], - 'Users' => [User::OPTION_ACL_USER_CREATE, null, User::OPTION_ACL_USER_MODIFY, User::OPTION_ACL_USER_DELETE], + '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], ]; /** diff --git a/src/Templates/User/Edit.phtml b/src/Templates/User/Edit.phtml index 49895bb7..eb68a4e3 100644 --- a/src/Templates/User/Edit.phtml +++ b/src/Templates/User/Edit.phtml @@ -58,6 +58,7 @@ $checkbox = function(int $bit, string $id, string $label, string $label_class = ); }; $unknown_bits = $options & ~EditModel::KNOWN_MASK; +$locked_mask = EditModel::KNOWN_MASK & ~$editable; $target = $model->target_user; require('./Includes/header.inc.phtml'); ?>
@@ -140,7 +141,11 @@ require('./Includes/header.inc.phtml'); ?>
-

Permissions you do not hold yourself are shown greyed out and cannot be changed. Presets only change permissions you can change, and nothing is saved until you click Save.

+

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.

+ +

Presets only change the permissions you can change. Nothing is saved until you click Save.

@@ -148,7 +153,11 @@ require('./Includes/header.inc.phtml'); ?> $bits) { ?> $bit) { ?> - + + + + + From da2c229b084931f0fe9c00f3f36092bb38f9d6b7 Mon Sep 17 00:00:00 2001 From: Carl Bennett Date: Wed, 4 Nov 2026 01:09:56 -0600 Subject: [PATCH 07/29] Edit a user's public profile from the staff user editor Since /user/update became self-only, staff had no way to edit another user's biography or social profile fields, for example to remove spam. /user/edit now has a public profile section with every profile field. Profile fields are validated together with the account fields, so nothing is saved unless all of them are valid. Changed fields are recorded in the USER_EDITED event with their old and new values, and listed by name in the Discord embed. A profile row is created for users who do not have one yet. Co-Authored-By: Claude Opus 5.5 --- src/Controllers/User/Edit.php | 102 +++++++++++++++++++++++++++++++--- src/Models/User/Edit.php | 24 ++++++++ src/Templates/User/Edit.phtml | 27 ++++++++- 3 files changed, 144 insertions(+), 9 deletions(-) diff --git a/src/Controllers/User/Edit.php b/src/Controllers/User/Edit.php index 03d124a3..8d522b8a 100644 --- a/src/Controllers/User/Edit.php +++ b/src/Controllers/User/Edit.php @@ -7,11 +7,31 @@ use \BNETDocs\Libraries\Core\Router; use \BNETDocs\Libraries\EventLog\EventTypes; use \BNETDocs\Libraries\EventLog\Logger; +use \BNETDocs\Libraries\User\Profile; use \BNETDocs\Libraries\User\User; use \BNETDocs\Models\User\Edit as EditModel; class Edit extends \BNETDocs\Controllers\Base { + /** + * Profile getter and setter for each of EditModel::PROFILE_FIELDS. + */ + private const PROFILE_ACCESSORS = [ + 'biography' => ['getBiography', 'setBiography'], + 'discord_username' => ['getDiscordUsername', 'setDiscordUsername'], + 'facebook_username' => ['getFacebookUsername', 'setFacebookUsername'], + 'github_username' => ['getGitHubUsername', 'setGitHubUsername'], + 'instagram_username' => ['getInstagramUsername', 'setInstagramUsername'], + 'phone' => ['getPhone', 'setPhone'], + 'reddit_username' => ['getRedditUsername', 'setRedditUsername'], + 'skype_username' => ['getSkypeUsername', 'setSkypeUsername'], + 'steam_id' => ['getSteamId', 'setSteamId'], + 'twitter_username' => ['getTwitterUsername', 'setTwitterUsername'], + 'website' => ['getWebsite', 'setWebsite'], + ]; + + private ?Profile $profile = null; + public function __construct() { $this->model = new EditModel(); @@ -93,6 +113,19 @@ public function invoke(?array $args): bool $model->options_before = $target->getOptions(); $model->username = $target->getUsername(); + $this->profile = $target->getUserProfile(); + if (!$this->profile) + { + // a new profile leaves its fields uninitialized, so start them all empty + $this->profile = new Profile(null); + $this->profile->setUserId($target->getId()); + foreach (self::PROFILE_ACCESSORS as [, $setter]) $this->profile->$setter(null); + } + foreach (\array_keys(self::PROFILE_ACCESSORS) as $field) + { + $model->profile[$field] = $this->getProfileValue($field); + } + $model->error = null; $model->_responseCode = HttpCode::HTTP_OK; @@ -159,6 +192,20 @@ private function handlePost(): void 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(self::PROFILE_ACCESSORS) 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; @@ -170,6 +217,13 @@ private function handlePost(): void 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->getProfileValue($field); + if ($value !== $old) $changes['profile'][$field] = [$old, $value]; + } if (empty($changes)) { @@ -177,16 +231,34 @@ private function handlePost(): void return; } - $target->setUsername($username); - $target->setDisplayName($display_name); - $target->setEmail($email); - $target->setOptions($options); + if ($user_changed) + { + $target->setUsername($username); + $target->setDisplayName($display_name); + $target->setEmail($email); + $target->setOptions($options); - if (!$target->commit()) + if (!$target->commit()) + { + $model->_responseCode = HttpCode::HTTP_INTERNAL_SERVER_ERROR; + $model->error = EditModel::ERROR_INTERNAL; + return; + } + } + + if (isset($changes['profile'])) { - $model->_responseCode = HttpCode::HTTP_INTERNAL_SERVER_ERROR; - $model->error = EditModel::ERROR_INTERNAL; - return; + foreach ($changes['profile'] as $field => [, $value]) + { + $this->profile->{self::PROFILE_ACCESSORS[$field][1]}($value); + } + + if (!$this->profile->commit()) + { + $model->_responseCode = HttpCode::HTTP_INTERNAL_SERVER_ERROR; + $model->error = EditModel::ERROR_INTERNAL; + return; + } } $model->saved = true; @@ -220,6 +292,13 @@ private function handlePost(): void } // 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 => EditModel::PROFILE_FIELDS[$field][0], + \array_keys($changes['profile']) + )); + } if (!empty($granted)) $fields['Granted'] = \implode(', ', $granted); if (!empty($revoked)) $fields['Revoked'] = \implode(', ', $revoked); @@ -229,4 +308,11 @@ private function handlePost(): void $model->options_before = $options; } + + private function getProfileValue(string $field): ?string + { + $getter = self::PROFILE_ACCESSORS[$field][0]; + // the website getter normalizes to a url by default, so ask for the raw value + return $field === 'website' ? $this->profile->getWebsite(false) : $this->profile->$getter(); + } } diff --git a/src/Models/User/Edit.php b/src/Models/User/Edit.php index 63db3c89..abce1f8c 100644 --- a/src/Models/User/Edit.php +++ b/src/Models/User/Edit.php @@ -63,6 +63,23 @@ class Edit extends \BNETDocs\Models\Core\HttpForm implements \JsonSerializable */ public const KNOWN_MASK = self::ACL_MASK | User::OPTION_DISABLED | User::OPTION_VERIFIED; + /** + * Public profile fields: form field => [label, placeholder]. + */ + public const PROFILE_FIELDS = [ + 'biography' => ['Biography', 'A brief biography'], + 'discord_username' => ['Discord username', 'Discord username'], + 'facebook_username' => ['Facebook username', 'Facebook username'], + 'github_username' => ['GitHub username', 'GitHub username'], + 'instagram_username' => ['Instagram username', 'Instagram username'], + 'phone' => ['Phone number', 'Phone number'], + 'reddit_username' => ['Reddit username', 'Reddit username'], + 'skype_username' => ['Skype username', 'Skype username'], + 'steam_id' => ['Steam id', 'Steam id'], + 'twitter_username' => ['Twitter username', 'Twitter username'], + 'website' => ['Website', 'Website url'], + ]; + /** * Bits the active user is permitted to change on the target user. */ @@ -86,6 +103,12 @@ class Edit extends \BNETDocs\Models\Core\HttpForm implements \JsonSerializable 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; @@ -129,6 +152,7 @@ public function jsonSerialize(): mixed 'is_self' => $this->is_self, 'options' => $this->options, 'options_before' => $this->options_before, + 'profile' => $this->profile, 'saved' => $this->saved, 'target_id' => $this->target_id, 'target_user' => $this->target_user, diff --git a/src/Templates/User/Edit.phtml b/src/Templates/User/Edit.phtml index eb68a4e3..72a2314c 100644 --- a/src/Templates/User/Edit.phtml +++ b/src/Templates/User/Edit.phtml @@ -28,6 +28,9 @@ $field_errors = [ '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.', @@ -38,7 +41,8 @@ $field_errors = [ $field_error = function(string $field) use ($model, $field_errors): string { $code = $model->errors[$field] ?? null; if (is_null($code)) return ''; - return sprintf('
%s
', $field_errors[$field][$code] ?? ('Internal error: ' . $code)); + $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); @@ -131,6 +135,27 @@ require('./Includes/header.inc.phtml'); ?> +
+

Public Profile

+

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

+
+ [$pf_label, $pf_placeholder]) { + $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"', + $field_invalid($pf_id), $pf, $pf_id, $model->profile_max_len, $esc($pf_placeholder)); ?> +
+ + + + + value="profile[$pf] ?? null)?>"/> + + +
+ +
+
+

Permissions

From f066dc3cabdc3f8dfce2fe7900d1d8cd0f8fdf5c Mon Sep 17 00:00:00 2001 From: Carl Bennett Date: Wed, 4 Nov 2026 01:15:44 -0600 Subject: [PATCH 08/29] Refuse account verification without a pending token /user/verify compared the submitted token to the user's verifier token with !==, so a user whose token was null could be verified by omitting the t parameter entirely. Require a non-empty pending token and compare it in constant time. Non-string query parameters are now treated as missing instead of throwing a TypeError and returning 500. Co-Authored-By: Claude Opus 5.5 --- src/Controllers/User/Verify.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Controllers/User/Verify.php b/src/Controllers/User/Verify.php index 25c2be25..b82434bf 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,10 @@ public function invoke(?array $args): bool catch (\UnexpectedValueException) { $this->model->user = null; } } + // 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 +55,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] ); From 3973d2d8693f9747654fc9157d5b605e0f9d733a Mon Sep 17 00:00:00 2001 From: Carl Bennett Date: Wed, 4 Nov 2026 01:16:11 -0600 Subject: [PATCH 09/29] Keep verification state consistent when staff toggle Verified The user editor changed OPTION_VERIFIED as a plain bit, which left the verified date and verifier token stale: unverifying a user left them without any token for a verification link to use. Toggle it through User::setVerified() instead, which issues a new token when unverifying and records the date and clears the token when verifying. Co-Authored-By: Claude Opus 5.5 --- src/Controllers/User/Edit.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Controllers/User/Edit.php b/src/Controllers/User/Edit.php index 8d522b8a..dec40fa9 100644 --- a/src/Controllers/User/Edit.php +++ b/src/Controllers/User/Edit.php @@ -236,7 +236,9 @@ private function handlePost(): void $target->setUsername($username); $target->setDisplayName($display_name); $target->setEmail($email); - $target->setOptions($options); + // toggle verified through setVerified() so its date and verifier token stay consistent + $target->setOptions(($options & ~User::OPTION_VERIFIED) | ($target->getOptions() & User::OPTION_VERIFIED)); + $target->setVerified(($options & User::OPTION_VERIFIED) !== 0); if (!$target->commit()) { From 9ef7fa8c25da54efc872f0c0a4f23f4c43bf3057 Mon Sep 17 00:00:00 2001 From: Carl Bennett Date: Wed, 4 Nov 2026 01:17:34 -0600 Subject: [PATCH 10/29] Move the verification email into a reusable library Registration built and sent the account verification email inline, so it could not be sent from anywhere else. VerificationEmail::send() now does this for any user with a pending verifier token, attributing the EMAIL_SENT event to a given actor, and registration uses it unchanged. It also takes a resend flag for emails sent after registration, which replace the welcome line and the note about the link expiring 24 hours after registering, neither of which applies to a resent link. Co-Authored-By: Claude Opus 5.5 --- src/Controllers/User/Register.php | 75 +------------ src/Libraries/User/VerificationEmail.php | 105 ++++++++++++++++++ src/Templates/Email/User/Register.plain.phtml | 13 +++ src/Templates/Email/User/Register.rich.phtml | 5 +- 4 files changed, 124 insertions(+), 74 deletions(-) create mode 100644 src/Libraries/User/VerificationEmail.php 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/Libraries/User/VerificationEmail.php b/src/Libraries/User/VerificationEmail.php new file mode 100644 index 00000000..52acbcb7 --- /dev/null +++ b/src/Libraries/User/VerificationEmail.php @@ -0,0 +1,105 @@ +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->resend = $resend; + $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/Templates/Email/User/Register.plain.phtml b/src/Templates/Email/User/Register.plain.phtml index c6510f9a..7cf6a5db 100644 --- a/src/Templates/Email/User/Register.plain.phtml +++ b/src/Templates/Email/User/Register.plain.phtml @@ -1,11 +1,23 @@ getContext()->name; +$resend = $this->getContext()->resend ?? false; $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 , + +A BNETDocs staff member has sent you a new link to verify your account. + +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. + + + + +If you were not expecting this email, you can safely ignore it. + Welcome to BNETDocs! Your account requires verification before being able to use this service. Click @@ -17,6 +29,7 @@ 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. + 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..3fddab8e 100644 --- a/src/Templates/Email/User/Register.rich.phtml +++ b/src/Templates/Email/User/Register.rich.phtml @@ -1,6 +1,7 @@ getContext()->name; +$resend = $this->getContext()->resend ?? false; $url = \BNETDocs\Libraries\Core\UrlFormatter::format('/user/verify?u=' . rawurlencode($this->getContext()->user_id) . '&t=' . rawurlencode($this->getContext()->token)); ?> @@ -193,7 +194,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 +249,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.

+

If you were not expecting this email, you can safely ignore it.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.

From 0b499cf72f6be5fd0567db7f545c09d0910732ed Mon Sep 17 00:00:00 2001 From: Carl Bennett Date: Wed, 4 Nov 2026 01:19:19 -0600 Subject: [PATCH 11/29] Add a button to resend a user's verification email The status section of /user/edit now has a button that sends an unverified user a new verification email at their current address, attributed to the staff member in the EMAIL_SENT event. A user missing a verifier token is issued one first. The button is disabled for verified users, with a hint to untick Verified and save to have them verify again. The button submits its own form, so it never saves the main form, and it asks for confirmation if the main form has unsaved changes. Co-Authored-By: Claude Opus 5.5 --- src/Controllers/User/Edit.php | 40 +++++++++++++++++++++++++++++++++++ src/Models/User/Edit.php | 10 +++++++++ src/Templates/User/Edit.phtml | 27 +++++++++++++++++++++++ 3 files changed, 77 insertions(+) diff --git a/src/Controllers/User/Edit.php b/src/Controllers/User/Edit.php index dec40fa9..724a9f45 100644 --- a/src/Controllers/User/Edit.php +++ b/src/Controllers/User/Edit.php @@ -9,6 +9,7 @@ use \BNETDocs\Libraries\EventLog\Logger; use \BNETDocs\Libraries\User\Profile; use \BNETDocs\Libraries\User\User; +use \BNETDocs\Libraries\User\VerificationEmail; use \BNETDocs\Models\User\Edit as EditModel; class Edit extends \BNETDocs\Controllers\Base @@ -144,6 +145,12 @@ private function handlePost(): void $f = $model->form_fields; $target = $model->target_user; + if (($f['action'] ?? null) === 'resend_verification') + { + $this->resendVerification(); + 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'] : ''); @@ -311,6 +318,39 @@ private function handlePost(): void $model->options_before = $options; } + private function resendVerification(): void + { + /** @var EditModel $model */ + $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'); + } + + VerificationEmail::send($target, $model->active_user, true); + $model->verification_email = EditModel::VERIFICATION_SENT; + } + catch (\Throwable $e) + { + \error_log(\sprintf('Failed to resend verification email to user #%d: %s', $target->getId(), $e->getMessage())); + $model->_responseCode = HttpCode::HTTP_INTERNAL_SERVER_ERROR; + $model->verification_email = EditModel::VERIFICATION_FAILED; + } + } + private function getProfileValue(string $field): ?string { $getter = self::PROFILE_ACCESSORS[$field][0]; diff --git a/src/Models/User/Edit.php b/src/Models/User/Edit.php index abce1f8c..c827c8bf 100644 --- a/src/Models/User/Edit.php +++ b/src/Models/User/Edit.php @@ -9,6 +9,10 @@ class Edit extends \BNETDocs\Models\Core\HttpForm implements \JsonSerializable public const ERROR_NOT_FOUND = 'NOT_FOUND'; public const ERROR_OUTRANKED = 'OUTRANKED'; + public const VERIFICATION_ALREADY_VERIFIED = 'ALREADY_VERIFIED'; + public const VERIFICATION_FAILED = 'FAILED'; + public const VERIFICATION_SENT = 'SENT'; + /** * Account status flags, shown separately from the permission grid. */ @@ -93,6 +97,11 @@ class Edit extends \BNETDocs\Models\Core\HttpForm implements \JsonSerializable 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; + public ?string $display_name = null; public int $display_name_max_len = User::MAX_DISPLAY_NAME; public ?string $email = null; @@ -157,6 +166,7 @@ public function jsonSerialize(): mixed 'target_id' => $this->target_id, 'target_user' => $this->target_user, 'username' => $this->username, + 'verification_email' => $this->verification_email, ]); } } diff --git a/src/Templates/User/Edit.phtml b/src/Templates/User/Edit.phtml index 72a2314c..f4083acb 100644 --- a/src/Templates/User/Edit.phtml +++ b/src/Templates/User/Edit.phtml @@ -95,6 +95,14 @@ require('./Includes/header.inc.phtml'); ?> 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.

+
@@ -125,6 +133,15 @@ require('./Includes/header.inc.phtml'); ?>
%s', $label), $bit === User::OPTION_DISABLED ? 'text-danger' : '')?> +options_before & User::OPTION_VERIFIED) !== 0; ?> +
+ + getEmail()))?> +
+
@@ -211,6 +228,9 @@ require('./Includes/header.inc.phtml'); ?>
+
+ + From aea74c9b860e9dddfbc845344210a66fc9d3c56b Mon Sep 17 00:00:00 2001 From: Carl Bennett Date: Wed, 4 Nov 2026 01:25:03 -0600 Subject: [PATCH 12/29] Show a friendly page when following a verification link again A verified user has no pending token, so following their verification link a second time showed the manual user id and token form with an "expired or invalid" error. Respond with 409 Conflict and a short page saying the account is already verified, with a link to log in, or to their profile if they are already logged in. Co-Authored-By: Claude Opus 5.5 --- phpstan-baseline.neon | 6 +++--- src/Controllers/User/Verify.php | 8 ++++++++ src/Models/User/Verify.php | 1 + src/Templates/User/Verify.phtml | 13 ++++++++++++- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index d289cc4f..e2d78f70 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -2169,13 +2169,13 @@ parameters: - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$_responseCode\.$#' identifier: property.notFound - count: 2 + count: 3 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 - @@ -2187,7 +2187,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/Verify.php b/src/Controllers/User/Verify.php index b82434bf..396731c2 100644 --- a/src/Controllers/User/Verify.php +++ b/src/Controllers/User/Verify.php @@ -34,6 +34,14 @@ 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; $token = $this->model->token; 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/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

From 71c4e8492c53d59eb6ee819919109b855243fc5d Mon Sep 17 00:00:00 2001 From: Carl Bennett Date: Wed, 4 Nov 2026 01:25:28 -0600 Subject: [PATCH 13/29] Check the user editor's model type at runtime instead of with @var The edit controller narrowed $this->model to EditModel with inline @var tags, which only inform static analysis. Narrow it in one helper with an instanceof check that throws InvalidModelException, as the views already do, so a wrong model fails loudly instead of being trusted. Co-Authored-By: Claude Opus 5.5 --- src/Controllers/User/Edit.php | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/Controllers/User/Edit.php b/src/Controllers/User/Edit.php index 724a9f45..85ef384f 100644 --- a/src/Controllers/User/Edit.php +++ b/src/Controllers/User/Edit.php @@ -2,6 +2,7 @@ namespace BNETDocs\Controllers\User; +use \BNETDocs\Exceptions\InvalidModelException; use \BNETDocs\Libraries\Core\Config; use \BNETDocs\Libraries\Core\HttpCode; use \BNETDocs\Libraries\Core\Router; @@ -40,8 +41,7 @@ public function __construct() public function invoke(?array $args): bool { - /** @var EditModel $model */ - $model = $this->model; + $model = $this->model(); if (!$model->active_user) { @@ -140,8 +140,7 @@ public function invoke(?array $args): bool private function handlePost(): void { - /** @var EditModel $model */ - $model = $this->model; + $model = $this->model(); $f = $model->form_fields; $target = $model->target_user; @@ -320,8 +319,7 @@ private function handlePost(): void private function resendVerification(): void { - /** @var EditModel $model */ - $model = $this->model; + $model = $this->model(); $target = $model->target_user; if ($target->isVerified()) @@ -351,6 +349,21 @@ private function resendVerification(): void } } + /** + * 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; + } + private function getProfileValue(string $field): ?string { $getter = self::PROFILE_ACCESSORS[$field][0]; From 532bf9959924ea43134b3f4f3ab4314b695bff14 Mon Sep 17 00:00:00 2001 From: Carl Bennett Date: Wed, 4 Nov 2026 01:26:07 -0600 Subject: [PATCH 14/29] Stop claiming verification links expire after 24 hours The verification email said its link would only be available for 24 hours after registering, but nothing enforces that. Say instead that the link is only kept for a limited time, and that a password reset still verifies the account if it stops working. Resent emails now include the same note. Co-Authored-By: Claude Opus 5.5 --- src/Templates/Email/User/Register.plain.phtml | 20 ++++++------------- src/Templates/Email/User/Register.rich.phtml | 2 +- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/src/Templates/Email/User/Register.plain.phtml b/src/Templates/Email/User/Register.plain.phtml index 7cf6a5db..912304eb 100644 --- a/src/Templates/Email/User/Register.plain.phtml +++ b/src/Templates/Email/User/Register.plain.phtml @@ -7,28 +7,20 @@ require('./Email/header.plain.inc.phtml'); ?> Hello , - -A BNETDocs staff member has sent you a new link to verify your account. - -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. - - + -If you were not expecting this email, you can safely ignore it. - -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. -**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 3fddab8e..5f30a8ef 100644 --- a/src/Templates/Email/User/Register.rich.phtml +++ b/src/Templates/Email/User/Register.rich.phtml @@ -249,7 +249,7 @@ $url = \BNETDocs\Libraries\Core\UrlFormatter::format('/user/verify?u=' . rawurle
-

If you were not expecting this email, you can safely ignore it.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.' : ''?>

From 6eaa92872c59fc8984c07ab204c048216d28e773 Mon Sep 17 00:00:00 2001 From: Carl Bennett Date: Wed, 4 Nov 2026 01:28:32 -0600 Subject: [PATCH 15/29] Use the shield emoji for every link to the user editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user list's edit button used 📝, the same as profile and content edit links, while the profile page's Edit user button used 🛡️. Use 🛡️ for both links to /user/edit, which marks it as a staff tool apart from editing a profile. Co-Authored-By: Claude Opus 5.5 --- src/Templates/User/Index.phtml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Templates/User/Index.phtml b/src/Templates/User/Index.phtml index 1bf8aa97..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('
', ( - $acl_user_modify ? sprintf('📝', UrlFormatter::format('/user/edit?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()))) : '' )) From 592820d920ca46c67f0e86ddb29df207e2428685 Mon Sep 17 00:00:00 2001 From: Carl Bennett Date: Wed, 4 Nov 2026 01:34:20 -0600 Subject: [PATCH 16/29] Color the social link bubbles on user profiles by platform Each social link on a profile gets a dot, border, and background tint in its platform's brand color, with the service name in a lightened brand color that keeps at least 4.5:1 contrast on the dark theme. GitHub uses its light dark-mode foreground since its black brand color would not show, Instagram's dot uses its gradient, and websites stay neutral. Co-Authored-By: Claude Opus 5.5 --- src/Static/a/user-profile.css | 26 ++++++++++++++++++++++++++ src/Templates/User/View.phtml | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) 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/User/View.phtml b/src/Templates/User/View.phtml index 9d989335..eb0672ed 100644 --- a/src/Templates/User/View.phtml +++ b/src/Templates/User/View.phtml @@ -155,7 +155,7 @@ require('./Includes/header.inc.phtml'); ?>
Resource
—' : '
' . $checkbox($bit, 'opt_' . $bit, sprintf('%s %s', EditModel::ACL_COLUMNS[$i], $row_label)) . '
'?>
%s %s', EditModel::ACL_COLUMNS[$i], $row_label))?>
%s%s
@@ -223,6 +223,9 @@ require('./Includes/header.inc.phtml'); ?> var before = options_before?>; var hidden = before & ~; var aclMask = ; + // presets need this script, so they are only shown when it runs + document.getElementById('permission_presets').hidden = false; + document.getElementById('permission_presets_note').hidden = false; function hex(v) { return '0x' + ('00000000' + v.toString(16).toUpperCase()).slice(-8); } function update() { var v = hidden; From 88698af7ddeddc87018427e3203ee23adcc5cc8a Mon Sep 17 00:00:00 2001 From: Carl Bennett Date: Wed, 4 Nov 2026 03:07:26 -0600 Subject: [PATCH 21/29] Require verifying a new email address on the user editor When staff change a user's email address on /user/edit, the account is now unverified, whatever the Verified box says, and a verification link is sent to the new address, as on /user/update. A change of letter case alone is still treated as the same address. A failed send is reported on the page and the resend button can be used to try again. Co-Authored-By: Claude Opus 5.5 --- src/Controllers/User/Edit.php | 47 ++++++++++++++++++++++++++++++----- src/Templates/User/Edit.phtml | 3 ++- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/Controllers/User/Edit.php b/src/Controllers/User/Edit.php index ef33a68c..5bd336bb 100644 --- a/src/Controllers/User/Edit.php +++ b/src/Controllers/User/Edit.php @@ -150,6 +150,10 @@ private function handlePost(): void $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; @@ -173,7 +177,7 @@ private function handlePost(): void 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 (\strtolower($email) !== \strtolower($target->getEmail())) + else if ($email_changed) { $existing = User::findIdByEmail($email); if (!\is_null($existing) && $existing !== $target->getId()) $model->errors['email'] = 'TAKEN'; @@ -223,9 +227,10 @@ private function handlePost(): void $target->setUsername($username); $target->setDisplayName($display_name); $target->setEmail($email); - // toggle verified through setVerified() so its date and verifier token stay consistent + // 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); + $target->setVerified(($options & User::OPTION_VERIFIED) !== 0, $email_changed); if (!$target->commit()) { @@ -296,6 +301,9 @@ private function handlePost(): void } $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 @@ -318,15 +326,42 @@ private function resendVerification(): void $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; + } + } + + /** + * 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(); - VerificationEmail::send($target, $model->active_user, VerificationEmail::REASON_STAFF_RESENT); + 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 resend verification email to user #%d: %s', $target->getId(), $e->getMessage())); - $model->_responseCode = HttpCode::HTTP_INTERNAL_SERVER_ERROR; + \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; } } diff --git a/src/Templates/User/Edit.phtml b/src/Templates/User/Edit.phtml index 604d97f1..86050875 100644 --- a/src/Templates/User/Edit.phtml +++ b/src/Templates/User/Edit.phtml @@ -101,7 +101,7 @@ require('./Includes/header.inc.phtml'); ?>

No email was sent because this user is already verified.

-

The verification email could not be sent. Try again later.

+

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

@@ -123,6 +123,7 @@ require('./Includes/header.inc.phtml'); ?> + Changing it unverifies the account and sends a verification link to the new address. From bacd13c1816a3130e2a8f619ed3092c38acae212 Mon Sep 17 00:00:00 2001 From: Carl Bennett Date: Wed, 4 Nov 2026 03:14:11 -0600 Subject: [PATCH 22/29] Caption the profile page's password button Change password The button on your profile page read "Password" while the same link on the profile update page reads "Change password". Both now use the latter, matching the verb-first captions of the buttons beside it. Co-Authored-By: Claude Opus 5.5 --- src/Templates/User/View.phtml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Templates/User/View.phtml b/src/Templates/User/View.phtml index eb0672ed..50f8aecb 100644 --- a/src/Templates/User/View.phtml +++ b/src/Templates/User/View.phtml @@ -172,7 +172,7 @@ require('./Includes/header.inc.phtml'); ?>
📝 Edit profile - 🔑 Password + 🔑 Change password 🛡️ Edit user From ad598c2ce39f370878291ac1bc2e87d5bc1ded32 Mon Sep 17 00:00:00 2001 From: Carl Bennett Date: Wed, 4 Nov 2026 03:16:54 -0600 Subject: [PATCH 23/29] Move the password reset email into a reusable library The reset password email was built and sent inside the ResetPassword controller. It now lives in ResetPasswordEmail, like VerificationEmail, so staff can send it too. It takes a reason that will change its wording; the controller sends the existing email unchanged. Co-Authored-By: Claude Opus 5.5 --- phpstan-baseline.neon | 2 +- src/Controllers/User/ResetPassword.php | 74 +-------------- src/Libraries/User/ResetPasswordEmail.php | 111 ++++++++++++++++++++++ 3 files changed, 114 insertions(+), 73 deletions(-) create mode 100644 src/Libraries/User/ResetPasswordEmail.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index abbe65be..2504f797 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1869,7 +1869,7 @@ parameters: - message: '#^Access to an undefined property BNETDocs\\Interfaces\\Model\:\:\$user\.$#' identifier: property.notFound - count: 10 + count: 5 path: src/Controllers/User/ResetPassword.php - diff --git a/src/Controllers/User/ResetPassword.php b/src/Controllers/User/ResetPassword.php index 5ccfa507..7ff46974 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 @@ -112,77 +110,9 @@ protected function doPasswordReset(): mixed 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/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); + } + } +} From 7f3b89c60e93c6917d9821c017b530696c438639 Mon Sep 17 00:00:00 2001 From: Carl Bennett Date: Wed, 4 Nov 2026 03:20:03 -0600 Subject: [PATCH 24/29] Make password reset links work only once Finishing a password reset called setVerified(true) to clear the token, but setVerified() does nothing for an account that is already verified, so the token was kept and the same link could reset the password again. The token is now cleared explicitly. Co-Authored-By: Claude Opus 5.5 --- src/Controllers/User/ResetPassword.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Controllers/User/ResetPassword.php b/src/Controllers/User/ResetPassword.php index 7ff46974..4e3c4424 100644 --- a/src/Controllers/User/ResetPassword.php +++ b/src/Controllers/User/ResetPassword.php @@ -105,6 +105,8 @@ 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; } From 8542c509638d0706eba7cebb513ff550a247b15b Mon Sep 17 00:00:00 2001 From: Carl Bennett Date: Wed, 4 Nov 2026 03:20:03 -0600 Subject: [PATCH 25/29] Word the password reset email for resets made by staff When sent with REASON_STAFF_RESET, the email says a staff member reset the password and logged the user out, and points to Recover Password if the link stops working, instead of telling them to ignore it if they did not ask. The plain text version also gets blank lines around the link. Co-Authored-By: Claude Opus 5.5 --- src/Templates/Email/User/ResetPassword.plain.phtml | 13 +++++++++++++ src/Templates/Email/User/ResetPassword.rich.phtml | 9 +++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) 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.

+

From 2e706de195e0b75fdfc94ead84103172a5055a42 Mon Sep 17 00:00:00 2001 From: Carl Bennett Date: Wed, 4 Nov 2026 03:20:03 -0600 Subject: [PATCH 26/29] Tell users without a password to reset it when logging in An account with no password hash can never log in, and was told its password was incorrect. It is now told the password must be reset, and how to do it. Co-Authored-By: Claude Opus 5.5 --- src/Controllers/User/Login.php | 2 ++ src/Models/User/Login.php | 1 + src/Templates/User/Login.phtml | 1 + 3 files changed, 4 insertions(+) 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/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/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; From bdd98b4113e6cf51c13a7428f71a9a3807ad9f2b Mon Sep 17 00:00:00 2001 From: Carl Bennett Date: Wed, 4 Nov 2026 03:20:03 -0600 Subject: [PATCH 27/29] Add a reset password button to the user editor Staff can reset another user's password from the Status card on /user/edit. It clears the password so it stops working immediately, logs the user out of every session, and emails them a link to choose a new one. A confirmation box must be ticked first, which works without JavaScript. The reset is logged as USER_PASSWORD_RESET by the staff member, with how many sessions were expired and whether the email was sent. Staff cannot reset their own password here. Co-Authored-By: Claude Opus 5.5 --- src/Controllers/User/Edit.php | 84 +++++++++++++++++++++++++++ src/Libraries/User/Authentication.php | 15 +++++ src/Models/User/Edit.php | 17 ++++++ src/Templates/User/Edit.phtml | 40 ++++++++++++- 4 files changed, 154 insertions(+), 2 deletions(-) diff --git a/src/Controllers/User/Edit.php b/src/Controllers/User/Edit.php index 5bd336bb..e9e56e9d 100644 --- a/src/Controllers/User/Edit.php +++ b/src/Controllers/User/Edit.php @@ -8,7 +8,9 @@ use \BNETDocs\Libraries\Core\Router; use \BNETDocs\Libraries\EventLog\EventTypes; use \BNETDocs\Libraries\EventLog\Logger; +use \BNETDocs\Libraries\User\Authentication; use \BNETDocs\Libraries\User\Profile; +use \BNETDocs\Libraries\User\ResetPasswordEmail; use \BNETDocs\Libraries\User\User; use \BNETDocs\Libraries\User\VerificationEmail; use \BNETDocs\Models\User\Edit as EditModel; @@ -131,6 +133,12 @@ private function handlePost(): void 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'] : ''); @@ -341,6 +349,82 @@ private function resendVerification(): void } } + /** + * 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. * diff --git a/src/Libraries/User/Authentication.php b/src/Libraries/User/Authentication.php index 2a369587..0be55fff 100644 --- a/src/Libraries/User/Authentication.php +++ b/src/Libraries/User/Authentication.php @@ -55,6 +55,21 @@ protected static function discard(string $key): bool finally { if ($q) $q->closeCursor(); } } + /** + * expireUserSessions() + * Discards every session of a user server-side, logging them out everywhere. + * + * @param User $user The user whose sessions are discarded. + * @return int|false The number of sessions discarded, or false on error. + * @throws PDOException if a PDO error occurs. + */ + public static function expireUserSessions(User $user): int|false + { + $q = MariaDb::instance()->prepare('DELETE FROM `user_sessions` WHERE `user_id` = ?;'); + try { return ($q && $q->execute([$user->getId()])) ? $q->rowCount() : false; } + finally { if ($q) $q->closeCursor(); } + } + /** * getFingerprint() * Generates a fingerprint that may be used to identify a returning client. diff --git a/src/Models/User/Edit.php b/src/Models/User/Edit.php index 249f4c6c..039fa088 100644 --- a/src/Models/User/Edit.php +++ b/src/Models/User/Edit.php @@ -9,6 +9,11 @@ class Edit extends \BNETDocs\Models\Core\HttpForm implements \JsonSerializable public const ERROR_NOT_FOUND = 'NOT_FOUND'; public const ERROR_OUTRANKED = 'OUTRANKED'; + public const PASSWORD_RESET_DONE = 'DONE'; + public const PASSWORD_RESET_EMAIL_FAILED = 'EMAIL_FAILED'; + public const PASSWORD_RESET_SELF = 'SELF'; + public const PASSWORD_RESET_UNCONFIRMED = 'UNCONFIRMED'; + public const VERIFICATION_ALREADY_VERIFIED = 'ALREADY_VERIFIED'; public const VERIFICATION_FAILED = 'FAILED'; public const VERIFICATION_SENT = 'SENT'; @@ -85,6 +90,16 @@ class Edit extends \BNETDocs\Models\Core\HttpForm implements \JsonSerializable */ 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; @@ -144,8 +159,10 @@ public function jsonSerialize(): mixed '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, diff --git a/src/Templates/User/Edit.phtml b/src/Templates/User/Edit.phtml index 86050875..df307282 100644 --- a/src/Templates/User/Edit.phtml +++ b/src/Templates/User/Edit.phtml @@ -103,6 +103,16 @@ require('./Includes/header.inc.phtml'); ?>

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.

+
@@ -145,6 +155,21 @@ require('./Includes/header.inc.phtml'); ?>
+
+ 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'?>
@@ -217,6 +242,11 @@ require('./Includes/header.inc.phtml'); ?> +is_self) { ?> +
+ + + From 0635b25f2e52dd3356c1f0b31e59f73f7059a58b Mon Sep 17 00:00:00 2001 From: Carl Bennett Date: Wed, 4 Nov 2026 03:24:54 -0600 Subject: [PATCH 28/29] Expire sessions on log out instead of deleting them Logging out tried to delete the session record, but compared the binary session id with its hexadecimal form, so nothing was ever removed and the session stayed valid server-side until it expired a month later. Logging out now sets the session's expiry to the current time, and staff password resets do the same for every active session of the user. Session records are no longer deleted by the site; removing expired ones is left to a separate cleanup job. Co-Authored-By: Claude Opus 5.5 --- src/Libraries/User/Authentication.php | 47 ++++++++++++++++++++------- 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/src/Libraries/User/Authentication.php b/src/Libraries/User/Authentication.php index 0be55fff..27d44a7a 100644 --- a/src/Libraries/User/Authentication.php +++ b/src/Libraries/User/Authentication.php @@ -41,35 +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() - * Discards every session of a user server-side, logging them out everywhere. + * 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 discarded. - * @return int|false The number of sessions discarded, or false on error. + * @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('DELETE FROM `user_sessions` WHERE `user_id` = ?;'); - try { return ($q && $q->execute([$user->getId()])) ? $q->rowCount() : 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. @@ -164,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; From 8a0a2d457ba27e6711d45496393c6af207e5aed1 Mon Sep 17 00:00:00 2001 From: Carl Bennett Date: Wed, 4 Nov 2026 03:26:16 -0600 Subject: [PATCH 29/29] Accept sessions without an expiry date The session lookup compared expires_datetime = NULL, which is never true in SQL, so a session with no expiry date was treated as invalid. It now uses IS NULL, matching how expiring a session already treats a missing date as still active. Co-Authored-By: Claude Opus 5.5 --- src/Libraries/User/Authentication.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Libraries/User/Authentication.php b/src/Libraries/User/Authentication.php index 27d44a7a..f2039693 100644 --- a/src/Libraries/User/Authentication.php +++ b/src/Libraries/User/Authentication.php @@ -229,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;
Resource