From a7ba6e187dd32a5fe5dd9ca04f4635b9978470c9 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Thu, 1 Oct 2026 12:51:29 +0200 Subject: [PATCH 001/115] Migrate SettingsForm to form builder, backed by form options for user options --- com.woltlab.wcf/package.xml | 1 + com.woltlab.wcf/templates/settings.tpl | 139 +---- .../update_com.woltlab.wcf_6.3_step1.php | 1 + ...oltlab.wcf_6.3_userOptionConfiguration.php | 40 ++ .../lib/data/user/option/UserOption.class.php | 62 +++ .../user/option/UserOptionEditor.class.php | 55 ++ .../files/lib/form/SettingsForm.class.php | 482 +++++++++++------- .../form/option/FormOptionHandler.class.php | 1 + .../form/option/TimezoneFormOption.class.php | 52 ++ wcfsetup/install/lang/de.xml | 1 + wcfsetup/install/lang/en.xml | 1 + wcfsetup/setup/db/install_com.woltlab.wcf.php | 1 + 12 files changed, 527 insertions(+), 309 deletions(-) create mode 100644 wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_userOptionConfiguration.php create mode 100644 wcfsetup/install/files/lib/system/form/option/TimezoneFormOption.class.php diff --git a/com.woltlab.wcf/package.xml b/com.woltlab.wcf/package.xml index d4f63a11a66..223262b33c4 100644 --- a/com.woltlab.wcf/package.xml +++ b/com.woltlab.wcf/package.xml @@ -88,6 +88,7 @@ acp/update_com.woltlab.wcf_6.3_paidSubscriptionL10n.php acp/update_com.woltlab.wcf_6.3_userOptionL10n.php acp/update_com.woltlab.wcf_6.3_captchaQuestionL10n.php + acp/update_com.woltlab.wcf_6.3_userOptionConfiguration.php acp/database/update_com.woltlab.wcf_6.3_step3.php The following step requires the new files and must run before the `cronjob` PIP, which precedes the `objectType` PIP: diff --git a/com.woltlab.wcf/templates/settings.tpl b/com.woltlab.wcf/templates/settings.tpl index ccb855d79bb..e63bd0d4a98 100644 --- a/com.woltlab.wcf/templates/settings.tpl +++ b/com.woltlab.wcf/templates/settings.tpl @@ -6,143 +6,6 @@ {include file='header' __disableAds=true __sidebarLeftHasMenu=true} -{include file='shared_formError'} - -{if $success|isset} - {lang}wcf.global.success.edit{/lang} -{/if} - -
- {if $category == 'general'} - {if $availableLanguages|count > 1} -
-

{lang}wcf.user.language{/lang}

- -
-
-
- - -
-
- - {hascontent} -
-
-
- {content} - {foreach from=$availableContentLanguages item=language} - - {/foreach} - {/content} - {lang}wcf.user.visibleLanguages.description{/lang}
-
- {/hascontent} - - {event name='languageFields'} -
- {/if} - -
-

{lang}wcf.user.styles{/lang}

- - {if $availableStyles|count > 1} -
-
-
- - {lang}wcf.user.style.description{/lang} -
-
- {/if} - -
-
-
- - {lang}wcf.user.style.colorScheme.description{/lang} -
-
- - {event name='styleFields'} -
- - {if MODULE_TROPHY && $__wcf->getSession()->getPermission('user.profile.trophy.maxUserSpecialTrophies') > 0 && $availableTrophies|count} -
-

{lang}wcf.user.trophy.trophies{/lang}

- -
{lang}wcf.user.trophy.specialTrophies{/lang}
-
-
    - {if $__wcf->getSession()->getPermission('user.profile.trophy.maxUserSpecialTrophies') == 1} - {foreach from=$availableTrophies item=trophy} -
  • - {/foreach} - {else} - {foreach from=$availableTrophies item=trophy} -
  • - {/foreach} - {/if} -
- {if $errorField == 'specialTrophies'} - - {lang}wcf.user.trophy.specialTrophies.error.{$errorType}{/lang} - - {/if} - {lang}wcf.user.trophy.specialTrophies.description{/lang} -
- - - {event name='trophyFields'} -
- {/if} - {/if} - - {if !$optionTree|empty} - {foreach from=$optionTree[0][categories][0][categories] item=optionCategory} -
-

{lang}wcf.user.option.category.{$optionCategory[object]->categoryName}{/lang}

