From fee19c61bc7621b54fbf09b1ae7f15c51b3f57a8 Mon Sep 17 00:00:00 2001 From: Marcin Kurczewski Date: Tue, 20 May 2014 19:46:05 +0200 Subject: [PATCH] Added support for custom avatars --- data/config.ini | 3 + public_html/avatars/.gitignore | 2 + public_html/media/js/user-edit.js | 11 ++ src/Api/JobArgs/JobArgs.php | 2 + src/Api/Jobs/UserJobs/EditUserAvatarJob.php | 64 ++++++++++ src/Api/Jobs/UserJobs/EditUserJob.php | 1 + src/Controllers/UserController.php | 13 +++ src/Enums/Privilege.php | 1 + src/Enums/UserAvatarStyle.php | 44 +++++++ src/Models/Entities/PostEntity.php | 2 +- src/Models/Entities/UserEntity.php | 109 +++++++++++++++++- src/Models/UserModel.php | 1 + src/Upgrades/mysql/Upgrade15.sql | 1 + src/Upgrades/sqlite/Upgrade15.sql | 1 + src/Views/input/input-basic.phtml | 2 +- src/Views/input/input-checkboxes.phtml | 4 + src/Views/input/input-radioboxes.phtml | 4 + src/Views/input/input-select.phtml | 10 +- src/Views/user/user-edit.phtml | 25 ++++ src/core.php | 1 + tests/SzurubooruTestRunner.php | 1 + tests/Tests/ApiTests/ApiArgumentTest.php | 9 ++ tests/Tests/ApiTests/ApiAuthTest.php | 1 + .../ApiTests/ApiEmailRequirementsTest.php | 1 + tests/Tests/ApiTests/ApiPrivilegeTest.php | 2 + .../Tests/JobTests/EditUserAvatarJobTest.php | 88 ++++++++++++++ tests/config.ini | 1 + 27 files changed, 395 insertions(+), 9 deletions(-) create mode 100644 public_html/avatars/.gitignore create mode 100644 public_html/media/js/user-edit.js create mode 100644 src/Api/Jobs/UserJobs/EditUserAvatarJob.php create mode 100644 src/Enums/UserAvatarStyle.php create mode 100644 src/Upgrades/mysql/Upgrade15.sql create mode 100644 src/Upgrades/sqlite/Upgrade15.sql create mode 100644 tests/Tests/JobTests/EditUserAvatarJobTest.php diff --git a/data/config.ini b/data/config.ini index b687fd54..43862b33 100644 --- a/data/config.ini +++ b/data/config.ini @@ -7,6 +7,7 @@ filesPath = "./data/files/" logsPath = "./data/logs/{yyyy}-{mm}.log" mediaPath = "./public_html/media/" thumbnailsPath = "./public_html/thumbs/" +avatarsPath = "./public_html/avatars/" title = "szurubooru" salt = "1A2/$_4xVa" @@ -129,6 +130,8 @@ editUserEmail.all=admin editUserEmailNoConfirm=admin editUserAccessRank=admin editUserName=moderator +editUserAvatar.own=registered +editUserAvatar.all=admin editUserSettings.own=registered editUserSettings.all=nobody acceptUserRegistration=moderator diff --git a/public_html/avatars/.gitignore b/public_html/avatars/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/public_html/avatars/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/public_html/media/js/user-edit.js b/public_html/media/js/user-edit.js new file mode 100644 index 00000000..c37f923e --- /dev/null +++ b/public_html/media/js/user-edit.js @@ -0,0 +1,11 @@ +$(function() +{ + $('.avatar-content').parents('.form-row').hide(); + $('.avatar-style').click(function() + { + if ($(this).val() == '2'/*custom*/) + { + $('.avatar-content').parents('.form-row').show(); + } + }); +}); diff --git a/src/Api/JobArgs/JobArgs.php b/src/Api/JobArgs/JobArgs.php index b0316e1b..d781d2c8 100644 --- a/src/Api/JobArgs/JobArgs.php +++ b/src/Api/JobArgs/JobArgs.php @@ -39,6 +39,8 @@ class JobArgs const ARG_NEW_EMAIL = 'new-email'; const ARG_NEW_USER_NAME = 'new-user-name'; const ARG_NEW_PASSWORD = 'new-password'; + const ARG_NEW_AVATAR_CONTENT = 'new-avatar-content'; + const ARG_NEW_AVATAR_STYLE = 'new-avatar-style'; const ARG_NEW_SETTINGS = 'new-settings'; const ARG_NEW_POST_SCORE = 'new-post-score'; diff --git a/src/Api/Jobs/UserJobs/EditUserAvatarJob.php b/src/Api/Jobs/UserJobs/EditUserAvatarJob.php new file mode 100644 index 00000000..58607911 --- /dev/null +++ b/src/Api/Jobs/UserJobs/EditUserAvatarJob.php @@ -0,0 +1,64 @@ +userRetriever = new UserRetriever($this); + } + + public function execute() + { + $user = $this->userRetriever->retrieve(); + $state = $this->getArgument(JobArgs::ARG_NEW_AVATAR_STYLE); + + if ($state == UserAvatarStyle::Custom) + { + $file = $this->getArgument(JobArgs::ARG_NEW_AVATAR_CONTENT); + $user->setCustomAvatarFromPath($file->filePath); + } + else + $user->setAvatarStyle(new UserAvatarStyle($state)); + + if ($this->getContext() == self::CONTEXT_NORMAL) + UserModel::save($user); + + Logger::log('{user} changed avatar for {subject}', [ + 'user' => TextHelper::reprUser(Auth::getCurrentUser()), + 'subject' => TextHelper::reprUser($user)]); + + return $user; + } + + public function getRequiredArguments() + { + return JobArgs::Conjunction( + $this->userRetriever->getRequiredArguments(), + JobArgs::ARG_NEW_AVATAR_STYLE, + JobArgs::Optional(JobArgs::ARG_NEW_AVATAR_CONTENT)); + } + + public function getRequiredMainPrivilege() + { + return $this->getContext() == self::CONTEXT_BATCH_ADD + ? Privilege::RegisterAccount + : Privilege::EditUserAvatar; + } + + public function getRequiredSubPrivileges() + { + return Access::getIdentity($this->userRetriever->retrieve()); + } + + public function isAuthenticationRequired() + { + return false; + } + + public function isConfirmedEmailRequired() + { + return false; + } +} + diff --git a/src/Api/Jobs/UserJobs/EditUserJob.php b/src/Api/Jobs/UserJobs/EditUserJob.php index 90fd516e..8b265557 100644 --- a/src/Api/Jobs/UserJobs/EditUserJob.php +++ b/src/Api/Jobs/UserJobs/EditUserJob.php @@ -10,6 +10,7 @@ class EditUserJob extends AbstractJob $this->addSubJob(new EditUserNameJob()); $this->addSubJob(new EditUserPasswordJob()); $this->addSubJob(new EditUserEmailJob()); + $this->addSubJob(new EditUserAvatarJob()); } public function canEditAnything($user) diff --git a/src/Controllers/UserController.php b/src/Controllers/UserController.php index d26f3a45..3048af40 100644 --- a/src/Controllers/UserController.php +++ b/src/Controllers/UserController.php @@ -76,12 +76,25 @@ class UserController extends AbstractController JobArgs::ARG_NEW_PASSWORD => InputHelper::get('password1'), JobArgs::ARG_NEW_EMAIL => InputHelper::get('email'), JobArgs::ARG_NEW_ACCESS_RANK => InputHelper::get('access-rank'), + Jobargs::ARG_NEW_AVATAR_STYLE => InputHelper::get('avatar-style'), ]; + + if (!empty($_FILES['avatar-content']['name'])) + { + $file = $_FILES['avatar-content']; + TransferHelper::handleUploadErrors($file); + + $args[JobArgs::ARG_NEW_AVATAR_CONTENT] = new ApiFileInput( + $file['tmp_name'], + $file['name']); + } + $args = $this->appendUserIdentifierArgument($args, $identifier); $args = array_filter($args); $user = Api::run(new EditUserJob(), $args); + Core::getContext()->transport->user = $user; if (Auth::getCurrentUser()->getId() == $user->getId()) Auth::setCurrentUser($user); diff --git a/src/Enums/Privilege.php b/src/Enums/Privilege.php index 4060f92f..4ed2c405 100644 --- a/src/Enums/Privilege.php +++ b/src/Enums/Privilege.php @@ -38,6 +38,7 @@ class Privilege extends AbstractEnum implements IEnum const EditUserEmail = 'editUserEmail'; const EditUserEmailNoConfirm = 'editUserEmailNoConfirm'; const EditUserName = 'editUserName'; + const EditUserAvatar = 'editUserAvatar'; const EditUserSettings = 'editUserSettings'; const DeleteUser = 'deleteUser'; const FlagUser = 'flagUser'; diff --git a/src/Enums/UserAvatarStyle.php b/src/Enums/UserAvatarStyle.php new file mode 100644 index 00000000..52823efd --- /dev/null +++ b/src/Enums/UserAvatarStyle.php @@ -0,0 +1,44 @@ +type = $type; + } + + public function toInteger() + { + return $this->type; + } + + public function toString() + { + switch ($this->type) + { + case self::None: return 'none'; + case self::Gravatar: return 'gravatar'; + case self::Custom: return 'custom'; + } + return null; + } + + public static function getAll() + { + return array_map(function($constantName) + { + return new self($constantName); + }, self::getAllConstants()); + } + + public function validate() + { + if (!in_array($this->type, self::getAllConstants())) + throw new SimpleException('Invalid user picture type "%s"', $this->type); + } +} diff --git a/src/Models/Entities/PostEntity.php b/src/Models/Entities/PostEntity.php index 230c8cf9..f939a45a 100644 --- a/src/Models/Entities/PostEntity.php +++ b/src/Models/Entities/PostEntity.php @@ -391,7 +391,7 @@ final class PostEntity extends AbstractEntity implements IValidatable, ISerializ { $width = Core::getConfig()->browsing->thumbnailWidth; $height = Core::getConfig()->browsing->thumbnailHeight; - $dstPath = $this->getThumbnailPath($width, $height); + $dstPath = $this->getThumbnailPath(); $thumbnailGenerator = new SmartThumbnailGenerator(); diff --git a/src/Models/Entities/UserEntity.php b/src/Models/Entities/UserEntity.php index b84694b2..9e78cdc1 100644 --- a/src/Models/Entities/UserEntity.php +++ b/src/Models/Entities/UserEntity.php @@ -14,6 +14,7 @@ final class UserEntity extends AbstractEntity implements IValidatable, ISerializ private $lastLoginDate; private $accessRank; private $banned = false; + private $avatarStyle; private $settings; private $_passwordChanged = false; @@ -23,6 +24,7 @@ final class UserEntity extends AbstractEntity implements IValidatable, ISerializ { $this->setAccessRank(new AccessRank(AccessRank::Anonymous)); $this->setPasswordSalt(md5(mt_rand() . uniqid())); + $this->avatarStyle = new UserAvatarStyle(UserAvatarStyle::Gravatar); $this->settings = new UserSettings(); } @@ -39,6 +41,7 @@ final class UserEntity extends AbstractEntity implements IValidatable, ISerializ $this->lastLoginDate = TextHelper::toIntegerOrNull($row['last_login_date']); $this->banned = $row['banned']; $this->setAccessRank(new AccessRank($row['access_rank'])); + $this->avatarStyle = new UserAvatarStyle($row['avatar_style']); $this->settings = new UserSettings($row['settings']); } @@ -61,6 +64,7 @@ final class UserEntity extends AbstractEntity implements IValidatable, ISerializ $this->validateAccessRank(); $this->validateEmails(); $this->settings->validate(); + $this->avatarStyle->validate(); if (empty($this->getAccessRank())) throw new Exception('No access rank detected'); @@ -276,14 +280,44 @@ final class UserEntity extends AbstractEntity implements IValidatable, ISerializ $this->accessRank = $accessRank; } + public function getAvatarStyle() + { + return $this->avatarStyle; + } + + public function setAvatarStyle(UserAvatarStyle $userAvatarStyle) + { + $this->avatarStyle = $userAvatarStyle; + } + public function getAvatarUrl($size = 32) { - $subject = !empty($this->getConfirmedEmail()) - ? $this->getConfirmedEmail() - : $this->passSalt . $this->getName(); - $hash = md5(strtolower(trim($subject))); - $url = 'http://www.gravatar.com/avatar/' . $hash . '?s=' . $size . '&d=retro'; - return $url; + switch ($this->avatarStyle->toInteger()) + { + case UserAvatarStyle::None: + return $this->getBlankAvatarUrl($size); + + case UserAvatarStyle::Gravatar: + return $this->getGravatarAvatarUrl($size); + + case UserAvatarStyle::Custom: + return $this->getCustomAvatarUrl($size); + } + } + + public function setCustomAvatarFromPath($srcPath) + { + $config = Core::getConfig(); + + $mimeType = mime_content_type($srcPath); + if (!in_array($mimeType, ['image/gif', 'image/png', 'image/jpeg'])) + throw new SimpleException('Invalid file type "%s"', $mimeType); + + $dstPath = $this->getCustomAvatarSourcePath(); + + TransferHelper::copy($srcPath, $dstPath); + $this->removeOldCustomAvatar(); + $this->setAvatarStyle(new UserAvatarStyle(UserAvatarStyle::Custom)); } public function getSettings() @@ -351,4 +385,67 @@ final class UserEntity extends AbstractEntity implements IValidatable, ISerializ $stmt->setCriterion(new Sql\EqualsFunctor('uploader_id', new Sql\Binding($this->getId()))); return (int) Database::fetchOne($stmt)['count']; } + + + private function getBlankAvatarUrl($size) + { + return 'http://www.gravatar.com/avatar/?s=' . $size . '&d=mm'; + } + + private function getGravatarAvatarUrl($size) + { + $subject = !empty($this->getConfirmedEmail()) + ? $this->getConfirmedEmail() + : $this->passSalt . $this->getName(); + $hash = md5(strtolower(trim($subject))); + return 'http://www.gravatar.com/avatar/' . $hash . '?s=' . $size . '&d=retro'; + } + + private function getCustomAvatarUrl($size) + { + $fileName = md5($this->getName()) . '-' . $size . '.avatar'; + $path = $this->getCustomAvatarPath($size); + if (!file_exists($path)) + $this->generateCustomAvatar($size); + if (file_exists($path)) + return \Chibi\Util\Url::makeAbsolute('/avatars/' . $fileName); + return $this->getBlankAvatarUrl($size); + } + + private function getCustomAvatarSourcePath() + { + $fileName = md5($this->getName()) . '.avatar_source'; + return Core::getConfig()->main->avatarsPath . DS . $fileName; + } + + private function getCustomAvatarPath($size) + { + $fileName = md5($this->getName()) . '-' . $size . '.avatar'; + return Core::getConfig()->main->avatarsPath . DS . $fileName; + } + + private function getCustomAvatarPaths() + { + $hash = md5($this->getName()); + return glob(Core::getConfig()->main->avatarsPath . DS . $hash . '*.avatar'); + } + + private function removeOldCustomAvatar() + { + foreach ($this->getCustomAvatarPaths() as $path) + TransferHelper::remove($path); + } + + private function generateCustomAvatar($size) + { + $srcPath = $this->getCustomAvatarSourcePath($size); + $dstPath = $this->getCustomAvatarPath($size); + + $thumbnailGenerator = new ImageThumbnailGenerator(); + return $thumbnailGenerator->generateFromFile( + $srcPath, + $dstPath, + $size, + $size); + } } diff --git a/src/Models/UserModel.php b/src/Models/UserModel.php index c2a823d1..050de316 100644 --- a/src/Models/UserModel.php +++ b/src/Models/UserModel.php @@ -29,6 +29,7 @@ final class UserModel extends AbstractCrudModel 'access_rank' => $user->getAccessRank()->toInteger(), 'settings' => $user->getSettings()->getAllAsSerializedString(), 'banned' => $user->isBanned() ? 1 : 0, + 'avatar_style' => $user->getAvatarStyle()->toInteger(), ]; $stmt = (new Sql\UpdateStatement) diff --git a/src/Upgrades/mysql/Upgrade15.sql b/src/Upgrades/mysql/Upgrade15.sql new file mode 100644 index 00000000..7eceb522 --- /dev/null +++ b/src/Upgrades/mysql/Upgrade15.sql @@ -0,0 +1 @@ +ALTER TABLE user ADD COLUMN avatar_style INTEGER DEFAULT 1; diff --git a/src/Upgrades/sqlite/Upgrade15.sql b/src/Upgrades/sqlite/Upgrade15.sql new file mode 100644 index 00000000..7eceb522 --- /dev/null +++ b/src/Upgrades/sqlite/Upgrade15.sql @@ -0,0 +1 @@ +ALTER TABLE user ADD COLUMN avatar_style INTEGER DEFAULT 1; diff --git a/src/Views/input/input-basic.phtml b/src/Views/input/input-basic.phtml index 83e86b64..2a791e9e 100644 --- a/src/Views/input/input-basic.phtml +++ b/src/Views/input/input-basic.phtml @@ -19,7 +19,7 @@ $noAutocomplete = isset($this->context->noAutocomplete) ? true : false; autocomplete="off" - + class="" type="" diff --git a/src/Views/input/input-checkboxes.phtml b/src/Views/input/input-checkboxes.phtml index 32a636c2..36d21057 100644 --- a/src/Views/input/input-checkboxes.phtml +++ b/src/Views/input/input-checkboxes.phtml @@ -7,6 +7,7 @@ $optionLabels = $this->context->optionLabels; $optionNames = $this->context->optionNames; $optionStates = $this->context->optionStates; $keys = array_keys($optionNames); +$inputClass = isset($this->context->inputClass) ? $this->context->inputClass : ''; ?>
@@ -17,6 +18,9 @@ $keys = array_keys($optionNames); + class="" + name="" value=""/> diff --git a/src/Views/input/input-radioboxes.phtml b/src/Views/input/input-radioboxes.phtml index 94538b8b..566097e7 100644 --- a/src/Views/input/input-radioboxes.phtml +++ b/src/Views/input/input-radioboxes.phtml @@ -6,6 +6,7 @@ $optionValues = $this->context->optionValues; $optionLabels = $this->context->optionLabels; $activeOptionValue = $this->context->activeOptionValue; $keys = array_keys($optionValues); +$inputClass = isset($this->context->inputClass) ? $this->context->inputClass : ''; ?>
@@ -18,6 +19,9 @@ $keys = array_keys($optionValues);