From a0ac8bc44e95f7659c1eabb0f52249471e3ba833 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Fri, 29 Aug 2025 19:23:42 +0200 Subject: [PATCH 1/5] Avatars do not require thumbnails --- .../processor/UserAvatarFileProcessor.class.php | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/wcfsetup/install/files/lib/system/file/processor/UserAvatarFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/UserAvatarFileProcessor.class.php index a21fc0882f6..8ed78d5f251 100644 --- a/wcfsetup/install/files/lib/system/file/processor/UserAvatarFileProcessor.class.php +++ b/wcfsetup/install/files/lib/system/file/processor/UserAvatarFileProcessor.class.php @@ -145,20 +145,7 @@ public function canDownload(File $file): bool #[\Override] public function getThumbnailFormats(): array { - return [ - new ThumbnailFormat( - '128', - UserAvatarFileProcessor::AVATAR_SIZE, - UserAvatarFileProcessor::AVATAR_SIZE, - false - ), - new ThumbnailFormat( - '256', - UserAvatarFileProcessor::AVATAR_SIZE_2X, - UserAvatarFileProcessor::AVATAR_SIZE_2X, - false - ), - ]; + return []; } #[\Override] From eb02fd42c5b9a78827a4fea75a48be0b2e2fcbda Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Sat, 30 Aug 2025 11:43:09 +0200 Subject: [PATCH 2/5] =?UTF-8?q?Store=20the=20path=20to=20the=20user?= =?UTF-8?q?=E2=80=99s=20avatar=20in=20the=20user=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This denormalization step allows us to show the user’s avatar without joining other tables or fetching additional data on runtime. --- .../update_com.woltlab.wcf_6.2_step1.php | 3 + .../files/lib/data/user/User.class.php | 1 + .../files/lib/data/user/UserProfile.class.php | 5 +- .../data/user/avatar/StaticAvatar.class.php | 65 +++++++++++++++++++ .../system/user/command/SetAvatar.class.php | 7 ++ wcfsetup/setup/db/install.sql | 1 + 6 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 wcfsetup/install/files/lib/data/user/avatar/StaticAvatar.class.php diff --git a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.2_step1.php b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.2_step1.php index a7bfc88c4ec..14e7290488c 100644 --- a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.2_step1.php +++ b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.2_step1.php @@ -10,6 +10,7 @@ use wcf\system\database\table\column\IntDatabaseTableColumn; use wcf\system\database\table\column\MediumtextDatabaseTableColumn; +use wcf\system\database\table\column\VarcharDatabaseTableColumn; use wcf\system\database\table\index\DatabaseTableForeignKey; use wcf\system\database\table\index\DatabaseTableIndex; use wcf\system\database\table\PartialDatabaseTable; @@ -26,6 +27,8 @@ IntDatabaseTableColumn::create('avatarFileID') ->length(10) ->defaultValue(null), + VarcharDatabaseTableColumn::create('avatarPathname') + ->defaultValue(null), IntDatabaseTableColumn::create('coverPhotoFileID') ->length(10) ->defaultValue(null), diff --git a/wcfsetup/install/files/lib/data/user/User.class.php b/wcfsetup/install/files/lib/data/user/User.class.php index c0e4f42303d..5c0e0527487 100644 --- a/wcfsetup/install/files/lib/data/user/User.class.php +++ b/wcfsetup/install/files/lib/data/user/User.class.php @@ -49,6 +49,7 @@ * @property-read string $registrationIpAddress ip address of the user at the time of registration or empty if user has been created manually or if no ip address are logged * @property-read int|null $avatarID id of the user's avatar or null if they have no avatar * @property-read int|null $avatarFileID id of the user's avatar core file or null if they have no avatar + * @property-read string|null $avatarPathname pathname of the user's avatar relative to the core itself * @property-read int $disableAvatar is `1` if the user's avatar has been disabled, otherwise `0` * @property-read string $disableAvatarReason reason why the user's avatar is disabled * @property-read int $disableAvatarExpires timestamp at which the user's avatar will automatically be enabled again diff --git a/wcfsetup/install/files/lib/data/user/UserProfile.class.php b/wcfsetup/install/files/lib/data/user/UserProfile.class.php index 3913e010668..4cb249c42ca 100644 --- a/wcfsetup/install/files/lib/data/user/UserProfile.class.php +++ b/wcfsetup/install/files/lib/data/user/UserProfile.class.php @@ -10,6 +10,7 @@ use wcf\data\user\avatar\AvatarDecorator; use wcf\data\user\avatar\DefaultAvatar; use wcf\data\user\avatar\IUserAvatar; +use wcf\data\user\avatar\StaticAvatar; use wcf\data\user\cover\photo\DefaultUserCoverPhoto; use wcf\data\user\cover\photo\IUserCoverPhoto; use wcf\data\user\cover\photo\UserCoverPhoto; @@ -353,7 +354,9 @@ public function getAvatar() $avatar = null; if (!$this->disableAvatar) { if ($this->canSeeAvatar()) { - if ($this->avatarFileID !== null) { + if ($this->avatarPathname !== null) { + $avatar = new StaticAvatar($this->avatarPathname); + } else if ($this->avatarFileID !== null) { $data = UserStorageHandler::getInstance()->getField('avatar', $this->userID); if ($data === null) { $avatar = FileRuntimeCache::getInstance()->getObject($this->avatarFileID); diff --git a/wcfsetup/install/files/lib/data/user/avatar/StaticAvatar.class.php b/wcfsetup/install/files/lib/data/user/avatar/StaticAvatar.class.php new file mode 100644 index 00000000000..570bc0e305e --- /dev/null +++ b/wcfsetup/install/files/lib/data/user/avatar/StaticAvatar.class.php @@ -0,0 +1,65 @@ + + * @since 6.2 + */ +final class StaticAvatar implements IUserAvatar, ISafeFormatAvatar +{ + private readonly string $src; + + public function __construct(string $pathname) + { + $this->src = WCF::getPath() . $pathname; + } + + #[\Override] + public function getImageTag($size = null) + { + if ($size === null) { + $size = UserAvatarFileProcessor::AVATAR_SIZE; + } + + return ''; + } + + #[\Override] + public function getSafeURL(?int $size = null): string + { + return $this->getURL($size); + } + + #[\Override] + public function getSafeImageTag(?int $size = null): string + { + return ''; + } + + #[\Override] + public function getURL($size = null) + { + return $this->src; + } + + #[\Override] + public function getHeight() + { + return UserAvatarFileProcessor::AVATAR_SIZE; + } + + #[\Override] + public function getWidth() + { + return UserAvatarFileProcessor::AVATAR_SIZE; + } +} diff --git a/wcfsetup/install/files/lib/system/user/command/SetAvatar.class.php b/wcfsetup/install/files/lib/system/user/command/SetAvatar.class.php index e4bbd648c4b..89b8db7fa4a 100644 --- a/wcfsetup/install/files/lib/system/user/command/SetAvatar.class.php +++ b/wcfsetup/install/files/lib/system/user/command/SetAvatar.class.php @@ -33,8 +33,15 @@ public function __invoke(): void (new FileAction([$this->user->avatarFileID], 'delete'))->executeAction(); } + $pathname = ''; + if ($this->file !== null) { + $filename = $this->file->getSourceFilenameWebp() ?? $this->file->getSourceFilename(); + $pathname = $this->file->getRelativePath() . $filename; + } + (new UserEditor($this->user))->update([ 'avatarFileID' => $this->file?->fileID, + 'avatarPathname' => $pathname, 'avatarID' => null, ]); diff --git a/wcfsetup/setup/db/install.sql b/wcfsetup/setup/db/install.sql index 99388982a3d..b315d88a360 100644 --- a/wcfsetup/setup/db/install.sql +++ b/wcfsetup/setup/db/install.sql @@ -1560,6 +1560,7 @@ CREATE TABLE wcf1_user ( registrationIpAddress VARCHAR(39) NOT NULL DEFAULT '', avatarID INT(10), avatarFileID INT(10) DEFAULT NULL, + avatarPathname VARCHAR(255) DEFAULT NULL, disableAvatar TINYINT(1) NOT NULL DEFAULT 0, disableAvatarReason TEXT, disableAvatarExpires INT(10) NOT NULL DEFAULT 0, From 5e5c99798ddf6b54f82f4d2839796da4bb30b016 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Sat, 30 Aug 2025 12:03:54 +0200 Subject: [PATCH 3/5] Notify file processors when an image is replaced with the WebP variant --- .../processor/AbstractFileProcessor.class.php | 6 +++++ .../file/processor/FileProcessor.class.php | 10 +++++++- .../file/processor/IFileProcessor.class.php | 8 +++++++ .../UserAvatarFileProcessor.class.php | 24 +++++++++++++++++++ 4 files changed, 47 insertions(+), 1 deletion(-) diff --git a/wcfsetup/install/files/lib/system/file/processor/AbstractFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/AbstractFileProcessor.class.php index 21634bd5a02..df62dfa0e84 100644 --- a/wcfsetup/install/files/lib/system/file/processor/AbstractFileProcessor.class.php +++ b/wcfsetup/install/files/lib/system/file/processor/AbstractFileProcessor.class.php @@ -97,4 +97,10 @@ public function getImageCropperConfiguration(): ?ImageCropperConfiguration // Do not crop images. return null; } + + #[\Override] + public function replacedWithWebpVariant(File $file): void + { + // There is usually no need to react to this change. + } } diff --git a/wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php index d8a847ace0c..e3e90a93ac9 100644 --- a/wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php +++ b/wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php @@ -438,7 +438,15 @@ public function convertImageFormat(File $file): File case 'webp': $command = new ReplaceWithWebpVariant($file); - return $command(); + $newFile = $command(); + + // The files identity differs if the file has been replaced. + if ($file !== $newFile) { + $processor = $newFile->getProcessor(); + $processor?->replacedWithWebpVariant($newFile); + } + + return $newFile; default: throw new \LogicException("Unreachable"); diff --git a/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php index dab5e98cd8c..911f2e2ee65 100644 --- a/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php +++ b/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php @@ -174,4 +174,12 @@ public function trackDownload(File $file): void; * @since 6.2 */ public function getImageCropperConfiguration(): ?ImageCropperConfiguration; + + /** + * Notifies the processor that one of its files was replaced with its WebP + * variant. + * + * @since 6.2 + */ + public function replacedWithWebpVariant(File $file): void; } diff --git a/wcfsetup/install/files/lib/system/file/processor/UserAvatarFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/UserAvatarFileProcessor.class.php index 8ed78d5f251..aee18cd9e7e 100644 --- a/wcfsetup/install/files/lib/system/file/processor/UserAvatarFileProcessor.class.php +++ b/wcfsetup/install/files/lib/system/file/processor/UserAvatarFileProcessor.class.php @@ -3,6 +3,7 @@ namespace wcf\system\file\processor; use wcf\data\file\File; +use wcf\data\user\UserEditor; use wcf\data\user\UserProfile; use wcf\system\cache\runtime\UserProfileRuntimeCache; use wcf\system\database\util\PreparedStatementConditionBuilder; @@ -203,6 +204,29 @@ public function getImageCropperConfiguration(): ImageCropperConfiguration ); } + #[\Override] + public function replacedWithWebpVariant(File $file): void + { + $user = $this->getUserByFile($file); + if ($user === null) { + return; + } + + $filename = $file->getSourceFilenameWebp() ?? $file->getSourceFilename(); + $pathname = $file->getRelativePath() . $filename; + + if ($user->avatarPathname === $pathname) { + return; + } + + // The relative path to the avatar is stored in a denormalized form in + // the user table. This path may be outdated if either the avatar is + // later converted to WebP or during the upload. + (new UserEditor($user->getDecoratedObject()))->update([ + 'avatarPathname' => $pathname, + ]); + } + /** * @param array $context */ From 51b56bd4463ea8fff134a1c4395b630bad7b24cb Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Sat, 30 Aug 2025 12:06:39 +0200 Subject: [PATCH 4/5] Do not store the avatar in the user storage --- .../files/lib/data/user/UserProfile.class.php | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/wcfsetup/install/files/lib/data/user/UserProfile.class.php b/wcfsetup/install/files/lib/data/user/UserProfile.class.php index 4cb249c42ca..8b91c5c036a 100644 --- a/wcfsetup/install/files/lib/data/user/UserProfile.class.php +++ b/wcfsetup/install/files/lib/data/user/UserProfile.class.php @@ -356,19 +356,6 @@ public function getAvatar() if ($this->canSeeAvatar()) { if ($this->avatarPathname !== null) { $avatar = new StaticAvatar($this->avatarPathname); - } else if ($this->avatarFileID !== null) { - $data = UserStorageHandler::getInstance()->getField('avatar', $this->userID); - if ($data === null) { - $avatar = FileRuntimeCache::getInstance()->getObject($this->avatarFileID); - - UserStorageHandler::getInstance()->update( - $this->userID, - 'avatar', - \serialize($avatar) - ); - } else { - $avatar = \unserialize($data); - } } else { $parameters = ['avatar' => null]; EventHandler::getInstance()->fireAction($this, 'getAvatar', $parameters); From d2e0df9e7c81f90f4c45f7c719e252c475ad5410 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Sat, 30 Aug 2025 12:09:53 +0200 Subject: [PATCH 5/5] Use the denormalized value to fetch avatars --- .../data/user/TUserAvatarObjectList.class.php | 36 ------------------- .../lib/data/user/UserProfileList.class.php | 4 --- .../user/follow/UserFollowerList.class.php | 13 +------ .../ignore/ViewableUserIgnoreList.class.php | 11 ------ .../user/online/UsersOnlineList.class.php | 5 --- .../visitor/UserProfileVisitorList.class.php | 13 +------ 6 files changed, 2 insertions(+), 80 deletions(-) delete mode 100644 wcfsetup/install/files/lib/data/user/TUserAvatarObjectList.class.php diff --git a/wcfsetup/install/files/lib/data/user/TUserAvatarObjectList.class.php b/wcfsetup/install/files/lib/data/user/TUserAvatarObjectList.class.php deleted file mode 100644 index 645465f5dd0..00000000000 --- a/wcfsetup/install/files/lib/data/user/TUserAvatarObjectList.class.php +++ /dev/null @@ -1,36 +0,0 @@ - - * - * @property UserProfile[] $objects - * @mixin UserProfileList - * - * @since 6.2 - */ -trait TUserAvatarObjectList -{ - protected function cacheAvatarFiles(): void - { - $avatarFileIDs = []; - foreach ($this->objects as $user) { - if ($user->avatarFileID !== null) { - $avatarFileIDs[] = $user->avatarFileID; - } - } - if ($avatarFileIDs === []) { - return; - } - - FileRuntimeCache::getInstance()->cacheObjectIDs($avatarFileIDs); - } -} diff --git a/wcfsetup/install/files/lib/data/user/UserProfileList.class.php b/wcfsetup/install/files/lib/data/user/UserProfileList.class.php index 8820bf30e64..20d481140b7 100644 --- a/wcfsetup/install/files/lib/data/user/UserProfileList.class.php +++ b/wcfsetup/install/files/lib/data/user/UserProfileList.class.php @@ -15,8 +15,6 @@ */ class UserProfileList extends UserList { - use TUserAvatarObjectList; - /** * @inheritDoc */ @@ -56,8 +54,6 @@ public function readObjects() parent::readObjects(); - $this->cacheAvatarFiles(); - $coverPhotoFileIDs = []; foreach ($this->objects as $object) { if ($object->coverPhotoFileID) { diff --git a/wcfsetup/install/files/lib/data/user/follow/UserFollowerList.class.php b/wcfsetup/install/files/lib/data/user/follow/UserFollowerList.class.php index 232ed2c3202..670587b7c81 100644 --- a/wcfsetup/install/files/lib/data/user/follow/UserFollowerList.class.php +++ b/wcfsetup/install/files/lib/data/user/follow/UserFollowerList.class.php @@ -2,7 +2,6 @@ namespace wcf\data\user\follow; -use wcf\data\user\TUserAvatarObjectList; use wcf\data\user\User; use wcf\data\user\UserProfile; @@ -21,8 +20,6 @@ */ class UserFollowerList extends UserFollowList { - use TUserAvatarObjectList; - /** * @inheritDoc */ @@ -50,18 +47,10 @@ public function __construct() { parent::__construct(); - $this->sqlSelects .= "user_table.username, user_table.email, user_table.disableAvatar"; + $this->sqlSelects .= "user_table.username, user_table.email, user_table.disableAvatar, user_table.avatarPathname"; $this->sqlJoins .= " LEFT JOIN wcf1_user user_table ON user_table.userID = user_follow.userID"; } - - #[\Override] - public function readObjects() - { - parent::readObjects(); - - $this->cacheAvatarFiles(); - } } diff --git a/wcfsetup/install/files/lib/data/user/ignore/ViewableUserIgnoreList.class.php b/wcfsetup/install/files/lib/data/user/ignore/ViewableUserIgnoreList.class.php index 6a419c2c171..c5848178fe2 100644 --- a/wcfsetup/install/files/lib/data/user/ignore/ViewableUserIgnoreList.class.php +++ b/wcfsetup/install/files/lib/data/user/ignore/ViewableUserIgnoreList.class.php @@ -2,7 +2,6 @@ namespace wcf\data\user\ignore; -use wcf\data\user\TUserAvatarObjectList; use wcf\data\user\User; use wcf\data\user\UserProfile; @@ -15,8 +14,6 @@ */ class ViewableUserIgnoreList extends UserIgnoreList { - use TUserAvatarObjectList; - /** * @inheritDoc */ @@ -58,12 +55,4 @@ public function __construct() $this->sqlSelects .= ", user_table.*"; } - - #[\Override] - public function readObjects() - { - parent::readObjects(); - - $this->cacheAvatarFiles(); - } } diff --git a/wcfsetup/install/files/lib/data/user/online/UsersOnlineList.class.php b/wcfsetup/install/files/lib/data/user/online/UsersOnlineList.class.php index 45d652816ab..bd573cc120c 100644 --- a/wcfsetup/install/files/lib/data/user/online/UsersOnlineList.class.php +++ b/wcfsetup/install/files/lib/data/user/online/UsersOnlineList.class.php @@ -5,7 +5,6 @@ use wcf\data\option\OptionAction; use wcf\data\session\SessionList; use wcf\data\user\group\UserGroup; -use wcf\data\user\TUserAvatarObjectList; use wcf\data\user\User; use wcf\data\user\UserProfile; use wcf\system\event\EventHandler; @@ -24,8 +23,6 @@ */ class UsersOnlineList extends SessionList { - use TUserAvatarObjectList; - /** * @inheritDoc */ @@ -91,8 +88,6 @@ public function readObjects() } $this->objectIDs = $this->indexToObject; $this->rewind(); - - $this->cacheAvatarFiles(); } /** diff --git a/wcfsetup/install/files/lib/data/user/profile/visitor/UserProfileVisitorList.class.php b/wcfsetup/install/files/lib/data/user/profile/visitor/UserProfileVisitorList.class.php index 871ee4b8fe0..fa693b8152b 100644 --- a/wcfsetup/install/files/lib/data/user/profile/visitor/UserProfileVisitorList.class.php +++ b/wcfsetup/install/files/lib/data/user/profile/visitor/UserProfileVisitorList.class.php @@ -3,7 +3,6 @@ namespace wcf\data\user\profile\visitor; use wcf\data\DatabaseObjectList; -use wcf\data\user\TUserAvatarObjectList; use wcf\data\user\User; use wcf\data\user\UserProfile; @@ -18,8 +17,6 @@ */ class UserProfileVisitorList extends DatabaseObjectList { - use TUserAvatarObjectList; - /** * @inheritDoc */ @@ -42,18 +39,10 @@ public function __construct() { parent::__construct(); - $this->sqlSelects .= "user_table.username, user_table.email, user_table.disableAvatar"; + $this->sqlSelects .= "user_table.username, user_table.email, user_table.disableAvatar, user_table.avatarPathname"; $this->sqlJoins .= " LEFT JOIN wcf1_user user_table ON user_table.userID = user_profile_visitor.userID"; } - - #[\Override] - public function readObjects() - { - parent::readObjects(); - - $this->cacheAvatarFiles(); - } }