- - {include file='userProfileOptionFieldList' options=$optionCategory[options] langPrefix='wcf.user.option.'} -
- {/foreach} - {/if} - - {event name='sections'} - -
- - {if $category != 'general'}{/if} - {csrfToken} -
-
+{unsafe:$form->getHtml()} {include file='footer' __disableAds=true} diff --git a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php index c089e60b350..1c8344f159a 100644 --- a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php +++ b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php @@ -67,6 +67,7 @@ PartialDatabaseTable::create('wcf1_user_option') ->columns([ DefaultFalseBooleanDatabaseTableColumn::create('showOnUserCard'), + MediumtextDatabaseTableColumn::create('configuration'), VarcharDatabaseTableColumn::create('l10nIdentifier') ->length(255), ]), diff --git a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_userOptionConfiguration.php b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_userOptionConfiguration.php new file mode 100644 index 00000000000..528839c9283 --- /dev/null +++ b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_userOptionConfiguration.php @@ -0,0 +1,40 @@ + + */ + +use wcf\data\user\option\UserOptionEditor; +use wcf\system\WCF; + +$sql = "SELECT optionID, selectOptions, required + FROM wcf1_user_option"; +$statement = WCF::getDB()->prepare($sql); +$statement->execute(); +$configurations = []; +while ($row = $statement->fetchArray()) { + $configurations[$row['optionID']] = UserOptionEditor::buildConfiguration( + $row['selectOptions'] ?? '', + (bool)$row['required'] + ); +} + +$sql = "UPDATE wcf1_user_option + SET configuration = ? + WHERE optionID = ?"; +$statement = WCF::getDB()->prepare($sql); + +WCF::getDB()->beginTransaction(); +foreach ($configurations as $optionID => $configuration) { + $statement->execute([ + $configuration, + $optionID, + ]); +} +WCF::getDB()->commitTransaction(); + +UserOptionEditor::resetCache(); diff --git a/wcfsetup/install/files/lib/data/user/option/UserOption.class.php b/wcfsetup/install/files/lib/data/user/option/UserOption.class.php index c27d363806a..477fd9bb93e 100644 --- a/wcfsetup/install/files/lib/data/user/option/UserOption.class.php +++ b/wcfsetup/install/files/lib/data/user/option/UserOption.class.php @@ -7,6 +7,8 @@ use wcf\data\user\User; use wcf\system\l10n\L10nDefinition; use wcf\system\l10n\L10nStorage; +use wcf\system\form\option\FormOptionHandler; +use wcf\system\form\option\IFormOption; use wcf\system\WCF; /** @@ -30,6 +32,7 @@ * @property-read ?string $l10nIdentifier base name of the language variable the localized title/description are derived from, `null` for options created by an administrator * * @phpstan-import-type L10nValue from L10nStorage + * @property-read ?string $configuration JSON-encoded configuration information depending on the option type */ class UserOption extends Option implements ITitledObject { @@ -105,6 +108,24 @@ class UserOption extends Option implements ITitledObject */ const EDITABILITY_OWNER_DURING_REGISTRATION_AND_ADMINISTRATOR = 6; + /** + * Maps the legacy option types to the ids of the form options with a compatible value format. + * Option types without a compatible form option fall back to the text form option. + * + * @var array + */ + private const FORM_OPTION_TYPES = [ + 'boolean' => 'boolean', + 'float' => 'float', + 'integer' => 'integer', + 'radioButton' => 'radioButton', + 'select' => 'select', + 'text' => 'text', + 'textarea' => 'textarea', + 'timezone' => 'timezone', + 'URL' => 'url', + ]; + /** * @inheritDoc */ @@ -191,6 +212,47 @@ public static function getL10nDefinition(): L10nDefinition ); } + /** + * Returns the identifier for the form field. + * + * @since 6.3 + */ + public function getIdentifier(): string + { + return "userOption{$this->optionID}"; + } + + /** + * @since 6.3 + */ + public function getFormOption(): IFormOption + { + $formOption = null; + if (isset(self::FORM_OPTION_TYPES[$this->optionType])) { + $formOption = FormOptionHandler::getInstance()->getOption(self::FORM_OPTION_TYPES[$this->optionType]); + } + + if ($formOption === null) { + $formOption = FormOptionHandler::getInstance()->getOption('text'); + \assert($formOption !== null); + } + + return $formOption; + } + + /** + * @return array + * @since 6.3 + */ + public function getConfiguration(): array + { + return ($this->configuration ?? '') !== '' ? \json_decode( + $this->configuration, + true, + flags: \JSON_THROW_ON_ERROR + ) : []; + } + /** * Sets target user object. * diff --git a/wcfsetup/install/files/lib/data/user/option/UserOptionEditor.class.php b/wcfsetup/install/files/lib/data/user/option/UserOptionEditor.class.php index 857dfe5cf36..7fc4f7fa0bc 100644 --- a/wcfsetup/install/files/lib/data/user/option/UserOptionEditor.class.php +++ b/wcfsetup/install/files/lib/data/user/option/UserOptionEditor.class.php @@ -6,6 +6,7 @@ use wcf\data\IEditableCachedObject; use wcf\system\cache\builder\UserOptionCacheBuilder; use wcf\system\WCF; +use wcf\util\StringUtil; /** * Provides functions to edit user options. @@ -28,6 +29,13 @@ class UserOptionEditor extends DatabaseObjectEditor implements IEditableCachedOb #[\Override] public static function create(array $parameters = []) { + if (!\array_key_exists('configuration', $parameters)) { + $parameters['configuration'] = self::buildConfiguration( + $parameters['selectOptions'] ?? '', + (bool)($parameters['required'] ?? false) + ); + } + /** @var UserOption $userOption */ $userOption = parent::create($parameters); @@ -52,6 +60,16 @@ public static function create(array $parameters = []) #[\Override] public function update(array $parameters = []) { + if ( + !\array_key_exists('configuration', $parameters) + && (\array_key_exists('selectOptions', $parameters) || \array_key_exists('required', $parameters)) + ) { + $parameters['configuration'] = self::buildConfiguration( + $parameters['selectOptions'] ?? $this->selectOptions ?? '', + (bool)($parameters['required'] ?? $this->required) + ); + } + parent::update($parameters); // alter the table "wcf1_user_option_value" with this new option @@ -170,6 +188,43 @@ public static function getColumnDefinition(string $optionType) return $column; } + /** + * Builds the JSON-encoded form option configuration from the legacy option data. + * + * @since 6.3 + */ + public static function buildConfiguration(string $selectOptions, bool $required): string + { + $configuration = []; + if ($required) { + $configuration['required'] = 1; + } + + $selectOptions = StringUtil::trim(StringUtil::unifyNewlines($selectOptions)); + if ($selectOptions !== '') { + $options = []; + foreach (\explode("\n", $selectOptions) as $selectOption) { + $key = $value = $selectOption; + if (\str_contains($selectOption, ':')) { + [$key, $value] = \explode(':', $selectOption, 2); + } + + $options[] = [ + 'key' => $key, + 'value' => [0 => $value], + ]; + } + + $configuration['selectOptions'] = \json_encode($options, \JSON_THROW_ON_ERROR); + } + + if ($configuration === []) { + return ''; + } + + return \json_encode($configuration, \JSON_THROW_ON_ERROR); + } + #[\Override] public static function resetCache() { diff --git a/wcfsetup/install/files/lib/form/SettingsForm.class.php b/wcfsetup/install/files/lib/form/SettingsForm.class.php index 45b35d3ce6c..e2fa95c4982 100644 --- a/wcfsetup/install/files/lib/form/SettingsForm.class.php +++ b/wcfsetup/install/files/lib/form/SettingsForm.class.php @@ -2,110 +2,89 @@ namespace wcf\form; +use wcf\command\user\SetColorScheme; use wcf\data\language\Language; +use wcf\data\option\category\OptionCategory; use wcf\data\style\Style; use wcf\data\trophy\Trophy; use wcf\data\trophy\TrophyCache; use wcf\data\user\option\category\UserOptionCategory; +use wcf\data\user\option\UserOption; use wcf\data\user\trophy\UserTrophyList; use wcf\data\user\UserAction; use wcf\data\user\UserProfile; use wcf\data\user\UserProfileAction; +use wcf\system\cache\builder\UserOptionCacheBuilder; use wcf\system\exception\IllegalLinkException; -use wcf\system\exception\UserInputException; +use wcf\system\form\builder\container\FormContainer; +use wcf\system\form\builder\field\AbstractFormField; +use wcf\system\form\builder\field\ICensorshipFormField; +use wcf\system\form\builder\field\IFormField; +use wcf\system\form\builder\field\MultipleSelectionFormField; +use wcf\system\form\builder\field\SelectFormField; +use wcf\system\form\builder\field\validation\FormFieldValidationError; +use wcf\system\form\builder\field\validation\FormFieldValidator; use wcf\system\language\LanguageFactory; use wcf\system\menu\user\UserMenu; -use wcf\system\option\user\UserOptionHandler; use wcf\system\request\LinkHandler; use wcf\system\style\StyleHandler; -use wcf\command\user\SetColorScheme; use wcf\system\user\storage\UserStorageHandler; use wcf\system\WCF; -use wcf\util\ArrayUtil; +use wcf\util\StringUtil; /** * Shows the dynamic options edit form. * - * @author Alexander Ebert - * @copyright 2001-2019 WoltLab GmbH - * @license GNU Lesser General Public License + * @author Marcel Werk, Alexander Ebert + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * + * @extends AbstractFormBuilderForm */ -class SettingsForm extends AbstractForm +final class SettingsForm extends AbstractFormBuilderForm { /** * @inheritDoc */ public $loginRequired = true; - /** - * user option handler - * @var ?UserOptionHandler - */ - public $optionHandler; - - /** - * @inheritDoc - */ - public $errorType = []; - /** * option category - * @var string */ - public $category = 'general'; + public string $category = 'general'; /** * list of available content languages * @var Language[] */ - public $availableContentLanguages = []; + public array $availableContentLanguages = []; /** * list of available languages * @var Language[] */ - public $availableLanguages = []; + public array $availableLanguages = []; /** * list of available styles * @var Style[] */ - public $availableStyles = []; + public array $availableStyles = []; /** * list of available trophies * @var Trophy[] */ - public $availableTrophies = []; - - /** - * list of content language ids - * @var int[] - */ - public $contentLanguageIDs = []; + public array $availableTrophies = []; /** - * language id - * @var int + * editable options grouped by the name of their category + * @var array */ - public $languageID = 0; - - /** - * style id - * @var int - */ - public $styleID = 0; - - /** - * special trophies - * @var int[] - */ - public $specialTrophies = []; - - public string $colorScheme = 'system'; + protected array $options = []; #[\Override] - public function readParameters() + public function readParameters(): void { parent::readParameters(); @@ -118,8 +97,7 @@ public function readParameters() } } - $this->optionHandler = new UserOptionHandler(false, '', 'settings.' . $this->category); - $this->optionHandler->setUser(WCF::getUser()); + $this->options = $this->getAvailableOptions('settings.' . $this->category); if ($this->category === 'general') { $this->availableContentLanguages = LanguageFactory::getInstance()->getContentLanguages(); @@ -134,193 +112,355 @@ public function readParameters() $this->availableTrophies = TrophyCache::getInstance()->getTrophiesByID($trophyIDs); Trophy::sort($this->availableTrophies, 'showOrder'); - } elseif ($this->optionHandler->countCategoryOptions('settings.' . $this->category) === 0) { + } elseif ($this->options === []) { throw new IllegalLinkException(); } } #[\Override] - public function readFormParameters() + protected function createForm(): void { - parent::readFormParameters(); - - $this->optionHandler->readUserInput($_POST); + parent::createForm(); - // static options if ($this->category === 'general') { - if (isset($_POST['contentLanguageIDs']) && \is_array($_POST['contentLanguageIDs'])) { - $this->contentLanguageIDs = ArrayUtil::toIntegerArray($_POST['contentLanguageIDs']); - } - if (isset($_POST['languageID'])) { - $this->languageID = \intval($_POST['languageID']); - } - if (isset($_POST['styleID'])) { - $this->styleID = \intval($_POST['styleID']); + $this->form->appendChildren($this->getGeneralFormContainers()); + } + + $categories = UserOptionCacheBuilder::getInstance()->getData([], 'categories'); + foreach ($this->options as $categoryName => $options) { + $this->form->appendChild( + FormContainer::create('optionCategory' . $categories[$categoryName]->categoryID) + ->label('wcf.user.option.category.' . $categoryName) + ->appendChildren(\array_map($this->getOptionFormField(...), $options)) + ); + } + } + + /** + * Returns the form containers for the static options of the general category. + * + * @return FormContainer[] + */ + protected function getGeneralFormContainers(): array + { + $containers = []; + + if (\count($this->availableLanguages) > 1) { + $languageContainer = FormContainer::create('languageSection') + ->label('wcf.user.language') + ->appendChild( + SelectFormField::create('languageID') + ->label('wcf.user.language.description') + ->options(\array_map( + static fn(Language $language) => StringUtil::encodeHTML($language->languageName), + $this->availableLanguages + )) + ->required() + ->ignoreInvalidValues() + ->value(WCF::getUser()->languageID) + ); + + if ($this->availableContentLanguages !== []) { + $languageContainer->appendChild( + MultipleSelectionFormField::create('contentLanguageIDs') + ->label('wcf.user.visibleLanguages') + ->description('wcf.user.visibleLanguages.description') + ->options(\array_map( + static fn(Language $language) => StringUtil::encodeHTML($language->languageName), + $this->availableContentLanguages + )) + ->ignoreInvalidValues() + ->value(WCF::getUser()->getLanguageIDs()) + ); } - if (isset($_POST['specialTrophies'])) { - $this->specialTrophies = ArrayUtil::toIntegerArray($_POST['specialTrophies']); + + $containers[] = $languageContainer; + } + + $styleContainer = FormContainer::create('styleSection') + ->label('wcf.user.styles'); + if (\count($this->availableStyles) > 1) { + $styles = [0 => 'wcf.global.defaultValue']; + foreach ($this->availableStyles as $style) { + $styles[$style->styleID] = StringUtil::encodeHTML($style->styleName); } - if (isset($_POST['colorScheme'])) { - $this->colorScheme = $_POST['colorScheme']; + + $styleContainer->appendChild( + SelectFormField::create('styleID') + ->label('wcf.user.style') + ->description('wcf.user.style.description') + ->options($styles) + ->required() + ->ignoreInvalidValues() + ->value(WCF::getUser()->styleID) + ); + } + $styleContainer->appendChild( + SelectFormField::create('colorScheme') + ->label('wcf.user.style.colorScheme') + ->description('wcf.user.style.colorScheme.description') + ->options([ + 'system' => 'wcf.style.setColorScheme.system', + 'light' => 'wcf.style.setColorScheme.light', + 'dark' => 'wcf.style.setColorScheme.dark', + ]) + ->required() + ->ignoreInvalidValues() + ->value(WCF::getUser()->getUserOption('colorScheme') ?? 'system') + ); + $containers[] = $styleContainer; + + $maxSpecialTrophies = (int)WCF::getSession()->getPermission('user.profile.trophy.maxUserSpecialTrophies'); + if (\MODULE_TROPHY !== 0 && $maxSpecialTrophies !== 0 && $this->availableTrophies !== []) { + $trophies = []; + foreach ($this->availableTrophies as $trophy) { + $trophies[$trophy->trophyID] = $trophy->renderTrophy(32) . ' ' . StringUtil::encodeHTML($trophy->getTitle()); } + + $containers[] = FormContainer::create('trophySection') + ->label('wcf.user.trophy.trophies') + ->appendChild( + MultipleSelectionFormField::create('specialTrophies') + ->label('wcf.user.trophy.specialTrophies') + ->description('wcf.user.trophy.specialTrophies.description') + ->options($trophies) + ->addValidator(new FormFieldValidator( + 'maximumMultiples', + static function (MultipleSelectionFormField $formField) use ($maxSpecialTrophies) { + if (\count($formField->getValue() ?? []) > $maxSpecialTrophies) { + $formField->addValidationError(new FormFieldValidationError( + 'tooMany', + 'wcf.user.trophy.specialTrophies.error.tooMany' + )); + } + } + )) + ->value(\array_values(\array_unique(\array_map( + static fn(Trophy $trophy) => $trophy->trophyID, + (new UserProfile(WCF::getUser()))->getSpecialTrophies() + )))) + ->ignoreInvalidValues() + ); } + + return $containers; } - #[\Override] - public function validate() + /** + * Returns the form field for the given user option. + */ + protected function getOptionFormField(UserOption $option): AbstractFormField { - parent::validate(); + $formOption = $option->getFormOption(); + $configuration = $option->getConfiguration(); - // dynamic options - $optionErrors = $this->optionHandler->validate(); - if ($optionErrors !== []) { - $this->errorType = $optionErrors; - throw new UserInputException('options', $this->errorType); + $formField = $formOption->getFormField($option->getIdentifier(), $configuration) + ->label('wcf.user.option.' . $option->optionName); + + $description = 'wcf.user.option.' . $option->optionName . '.description'; + if (WCF::getLanguage()->get($description, true) !== '') { + $formField->description($description); } - // static options - if ($this->category === 'general') { - // validate language id - if (!isset($this->availableLanguages[$this->languageID])) { - $this->languageID = LanguageFactory::getInstance()->getDefaultLanguageID(); - } + if ((bool)($configuration['required'] ?? false)) { + $formField->required(); + } + + if ($formField instanceof ICensorshipFormField) { + $formField->censorship(); + } - // validate content language ids - foreach ($this->contentLanguageIDs as $key => $languageID) { - if (!isset($this->availableContentLanguages[$languageID])) { - unset($this->contentLanguageIDs[$key]); + if ($formField instanceof SelectFormField && isset($formField->getOptions()[$option->defaultValue ?? ''])) { + $formField->defaultValue($option->defaultValue); + } + + if (($option->validationPattern ?? '') !== '') { + $formField->addValidator(new FormFieldValidator( + 'validationPattern', + static function (IFormField $formField) use ($option) { + $value = (string)$formField->getValue(); + if ($value !== '' && !\preg_match('~' . $option->validationPattern . '~', $value)) { + $formField->addValidationError(new FormFieldValidationError( + 'validationFailed', + 'wcf.user.option.error.validationFailed' + )); + } } - } + )); + } - if ($this->contentLanguageIDs === [] && isset($this->availableContentLanguages[$this->languageID])) { - $this->contentLanguageIDs[] = $this->languageID; - } + $value = WCF::getUser()->{$option->getIdentifier()}; + if ($value !== null) { + $formField->value($formOption->unserializeValue((string)$value)); + } - // validate style id - if (!isset($this->availableStyles[$this->styleID])) { - $this->styleID = 0; + return $formField; + } + + /** + * Returns the options of the child categories of the given category that + * the active user can edit, grouped by the name of the child category. + * + * @return array + */ + protected function getAvailableOptions(string $parentCategoryName): array + { + $categories = UserOptionCacheBuilder::getInstance()->getData([], 'categories'); + $categoryStructure = UserOptionCacheBuilder::getInstance()->getData([], 'categoryStructure'); + + $options = []; + foreach ($categoryStructure[$parentCategoryName] ?? [] as $categoryName) { + if (!$this->isAccessibleCategory($categories[$categoryName])) { + continue; } - // validate special trophies - if (\count($this->specialTrophies) > WCF::getSession()->getPermission('user.profile.trophy.maxUserSpecialTrophies')) { - throw new UserInputException('specialTrophies', 'tooMany'); + $categoryOptions = $this->getCategoryOptions($categoryName); + if ($categoryOptions !== []) { + $options[$categoryName] = $categoryOptions; } + } - foreach ($this->specialTrophies as $trophyID) { - if ( - !\in_array($trophyID, \array_map(static function ($trophy) { - return $trophy->trophyID; - }, $this->availableTrophies), true) - ) { - throw new UserInputException('specialTrophies', 'invalid'); - } + return $options; + } + + /** + * Returns the editable options of the given category and its child categories. + * + * @return UserOption[] + */ + protected function getCategoryOptions(string $categoryName): array + { + $categories = UserOptionCacheBuilder::getInstance()->getData([], 'categories'); + $categoryStructure = UserOptionCacheBuilder::getInstance()->getData([], 'categoryStructure'); + $cachedOptions = UserOptionCacheBuilder::getInstance()->getData([], 'options'); + $optionToCategories = UserOptionCacheBuilder::getInstance()->getData([], 'optionToCategories'); + + $options = []; + foreach ($optionToCategories[$categoryName] ?? [] as $optionName) { + $option = $cachedOptions[$optionName]; + if ($option->validateOptions() && $option->validatePermissions() && $option->isEditable()) { + $options[] = $option; } + } - if ($this->colorScheme !== 'light' && $this->colorScheme !== 'dark') { - $this->colorScheme = 'system'; + foreach ($categoryStructure[$categoryName] ?? [] as $childCategoryName) { + if ($this->isAccessibleCategory($categories[$childCategoryName])) { + $options = \array_merge($options, $this->getCategoryOptions($childCategoryName)); } } + + return $options; } - #[\Override] - public function readData() + private function isAccessibleCategory(OptionCategory $category): bool { - parent::readData(); - - // default values - if ($_POST === []) { - // static options - if ($this->category === 'general') { - $this->contentLanguageIDs = WCF::getUser()->getLanguageIDs(); - if (isset($this->availableLanguages[WCF::getUser()->languageID])) { - $this->languageID = WCF::getUser()->languageID; - } - $this->styleID = WCF::getUser()->styleID; - - $this->specialTrophies = \array_unique(\array_map(static function ($trophy) { - return $trophy->trophyID; - }, (new UserProfile(WCF::getUser()))->getSpecialTrophies())); + return $category->categoryName !== 'hidden' + && $category->validateOptions() + && $category->validatePermissions(); + } - $this->colorScheme = WCF::getUser()->getUserOption('colorScheme') ?? 'system'; - } + #[\Override] + protected function setFormAction() + { + $parameters = []; + if ($this->category !== 'general') { + $parameters['category'] = $this->category; } + + $this->form->action(LinkHandler::getInstance()->getControllerLink(static::class, $parameters)); } #[\Override] - public function save() + public function save(): void { - parent::save(); + AbstractForm::save(); + + $formData = $this->form->getData(); + + $optionValues = []; + foreach ($this->options as $options) { + foreach ($options as $option) { + $key = $option->getIdentifier(); + + if (\array_key_exists($key, $formData['data'])) { + $value = $formData['data'][$key]; + } elseif (\array_key_exists($key, $formData)) { + // multi-select fields place value at top-level via custom processor, see MultipleSelectionFormField::populate + $value = $formData[$key]; + } else { + continue; + } + + $optionValues[$option->optionID] = $value !== null + ? $option->getFormOption()->serializeValue($value) + : $option->defaultValue; + } + } - $saveOptions = $this->optionHandler->save(); - $parameters = ['options' => $saveOptions]; - // static options + $parameters = ['options' => $optionValues]; if ($this->category === 'general') { - $parameters['data'] = \array_merge($this->additionalFields, [ - 'languageID' => $this->languageID, - 'styleID' => $this->styleID, - ]); - $parameters['languageIDs'] = $this->contentLanguageIDs; + $parameters['data'] = $this->additionalFields; + + if (isset($formData['data']['languageID'])) { + $parameters['data']['languageID'] = (int)$formData['data']['languageID']; + + $contentLanguageIDs = \array_map(\intval(...), $formData['contentLanguageIDs'] ?? []); + if ($contentLanguageIDs === [] && isset($this->availableContentLanguages[$parameters['data']['languageID']])) { + $contentLanguageIDs[] = $parameters['data']['languageID']; + } + $parameters['languageIDs'] = $contentLanguageIDs; + } + + if (isset($formData['data']['styleID'])) { + $parameters['data']['styleID'] = (int)$formData['data']['styleID']; + } } $this->objectAction = new UserAction([WCF::getUser()], 'update', $parameters); $this->objectAction->executeAction(); - // static options if ($this->category === 'general') { // reset user language ids cache UserStorageHandler::getInstance()->reset([WCF::getUser()->userID], 'languageIDs'); - $userProfileAction = new UserProfileAction([WCF::getUser()->userID], 'updateSpecialTrophies', [ - 'trophyIDs' => $this->specialTrophies, - ]); - $userProfileAction->executeAction(); + if ($this->form->getNodeById('specialTrophies') !== null) { + $userProfileAction = new UserProfileAction([WCF::getUser()->userID], 'updateSpecialTrophies', [ + 'trophyIDs' => \array_map(\intval(...), $formData['specialTrophies'] ?? []), + ]); + $userProfileAction->executeAction(); + } - if (WCF::getUser()->getUserOption('colorScheme') !== $this->colorScheme) { - new SetColorScheme(WCF::getUser(), $this->colorScheme)(); + $colorScheme = $formData['data']['colorScheme'] ?? 'system'; + if (WCF::getUser()->getUserOption('colorScheme') !== $colorScheme) { + new SetColorScheme(WCF::getUser(), $colorScheme)(); } } + $this->saved(); + } + + #[\Override] + public function saved(): void + { + AbstractForm::saved(); - WCF::getTPL()->assign('success', true); + // The form is intentionally not rebuilt, because `WCF::getUser()` still + // holds the values from before saving. + $this->form->showSuccessMessage(true); } #[\Override] - public function assignVariables() + public function assignVariables(): void { parent::assignVariables(); - $formActionParameters = []; - if ($this->category !== 'general') { - $formActionParameters['category'] = $this->category; - } - WCF::getTPL()->assign([ - 'optionTree' => $this->optionHandler->getOptionTree(), 'category' => $this->category, - 'formAction' => LinkHandler::getInstance()->getControllerLink( - static::class, - $formActionParameters - ), ]); - - // static options - if ($this->category === 'general') { - WCF::getTPL()->assign([ - 'availableContentLanguages' => $this->availableContentLanguages, - 'availableLanguages' => $this->availableLanguages, - 'availableStyles' => $this->availableStyles, - 'availableTrophies' => $this->availableTrophies, - 'contentLanguageIDs' => $this->contentLanguageIDs, - 'languageID' => $this->languageID, - 'styleID' => $this->styleID, - 'specialTrophies' => $this->specialTrophies, - 'colorScheme' => $this->colorScheme, - ]); - } } #[\Override] - public function show() + public function show(): void { // set active tab UserMenu::getInstance()->setActiveMenuItem('wcf.user.option.category.settings.' . $this->category); diff --git a/wcfsetup/install/files/lib/system/form/option/FormOptionHandler.class.php b/wcfsetup/install/files/lib/system/form/option/FormOptionHandler.class.php index 0f13266d8e4..2d2271efe1b 100644 --- a/wcfsetup/install/files/lib/system/form/option/FormOptionHandler.class.php +++ b/wcfsetup/install/files/lib/system/form/option/FormOptionHandler.class.php @@ -51,6 +51,7 @@ private function getDefaultFormOptions(): array new SourceCodeFormOption(), new TextFormOption(), new TextareaFormOption(), + new TimezoneFormOption(), new UrlFormOption(), new WysiwygFormOption(), ] as $defaultOption diff --git a/wcfsetup/install/files/lib/system/form/option/TimezoneFormOption.class.php b/wcfsetup/install/files/lib/system/form/option/TimezoneFormOption.class.php new file mode 100644 index 00000000000..36b6d1744f3 --- /dev/null +++ b/wcfsetup/install/files/lib/system/form/option/TimezoneFormOption.class.php @@ -0,0 +1,52 @@ + + * @since 6.3 + */ +class TimezoneFormOption extends AbstractFormOption +{ + #[\Override] + public function getId(): string + { + return 'timezone'; + } + + #[\Override] + public function getFormField(string $id, array $configuration = []): AbstractFormField + { + $timezones = []; + foreach (DateUtil::getAvailableTimezones() as $timezone) { + $timezones[$timezone] = 'wcf.date.timezone.' . \str_replace('/', '.', \strtolower($timezone)); + } + + $formField = SelectFormField::create($id) + ->options($timezones) + ->ignoreInvalidValues(); + + if (isset($timezones[\TIMEZONE])) { + $formField->defaultValue(\TIMEZONE); + } + + return $formField; + } + + #[\Override] + public function getDatabaseTableColumn(string $name): AbstractDatabaseTableColumn + { + return VarcharDatabaseTableColumn::create($name) + ->length(255); + } +} diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml index 76561c2604a..85a2197c493 100644 --- a/wcfsetup/install/lang/de.xml +++ b/wcfsetup/install/lang/de.xml @@ -4099,6 +4099,7 @@ Dateianhänge: + diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml index 35f06cddc81..587c1bf92d2 100644 --- a/wcfsetup/install/lang/en.xml +++ b/wcfsetup/install/lang/en.xml @@ -4045,6 +4045,7 @@ Attachments: + diff --git a/wcfsetup/setup/db/install_com.woltlab.wcf.php b/wcfsetup/setup/db/install_com.woltlab.wcf.php index 03b8be44f41..58b73ba8f86 100644 --- a/wcfsetup/setup/db/install_com.woltlab.wcf.php +++ b/wcfsetup/setup/db/install_com.woltlab.wcf.php @@ -4823,6 +4823,7 @@ DefaultFalseBooleanDatabaseTableColumn::create('showOnUserCard'), VarcharDatabaseTableColumn::create('l10nIdentifier') ->length(255), + MediumtextDatabaseTableColumn::create('configuration'), ]) ->indices([ DatabaseTablePrimaryIndex::create() From cc587eed3371b7989dbb963614649ae362c3f2de Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Thu, 1 Oct 2026 21:07:10 +0200 Subject: [PATCH 002/115] Add `UserOptionBuilder` with create/update/delete commands --- ...oltlab.wcf_6.3_userOptionConfiguration.php | 6 +- .../user/option/CreateOption.class.php | 34 +++++++ .../user/option/DeleteOption.class.php | 5 +- .../user/option/UpdateOption.class.php | 34 +++++++ .../user/option/UserOptionBuilder.class.php | 88 +++++++++++++++++++ .../user/option/UserOptionEditor.class.php | 42 +-------- 6 files changed, 163 insertions(+), 46 deletions(-) create mode 100644 wcfsetup/install/files/lib/command/user/option/CreateOption.class.php create mode 100644 wcfsetup/install/files/lib/command/user/option/UpdateOption.class.php diff --git a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_userOptionConfiguration.php b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_userOptionConfiguration.php index 528839c9283..70778b01e24 100644 --- a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_userOptionConfiguration.php +++ b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_userOptionConfiguration.php @@ -8,7 +8,7 @@ * @license GNU Lesser General Public License */ -use wcf\data\user\option\UserOptionEditor; +use wcf\data\user\option\UserOptionBuilder; use wcf\system\WCF; $sql = "SELECT optionID, selectOptions, required @@ -17,7 +17,7 @@ $statement->execute(); $configurations = []; while ($row = $statement->fetchArray()) { - $configurations[$row['optionID']] = UserOptionEditor::buildConfiguration( + $configurations[$row['optionID']] = UserOptionBuilder::buildConfiguration( $row['selectOptions'] ?? '', (bool)$row['required'] ); @@ -37,4 +37,4 @@ } WCF::getDB()->commitTransaction(); -UserOptionEditor::resetCache(); +UserOptionBuilder::resetCache(); diff --git a/wcfsetup/install/files/lib/command/user/option/CreateOption.class.php b/wcfsetup/install/files/lib/command/user/option/CreateOption.class.php new file mode 100644 index 00000000000..6daf1b7c206 --- /dev/null +++ b/wcfsetup/install/files/lib/command/user/option/CreateOption.class.php @@ -0,0 +1,34 @@ + + * @since 6.3 + */ +final class CreateOption +{ + public function __construct( + private readonly UserOptionBuilder $builder, + ) {} + + public function __invoke(): UserOption + { + $option = $this->builder->create(); + + UserOptionBuilder::resetCache(); + + EventHandler::getInstance()->fire(new UserOptionCreated($option, $this->builder)); + + return $option; + } +} diff --git a/wcfsetup/install/files/lib/command/user/option/DeleteOption.class.php b/wcfsetup/install/files/lib/command/user/option/DeleteOption.class.php index 96fbfcc5204..da3e3f75a51 100644 --- a/wcfsetup/install/files/lib/command/user/option/DeleteOption.class.php +++ b/wcfsetup/install/files/lib/command/user/option/DeleteOption.class.php @@ -5,14 +5,13 @@ use wcf\data\user\option\UserOption; use wcf\data\user\option\UserOptionBuilder; use wcf\event\user\option\UserOptionDeleted; -use wcf\system\cache\builder\UserOptionCacheBuilder; use wcf\system\event\EventHandler; /** * Deletes a user option. * * @author Marcel Werk - * @copyright 2001-2024 WoltLab GmbH + * @copyright 2001-2026 WoltLab GmbH * @license GNU Lesser General Public License * @since 6.2 */ @@ -26,7 +25,7 @@ public function __invoke(): void { UserOptionBuilder::delete($this->option); - UserOptionCacheBuilder::getInstance()->reset(); + UserOptionBuilder::resetCache(); EventHandler::getInstance()->fire(new UserOptionDeleted($this->option)); } diff --git a/wcfsetup/install/files/lib/command/user/option/UpdateOption.class.php b/wcfsetup/install/files/lib/command/user/option/UpdateOption.class.php new file mode 100644 index 00000000000..5eeadff82e6 --- /dev/null +++ b/wcfsetup/install/files/lib/command/user/option/UpdateOption.class.php @@ -0,0 +1,34 @@ + + * @since 6.3 + */ +final class UpdateOption +{ + public function __construct( + private readonly UserOptionBuilder $builder, + ) {} + + public function __invoke(): UserOption + { + $option = $this->builder->update(); + + UserOptionBuilder::resetCache(); + + EventHandler::getInstance()->fire(new UserOptionUpdated($option, $this->builder)); + + return $option; + } +} diff --git a/wcfsetup/install/files/lib/data/user/option/UserOptionBuilder.class.php b/wcfsetup/install/files/lib/data/user/option/UserOptionBuilder.class.php index 4baf1a987fa..c9415154a60 100644 --- a/wcfsetup/install/files/lib/data/user/option/UserOptionBuilder.class.php +++ b/wcfsetup/install/files/lib/data/user/option/UserOptionBuilder.class.php @@ -4,6 +4,7 @@ use wcf\data\DatabaseObject; use wcf\data\DatabaseObjectBuilder; +use wcf\system\cache\builder\UserOptionCacheBuilder; use wcf\system\l10n\L10nStorage; use wcf\system\WCF; use wcf\util\StringUtil; @@ -29,6 +30,12 @@ final class UserOptionBuilder extends DatabaseObjectBuilder { private bool $isGenericOptionName = false; + /** + * Is `true` if the configuration has been set explicitly and must not be + * derived from the select options and the required flag. + */ + private bool $hasExplicitConfiguration = false; + /** * @var L10nValue */ @@ -107,6 +114,10 @@ public function setCategoryName(string $categoryName): static return $this; } + /** + * Sets the legacy option type, see `UserOption::getFormOption()` for the + * mapping to form options. + */ public function setOptionType(string $optionType): static { $this->properties['optionType'] = $optionType; @@ -128,9 +139,13 @@ public function setValidationPattern(string $validationPattern): static return $this; } + /** + * Sets the select options in the legacy format, one `value:label` pair per line. + */ public function setSelectOptions(string $selectOptions): static { $this->properties['selectOptions'] = $selectOptions; + $this->syncConfiguration(); return $this; } @@ -180,6 +195,7 @@ public function setVisible(int $visible): static public function setRequired(bool $required): static { $this->properties['required'] = $required ? 1 : 0; + $this->syncConfiguration(); return $this; } @@ -243,6 +259,22 @@ public function setAdditionalData(array $additionalData): static return $this; } + /** + * Sets the form option configuration explicitly instead of deriving it + * from the select options and the required flag. + * + * @param array $configuration + */ + public function setConfiguration(array $configuration): static + { + $this->hasExplicitConfiguration = true; + $this->properties['configuration'] = $configuration !== [] + ? \json_encode($configuration, \JSON_THROW_ON_ERROR) + : ''; + + return $this; + } + #[\Override] protected function getRequiredProperties(): array { @@ -325,4 +357,60 @@ protected static function beforeDeleteAll(array $objectIDs): void WCF::getDB()->getEditor()->dropColumn('wcf1_user_option_value', 'userOption' . $objectID); } } + + /** + * Derives the configuration from the select options and the required flag, + * falling back to the values of the updated option for the missing one. + */ + private function syncConfiguration(): void + { + if ($this->hasExplicitConfiguration) { + return; + } + + $this->properties['configuration'] = self::buildConfiguration( + (string)($this->properties['selectOptions'] ?? ($this->isUpdate() ? $this->getObject()->selectOptions : '')), + (bool)($this->properties['required'] ?? ($this->isUpdate() ? $this->getObject()->required : false)) + ); + } + + /** + * Builds the JSON-encoded form option configuration from the legacy option data. + */ + public static function buildConfiguration(string $selectOptions, bool $required): string + { + $configuration = []; + if ($required) { + $configuration['required'] = 1; + } + + $selectOptions = StringUtil::trim(StringUtil::unifyNewlines($selectOptions)); + if ($selectOptions !== '') { + $options = []; + foreach (\explode("\n", $selectOptions) as $selectOption) { + $key = $value = $selectOption; + if (\str_contains($selectOption, ':')) { + [$key, $value] = \explode(':', $selectOption, 2); + } + + $options[] = [ + 'key' => $key, + 'value' => [0 => $value], + ]; + } + + $configuration['selectOptions'] = \json_encode($options, \JSON_THROW_ON_ERROR); + } + + if ($configuration === []) { + return ''; + } + + return \json_encode($configuration, \JSON_THROW_ON_ERROR); + } + + public static function resetCache(): void + { + UserOptionCacheBuilder::getInstance()->reset(); + } } diff --git a/wcfsetup/install/files/lib/data/user/option/UserOptionEditor.class.php b/wcfsetup/install/files/lib/data/user/option/UserOptionEditor.class.php index 7fc4f7fa0bc..395f2ced131 100644 --- a/wcfsetup/install/files/lib/data/user/option/UserOptionEditor.class.php +++ b/wcfsetup/install/files/lib/data/user/option/UserOptionEditor.class.php @@ -6,7 +6,6 @@ use wcf\data\IEditableCachedObject; use wcf\system\cache\builder\UserOptionCacheBuilder; use wcf\system\WCF; -use wcf\util\StringUtil; /** * Provides functions to edit user options. @@ -30,7 +29,7 @@ class UserOptionEditor extends DatabaseObjectEditor implements IEditableCachedOb public static function create(array $parameters = []) { if (!\array_key_exists('configuration', $parameters)) { - $parameters['configuration'] = self::buildConfiguration( + $parameters['configuration'] = UserOptionBuilder::buildConfiguration( $parameters['selectOptions'] ?? '', (bool)($parameters['required'] ?? false) ); @@ -64,7 +63,7 @@ public function update(array $parameters = []) !\array_key_exists('configuration', $parameters) && (\array_key_exists('selectOptions', $parameters) || \array_key_exists('required', $parameters)) ) { - $parameters['configuration'] = self::buildConfiguration( + $parameters['configuration'] = UserOptionBuilder::buildConfiguration( $parameters['selectOptions'] ?? $this->selectOptions ?? '', (bool)($parameters['required'] ?? $this->required) ); @@ -188,43 +187,6 @@ public static function getColumnDefinition(string $optionType) return $column; } - /** - * Builds the JSON-encoded form option configuration from the legacy option data. - * - * @since 6.3 - */ - public static function buildConfiguration(string $selectOptions, bool $required): string - { - $configuration = []; - if ($required) { - $configuration['required'] = 1; - } - - $selectOptions = StringUtil::trim(StringUtil::unifyNewlines($selectOptions)); - if ($selectOptions !== '') { - $options = []; - foreach (\explode("\n", $selectOptions) as $selectOption) { - $key = $value = $selectOption; - if (\str_contains($selectOption, ':')) { - [$key, $value] = \explode(':', $selectOption, 2); - } - - $options[] = [ - 'key' => $key, - 'value' => [0 => $value], - ]; - } - - $configuration['selectOptions'] = \json_encode($options, \JSON_THROW_ON_ERROR); - } - - if ($configuration === []) { - return ''; - } - - return \json_encode($configuration, \JSON_THROW_ON_ERROR); - } - #[\Override] public static function resetCache() { From 03cf16bf6ef54dc8c73c0b1748750359637a42f2 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Thu, 1 Oct 2026 21:45:59 +0200 Subject: [PATCH 003/115] Set default values for required language, style and color scheme selects in SettingsForm --- wcfsetup/install/files/lib/form/SettingsForm.class.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/wcfsetup/install/files/lib/form/SettingsForm.class.php b/wcfsetup/install/files/lib/form/SettingsForm.class.php index e2fa95c4982..848ea081642 100644 --- a/wcfsetup/install/files/lib/form/SettingsForm.class.php +++ b/wcfsetup/install/files/lib/form/SettingsForm.class.php @@ -158,6 +158,7 @@ protected function getGeneralFormContainers(): array ->required() ->ignoreInvalidValues() ->value(WCF::getUser()->languageID) + ->defaultValue(LanguageFactory::getInstance()->getDefaultLanguageID()) ); if ($this->availableContentLanguages !== []) { @@ -193,6 +194,7 @@ protected function getGeneralFormContainers(): array ->required() ->ignoreInvalidValues() ->value(WCF::getUser()->styleID) + ->defaultValue(0) ); } $styleContainer->appendChild( @@ -207,6 +209,7 @@ protected function getGeneralFormContainers(): array ->required() ->ignoreInvalidValues() ->value(WCF::getUser()->getUserOption('colorScheme') ?? 'system') + ->defaultValue('system') ); $containers[] = $styleContainer; From 3918c7625193870b39241607586611565bebfa88 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Fri, 2 Oct 2026 13:37:10 +0200 Subject: [PATCH 004/115] Add `UserBuilder` with create/update/delete commands and migrate `SettingsForm` to it --- .../lib/command/user/CreateUser.class.php | 41 ++ .../lib/command/user/DeleteUser.class.php | 30 ++ .../lib/command/user/UpdateUser.class.php | 97 +++++ .../files/lib/data/user/UserBuilder.class.php | 409 ++++++++++++++++++ .../lib/event/user/UserCreated.class.php | 23 + .../lib/event/user/UserDeleted.class.php | 21 + .../lib/event/user/UserRenamed.class.php | 23 + .../lib/event/user/UserUpdated.class.php | 23 + .../files/lib/form/SettingsForm.class.php | 175 ++++---- 9 files changed, 750 insertions(+), 92 deletions(-) create mode 100644 wcfsetup/install/files/lib/command/user/CreateUser.class.php create mode 100644 wcfsetup/install/files/lib/command/user/DeleteUser.class.php create mode 100644 wcfsetup/install/files/lib/command/user/UpdateUser.class.php create mode 100644 wcfsetup/install/files/lib/data/user/UserBuilder.class.php create mode 100644 wcfsetup/install/files/lib/event/user/UserCreated.class.php create mode 100644 wcfsetup/install/files/lib/event/user/UserDeleted.class.php create mode 100644 wcfsetup/install/files/lib/event/user/UserRenamed.class.php create mode 100644 wcfsetup/install/files/lib/event/user/UserUpdated.class.php diff --git a/wcfsetup/install/files/lib/command/user/CreateUser.class.php b/wcfsetup/install/files/lib/command/user/CreateUser.class.php new file mode 100644 index 00000000000..986166fbff6 --- /dev/null +++ b/wcfsetup/install/files/lib/command/user/CreateUser.class.php @@ -0,0 +1,41 @@ + + * @since 6.3 + */ +final class CreateUser +{ + public function __construct( + private readonly UserBuilder $builder, + ) {} + + public function __invoke(): User + { + // The object returned by the builder lacks the user options. + $user = new User($this->builder->create()->userID); + + if (\MODULE_USER_RANK !== 0) { + $action = new UserProfileAction([$user], 'updateUserRank'); + $action->executeAction(); + } + $action = new UserProfileAction([$user], 'updateUserOnlineMarking'); + $action->executeAction(); + + EventHandler::getInstance()->fire(new UserCreated($user, $this->builder)); + + return $user; + } +} diff --git a/wcfsetup/install/files/lib/command/user/DeleteUser.class.php b/wcfsetup/install/files/lib/command/user/DeleteUser.class.php new file mode 100644 index 00000000000..de937fc33ec --- /dev/null +++ b/wcfsetup/install/files/lib/command/user/DeleteUser.class.php @@ -0,0 +1,30 @@ + + * @since 6.3 + */ +final class DeleteUser +{ + public function __construct( + private readonly User $user, + ) {} + + public function __invoke(): void + { + UserBuilder::delete($this->user); + + EventHandler::getInstance()->fire(new UserDeleted($this->user)); + } +} diff --git a/wcfsetup/install/files/lib/command/user/UpdateUser.class.php b/wcfsetup/install/files/lib/command/user/UpdateUser.class.php new file mode 100644 index 00000000000..bd158067ad0 --- /dev/null +++ b/wcfsetup/install/files/lib/command/user/UpdateUser.class.php @@ -0,0 +1,97 @@ + + * @since 6.3 + */ +final class UpdateUser +{ + public function __construct( + private readonly UserBuilder $builder, + ) {} + + public function __invoke(): User + { + $oldUser = $this->builder->getObject(); + $this->builder->update(); + + // The object returned by the builder lacks the changes to the user options. + $user = new User($oldUser->userID); + + if ($user->username !== $oldUser->username) { + $this->rename($user, $oldUser->username); + } + + if ($this->builder->isPasswordChanged()) { + SessionHandler::getInstance()->deleteUserSessionsExcept( + $user, + SessionHandler::getInstance()->sessionID + ); + } + + if ($user->userID === WCF::getUser()->userID && $user->languageID !== $oldUser->languageID) { + WCF::setLanguage($user->languageID); + } + + if ($this->builder->getLanguageIDs() !== null) { + UserStorageHandler::getInstance()->reset([$user->userID], 'languageIDs'); + } + + if ($this->builder->getGroupIDs() !== null) { + UserStorageHandler::getInstance()->reset([$user->userID], 'groupIDs'); + + UserGroupAssignmentHandler::getInstance()->checkUsers([$user->userID]); + + if (\MODULE_USER_RANK !== 0) { + $action = new UserProfileAction([$user], 'updateUserRank'); + $action->executeAction(); + } + if (\MODULE_USERS_ONLINE !== 0) { + $action = new UserProfileAction([$user], 'updateUserOnlineMarking'); + $action->executeAction(); + } + } + + EventHandler::getInstance()->fire(new UserUpdated($user, $this->builder)); + + return $user; + } + + /** + * Updates the username that is stored alongside the user id in other tables. + */ + private function rename(User $user, string $oldUsername): void + { + WCF::getDB()->beginTransaction(); + foreach (['wcf1_article', 'wcf1_comment', 'wcf1_comment_response', 'wcf1_media', 'wcf1_modification_log'] as $tableName) { + $sql = "UPDATE {$tableName} + SET username = ? + WHERE userID = ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([ + $user->username, + $user->userID, + ]); + } + WCF::getDB()->commitTransaction(); + + EventHandler::getInstance()->fire(new UserRenamed($user, $oldUsername)); + } +} diff --git a/wcfsetup/install/files/lib/data/user/UserBuilder.class.php b/wcfsetup/install/files/lib/data/user/UserBuilder.class.php new file mode 100644 index 00000000000..ce23a0bbdbe --- /dev/null +++ b/wcfsetup/install/files/lib/data/user/UserBuilder.class.php @@ -0,0 +1,409 @@ + + * @since 6.3 + * + * @extends DatabaseObjectBuilder + */ +final class UserBuilder extends DatabaseObjectBuilder +{ + /** + * values of the user options by option id, `null` if the user options are not changed + * @var ?array + */ + private ?array $userOptions = null; + + /** + * ids of the user groups, `null` if the user groups are not changed + * @var ?list + */ + private ?array $groupIDs = null; + + /** + * ids of the content languages, `null` if the content languages are not changed + * @var ?list + */ + private ?array $languageIDs = null; + + private bool $passwordChanged = false; + + public function setUsername(string $username): static + { + $this->properties['username'] = $username; + + return $this; + } + + public function setEmail(string $email): static + { + $this->properties['email'] = $email; + + return $this; + } + + /** + * Sets the password of the user, which is stored as a hash. Passing `null` + * sets an invalid password that prevents logging in with a password. + * + * Changing the password also changes the access token of the user. + */ + public function setPassword( + #[\SensitiveParameter] + ?string $password + ): static { + $this->properties['password'] = self::getPasswordHash($password); + $this->properties['accessToken'] = Hex::encode(\random_bytes(20)); + $this->passwordChanged = true; + + return $this; + } + + public function setLanguageID(int $languageID): static + { + $this->properties['languageID'] = $languageID; + + return $this; + } + + /** + * Sets the id of the style chosen by the user, `0` uses the default style. + */ + public function setStyleID(int $styleID): static + { + $this->properties['styleID'] = $styleID; + + return $this; + } + + public function setRegistrationDate(int $registrationDate): static + { + $this->properties['registrationDate'] = $registrationDate; + + return $this; + } + + public function setRegistrationIpAddress(string $registrationIpAddress): static + { + $this->properties['registrationIpAddress'] = $registrationIpAddress; + + return $this; + } + + /** + * Sets the activation code, `0` marks the user as activated. + */ + public function setActivationCode(int $activationCode): static + { + $this->properties['activationCode'] = $activationCode; + + return $this; + } + + /** + * Sets the code to confirm the email address, `null` marks the email + * address as confirmed. + */ + public function setEmailConfirmed(?string $emailConfirmed): static + { + $this->properties['emailConfirmed'] = $emailConfirmed; + + return $this; + } + + public function setBanned(bool $banned): static + { + $this->properties['banned'] = (int)$banned; + + return $this; + } + + public function setBanReason(?string $banReason): static + { + $this->properties['banReason'] = $banReason; + + return $this; + } + + /** + * Sets the timestamp at which the ban expires, `0` bans the user permanently. + */ + public function setBanExpires(int $banExpires): static + { + $this->properties['banExpires'] = $banExpires; + + return $this; + } + + public function setSignature(?string $signature): static + { + $this->properties['signature'] = $signature; + + return $this; + } + + public function setSignatureEnableHtml(bool $signatureEnableHtml): static + { + $this->properties['signatureEnableHtml'] = (int)$signatureEnableHtml; + + return $this; + } + + public function setDisableSignature(bool $disableSignature): static + { + $this->properties['disableSignature'] = (int)$disableSignature; + + return $this; + } + + public function setDisableSignatureReason(?string $disableSignatureReason): static + { + $this->properties['disableSignatureReason'] = $disableSignatureReason; + + return $this; + } + + public function setDisableSignatureExpires(int $disableSignatureExpires): static + { + $this->properties['disableSignatureExpires'] = $disableSignatureExpires; + + return $this; + } + + public function setUserTitle(string $userTitle): static + { + $this->properties['userTitle'] = $userTitle; + + return $this; + } + + public function setLastUsernameChange(int $lastUsernameChange): static + { + $this->properties['lastUsernameChange'] = $lastUsernameChange; + + return $this; + } + + public function setOldUsername(string $oldUsername): static + { + $this->properties['oldUsername'] = $oldUsername; + + return $this; + } + + public function setAuthData(string $authData): static + { + $this->properties['authData'] = $authData; + + return $this; + } + + /** + * Sets the value of a user option, see `UserOption::getFormOption()` for + * serializing form values. + */ + public function setUserOption(int $optionID, ?string $value): static + { + $this->userOptions ??= []; + $this->userOptions[$optionID] = $value; + + return $this; + } + + /** + * Sets the user groups of the user, replacing the existing ones. New users + * are always added to the default groups `EVERYONE` and `USERS`. + * + * @param list $groupIDs + */ + public function setGroupIDs(array $groupIDs): static + { + $this->groupIDs = $groupIDs; + + return $this; + } + + /** + * Sets the content languages of the user, replacing the existing ones. An + * empty list falls back to the default language. + * + * @param list $languageIDs + */ + public function setLanguageIDs(array $languageIDs): static + { + $this->languageIDs = $languageIDs; + + return $this; + } + + /** + * @return ?array + */ + public function getUserOptions(): ?array + { + return $this->userOptions; + } + + /** + * @return ?list + */ + public function getGroupIDs(): ?array + { + return $this->groupIDs; + } + + /** + * @return ?list + */ + public function getLanguageIDs(): ?array + { + return $this->languageIDs; + } + + public function isPasswordChanged(): bool + { + return $this->passwordChanged; + } + + #[\Override] + protected function getRequiredProperties(): array + { + return ['username', 'email', 'password', 'registrationDate']; + } + + #[\Override] + protected function afterCreate(DatabaseObject $object): void + { + $this->createUserOptions($object); + + $editor = new UserEditor($object); + $editor->addToGroups($this->groupIDs ?? [], false, true); + $editor->addToLanguages($this->languageIDs ?? [], false); + + if (\PACKAGE_ID !== 0) { + // set default notifications + $sql = "INSERT INTO wcf1_user_notification_event_to_user + (userID, eventID, mailNotificationType) + SELECT ?, eventID, presetMailNotificationType + FROM wcf1_user_notification_event + WHERE preset = ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([$object->userID, 1]); + } + } + + #[\Override] + protected function afterUpdate(DatabaseObject $object): void + { + $editor = new UserEditor($object); + + if ($this->userOptions !== null) { + $editor->updateUserOptions($this->userOptions); + } + + if ($this->groupIDs !== null) { + $editor->addToGroups($this->groupIDs, true, false); + } + + if ($this->languageIDs !== null) { + $editor->addToLanguages($this->languageIDs, true); + } + } + + #[\Override] + protected static function beforeDeleteAll(array $objectIDs): void + { + ClipboardHandler::getInstance()->unmark( + $objectIDs, + ClipboardHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.user') + ); + + $conditionBuilder = new PreparedStatementConditionBuilder(); + $conditionBuilder->add('userID IN (?)', [$objectIDs]); + $conditionBuilder->add('avatarFileID IS NOT NULL'); + + $sql = "SELECT avatarFileID + FROM wcf1_user + {$conditionBuilder}"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute($conditionBuilder->getParameters()); + $avatarFiles = File::findByIDs($statement->fetchAll(\PDO::FETCH_COLUMN)); + if ($avatarFiles !== []) { + new DeleteFiles($avatarFiles)(); + } + + CommentHandler::getInstance()->deleteObjects('com.woltlab.wcf.user.profileComment', $objectIDs); + AttachmentHandler::removeAttachments('com.woltlab.wcf.user.signature', $objectIDs); + } + + /** + * Inserts the row with the user option values, using the default value of + * the options that have not been set explicitly. + */ + private function createUserOptions(User $user): void + { + $sql = "SELECT optionID, defaultValue + FROM wcf1_user_option + WHERE defaultValue IS NOT NULL"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute(); + $values = $statement->fetchMap('optionID', 'defaultValue'); + + foreach ($this->userOptions ?? [] as $optionID => $value) { + $values[$optionID] = $value; + } + + $keys = $placeholders = ''; + $statementParameters = [$user->userID]; + foreach ($values as $optionID => $value) { + $keys .= ', userOption' . $optionID; + $placeholders .= ', ?'; + $statementParameters[] = $value; + } + + $sql = "INSERT INTO wcf1_user_option_value + (userID" . $keys . ") + VALUES (?" . $placeholders . ")"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute($statementParameters); + } + + private static function getPasswordHash( + #[\SensitiveParameter] + ?string $password + ): string { + $manager = PasswordAlgorithmManager::getInstance(); + + $algorithm = $manager->getDefaultAlgorithm(); + if ($password === null) { + $algorithm = new InvalidPasswordAlgorithm(); + $password = ''; + } + + return $manager->getNameFromAlgorithm($algorithm) . ':' . $algorithm->hash($password); + } +} diff --git a/wcfsetup/install/files/lib/event/user/UserCreated.class.php b/wcfsetup/install/files/lib/event/user/UserCreated.class.php new file mode 100644 index 00000000000..f16e2d368c3 --- /dev/null +++ b/wcfsetup/install/files/lib/event/user/UserCreated.class.php @@ -0,0 +1,23 @@ + + * @since 6.3 + */ +final class UserCreated implements IPsr14Event +{ + public function __construct( + public readonly User $user, + public readonly UserBuilder $builder, + ) {} +} diff --git a/wcfsetup/install/files/lib/event/user/UserDeleted.class.php b/wcfsetup/install/files/lib/event/user/UserDeleted.class.php new file mode 100644 index 00000000000..879fb79aed2 --- /dev/null +++ b/wcfsetup/install/files/lib/event/user/UserDeleted.class.php @@ -0,0 +1,21 @@ + + * @since 6.3 + */ +final class UserDeleted implements IPsr14Event +{ + public function __construct( + public readonly User $user, + ) {} +} diff --git a/wcfsetup/install/files/lib/event/user/UserRenamed.class.php b/wcfsetup/install/files/lib/event/user/UserRenamed.class.php new file mode 100644 index 00000000000..644f5c01a07 --- /dev/null +++ b/wcfsetup/install/files/lib/event/user/UserRenamed.class.php @@ -0,0 +1,23 @@ + + * @since 6.3 + */ +final class UserRenamed implements IPsr14Event +{ + public function __construct( + public readonly User $user, + public readonly string $oldUsername, + ) {} +} diff --git a/wcfsetup/install/files/lib/event/user/UserUpdated.class.php b/wcfsetup/install/files/lib/event/user/UserUpdated.class.php new file mode 100644 index 00000000000..dec6188db88 --- /dev/null +++ b/wcfsetup/install/files/lib/event/user/UserUpdated.class.php @@ -0,0 +1,23 @@ + + * @since 6.3 + */ +final class UserUpdated implements IPsr14Event +{ + public function __construct( + public readonly User $user, + public readonly UserBuilder $builder, + ) {} +} diff --git a/wcfsetup/install/files/lib/form/SettingsForm.class.php b/wcfsetup/install/files/lib/form/SettingsForm.class.php index 848ea081642..8a62484f454 100644 --- a/wcfsetup/install/files/lib/form/SettingsForm.class.php +++ b/wcfsetup/install/files/lib/form/SettingsForm.class.php @@ -2,7 +2,8 @@ namespace wcf\form; -use wcf\command\user\SetColorScheme; +use wcf\command\user\UpdateUser; +use wcf\data\DatabaseObjectBuilder; use wcf\data\language\Language; use wcf\data\option\category\OptionCategory; use wcf\data\style\Style; @@ -11,7 +12,8 @@ use wcf\data\user\option\category\UserOptionCategory; use wcf\data\user\option\UserOption; use wcf\data\user\trophy\UserTrophyList; -use wcf\data\user\UserAction; +use wcf\data\user\User; +use wcf\data\user\UserBuilder; use wcf\data\user\UserProfile; use wcf\data\user\UserProfileAction; use wcf\system\cache\builder\UserOptionCacheBuilder; @@ -28,7 +30,6 @@ use wcf\system\menu\user\UserMenu; use wcf\system\request\LinkHandler; use wcf\system\style\StyleHandler; -use wcf\system\user\storage\UserStorageHandler; use wcf\system\WCF; use wcf\util\StringUtil; @@ -39,15 +40,20 @@ * @copyright 2001-2026 WoltLab GmbH * @license GNU Lesser General Public License * - * @extends AbstractFormBuilderForm + * @extends AbstractDatabaseObjectBuilderForm */ -final class SettingsForm extends AbstractFormBuilderForm +final class SettingsForm extends AbstractDatabaseObjectBuilderForm { /** * @inheritDoc */ public $loginRequired = true; + /** + * @inheritDoc + */ + public string $formAction = 'edit'; + /** * option category */ @@ -97,6 +103,7 @@ public function readParameters(): void } } + $this->formObject = new User(WCF::getUser()->userID); $this->options = $this->getAvailableOptions('settings.' . $this->category); if ($this->category === 'general') { @@ -117,6 +124,18 @@ public function readParameters(): void } } + #[\Override] + protected function getDatabaseObjectBuilder(): UserBuilder + { + return UserBuilder::forUpdate($this->formObject); + } + + #[\Override] + protected function getCommand(DatabaseObjectBuilder $builder): callable + { + return new UpdateUser($builder); + } + #[\Override] protected function createForm(): void { @@ -157,8 +176,10 @@ protected function getGeneralFormContainers(): array )) ->required() ->ignoreInvalidValues() - ->value(WCF::getUser()->languageID) ->defaultValue(LanguageFactory::getInstance()->getDefaultLanguageID()) + ->saveValueCallback(static function (UserBuilder $builder, SelectFormField $field) { + $builder->setLanguageID((int)$field->getSaveValue()); + }) ); if ($this->availableContentLanguages !== []) { @@ -171,7 +192,20 @@ protected function getGeneralFormContainers(): array $this->availableContentLanguages )) ->ignoreInvalidValues() - ->value(WCF::getUser()->getLanguageIDs()) + ->loadValueCallback(static function (User $user, MultipleSelectionFormField $field) { + $field->value($user->getLanguageIDs()); + }) + ->saveValueCallback(function (UserBuilder $builder, MultipleSelectionFormField $field) { + $languageIDs = \array_map(\intval(...), $field->getValue() ?? []); + if ($languageIDs === []) { + $languageID = (int)$field->getDocument()->getFormField('languageID')?->getSaveValue(); + if (isset($this->availableContentLanguages[$languageID])) { + $languageIDs[] = $languageID; + } + } + + $builder->setLanguageIDs($languageIDs); + }) ); } @@ -193,8 +227,10 @@ protected function getGeneralFormContainers(): array ->options($styles) ->required() ->ignoreInvalidValues() - ->value(WCF::getUser()->styleID) ->defaultValue(0) + ->saveValueCallback(static function (UserBuilder $builder, SelectFormField $field) { + $builder->setStyleID((int)$field->getSaveValue()); + }) ); } $styleContainer->appendChild( @@ -208,8 +244,16 @@ protected function getGeneralFormContainers(): array ]) ->required() ->ignoreInvalidValues() - ->value(WCF::getUser()->getUserOption('colorScheme') ?? 'system') ->defaultValue('system') + ->loadValueCallback(static function (User $user, SelectFormField $field) { + $field->value($user->getUserOption('colorScheme') ?? 'system'); + }) + ->saveValueCallback(static function (UserBuilder $builder, SelectFormField $field) { + $optionID = User::getUserOptionID('colorScheme'); + if ($optionID !== null) { + $builder->setUserOption($optionID, (string)$field->getSaveValue()); + } + }) ); $containers[] = $styleContainer; @@ -238,11 +282,13 @@ static function (MultipleSelectionFormField $formField) use ($maxSpecialTrophies } } )) - ->value(\array_values(\array_unique(\array_map( - static fn(Trophy $trophy) => $trophy->trophyID, - (new UserProfile(WCF::getUser()))->getSpecialTrophies() - )))) ->ignoreInvalidValues() + ->loadValueCallback(static function (User $user, MultipleSelectionFormField $field) { + $field->value(\array_values(\array_unique(\array_map( + static fn(Trophy $trophy) => $trophy->trophyID, + (new UserProfile($user))->getSpecialTrophies() + )))); + }) ); } @@ -292,10 +338,20 @@ static function (IFormField $formField) use ($option) { )); } - $value = WCF::getUser()->{$option->getIdentifier()}; - if ($value !== null) { - $formField->value($formOption->unserializeValue((string)$value)); - } + $formField->loadValueCallback(static function (User $user, IFormField $formField) use ($option, $formOption) { + $value = $user->{$option->getIdentifier()}; + if ($value !== null) { + $formField->value($formOption->unserializeValue((string)$value)); + } + }); + $formField->saveValueCallback(static function (UserBuilder $builder, IFormField $formField) use ($option, $formOption) { + $value = $formField->getSaveValue(); + + $builder->setUserOption( + $option->optionID, + $value !== null ? $formOption->serializeValue($value) : $option->defaultValue + ); + }); return $formField; } @@ -363,7 +419,7 @@ private function isAccessibleCategory(OptionCategory $category): bool } #[\Override] - protected function setFormAction() + protected function setFormAction(): void { $parameters = []; if ($this->category !== 'general') { @@ -374,82 +430,17 @@ protected function setFormAction() } #[\Override] - public function save(): void + protected function afterSave(): void { - AbstractForm::save(); - - $formData = $this->form->getData(); - - $optionValues = []; - foreach ($this->options as $options) { - foreach ($options as $option) { - $key = $option->getIdentifier(); - - if (\array_key_exists($key, $formData['data'])) { - $value = $formData['data'][$key]; - } elseif (\array_key_exists($key, $formData)) { - // multi-select fields place value at top-level via custom processor, see MultipleSelectionFormField::populate - $value = $formData[$key]; - } else { - continue; - } - - $optionValues[$option->optionID] = $value !== null - ? $option->getFormOption()->serializeValue($value) - : $option->defaultValue; - } + parent::afterSave(); + + $specialTrophies = $this->form->getFormField('specialTrophies'); + if ($specialTrophies !== null) { + $userProfileAction = new UserProfileAction([$this->object->userID], 'updateSpecialTrophies', [ + 'trophyIDs' => \array_map(\intval(...), $specialTrophies->getValue() ?? []), + ]); + $userProfileAction->executeAction(); } - - $parameters = ['options' => $optionValues]; - if ($this->category === 'general') { - $parameters['data'] = $this->additionalFields; - - if (isset($formData['data']['languageID'])) { - $parameters['data']['languageID'] = (int)$formData['data']['languageID']; - - $contentLanguageIDs = \array_map(\intval(...), $formData['contentLanguageIDs'] ?? []); - if ($contentLanguageIDs === [] && isset($this->availableContentLanguages[$parameters['data']['languageID']])) { - $contentLanguageIDs[] = $parameters['data']['languageID']; - } - $parameters['languageIDs'] = $contentLanguageIDs; - } - - if (isset($formData['data']['styleID'])) { - $parameters['data']['styleID'] = (int)$formData['data']['styleID']; - } - } - - $this->objectAction = new UserAction([WCF::getUser()], 'update', $parameters); - $this->objectAction->executeAction(); - - if ($this->category === 'general') { - // reset user language ids cache - UserStorageHandler::getInstance()->reset([WCF::getUser()->userID], 'languageIDs'); - - if ($this->form->getNodeById('specialTrophies') !== null) { - $userProfileAction = new UserProfileAction([WCF::getUser()->userID], 'updateSpecialTrophies', [ - 'trophyIDs' => \array_map(\intval(...), $formData['specialTrophies'] ?? []), - ]); - $userProfileAction->executeAction(); - } - - $colorScheme = $formData['data']['colorScheme'] ?? 'system'; - if (WCF::getUser()->getUserOption('colorScheme') !== $colorScheme) { - new SetColorScheme(WCF::getUser(), $colorScheme)(); - } - } - - $this->saved(); - } - - #[\Override] - public function saved(): void - { - AbstractForm::saved(); - - // The form is intentionally not rebuilt, because `WCF::getUser()` still - // holds the values from before saving. - $this->form->showSuccessMessage(true); } #[\Override] From 6039d531c6b1c975cae5e3cdaaf225d9f9660422 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Fri, 2 Oct 2026 14:42:18 +0200 Subject: [PATCH 005/115] Move user deletion cleanup from `UserBuilder` into `DeleteUser` --- .../lib/command/user/DeleteUser.class.php | 33 +++++++++++++++++++ .../files/lib/data/user/UserBuilder.class.php | 32 ------------------ 2 files changed, 33 insertions(+), 32 deletions(-) diff --git a/wcfsetup/install/files/lib/command/user/DeleteUser.class.php b/wcfsetup/install/files/lib/command/user/DeleteUser.class.php index de937fc33ec..4bfa7948d53 100644 --- a/wcfsetup/install/files/lib/command/user/DeleteUser.class.php +++ b/wcfsetup/install/files/lib/command/user/DeleteUser.class.php @@ -2,9 +2,14 @@ namespace wcf\command\user; +use wcf\command\file\DeleteFiles; +use wcf\data\file\File; use wcf\data\user\User; use wcf\data\user\UserBuilder; use wcf\event\user\UserDeleted; +use wcf\system\attachment\AttachmentHandler; +use wcf\system\clipboard\ClipboardHandler; +use wcf\system\comment\CommentHandler; use wcf\system\event\EventHandler; /** @@ -23,8 +28,36 @@ public function __construct( public function __invoke(): void { + $this->cleanup($this->user); + UserBuilder::delete($this->user); EventHandler::getInstance()->fire(new UserDeleted($this->user)); } + + private function cleanup(User $user): void + { + ClipboardHandler::getInstance()->unmark( + [$user->userID], + ClipboardHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.user') + ); + + $fileIDs = \array_values(\array_filter([$user->avatarFileID, $user->coverPhotoFileID])); + if ($fileIDs !== []) { + $files = File::findByIDs($fileIDs); + if ($files !== []) { + new DeleteFiles(\array_values($files))(); + } + } + + CommentHandler::getInstance()->deleteObjects( + 'com.woltlab.wcf.user.profileComment', + [$user->userID] + ); + + AttachmentHandler::removeAttachments( + 'com.woltlab.wcf.user.signature', + [$user->userID] + ); + } } diff --git a/wcfsetup/install/files/lib/data/user/UserBuilder.class.php b/wcfsetup/install/files/lib/data/user/UserBuilder.class.php index ce23a0bbdbe..228f6142d82 100644 --- a/wcfsetup/install/files/lib/data/user/UserBuilder.class.php +++ b/wcfsetup/install/files/lib/data/user/UserBuilder.class.php @@ -3,14 +3,8 @@ namespace wcf\data\user; use ParagonIE\ConstantTime\Hex; -use wcf\command\file\DeleteFiles; use wcf\data\DatabaseObject; use wcf\data\DatabaseObjectBuilder; -use wcf\data\file\File; -use wcf\system\attachment\AttachmentHandler; -use wcf\system\clipboard\ClipboardHandler; -use wcf\system\comment\CommentHandler; -use wcf\system\database\util\PreparedStatementConditionBuilder; use wcf\system\user\authentication\password\algorithm\Invalid as InvalidPasswordAlgorithm; use wcf\system\user\authentication\password\PasswordAlgorithmManager; use wcf\system\WCF; @@ -334,32 +328,6 @@ protected function afterUpdate(DatabaseObject $object): void } } - #[\Override] - protected static function beforeDeleteAll(array $objectIDs): void - { - ClipboardHandler::getInstance()->unmark( - $objectIDs, - ClipboardHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.user') - ); - - $conditionBuilder = new PreparedStatementConditionBuilder(); - $conditionBuilder->add('userID IN (?)', [$objectIDs]); - $conditionBuilder->add('avatarFileID IS NOT NULL'); - - $sql = "SELECT avatarFileID - FROM wcf1_user - {$conditionBuilder}"; - $statement = WCF::getDB()->prepare($sql); - $statement->execute($conditionBuilder->getParameters()); - $avatarFiles = File::findByIDs($statement->fetchAll(\PDO::FETCH_COLUMN)); - if ($avatarFiles !== []) { - new DeleteFiles($avatarFiles)(); - } - - CommentHandler::getInstance()->deleteObjects('com.woltlab.wcf.user.profileComment', $objectIDs); - AttachmentHandler::removeAttachments('com.woltlab.wcf.user.signature', $objectIDs); - } - /** * Inserts the row with the user option values, using the default value of * the options that have not been set explicitly. From ca8e7b1a6ef1caec254e091f474dc836e91f383d Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Fri, 2 Oct 2026 15:37:59 +0200 Subject: [PATCH 006/115] Migrate `RegisterForm` to form builder --- com.woltlab.wcf/templates/register.tpl | 253 +----- phpstan-baseline.neon | 6 - .../files/lib/data/user/UserBuilder.class.php | 30 +- .../lib/data/user/option/UserOption.class.php | 2 + .../files/lib/form/RegisterForm.class.php | 719 ++++++++++-------- .../files/lib/form/SettingsForm.class.php | 147 +--- .../user/TUserOptionFormFields.class.php | 178 +++++ .../lib/util/UserRegistrationUtil.class.php | 2 + 8 files changed, 628 insertions(+), 709 deletions(-) create mode 100644 wcfsetup/install/files/lib/system/form/option/user/TUserOptionFormFields.class.php diff --git a/com.woltlab.wcf/templates/register.tpl b/com.woltlab.wcf/templates/register.tpl index e6831cc5d1e..3c7f791a31c 100644 --- a/com.woltlab.wcf/templates/register.tpl +++ b/com.woltlab.wcf/templates/register.tpl @@ -6,236 +6,29 @@ {lang}wcf.user.3rdparty.{$__wcf->session->getVar('__3rdPartyProvider')}.register{/lang} {/if} -{include file='shared_formError'} - -
- - -
- -
- * -
-
- - {if $errorType[username]|isset} - - {if $errorType[username] == 'empty'} - {lang}wcf.global.form.error.empty{/lang} - {else} - {lang}wcf.user.username.error.{$errorType[username]}{/lang} - {/if} - - {/if} - {lang}wcf.user.username.description{/lang} -
- - - -
- * -
-
- - {if $errorType[email]|isset} - - {if $errorType[email] == 'empty'} - {lang}wcf.global.form.error.empty{/lang} - {else} - {lang}wcf.user.email.error.{$errorType[email]}{/lang} - {/if} - - {/if} -
- - - {if !$isExternalAuthentication} -
-
- * -
-
- - {if $errorType[password]|isset} - - {if $errorType[password] == 'empty'} - {lang}wcf.global.form.error.empty{/lang} - {else} - {lang}wcf.user.password.error.{$errorType[password]}{/lang} - {/if} - - {/if} - {lang}wcf.user.password.description{/lang} -
-
- {/if} - - {if $availableLanguages|count > 1} -
-
-
- - -
-
- - {hascontent} -
-
-
- {content} - {foreach from=$availableContentLanguages item=language} - - {/foreach} - {/content} - {lang}wcf.user.visibleLanguages.description{/lang}
-
- {/hascontent} - {/if} - - {if REGISTER_ENABLE_DISCLAIMER} - -
-
- - {if $errorType[termsConfirmed]|isset} - - {lang}wcf.global.form.error.empty{/lang} - - {/if} -
- - {/if} - - {event name='generalFields'} -
- - {foreach from=$optionTree item=category} - {include file='userOptionFieldList' options=$category[options] langPrefix='wcf.user.option.'} - {/foreach} - - {event name='sections'} - - {include file='shared_captcha'} - -
- - {csrfToken} -
- - {include file='thirdPartySsoButtons'} -
- -

- * - {lang}wcf.global.form.required{/lang} -

- - + +{/if} {include file='authFlowFooter'} diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 64fa3f6f964..299a0464995 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -763,12 +763,6 @@ parameters: count: 1 path: wcfsetup/install/files/lib/form/NotificationUnsubscribeForm.class.php - - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' - identifier: empty.notAllowed - count: 4 - path: wcfsetup/install/files/lib/form/RegisterForm.class.php - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' identifier: empty.notAllowed diff --git a/wcfsetup/install/files/lib/data/user/UserBuilder.class.php b/wcfsetup/install/files/lib/data/user/UserBuilder.class.php index 228f6142d82..6135e7cdb98 100644 --- a/wcfsetup/install/files/lib/data/user/UserBuilder.class.php +++ b/wcfsetup/install/files/lib/data/user/UserBuilder.class.php @@ -230,7 +230,8 @@ public function setUserOption(int $optionID, ?string $value): static /** * Sets the user groups of the user, replacing the existing ones. New users - * are always added to the default groups `EVERYONE` and `USERS`. + * are added to the default groups `EVERYONE` and `USERS` unless the user + * groups are set explicitly. * * @param list $groupIDs */ @@ -283,6 +284,20 @@ public function isPasswordChanged(): bool return $this->passwordChanged; } + /** + * Sets the matches of the spam check during the registration. + * + * @param string[] $blacklistMatches + */ + public function setBlacklistMatches(array $blacklistMatches): static + { + $this->properties['blacklistMatches'] = $blacklistMatches !== [] + ? \json_encode($blacklistMatches, \JSON_THROW_ON_ERROR) + : ''; + + return $this; + } + #[\Override] protected function getRequiredProperties(): array { @@ -295,7 +310,11 @@ protected function afterCreate(DatabaseObject $object): void $this->createUserOptions($object); $editor = new UserEditor($object); - $editor->addToGroups($this->groupIDs ?? [], false, true); + if ($this->groupIDs === null) { + $editor->addToGroups([], false, true); + } else { + $editor->addToGroups($this->groupIDs, false, false); + } $editor->addToLanguages($this->languageIDs ?? [], false); if (\PACKAGE_ID !== 0) { @@ -342,7 +361,12 @@ private function createUserOptions(User $user): void $values = $statement->fetchMap('optionID', 'defaultValue'); foreach ($this->userOptions ?? [] as $optionID => $value) { - $values[$optionID] = $value; + if ($value === null) { + // Omit the column to fall back to its default value. + unset($values[$optionID]); + } else { + $values[$optionID] = $value; + } } $keys = $placeholders = ''; diff --git a/wcfsetup/install/files/lib/data/user/option/UserOption.class.php b/wcfsetup/install/files/lib/data/user/option/UserOption.class.php index 477fd9bb93e..c016fe9c678 100644 --- a/wcfsetup/install/files/lib/data/user/option/UserOption.class.php +++ b/wcfsetup/install/files/lib/data/user/option/UserOption.class.php @@ -115,7 +115,9 @@ class UserOption extends Option implements ITitledObject * @var array */ private const FORM_OPTION_TYPES = [ + 'birthday' => 'date', 'boolean' => 'boolean', + 'date' => 'date', 'float' => 'float', 'integer' => 'integer', 'radioButton' => 'radioButton', diff --git a/wcfsetup/install/files/lib/form/RegisterForm.class.php b/wcfsetup/install/files/lib/form/RegisterForm.class.php index 3e9971a6c48..0efee8c4a4d 100644 --- a/wcfsetup/install/files/lib/form/RegisterForm.class.php +++ b/wcfsetup/install/files/lib/form/RegisterForm.class.php @@ -3,15 +3,17 @@ namespace wcf\form; use ParagonIE\ConstantTime\Hex; -use wcf\acp\form\UserAddForm; use wcf\action\EmailValidationAction; use wcf\action\UsernameValidationAction; -use wcf\data\object\type\ObjectType; +use wcf\command\user\CreateRegistrationNotification; +use wcf\command\user\CreateUser; +use wcf\data\DatabaseObjectBuilder; +use wcf\data\language\Language; use wcf\data\user\group\UserGroup; +use wcf\data\user\option\UserOption; use wcf\data\user\User; -use wcf\data\user\UserAction; +use wcf\data\user\UserBuilder; use wcf\event\user\RegistrationSpamChecking; -use wcf\system\captcha\CaptchaHandler; use wcf\system\email\Email; use wcf\system\email\mime\MimePartFacade; use wcf\system\email\mime\RecipientAwareTextMimePart; @@ -19,14 +21,27 @@ use wcf\system\event\EventHandler; use wcf\system\exception\NamedUserException; use wcf\system\exception\PermissionDeniedException; -use wcf\system\exception\SystemException; use wcf\system\exception\UserInputException; +use wcf\system\form\builder\container\FormContainer; +use wcf\system\form\builder\field\AbstractFormField; +use wcf\system\form\builder\field\BooleanFormField; +use wcf\system\form\builder\field\CaptchaFormField; +use wcf\system\form\builder\field\EmailFormField; +use wcf\system\form\builder\field\IFormField; +use wcf\system\form\builder\field\MultipleSelectionFormField; +use wcf\system\form\builder\field\PasswordFormField; +use wcf\system\form\builder\field\SelectFormField; +use wcf\system\form\builder\field\user\UsernameFormField; +use wcf\system\form\builder\field\validation\FormFieldValidationError; +use wcf\system\form\builder\field\validation\FormFieldValidator; +use wcf\system\form\option\user\TUserOptionFormFields; +use wcf\system\language\LanguageFactory; use wcf\system\request\LinkHandler; use wcf\system\user\authentication\configuration\UserAuthenticationConfigurationFactory; use wcf\system\user\authentication\LoginRedirect; -use wcf\command\user\CreateRegistrationNotification; use wcf\system\user\group\assignment\UserGroupAssignmentHandler; use wcf\system\WCF; +use wcf\util\DateUtil; use wcf\util\HeaderUtil; use wcf\util\HtmlString; use wcf\util\StringUtil; @@ -36,89 +51,53 @@ /** * Shows the user registration form. * - * @author Marcel Werk - * @copyright 2001-2020 WoltLab GmbH - * @license GNU Lesser General Public License + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * + * @extends AbstractDatabaseObjectBuilderForm */ -class RegisterForm extends UserAddForm +final class RegisterForm extends AbstractDatabaseObjectBuilderForm { - /** - * true if external authentication is used - * @var bool - */ - public $isExternalAuthentication = false; - - /** - * @inheritDoc - */ - public $neededPermissions = []; - - /** - * holds a language variable with information about the registration process - * e.g. if you need to activate your account - * @var string - */ - public $message = ''; - - /** - * captcha object type object - * @var ?ObjectType - */ - public $captchaObjectType; - - /** - * name of the captcha object type; if empty, captcha is disabled - * @var string - */ - public $captchaObjectTypeName = \CAPTCHA_TYPE; - - /** - * true if captcha is used - * @var bool - */ - public $useCaptcha = \REGISTER_USE_CAPTCHA !== 0; + use TUserOptionFormFields { + getUserOptionFormField as getDefaultUserOptionFormField; + } /** - * field names - * @var array + * min number of seconds between form request and submit */ - public $randomFieldNames = []; + public static int $minRegistrationTime = 3; /** - * list of fields that have matches in the blacklist - * @var string[] - * @since 5.2 - * @deprecated 6.1 + * true if external authentication is used */ - public $blacklistMatches = []; + public bool $isExternalAuthentication = false; /** - * min number of seconds between form request and submit - * @var int + * name of the language item with information about the registration process, + * e.g. if the account needs to be activated */ - public static $minRegistrationTime = 3; + public string $message = ''; /** - * @var mixed[] + * true if the user registers with the email address provided by the external authentication */ - public $passwordStrengthVerdict = []; + private bool $registerVia3rdParty = false; /** - * @since 6.1 + * options that are asked during the registration grouped by the name of their category + * @var array */ - public bool $termsConfirmed = false; + private array $options = []; - /** - * @since 6.1 - */ private RegistrationSpamChecking $spamCheckEvent; #[\Override] - public function readParameters() + public function readParameters(): void { parent::readParameters(); - if (!empty($_REQUEST['url'])) { + if (isset($_REQUEST['url']) && $_REQUEST['url'] !== '') { LoginRedirect::setUrl(StringUtil::trim($_REQUEST['url'])); } @@ -137,361 +116,435 @@ public function readParameters() if (WCF::getSession()->getVar('__3rdPartyProvider') !== null) { $this->isExternalAuthentication = true; } + + $this->options = $this->getUserOptionsByCategory( + 'profile', + $this->isAskedDuringRegistration(...) + ); + + if ($_POST === []) { + WCF::getSession()->register('registrationStartTime', \TIME_NOW); + } + } + + /** + * Returns true if the given option is asked during the registration. + */ + private function isAskedDuringRegistration(UserOption $option): bool + { + if (!$option->validateOptions() || !$option->validatePermissions() || !$option->isEditable(true)) { + return false; + } + + return $option->askDuringRegistration !== 0 + || $option->required !== 0 + || ($option->editable & UserOption::EDITABILITY_OWNER_DURING_REGISTRATION) !== 0 + || $this->isBirthdayWithMinimumAge($option); + } + + private function isBirthdayWithMinimumAge(UserOption $option): bool + { + return $option->optionName === 'birthday' && \REGISTER_MIN_USER_AGE !== 0; } #[\Override] - public function readFormParameters() + protected function getDatabaseObjectBuilder(): UserBuilder { - parent::readFormParameters(); + $builder = UserBuilder::forCreate() + ->setRegistrationDate(\TIME_NOW) + ->setLanguageID(WCF::getLanguage()->languageID) + ->setSignatureEnableHtml(true); - if (!empty($this->username) || !empty($this->email)) { - throw new PermissionDeniedException(); + if (\LOG_IP_ADDRESS !== 0) { + $builder->setRegistrationIpAddress(UserUtil::getIpAddress()); } - $this->randomFieldNames = WCF::getSession()->getVar('registrationRandomFieldNames'); - if ($this->randomFieldNames === null) { - throw new PermissionDeniedException(); - } + if ($this->isExternalAuthentication) { + $provider = WCF::getSession()->getVar('__3rdPartyProvider'); + switch ($provider) { + case 'github': + case 'facebook': + case 'google': + case 'twitter': + if (($oauthUser = WCF::getSession()->getVar('__oauthUser')) !== null) { + $builder->setAuthData($provider . ':' . $oauthUser->getId()); + } + break; + } - if (isset($_POST[$this->randomFieldNames['username']])) { - $this->username = StringUtil::trim($_POST[$this->randomFieldNames['username']]); - } - if (isset($_POST[$this->randomFieldNames['email']])) { - $this->email = StringUtil::trim($_POST[$this->randomFieldNames['email']]); + // Accounts connected to a 3rdParty login do not have passwords. + $builder->setPassword(null); + + $email = WCF::getSession()->getVar('__email'); + if ($email !== null && $email === $this->form->getFormField('email')?->getSaveValue()) { + $this->registerVia3rdParty = true; + } } - if (isset($_POST[$this->randomFieldNames['password']])) { - $this->password = $_POST[$this->randomFieldNames['password']]; + + if ($this->spamCheckEvent->hasMatches()) { + $builder->setBlacklistMatches($this->spamCheckEvent->getMatches()); } - if (isset($_POST[$this->randomFieldNames['password'] . '_passwordStrengthVerdict'])) { - try { - $this->passwordStrengthVerdict = \json_decode( - $_POST[$this->randomFieldNames['password'] . '_passwordStrengthVerdict'], - true, - flags: \JSON_THROW_ON_ERROR - ); - } catch (\JsonException) { - // ignore + + if ($this->requiresActivation()) { + $builder->setActivationCode(UserRegistrationUtil::getActivationCode()); + if (!$this->registerVia3rdParty) { + $builder->setEmailConfirmed(Hex::encode(\random_bytes(20))); } - } - if (!empty($_POST['termsConfirmed'])) { - $this->termsConfirmed = true; + $builder->setGroupIDs(UserGroup::getGroupIDsByType([UserGroup::EVERYONE, UserGroup::GUESTS])); } - $this->groupIDs = []; + return $builder; + } - if ($this->captchaObjectType !== null) { - $this->captchaObjectType->getProcessor()->readFormParameters(); - } + private function requiresActivation(): bool + { + return $this->spamCheckEvent->hasMatches() + || ( + ((int)\REGISTER_ACTIVATION_METHOD & User::REGISTER_ACTIVATION_USER) !== 0 + && !$this->registerVia3rdParty + ) + || ((int)\REGISTER_ACTIVATION_METHOD & User::REGISTER_ACTIVATION_ADMIN) !== 0; } #[\Override] - protected function initOptionHandler() + protected function getCommand(DatabaseObjectBuilder $builder): callable { - $this->optionHandler->setInRegistration(); - parent::initOptionHandler(); + return new CreateUser($builder); } #[\Override] - public function validate() + protected function createForm(): void { - // validate captcha first - $this->validateCaptcha(); + parent::createForm(); - if (\REGISTER_ENABLE_DISCLAIMER !== 0 && !$this->termsConfirmed) { - $this->errorType['termsConfirmed'] = 'empty'; - } - - parent::validate(); + $this->form->appendChild($this->getGeneralFormContainer()); + $this->form->appendChildren($this->getUserOptionFormContainers($this->options)); - // validate registration time if ( - !$this->isExternalAuthentication - && ( - WCF::getSession()->getVar('registrationStartTime') === null - || (\TIME_NOW - WCF::getSession()->getVar('registrationStartTime')) < self::$minRegistrationTime - ) + \REGISTER_USE_CAPTCHA !== 0 + && WCF::getSession()->getVar('noRegistrationCaptcha') !== true ) { - throw new UserInputException('registrationStartTime', []); - } - - $this->spamCheckEvent = new RegistrationSpamChecking($this->username, $this->email, UserUtil::getIpAddress()); - EventHandler::getInstance()->fire($this->spamCheckEvent); - if ($this->spamCheckEvent->hasMatches() && \REGISTER_ANTISPAM_ACTION === 'block') { - throw new NamedUserException(HtmlString::fromSafeHtml( - WCF::getLanguage()->getDynamicVariable('wcf.user.register.error.blacklistMatches') - )); + $this->form->appendChild( + CaptchaFormField::create() + ->objectType(\CAPTCHA_TYPE) + ); } } #[\Override] - public function readData() + protected function finalizeForm(): void { - if ($this->useCaptcha && $this->captchaObjectTypeName !== '') { - $this->captchaObjectType = CaptchaHandler::getInstance()->getObjectTypeByName($this->captchaObjectTypeName); - if ($this->captchaObjectType === null) { - throw new SystemException("Unknown captcha object type with id '" . $this->captchaObjectTypeName . "'"); - } + parent::finalizeForm(); - if (!$this->captchaObjectType->getProcessor()->isAvailable()) { - $this->captchaObjectType = null; - } - - if (WCF::getSession()->getVar('noRegistrationCaptcha') === true) { - $this->captchaObjectType = null; - } - } + $this->form->getButton('submitButton')->label('wcf.user.button.register'); + } - parent::readData(); + private function getGeneralFormContainer(): FormContainer + { + $container = FormContainer::create('general') + ->appendChildren([ + UsernameFormField::create() + ->required() + ->minimumLength(\REGISTER_USERNAME_MIN_LENGTH) + ->maximumLength(\REGISTER_USERNAME_MAX_LENGTH) + ->autoComplete('username') + ->fieldAttribute( + 'data-validation-endpoint', + LinkHandler::getInstance()->getControllerLink(UsernameValidationAction::class) + ) + ->description('wcf.user.username.description') + ->value(WCF::getSession()->getVar('__username') ?? '') + ->addValidator(new FormFieldValidator( + 'registrationUtil', + static function (UsernameFormField $formField) { + if (!UserRegistrationUtil::isValidUsername((string)$formField->getValue())) { + $formField->addValidationError(new FormFieldValidationError( + 'invalid', + 'wcf.user.username.error.invalid' + )); + } + } + )) + ->saveValueCallback(static function (UserBuilder $builder, UsernameFormField $field) { + $builder->setUsername($field->getSaveValue()); + }), + EmailFormField::create('email') + ->label('wcf.user.email') + ->required() + ->autoComplete('email') + ->fieldAttribute( + 'data-validation-endpoint', + LinkHandler::getInstance()->getControllerLink(EmailValidationAction::class) + ) + ->value(WCF::getSession()->getVar('__email') ?? '') + ->addValidator(new FormFieldValidator( + 'registrationUtil', + static function (EmailFormField $formField) { + $email = (string)$formField->getValue(); + if (!UserRegistrationUtil::isValidEmail($email)) { + $formField->addValidationError(new FormFieldValidationError( + 'invalid', + 'wcf.user.email.error.invalid' + )); + } elseif (!User::getUserByEmail($email)->isGuest()) { + $formField->addValidationError(new FormFieldValidationError( + 'notUnique', + 'wcf.user.email.error.notUnique' + )); + } + } + )) + ->saveValueCallback(static function (UserBuilder $builder, EmailFormField $field) { + $builder->setEmail($field->getSaveValue()); + }), + ]); - if ($_POST === []) { - $this->languageID = WCF::getLanguage()->languageID; + if (!$this->isExternalAuthentication) { + $container->appendChild( + PasswordFormField::create('password') + ->label('wcf.user.password') + ->description('wcf.user.password.description') + ->addClass('passwordFormField--stacked') + ->required() + ->autoComplete('new-password') + ->fieldAttribute('passwordrules', UserRegistrationUtil::getPasswordRulesAttributeValue()) + ->passwordStrengthMeter() + ->addMeterRelatedFieldId('username') + ->addMeterRelatedFieldId('email') + ->addValidator(new FormFieldValidator('passwordStrength', $this->validatePasswordStrength(...))) + ->saveValueCallback(static function (UserBuilder $builder, PasswordFormField $field) { + $builder->setPassword($field->getSaveValue()); + }) + ); + } - if (WCF::getSession()->getVar('__username') !== null) { - $this->username = WCF::getSession()->getVar('__username'); - } - if (WCF::getSession()->getVar('__email') !== null) { - $this->email = WCF::getSession()->getVar('__email'); + $availableLanguages = LanguageFactory::getInstance()->getLanguages(); + if (\count($availableLanguages) > 1) { + $availableContentLanguages = LanguageFactory::getInstance()->getContentLanguages(); + + $container->appendChild( + SelectFormField::create('languageID') + ->label('wcf.user.language') + ->description('wcf.user.language.description') + ->options(\array_map( + static fn(Language $language) => StringUtil::encodeHTML($language->languageName), + $availableLanguages + )) + ->required() + ->ignoreInvalidValues() + ->defaultValue(LanguageFactory::getInstance()->getDefaultLanguageID()) + ->value(WCF::getLanguage()->languageID) + ->saveValueCallback(static function (UserBuilder $builder, SelectFormField $field) { + $builder->setLanguageID((int)$field->getSaveValue()); + }) + ); + + if ($availableContentLanguages !== []) { + $container->appendChild( + MultipleSelectionFormField::create('visibleLanguages') + ->label('wcf.user.visibleLanguages') + ->description('wcf.user.visibleLanguages.description') + ->options(\array_map( + static fn(Language $language) => StringUtil::encodeHTML($language->languageName), + $availableContentLanguages + )) + ->ignoreInvalidValues() + ->saveValueCallback( + static function (UserBuilder $builder, MultipleSelectionFormField $field) use ($availableContentLanguages) { + $languageIDs = \array_map(\intval(...), $field->getValue() ?? []); + if ($languageIDs === []) { + $languageID = (int)$field->getDocument()->getFormField('languageID')?->getSaveValue(); + if (isset($availableContentLanguages[$languageID])) { + $languageIDs[] = $languageID; + } + } + + $builder->setLanguageIDs($languageIDs); + } + ) + ); } + } - WCF::getSession()->register('registrationStartTime', \TIME_NOW); - - // generate random field names - $this->randomFieldNames = [ - 'username' => UserRegistrationUtil::getRandomFieldName('username'), - 'email' => UserRegistrationUtil::getRandomFieldName('email'), - 'password' => UserRegistrationUtil::getRandomFieldName('password'), - ]; - - WCF::getSession()->register('registrationRandomFieldNames', $this->randomFieldNames); + if (\REGISTER_ENABLE_DISCLAIMER !== 0) { + $container->appendChild( + BooleanFormField::create('termsConfirmed') + ->label('wcf.user.register.confirmTerms') + ->required() + ); } + + return $container; } /** - * Reads option tree on page init. + * Returns the form field for the given user option, enforcing the minimum + * age for the birthday. */ - #[\Override] - protected function readOptionTree() - { - $this->optionTree = $this->optionHandler->getOptionTree('profile'); - } - - #[\Override] - public function assignVariables() + protected function getUserOptionFormField(UserOption $option): AbstractFormField { - parent::assignVariables(); - - WCF::getTPL()->assign([ - 'captchaObjectType' => $this->captchaObjectType, - 'isExternalAuthentication' => $this->isExternalAuthentication, - 'randomFieldNames' => $this->randomFieldNames, - 'passwordRulesAttributeValue' => UserRegistrationUtil::getPasswordRulesAttributeValue(), - 'usernameValidationEndpoint' => LinkHandler::getInstance()->getControllerLink(UsernameValidationAction::class), - 'emailValidationEndpoint' => LinkHandler::getInstance()->getControllerLink(EmailValidationAction::class), - 'termsConfirmed' => $this->termsConfirmed, - ]); - } + $formField = $this->getDefaultUserOptionFormField($option); + + if ($this->isBirthdayWithMinimumAge($option)) { + $formField->required(); + $formField->addValidator(new FormFieldValidator( + 'minimumAge', + static function (IFormField $formField) { + $value = $formField->getSaveValue(); + if ($value !== null && $value !== '' && DateUtil::getAge($value) < \REGISTER_MIN_USER_AGE) { + $formField->addValidationError(new FormFieldValidationError( + 'birthdayTooYoung', + 'wcf.user.option.error.birthdayTooYoung' + )); + } + } + )); + } - #[\Override] - public function show() - { - AbstractForm::show(); + return $formField; } - /** - * @return void - */ - protected function validateCaptcha() + private function validatePasswordStrength(PasswordFormField $formField): void { - if ($this->captchaObjectType !== null) { + $verdict = []; + if (isset($_POST['password_passwordStrengthVerdict'])) { try { - $this->captchaObjectType->getProcessor()->validate(); - } catch (UserInputException $e) { - $this->errorType[$e->getField()] = $e->getType(); + $verdict = \json_decode($_POST['password_passwordStrengthVerdict'], true, flags: \JSON_THROW_ON_ERROR); + } catch (\JsonException) { + // ignore } } + + if (($verdict['score'] ?? 4) < \PASSWORD_MIN_SCORE) { + $formField->addValidationError(new FormFieldValidationError( + 'notSecure', + 'wcf.user.password.error.notSecure' + )); + } } #[\Override] - protected function validateUsername(string $username) + public function validate(): void { - parent::validateUsername($username); + parent::validate(); - // check for min-max length - if (!UserRegistrationUtil::isValidUsername($username)) { - throw new UserInputException('username', 'invalid'); - } - } + // validate registration time + if ( + !$this->isExternalAuthentication + && ( + WCF::getSession()->getVar('registrationStartTime') === null + || (\TIME_NOW - WCF::getSession()->getVar('registrationStartTime')) < self::$minRegistrationTime + ) + ) { + $this->form->invalid(); - #[\Override] - protected function validatePassword( - #[\SensitiveParameter] - string $password - ): void { - if (!$this->isExternalAuthentication) { - parent::validatePassword($password); + throw new UserInputException($this->form->getPrefixedId()); + } - // check security of the given password - if (($this->passwordStrengthVerdict['score'] ?? 4) < \PASSWORD_MIN_SCORE) { - throw new UserInputException('password', 'notSecure'); - } + $this->spamCheckEvent = new RegistrationSpamChecking( + $this->form->getFormField('username')->getSaveValue(), + $this->form->getFormField('email')->getSaveValue(), + UserUtil::getIpAddress() + ); + EventHandler::getInstance()->fire($this->spamCheckEvent); + if ($this->spamCheckEvent->hasMatches() && \REGISTER_ANTISPAM_ACTION === 'block') { + throw new NamedUserException(HtmlString::fromSafeHtml( + WCF::getLanguage()->getDynamicVariable('wcf.user.register.error.blacklistMatches') + )); } } #[\Override] - protected function validateEmail(string $email): void + public function saved(): void { - parent::validateEmail($email); + $user = $this->object; + \assert($user instanceof User); - if (!UserRegistrationUtil::isValidEmail($email)) { - throw new UserInputException('email', 'invalid'); - } + WCF::getSession()->changeUser($user); + + $this->message = $this->handleActivation($user); + + new CreateRegistrationNotification($user)(); + + // The form is not rebuilt, because the user is redirected after the registration. + AbstractForm::saved(); } #[\Override] - public function save() + protected function afterSave(): void { - AbstractForm::save(); - - // get options - $saveOptions = $this->optionHandler->save(); - $registerVia3rdParty = false; + parent::afterSave(); - if ($this->isExternalAuthentication) { - $provider = WCF::getSession()->getVar('__3rdPartyProvider'); - switch ($provider) { - case 'github': - case 'facebook': - case 'google': - case 'twitter': - if (($oauthUser = WCF::getSession()->getVar('__oauthUser')) !== null) { - $this->additionalFields['authData'] = $provider . ':' . $oauthUser->getId(); - } - break; - } + $this->form->cleanup(); + WCF::getSession()->unregister('noRegistrationCaptcha'); + WCF::getSession()->unregister('registrationStartTime'); - // Accounts connected to a 3rdParty login do not have passwords. - $this->password = null; + HeaderUtil::delayedRedirect( + LoginRedirect::getUrl(), + WCF::getLanguage()->getDynamicVariable($this->message, ['user' => $this->object]), + 15, + 'success', + true + ); - if (WCF::getSession()->getVar('__email') !== null && WCF::getSession()->getVar('__email') === $this->email) { - $registerVia3rdParty = true; - } - } + exit; + } - $eventParameters = [ - 'saveOptions' => $saveOptions, - 'registerVia3rdParty' => $registerVia3rdParty, - ]; - EventHandler::getInstance()->fireAction($this, 'registerVia3rdParty', $eventParameters); - $saveOptions = $eventParameters['saveOptions']; - $registerVia3rdParty = $eventParameters['registerVia3rdParty']; + /** + * Handles the activation of the new user and returns the name of the + * language item with the message that is shown to the user. + */ + private function handleActivation(User $user): string + { + if ((int)\REGISTER_ACTIVATION_METHOD === User::REGISTER_ACTIVATION_NONE && !$this->spamCheckEvent->hasMatches()) { + UserGroupAssignmentHandler::getInstance()->checkUsers([$user->userID]); - $this->additionalFields['languageID'] = $this->languageID; - if (\LOG_IP_ADDRESS !== 0) { - $this->additionalFields['registrationIpAddress'] = UserUtil::getIpAddress(); + return 'wcf.user.register.success'; } - // generate activation code - $addDefaultGroups = true; if ( - $this->spamCheckEvent->hasMatches() - || ( - ((int)\REGISTER_ACTIVATION_METHOD & User::REGISTER_ACTIVATION_USER) !== 0 - && $registerVia3rdParty !== true - ) - || ((int)\REGISTER_ACTIVATION_METHOD & User::REGISTER_ACTIVATION_ADMIN) !== 0 - ) { - $activationCode = UserRegistrationUtil::getActivationCode(); - $this->additionalFields['activationCode'] = $activationCode; - if ($registerVia3rdParty !== true) { - $emailConfirmCode = Hex::encode(\random_bytes(20)); - $this->additionalFields['emailConfirmed'] = $emailConfirmCode; - } - $addDefaultGroups = false; - $this->groupIDs = UserGroup::getGroupIDsByType([UserGroup::EVERYONE, UserGroup::GUESTS]); - } - - // create user - $data = [ - 'data' => \array_merge($this->additionalFields, [ - 'username' => $this->username, - 'email' => $this->email, - 'password' => $this->password, - 'blacklistMatches' => $this->spamCheckEvent->hasMatches() ? \json_encode($this->spamCheckEvent->getMatches(), \JSON_THROW_ON_ERROR) : '', - 'signatureEnableHtml' => 1, - ]), - 'groups' => $this->groupIDs, - 'languageIDs' => $this->visibleLanguages, - 'options' => $saveOptions, - 'addDefaultGroups' => $addDefaultGroups, - ]; - $this->objectAction = new UserAction([], 'create', $data); - $result = $this->objectAction->executeAction(); - /** @var User $user */ - $user = $result['returnValues']; - - // update session - WCF::getSession()->changeUser($user); - - // activation management - if ((int)\REGISTER_ACTIVATION_METHOD === User::REGISTER_ACTIVATION_NONE && !$this->spamCheckEvent->hasMatches()) { - $this->message = 'wcf.user.register.success'; - - UserGroupAssignmentHandler::getInstance()->checkUsers([$user->userID]); - } elseif ( ((int)\REGISTER_ACTIVATION_METHOD & User::REGISTER_ACTIVATION_USER) !== 0 && !$this->spamCheckEvent->hasMatches() ) { // registering via 3rdParty leads to instant activation - if ($registerVia3rdParty === true) { + if ($this->registerVia3rdParty) { if (((int)\REGISTER_ACTIVATION_METHOD & User::REGISTER_ACTIVATION_ADMIN) !== 0) { - $this->message = 'wcf.user.register.success.awaitActivation'; - } else { - $this->message = 'wcf.user.register.success'; + return 'wcf.user.register.success.awaitActivation'; } - } else { - $email = new Email(); - $email->addRecipient(new UserMailbox(WCF::getUser())); - $email->setSubject( - WCF::getLanguage()->getDynamicVariable('wcf.user.register.needActivation.mail.subject') - ); - $email->setBody(new MimePartFacade([ - new RecipientAwareTextMimePart('text/html', 'email_registerNeedActivation'), - new RecipientAwareTextMimePart('text/plain', 'email_registerNeedActivation'), - ])); - $email->send(); - $this->message = 'wcf.user.register.success.needActivation'; - } - } elseif ( - ((int)\REGISTER_ACTIVATION_METHOD & User::REGISTER_ACTIVATION_ADMIN) !== 0 - || $this->spamCheckEvent->hasMatches() - ) { - $this->message = 'wcf.user.register.success.awaitActivation'; - } - new CreateRegistrationNotification($user)(); + return 'wcf.user.register.success'; + } - if ($this->captchaObjectType !== null) { - $this->captchaObjectType->getProcessor()->reset(); + $email = new Email(); + $email->addRecipient(new UserMailbox($user)); + $email->setSubject( + WCF::getLanguage()->getDynamicVariable('wcf.user.register.needActivation.mail.subject') + ); + $email->setBody(new MimePartFacade([ + new RecipientAwareTextMimePart('text/html', 'email_registerNeedActivation'), + new RecipientAwareTextMimePart('text/plain', 'email_registerNeedActivation'), + ])); + $email->send(); + + return 'wcf.user.register.success.needActivation'; } - if (WCF::getSession()->getVar('noRegistrationCaptcha') === true) { - WCF::getSession()->unregister('noRegistrationCaptcha'); - } + return 'wcf.user.register.success.awaitActivation'; + } - // login user - WCF::getSession()->unregister('registrationRandomFieldNames'); - WCF::getSession()->unregister('registrationStartTime'); - $this->saved(); + #[\Override] + protected function setFormAction(): void + { + $this->form->action(LinkHandler::getInstance()->getControllerLink(static::class)); + } - // forward to index page - HeaderUtil::delayedRedirect( - LoginRedirect::getUrl(), - WCF::getLanguage()->getDynamicVariable($this->message, ['user' => $user]), - 15, - 'success', - true - ); + #[\Override] + public function assignVariables(): void + { + parent::assignVariables(); - exit; + WCF::getTPL()->assign([ + 'isExternalAuthentication' => $this->isExternalAuthentication, + ]); } } diff --git a/wcfsetup/install/files/lib/form/SettingsForm.class.php b/wcfsetup/install/files/lib/form/SettingsForm.class.php index 8a62484f454..6f5d6c36e72 100644 --- a/wcfsetup/install/files/lib/form/SettingsForm.class.php +++ b/wcfsetup/install/files/lib/form/SettingsForm.class.php @@ -5,7 +5,6 @@ use wcf\command\user\UpdateUser; use wcf\data\DatabaseObjectBuilder; use wcf\data\language\Language; -use wcf\data\option\category\OptionCategory; use wcf\data\style\Style; use wcf\data\trophy\Trophy; use wcf\data\trophy\TrophyCache; @@ -16,16 +15,13 @@ use wcf\data\user\UserBuilder; use wcf\data\user\UserProfile; use wcf\data\user\UserProfileAction; -use wcf\system\cache\builder\UserOptionCacheBuilder; use wcf\system\exception\IllegalLinkException; use wcf\system\form\builder\container\FormContainer; -use wcf\system\form\builder\field\AbstractFormField; -use wcf\system\form\builder\field\ICensorshipFormField; -use wcf\system\form\builder\field\IFormField; use wcf\system\form\builder\field\MultipleSelectionFormField; use wcf\system\form\builder\field\SelectFormField; use wcf\system\form\builder\field\validation\FormFieldValidationError; use wcf\system\form\builder\field\validation\FormFieldValidator; +use wcf\system\form\option\user\TUserOptionFormFields; use wcf\system\language\LanguageFactory; use wcf\system\menu\user\UserMenu; use wcf\system\request\LinkHandler; @@ -44,6 +40,8 @@ */ final class SettingsForm extends AbstractDatabaseObjectBuilderForm { + use TUserOptionFormFields; + /** * @inheritDoc */ @@ -104,7 +102,12 @@ public function readParameters(): void } $this->formObject = new User(WCF::getUser()->userID); - $this->options = $this->getAvailableOptions('settings.' . $this->category); + $this->options = $this->getUserOptionsByCategory( + 'settings.' . $this->category, + static fn(UserOption $option) => $option->validateOptions() + && $option->validatePermissions() + && $option->isEditable() + ); if ($this->category === 'general') { $this->availableContentLanguages = LanguageFactory::getInstance()->getContentLanguages(); @@ -145,14 +148,7 @@ protected function createForm(): void $this->form->appendChildren($this->getGeneralFormContainers()); } - $categories = UserOptionCacheBuilder::getInstance()->getData([], 'categories'); - foreach ($this->options as $categoryName => $options) { - $this->form->appendChild( - FormContainer::create('optionCategory' . $categories[$categoryName]->categoryID) - ->label('wcf.user.option.category.' . $categoryName) - ->appendChildren(\array_map($this->getOptionFormField(...), $options)) - ); - } + $this->form->appendChildren($this->getUserOptionFormContainers($this->options)); } /** @@ -295,129 +291,6 @@ static function (MultipleSelectionFormField $formField) use ($maxSpecialTrophies return $containers; } - /** - * Returns the form field for the given user option. - */ - protected function getOptionFormField(UserOption $option): AbstractFormField - { - $formOption = $option->getFormOption(); - $configuration = $option->getConfiguration(); - - $formField = $formOption->getFormField($option->getIdentifier(), $configuration) - ->label('wcf.user.option.' . $option->optionName); - - $description = 'wcf.user.option.' . $option->optionName . '.description'; - if (WCF::getLanguage()->get($description, true) !== '') { - $formField->description($description); - } - - if ((bool)($configuration['required'] ?? false)) { - $formField->required(); - } - - if ($formField instanceof ICensorshipFormField) { - $formField->censorship(); - } - - if ($formField instanceof SelectFormField && isset($formField->getOptions()[$option->defaultValue ?? ''])) { - $formField->defaultValue($option->defaultValue); - } - - if (($option->validationPattern ?? '') !== '') { - $formField->addValidator(new FormFieldValidator( - 'validationPattern', - static function (IFormField $formField) use ($option) { - $value = (string)$formField->getValue(); - if ($value !== '' && !\preg_match('~' . $option->validationPattern . '~', $value)) { - $formField->addValidationError(new FormFieldValidationError( - 'validationFailed', - 'wcf.user.option.error.validationFailed' - )); - } - } - )); - } - - $formField->loadValueCallback(static function (User $user, IFormField $formField) use ($option, $formOption) { - $value = $user->{$option->getIdentifier()}; - if ($value !== null) { - $formField->value($formOption->unserializeValue((string)$value)); - } - }); - $formField->saveValueCallback(static function (UserBuilder $builder, IFormField $formField) use ($option, $formOption) { - $value = $formField->getSaveValue(); - - $builder->setUserOption( - $option->optionID, - $value !== null ? $formOption->serializeValue($value) : $option->defaultValue - ); - }); - - return $formField; - } - - /** - * Returns the options of the child categories of the given category that - * the active user can edit, grouped by the name of the child category. - * - * @return array - */ - protected function getAvailableOptions(string $parentCategoryName): array - { - $categories = UserOptionCacheBuilder::getInstance()->getData([], 'categories'); - $categoryStructure = UserOptionCacheBuilder::getInstance()->getData([], 'categoryStructure'); - - $options = []; - foreach ($categoryStructure[$parentCategoryName] ?? [] as $categoryName) { - if (!$this->isAccessibleCategory($categories[$categoryName])) { - continue; - } - - $categoryOptions = $this->getCategoryOptions($categoryName); - if ($categoryOptions !== []) { - $options[$categoryName] = $categoryOptions; - } - } - - return $options; - } - - /** - * Returns the editable options of the given category and its child categories. - * - * @return UserOption[] - */ - protected function getCategoryOptions(string $categoryName): array - { - $categories = UserOptionCacheBuilder::getInstance()->getData([], 'categories'); - $categoryStructure = UserOptionCacheBuilder::getInstance()->getData([], 'categoryStructure'); - $cachedOptions = UserOptionCacheBuilder::getInstance()->getData([], 'options'); - $optionToCategories = UserOptionCacheBuilder::getInstance()->getData([], 'optionToCategories'); - - $options = []; - foreach ($optionToCategories[$categoryName] ?? [] as $optionName) { - $option = $cachedOptions[$optionName]; - if ($option->validateOptions() && $option->validatePermissions() && $option->isEditable()) { - $options[] = $option; - } - } - - foreach ($categoryStructure[$categoryName] ?? [] as $childCategoryName) { - if ($this->isAccessibleCategory($categories[$childCategoryName])) { - $options = \array_merge($options, $this->getCategoryOptions($childCategoryName)); - } - } - - return $options; - } - - private function isAccessibleCategory(OptionCategory $category): bool - { - return $category->categoryName !== 'hidden' - && $category->validateOptions() - && $category->validatePermissions(); - } - #[\Override] protected function setFormAction(): void { diff --git a/wcfsetup/install/files/lib/system/form/option/user/TUserOptionFormFields.class.php b/wcfsetup/install/files/lib/system/form/option/user/TUserOptionFormFields.class.php new file mode 100644 index 00000000000..1c67c0b26b0 --- /dev/null +++ b/wcfsetup/install/files/lib/system/form/option/user/TUserOptionFormFields.class.php @@ -0,0 +1,178 @@ + + * @since 6.3 + */ +trait TUserOptionFormFields +{ + /** + * Returns the options of the child categories of the given category that + * pass the given filter, grouped by the name of the child category. The + * options of deeper categories are added to their child category. + * + * @param \Closure(UserOption): bool $filter + * @return array + */ + protected function getUserOptionsByCategory(string $parentCategoryName, \Closure $filter): array + { + $categories = UserOptionCacheBuilder::getInstance()->getData([], 'categories'); + $categoryStructure = UserOptionCacheBuilder::getInstance()->getData([], 'categoryStructure'); + + $options = []; + foreach ($categoryStructure[$parentCategoryName] ?? [] as $categoryName) { + if (!$this->isAccessibleUserOptionCategory($categories[$categoryName])) { + continue; + } + + $categoryOptions = $this->getUserOptionsOfCategory($categoryName, $filter); + if ($categoryOptions !== []) { + $options[$categoryName] = $categoryOptions; + } + } + + return $options; + } + + /** + * Returns one form container per category with the form fields of the options. + * + * @param array $optionsByCategory + * @return FormContainer[] + */ + protected function getUserOptionFormContainers(array $optionsByCategory): array + { + $categories = UserOptionCacheBuilder::getInstance()->getData([], 'categories'); + + $containers = []; + foreach ($optionsByCategory as $categoryName => $options) { + $containers[] = FormContainer::create('optionCategory' . $categories[$categoryName]->categoryID) + ->label('wcf.user.option.category.' . $categoryName) + ->appendChildren(\array_map($this->getUserOptionFormField(...), $options)); + } + + return $containers; + } + + /** + * Returns the form field for the given user option. + */ + protected function getUserOptionFormField(UserOption $option): AbstractFormField + { + $formOption = $option->getFormOption(); + $configuration = $option->getConfiguration(); + + $formField = $formOption->getFormField($option->getIdentifier(), $configuration) + ->label('wcf.user.option.' . $option->optionName); + + $description = 'wcf.user.option.' . $option->optionName . '.description'; + if (WCF::getLanguage()->get($description, true) !== '') { + $formField->description($description); + } + + if ((bool)($configuration['required'] ?? false)) { + $formField->required(); + } + + if ($formField instanceof ICensorshipFormField) { + $formField->censorship(); + } + + if ($formField instanceof SelectFormField && isset($formField->getOptions()[$option->defaultValue ?? ''])) { + $formField->defaultValue($option->defaultValue); + } + + if (($option->validationPattern ?? '') !== '') { + $formField->addValidator(new FormFieldValidator( + 'validationPattern', + static function (IFormField $formField) use ($option) { + $value = (string)$formField->getValue(); + if ($value !== '' && !\preg_match('~' . $option->validationPattern . '~', $value)) { + $formField->addValidationError(new FormFieldValidationError( + 'validationFailed', + 'wcf.user.option.error.validationFailed' + )); + } + } + )); + } + + $formField->loadValueCallback(static function (User $user, IFormField $formField) use ($option, $formOption) { + $value = $user->{$option->getIdentifier()}; + + // Legacy date options use `0000-00-00` to represent an empty value. + if ($value !== null && $value !== '0000-00-00') { + $formField->value($formOption->unserializeValue((string)$value)); + } + }); + $formField->saveValueCallback(static function (UserBuilder $builder, IFormField $formField) use ($option, $formOption) { + $value = $formField->getSaveValue(); + + $builder->setUserOption( + $option->optionID, + $value !== null ? $formOption->serializeValue($value) : $option->defaultValue + ); + }); + + return $formField; + } + + /** + * Returns the options of the given category and its child categories that + * pass the given filter. + * + * @param \Closure(UserOption): bool $filter + * @return UserOption[] + */ + private function getUserOptionsOfCategory(string $categoryName, \Closure $filter): array + { + $categories = UserOptionCacheBuilder::getInstance()->getData([], 'categories'); + $categoryStructure = UserOptionCacheBuilder::getInstance()->getData([], 'categoryStructure'); + $cachedOptions = UserOptionCacheBuilder::getInstance()->getData([], 'options'); + $optionToCategories = UserOptionCacheBuilder::getInstance()->getData([], 'optionToCategories'); + + $options = []; + foreach ($optionToCategories[$categoryName] ?? [] as $optionName) { + $option = $cachedOptions[$optionName]; + if ($filter($option)) { + $options[] = $option; + } + } + + foreach ($categoryStructure[$categoryName] ?? [] as $childCategoryName) { + if ($this->isAccessibleUserOptionCategory($categories[$childCategoryName])) { + $options = \array_merge($options, $this->getUserOptionsOfCategory($childCategoryName, $filter)); + } + } + + return $options; + } + + private function isAccessibleUserOptionCategory(OptionCategory $category): bool + { + return $category->categoryName !== 'hidden' + && $category->validateOptions() + && $category->validatePermissions(); + } +} diff --git a/wcfsetup/install/files/lib/util/UserRegistrationUtil.class.php b/wcfsetup/install/files/lib/util/UserRegistrationUtil.class.php index 15c8fbd55bf..bd7ee74da4b 100644 --- a/wcfsetup/install/files/lib/util/UserRegistrationUtil.class.php +++ b/wcfsetup/install/files/lib/util/UserRegistrationUtil.class.php @@ -100,6 +100,8 @@ public static function getActivationCode(int $length = 9): int /** * Generates a random field name. + * + * @deprecated 6.3 No longer in use. */ public static function getRandomFieldName(string $unused): string { From 921d35b8e43165b3f547a78976b9894aecb61513 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Fri, 2 Oct 2026 16:51:21 +0200 Subject: [PATCH 007/115] Drop `SingletonFactory` requirement for user profile menu content classes --- .../menu/item/UserProfileMenuItem.class.php | 12 ++--- .../AboutUserProfileMenuContent.class.php | 13 +++-- .../CommentUserProfileMenuContent.class.php | 50 +++++++++---------- .../LikesUserProfileMenuContent.class.php | 13 +++-- ...ntActivityUserProfileMenuContent.class.php | 13 +++-- 5 files changed, 47 insertions(+), 54 deletions(-) diff --git a/wcfsetup/install/files/lib/data/user/profile/menu/item/UserProfileMenuItem.class.php b/wcfsetup/install/files/lib/data/user/profile/menu/item/UserProfileMenuItem.class.php index 49c6833507a..2dfa8dfee02 100644 --- a/wcfsetup/install/files/lib/data/user/profile/menu/item/UserProfileMenuItem.class.php +++ b/wcfsetup/install/files/lib/data/user/profile/menu/item/UserProfileMenuItem.class.php @@ -7,7 +7,6 @@ use wcf\data\TDatabaseObjectPermissions; use wcf\system\exception\ClassNotFoundException; use wcf\system\exception\ImplementationException; -use wcf\system\exception\ParentClassException; use wcf\system\exception\SystemException; use wcf\system\menu\user\profile\content\IUserProfileMenuContent; use wcf\system\SingletonFactory; @@ -67,15 +66,16 @@ public function getContentManager() throw new ClassNotFoundException($this->className); } - if (!\is_subclass_of($this->className, SingletonFactory::class)) { - throw new ParentClassException($this->className, SingletonFactory::class); - } - if (!\is_subclass_of($this->className, IUserProfileMenuContent::class)) { throw new ImplementationException($this->className, IUserProfileMenuContent::class); } - $this->contentManager = \call_user_func([$this->className, 'getInstance']); + // Extending `SingletonFactory` is no longer required, but still supported for backwards compatibility. + if (\is_subclass_of($this->className, SingletonFactory::class)) { + $this->contentManager = \call_user_func([$this->className, 'getInstance']); + } else { + $this->contentManager = new $this->className(); + } } return $this->contentManager; diff --git a/wcfsetup/install/files/lib/system/menu/user/profile/content/AboutUserProfileMenuContent.class.php b/wcfsetup/install/files/lib/system/menu/user/profile/content/AboutUserProfileMenuContent.class.php index cef51a88f0c..ce07ac0317f 100644 --- a/wcfsetup/install/files/lib/system/menu/user/profile/content/AboutUserProfileMenuContent.class.php +++ b/wcfsetup/install/files/lib/system/menu/user/profile/content/AboutUserProfileMenuContent.class.php @@ -5,17 +5,16 @@ use wcf\data\user\User; use wcf\system\cache\runtime\UserProfileRuntimeCache; use wcf\system\option\user\UserOptionHandler; -use wcf\system\SingletonFactory; use wcf\system\WCF; /** * Handles user profile information content. * - * @author Alexander Ebert - * @copyright 2001-2019 WoltLab GmbH - * @license GNU Lesser General Public License + * @author Alexander Ebert + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License */ -class AboutUserProfileMenuContent extends SingletonFactory implements IUserProfileMenuContent +final class AboutUserProfileMenuContent implements IUserProfileMenuContent { /** * user option handler object @@ -24,7 +23,7 @@ class AboutUserProfileMenuContent extends SingletonFactory implements IUserProfi public $optionHandler; #[\Override] - public function getContent(int $userID) + public function getContent(int $userID): string { if ($this->optionHandler === null) { $this->optionHandler = new UserOptionHandler(false, '', 'profile'); @@ -43,7 +42,7 @@ public function getContent(int $userID) } #[\Override] - public function isVisible(int $userID) + public function isVisible(int $userID): bool { return true; } diff --git a/wcfsetup/install/files/lib/system/menu/user/profile/content/CommentUserProfileMenuContent.class.php b/wcfsetup/install/files/lib/system/menu/user/profile/content/CommentUserProfileMenuContent.class.php index 87e77e67e74..bf9a71b828f 100644 --- a/wcfsetup/install/files/lib/system/menu/user/profile/content/CommentUserProfileMenuContent.class.php +++ b/wcfsetup/install/files/lib/system/menu/user/profile/content/CommentUserProfileMenuContent.class.php @@ -3,56 +3,52 @@ namespace wcf\system\menu\user\profile\content; use wcf\system\comment\CommentHandler; -use wcf\system\SingletonFactory; +use wcf\system\comment\manager\ICommentManager; use wcf\system\WCF; /** * Handles user profile comment content. * - * @author Alexander Ebert - * @copyright 2001-2019 WoltLab GmbH - * @license GNU Lesser General Public License + * @author Alexander Ebert + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License */ -class CommentUserProfileMenuContent extends SingletonFactory implements IUserProfileMenuContent +final class CommentUserProfileMenuContent implements IUserProfileMenuContent { - /** - * comment manager object - * @var ?\wcf\system\comment\manager\ICommentManager - */ - public $commentManager; - - /** - * object type id - * @var int - */ - public $objectTypeID = 0; + private ICommentManager $commentManager; #[\Override] - public function getContent(int $userID) + public function getContent(int $userID): string { - if ($this->commentManager === null) { - $this->objectTypeID = CommentHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.user.profileComment'); - $objectType = CommentHandler::getInstance()->getObjectType($this->objectTypeID); - $this->commentManager = $objectType->getProcessor(); - } + $objectTypeID = CommentHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.user.profileComment'); $commentList = CommentHandler::getInstance()->getCommentList( - $this->commentManager, - $this->objectTypeID, + $this->getCommentManager(), + $objectTypeID, $userID ); return WCF::getTPL()->render('wcf', 'userProfileCommentList', [ - 'commentCanAdd' => $this->commentManager->canAdd($userID), + 'commentCanAdd' => $this->getCommentManager()->canAdd($userID), 'commentList' => $commentList, - 'commentObjectTypeID' => $this->objectTypeID, + 'commentObjectTypeID' => $objectTypeID, 'userID' => $userID, 'lastCommentTime' => $commentList->getMinCommentTime(), ]); } + private function getCommentManager(): ICommentManager + { + if (!isset($this->commentManager)) { + $objectTypeID = CommentHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.user.profileComment'); + $this->commentManager = CommentHandler::getInstance()->getObjectType($objectTypeID)->getProcessor(); + } + + return $this->commentManager; + } + #[\Override] - public function isVisible(int $userID) + public function isVisible(int $userID): bool { return true; } diff --git a/wcfsetup/install/files/lib/system/menu/user/profile/content/LikesUserProfileMenuContent.class.php b/wcfsetup/install/files/lib/system/menu/user/profile/content/LikesUserProfileMenuContent.class.php index 0e4a9a6e24a..c41c1d7f869 100644 --- a/wcfsetup/install/files/lib/system/menu/user/profile/content/LikesUserProfileMenuContent.class.php +++ b/wcfsetup/install/files/lib/system/menu/user/profile/content/LikesUserProfileMenuContent.class.php @@ -3,20 +3,19 @@ namespace wcf\system\menu\user\profile\content; use wcf\data\like\ViewableLikeList; -use wcf\system\SingletonFactory; use wcf\system\WCF; /** * Handles user profile likes content. * - * @author Alexander Ebert - * @copyright 2001-2019 WoltLab GmbH - * @license GNU Lesser General Public License + * @author Alexander Ebert + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License */ -class LikesUserProfileMenuContent extends SingletonFactory implements IUserProfileMenuContent +final class LikesUserProfileMenuContent implements IUserProfileMenuContent { #[\Override] - public function getContent(int $userID) + public function getContent(int $userID): string { $likeList = new ViewableLikeList(); $likeList->getConditionBuilder()->add("like_table.objectUserID = ?", [$userID]); @@ -30,7 +29,7 @@ public function getContent(int $userID) } #[\Override] - public function isVisible(int $userID) + public function isVisible(int $userID): bool { if (!WCF::getSession()->hasPermission('user.like.canViewLike')) { return false; diff --git a/wcfsetup/install/files/lib/system/menu/user/profile/content/RecentActivityUserProfileMenuContent.class.php b/wcfsetup/install/files/lib/system/menu/user/profile/content/RecentActivityUserProfileMenuContent.class.php index c06e642eecb..854347ffa9f 100644 --- a/wcfsetup/install/files/lib/system/menu/user/profile/content/RecentActivityUserProfileMenuContent.class.php +++ b/wcfsetup/install/files/lib/system/menu/user/profile/content/RecentActivityUserProfileMenuContent.class.php @@ -3,21 +3,20 @@ namespace wcf\system\menu\user\profile\content; use wcf\data\user\activity\event\ViewableUserActivityEventList; -use wcf\system\SingletonFactory; use wcf\system\user\activity\event\UserActivityEventHandler; use wcf\system\WCF; /** * Handles user activity events. * - * @author Alexander Ebert - * @copyright 2001-2019 WoltLab GmbH - * @license GNU Lesser General Public License + * @author Alexander Ebert + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License */ -class RecentActivityUserProfileMenuContent extends SingletonFactory implements IUserProfileMenuContent +final class RecentActivityUserProfileMenuContent implements IUserProfileMenuContent { #[\Override] - public function getContent(int $userID) + public function getContent(int $userID): string { $eventList = new ViewableUserActivityEventList(); @@ -41,7 +40,7 @@ public function getContent(int $userID) } #[\Override] - public function isVisible(int $userID) + public function isVisible(int $userID): bool { return true; } From 68d08f99e31bb732e4818c8700e02573f7b1cab7 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Sat, 3 Oct 2026 17:10:54 +0200 Subject: [PATCH 008/115] Render the user profile "About" tab with UserProfileAboutView and form option formatters --- .../templates/userProfileAbout.tpl | 24 ++- .../lib/data/user/UserProfileAction.class.php | 12 +- .../MultipleSelectionFormatter.class.php | 4 + .../formatter/SelectFormatter.class.php | 4 + .../TSelectOptionLabelFormatter.class.php | 32 ++++ .../user/TUserOptionFormFields.class.php | 68 +-------- .../AboutUserProfileMenuContent.class.php | 23 +-- .../user/TUserOptionsByCategory.class.php | 84 +++++++++++ .../profile/UserProfileAboutView.class.php | 139 ++++++++++++++++++ 9 files changed, 287 insertions(+), 103 deletions(-) create mode 100644 wcfsetup/install/files/lib/system/form/option/formatter/TSelectOptionLabelFormatter.class.php create mode 100644 wcfsetup/install/files/lib/system/option/user/TUserOptionsByCategory.class.php create mode 100644 wcfsetup/install/files/lib/system/view/user/profile/UserProfileAboutView.class.php diff --git a/com.woltlab.wcf/templates/userProfileAbout.tpl b/com.woltlab.wcf/templates/userProfileAbout.tpl index bcf9db8681a..5b46d0df7c6 100644 --- a/com.woltlab.wcf/templates/userProfileAbout.tpl +++ b/com.woltlab.wcf/templates/userProfileAbout.tpl @@ -2,19 +2,17 @@ {content} {event name='beforeUserOptions'} - {foreach from=$options item=category} - {foreach from=$category[categories] item=optionCategory} -
-

{lang}wcf.user.option.category.{$optionCategory[object]->categoryName}{/lang}

- - {foreach from=$optionCategory[options] item=userOption} -
-
{$userOption[object]->getTitle()}
-
{unsafe:$userOption[object]->optionValue}
-
- {/foreach} -
- {/foreach} + {foreach from=$view->getOptionValuesOutput() key=categoryName item=userOptions} +
+

{lang}wcf.user.option.category.{$categoryName}{/lang}

+ + {foreach from=$userOptions item=userOption} +
+
{$userOption[object]->getTitle()}
+
{unsafe:$userOption[output]}
+
+ {/foreach} +
{/foreach} {event name='afterUserOptions'} diff --git a/wcfsetup/install/files/lib/data/user/UserProfileAction.class.php b/wcfsetup/install/files/lib/data/user/UserProfileAction.class.php index 0327ba5ccfb..1b60f61c287 100644 --- a/wcfsetup/install/files/lib/data/user/UserProfileAction.class.php +++ b/wcfsetup/install/files/lib/data/user/UserProfileAction.class.php @@ -16,6 +16,7 @@ use wcf\system\upload\UploadFile; use wcf\system\user\group\assignment\UserGroupAssignmentHandler; use wcf\system\user\storage\UserStorageHandler; +use wcf\system\view\user\profile\UserProfileAboutView; use wcf\system\WCF; use wcf\util\ArrayUtil; use wcf\util\MessageUtil; @@ -307,17 +308,14 @@ public function save() $action->executeAction(); } - // reload option handler - $optionHandler = $this->getOptionHandler($user, false); - - // return parsed template - $options = $optionHandler->getOptionTree(); + $userProfile = new UserProfile($user); return [ 'success' => true, 'template' => WCF::getTPL()->render('wcf', 'userProfileAbout', [ - 'options' => $options, - 'userID' => $this->userProfile->userID, + 'view' => new UserProfileAboutView($userProfile), + 'userID' => $userProfile->userID, + 'user' => $userProfile, ]), ]; } else { diff --git a/wcfsetup/install/files/lib/system/form/option/formatter/MultipleSelectionFormatter.class.php b/wcfsetup/install/files/lib/system/form/option/formatter/MultipleSelectionFormatter.class.php index e2c153c5ed2..96876c7cb59 100644 --- a/wcfsetup/install/files/lib/system/form/option/formatter/MultipleSelectionFormatter.class.php +++ b/wcfsetup/install/files/lib/system/form/option/formatter/MultipleSelectionFormatter.class.php @@ -15,6 +15,8 @@ */ final class MultipleSelectionFormatter implements IFormOptionFormatter { + use TSelectOptionLabelFormatter; + #[\Override] public function format(string $value, int $languageID, array $configuration): string { @@ -37,6 +39,8 @@ public function format(string $value, int $languageID, array $configuration): st $value = reset($selectOption['value']); } + $value = $this->resolveLabel((string)$value, $languageID); + if ($html !== '') { $html .= ', '; } diff --git a/wcfsetup/install/files/lib/system/form/option/formatter/SelectFormatter.class.php b/wcfsetup/install/files/lib/system/form/option/formatter/SelectFormatter.class.php index 74534c582e2..bede2fe2721 100644 --- a/wcfsetup/install/files/lib/system/form/option/formatter/SelectFormatter.class.php +++ b/wcfsetup/install/files/lib/system/form/option/formatter/SelectFormatter.class.php @@ -15,6 +15,8 @@ */ final class SelectFormatter implements IFormOptionFormatter { + use TSelectOptionLabelFormatter; + public function __construct(private readonly bool $encode = true) {} #[\Override] @@ -30,6 +32,8 @@ public function format(string $value, int $languageID, array $configuration): st $value = reset($selectOption['value']); } + $value = $this->resolveLabel((string)$value, $languageID); + if ($this->encode) { return StringUtil::encodeHTML($value); } diff --git a/wcfsetup/install/files/lib/system/form/option/formatter/TSelectOptionLabelFormatter.class.php b/wcfsetup/install/files/lib/system/form/option/formatter/TSelectOptionLabelFormatter.class.php new file mode 100644 index 00000000000..b199875908a --- /dev/null +++ b/wcfsetup/install/files/lib/system/form/option/formatter/TSelectOptionLabelFormatter.class.php @@ -0,0 +1,32 @@ + + * @since 6.3 + */ +trait TSelectOptionLabelFormatter +{ + /** + * Returns the label of the select option, resolving it if it is the name + * of a language item, see `TSelectionFormField::options()`. + */ + private function resolveLabel(string $label, int $languageID): string + { + if (\preg_match('~^([a-zA-Z0-9-_]+\.){2,}[a-zA-Z0-9-_]+$~', $label) !== 1) { + return $label; + } + + $language = LanguageFactory::getInstance()->getLanguage($languageID) + ?? LanguageFactory::getInstance()->getDefaultLanguage(); + + return $language->getDynamicVariable($label); + } +} diff --git a/wcfsetup/install/files/lib/system/form/option/user/TUserOptionFormFields.class.php b/wcfsetup/install/files/lib/system/form/option/user/TUserOptionFormFields.class.php index 1c67c0b26b0..a209b7a9851 100644 --- a/wcfsetup/install/files/lib/system/form/option/user/TUserOptionFormFields.class.php +++ b/wcfsetup/install/files/lib/system/form/option/user/TUserOptionFormFields.class.php @@ -2,7 +2,6 @@ namespace wcf\system\form\option\user; -use wcf\data\option\category\OptionCategory; use wcf\data\user\option\UserOption; use wcf\data\user\User; use wcf\data\user\UserBuilder; @@ -14,6 +13,7 @@ use wcf\system\form\builder\field\SelectFormField; use wcf\system\form\builder\field\validation\FormFieldValidationError; use wcf\system\form\builder\field\validation\FormFieldValidator; +use wcf\system\option\user\TUserOptionsByCategory; use wcf\system\WCF; /** @@ -27,33 +27,7 @@ */ trait TUserOptionFormFields { - /** - * Returns the options of the child categories of the given category that - * pass the given filter, grouped by the name of the child category. The - * options of deeper categories are added to their child category. - * - * @param \Closure(UserOption): bool $filter - * @return array - */ - protected function getUserOptionsByCategory(string $parentCategoryName, \Closure $filter): array - { - $categories = UserOptionCacheBuilder::getInstance()->getData([], 'categories'); - $categoryStructure = UserOptionCacheBuilder::getInstance()->getData([], 'categoryStructure'); - - $options = []; - foreach ($categoryStructure[$parentCategoryName] ?? [] as $categoryName) { - if (!$this->isAccessibleUserOptionCategory($categories[$categoryName])) { - continue; - } - - $categoryOptions = $this->getUserOptionsOfCategory($categoryName, $filter); - if ($categoryOptions !== []) { - $options[$categoryName] = $categoryOptions; - } - } - - return $options; - } + use TUserOptionsByCategory; /** * Returns one form container per category with the form fields of the options. @@ -137,42 +111,4 @@ static function (IFormField $formField) use ($option) { return $formField; } - - /** - * Returns the options of the given category and its child categories that - * pass the given filter. - * - * @param \Closure(UserOption): bool $filter - * @return UserOption[] - */ - private function getUserOptionsOfCategory(string $categoryName, \Closure $filter): array - { - $categories = UserOptionCacheBuilder::getInstance()->getData([], 'categories'); - $categoryStructure = UserOptionCacheBuilder::getInstance()->getData([], 'categoryStructure'); - $cachedOptions = UserOptionCacheBuilder::getInstance()->getData([], 'options'); - $optionToCategories = UserOptionCacheBuilder::getInstance()->getData([], 'optionToCategories'); - - $options = []; - foreach ($optionToCategories[$categoryName] ?? [] as $optionName) { - $option = $cachedOptions[$optionName]; - if ($filter($option)) { - $options[] = $option; - } - } - - foreach ($categoryStructure[$categoryName] ?? [] as $childCategoryName) { - if ($this->isAccessibleUserOptionCategory($categories[$childCategoryName])) { - $options = \array_merge($options, $this->getUserOptionsOfCategory($childCategoryName, $filter)); - } - } - - return $options; - } - - private function isAccessibleUserOptionCategory(OptionCategory $category): bool - { - return $category->categoryName !== 'hidden' - && $category->validateOptions() - && $category->validatePermissions(); - } } diff --git a/wcfsetup/install/files/lib/system/menu/user/profile/content/AboutUserProfileMenuContent.class.php b/wcfsetup/install/files/lib/system/menu/user/profile/content/AboutUserProfileMenuContent.class.php index ce07ac0317f..a83949cc4a9 100644 --- a/wcfsetup/install/files/lib/system/menu/user/profile/content/AboutUserProfileMenuContent.class.php +++ b/wcfsetup/install/files/lib/system/menu/user/profile/content/AboutUserProfileMenuContent.class.php @@ -2,9 +2,8 @@ namespace wcf\system\menu\user\profile\content; -use wcf\data\user\User; use wcf\system\cache\runtime\UserProfileRuntimeCache; -use wcf\system\option\user\UserOptionHandler; +use wcf\system\view\user\profile\UserProfileAboutView; use wcf\system\WCF; /** @@ -16,28 +15,18 @@ */ final class AboutUserProfileMenuContent implements IUserProfileMenuContent { - /** - * user option handler object - * @var ?UserOptionHandler - */ - public $optionHandler; - #[\Override] public function getContent(int $userID): string { - if ($this->optionHandler === null) { - $this->optionHandler = new UserOptionHandler(false, '', 'profile'); - $this->optionHandler->enableEditMode(false); - $this->optionHandler->showEmptyOptions(false); + $user = UserProfileRuntimeCache::getInstance()->getObject($userID); + if ($user === null) { + return ''; } - $user = new User($userID); - $this->optionHandler->setUser($user); - return WCF::getTPL()->render('wcf', 'userProfileAbout', [ - 'options' => $this->optionHandler->getOptionTree(), + 'view' => new UserProfileAboutView($user), 'userID' => $user->userID, - 'user' => UserProfileRuntimeCache::getInstance()->getObject($user->userID), + 'user' => $user, ]); } diff --git a/wcfsetup/install/files/lib/system/option/user/TUserOptionsByCategory.class.php b/wcfsetup/install/files/lib/system/option/user/TUserOptionsByCategory.class.php new file mode 100644 index 00000000000..2b23b0cf4f4 --- /dev/null +++ b/wcfsetup/install/files/lib/system/option/user/TUserOptionsByCategory.class.php @@ -0,0 +1,84 @@ + + * @since 6.3 + */ +trait TUserOptionsByCategory +{ + /** + * Returns the options of the child categories of the given category that + * pass the given filter, grouped by the name of the child category. The + * options of deeper categories are added to their child category. + * + * @param \Closure(UserOption): bool $filter + * @return array + */ + protected function getUserOptionsByCategory(string $parentCategoryName, \Closure $filter): array + { + $categories = UserOptionCacheBuilder::getInstance()->getData([], 'categories'); + $categoryStructure = UserOptionCacheBuilder::getInstance()->getData([], 'categoryStructure'); + + $options = []; + foreach ($categoryStructure[$parentCategoryName] ?? [] as $categoryName) { + if (!$this->isAccessibleUserOptionCategory($categories[$categoryName])) { + continue; + } + + $categoryOptions = $this->getUserOptionsOfCategory($categoryName, $filter); + if ($categoryOptions !== []) { + $options[$categoryName] = $categoryOptions; + } + } + + return $options; + } + + /** + * Returns the options of the given category and its child categories that + * pass the given filter. + * + * @param \Closure(UserOption): bool $filter + * @return UserOption[] + */ + private function getUserOptionsOfCategory(string $categoryName, \Closure $filter): array + { + $categories = UserOptionCacheBuilder::getInstance()->getData([], 'categories'); + $categoryStructure = UserOptionCacheBuilder::getInstance()->getData([], 'categoryStructure'); + $cachedOptions = UserOptionCacheBuilder::getInstance()->getData([], 'options'); + $optionToCategories = UserOptionCacheBuilder::getInstance()->getData([], 'optionToCategories'); + + $options = []; + foreach ($optionToCategories[$categoryName] ?? [] as $optionName) { + $option = $cachedOptions[$optionName]; + if ($filter($option)) { + $options[] = $option; + } + } + + foreach ($categoryStructure[$categoryName] ?? [] as $childCategoryName) { + if ($this->isAccessibleUserOptionCategory($categories[$childCategoryName])) { + $options = \array_merge($options, $this->getUserOptionsOfCategory($childCategoryName, $filter)); + } + } + + return $options; + } + + private function isAccessibleUserOptionCategory(OptionCategory $category): bool + { + return $category->categoryName !== 'hidden' + && $category->validateOptions() + && $category->validatePermissions(); + } +} diff --git a/wcfsetup/install/files/lib/system/view/user/profile/UserProfileAboutView.class.php b/wcfsetup/install/files/lib/system/view/user/profile/UserProfileAboutView.class.php new file mode 100644 index 00000000000..da58a157db3 --- /dev/null +++ b/wcfsetup/install/files/lib/system/view/user/profile/UserProfileAboutView.class.php @@ -0,0 +1,139 @@ + + * @since 6.3 + */ +final class UserProfileAboutView +{ + use TUserOptionsByCategory; + + /** + * @var array + */ + private array $categories; + + public function __construct( + public readonly UserProfile $user + ) {} + + /** + * Returns the user options that are visible to the active user, grouped + * by the name of their category. + * + * @return array + */ + private function getCategories(): array + { + if (!isset($this->categories)) { + $this->categories = $this->getUserOptionsByCategory('profile', function (UserOption $option) { + if (!$option->validateOptions() || !$option->validatePermissions()) { + return false; + } + + // The cached option objects are shared, the visibility depends on the user. + $option = clone $option; + $option->setUser($this->user->getDecoratedObject()); + + return $option->isVisible(); + }); + } + + return $this->categories; + } + + /** + * Returns the formatted values of the options grouped by the name of their + * category, options without a value and categories without any formatted + * value are skipped. + * + * @return array> + */ + public function getOptionValuesOutput(): array + { + $output = []; + foreach ($this->getCategories() as $categoryName => $options) { + $categoryOutput = $this->getCategoryOptionValuesOutput($options); + if ($categoryOutput !== []) { + $output[$categoryName] = $categoryOutput; + } + } + + return $output; + } + + /** + * @param UserOption[] $options + * @return list + */ + private function getCategoryOptionValuesOutput(array $options): array + { + $output = []; + foreach ($options as $option) { + $value = $this->user->{$option->getIdentifier()}; + if ($value === null || !(bool)$value || $value === '0000-00-00') { + continue; + } + + $formattedValue = match ($option->optionType) { + 'birthday' => $this->user->getBirthday(), + 'aboutMe', 'message' => $this->formatMessage($option, (string)$value), + default => $option->getFormOption()->getFormatter()->format( + (string)$value, + WCF::getLanguage()->languageID, + $option->getConfiguration() + ), + }; + + if ($formattedValue === '') { + continue; + } + + $output[] = [ + 'object' => $option, + 'output' => $formattedValue, + ]; + } + + return $output; + } + + private function formatMessage(UserOption $option, string $value): string + { + $objectType = $option->messageObjectType ?: 'com.woltlab.wcf.user.aboutMe'; + + // Load embedded objects by parsing the original message again because for the user options, there + // is no central way to save the embedded objects in the database. + $htmlInputProcessor = new HtmlInputProcessor(); + $htmlInputProcessor->setContext($objectType, $this->user->userID); + $htmlInputProcessor->processIntermediate($value); + $htmlInputProcessor->processEmbeddedContent($value, $objectType, $this->user->userID); + MessageEmbeddedObjectManager::getInstance()->registerTemporaryMessage($htmlInputProcessor); + + return (new WysiwygFormatter($objectType, $this->user->userID))->format( + $value, + WCF::getLanguage()->languageID, + [] + ); + } +} From a4c0164ee46e8f16955680fd3a3bd91317bf4954 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Sat, 3 Oct 2026 17:57:15 +0200 Subject: [PATCH 009/115] Replace legacy inline profile editor with form builder dialog Closes #6094 --- com.woltlab.wcf/templateDelete.xml | 2 + com.woltlab.wcf/templates/user.tpl | 15 +- .../templates/userProfileAboutEditable.tpl | 29 -- .../templates/userProfileHeader.tpl | 2 +- .../templates/userProfileOptionFieldList.tpl | 23 -- .../Core/Component/User/Profile/Editor.ts | 39 +++ .../Core/Controller/User/Profile.ts | 17 + wcfsetup/install/files/js/WCF.User.js | 302 ------------------ .../Core/Component/User/Profile/Editor.js | 32 ++ .../Core/Controller/User/Profile.js | 15 + .../action/UserProfileEditAction.class.php | 170 ++++++++++ .../lib/command/user/UpdateUser.class.php | 15 +- .../lib/data/user/UserProfileAction.class.php | 174 ---------- .../lib/data/user/option/UserOption.class.php | 2 + .../user/TUserOptionFormFields.class.php | 4 + 15 files changed, 301 insertions(+), 540 deletions(-) delete mode 100644 com.woltlab.wcf/templates/userProfileAboutEditable.tpl delete mode 100644 com.woltlab.wcf/templates/userProfileOptionFieldList.tpl create mode 100644 ts/WoltLabSuite/Core/Component/User/Profile/Editor.ts create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Component/User/Profile/Editor.js create mode 100644 wcfsetup/install/files/lib/action/UserProfileEditAction.class.php diff --git a/com.woltlab.wcf/templateDelete.xml b/com.woltlab.wcf/templateDelete.xml index 7198bdb3fa6..8cb59f7173c 100644 --- a/com.woltlab.wcf/templateDelete.xml +++ b/com.woltlab.wcf/templateDelete.xml @@ -118,5 +118,7 @@ + + diff --git a/com.woltlab.wcf/templates/user.tpl b/com.woltlab.wcf/templates/user.tpl index 7df99f62937..52a4f0add71 100644 --- a/com.woltlab.wcf/templates/user.tpl +++ b/com.woltlab.wcf/templates/user.tpl @@ -46,18 +46,21 @@ {jsphrase name='wcf.user.activityPoint'} {/if} - {if $user->canEdit() || ($__wcf->getUser()->userID == $user->userID && $user->canEditOwnProfile())} - {jsphrase name='wcf.user.editProfile'} - - new WCF.User.Profile.Editor({$user->userID}, {if $editOnInit}true{else}false{/if}); - {/if} - {event name='javascriptInit'} }); require(['WoltLabSuite/Core/Controller/User/Profile'], ({ setup }) => { setup({$user->userID}); }); + + {if $user->canEdit() || ($__wcf->getUser()->userID == $user->userID && $user->canEditOwnProfile())} + require(['WoltLabSuite/Core/Component/User/Profile/Editor'], ({ setup }) => { + const button = document.querySelector('.jsButtonEditProfile'); + if (button !== null) { + setup(button, {$user->userID}, {if $editOnInit}true{else}false{/if}); + } + }); + {/if}